Compare commits

..

456 Commits

Author SHA1 Message Date
Claude VM 6b3d56e1e8 refactor(deploy): externalize all secrets to .env, migrate Brevo SMTP → REST API
- docker-compose.yml: replace 43 hardcoded env values with ${VAR} references.
  Operators must provide /opt/architools/.env (chmod 600, gitignored) with the
  matching keys. Removes the historical leak surface where every edit risked
  echoing secrets.
- email-service.ts: drop nodemailer SMTP transport; use Brevo REST API
  (POST https://api.brevo.com/v3/smtp/email) with BREVO_API_KEY header.
  Brevo SMTP relay credentials have been deleted upstream.
- package.json: remove nodemailer + @types/nodemailer.

NOTE: legacy hardcoded credentials present in git history must still be
rotated separately (DB password, Authentik client secret, ENCRYPTION_SECRET,
ANCPI password, etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 07:49:08 +03:00
Claude VM 265e1c934b chore(parcel-sync): disable auto-refresh scheduler during GIS DB overhaul
Prevents nightly delta sync (Mon-Fri 01-05) and weekend deep sync
(Fri-Sun 23-04) from writing to GisFeature/GisUat while the schema
is being reworked. Re-enable by uncommenting the import in
src/instrumentation.ts once the new DB layout is stable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:34:47 +03:00
Claude VM ddf27d9b17 fix(webhook): treat HTTP 409 (rebuild already running) as success, not error
The pmtiles-webhook returns 409 when a rebuild is already in progress.
Previously this was treated as a failure, showing 'Webhook PMTiles
indisponibil' error to the user. Now 409 is handled as a valid state
with appropriate messaging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:29:01 +03:00
Claude VM 377b88c48d feat(sync): auto-trigger PMTiles rebuild after sync + fix progress display
- Add pmtiles-webhook.ts shared helper for triggering PMTiles rebuild
- sync-county: trigger rebuild when new features synced, pass jobId to
  syncLayer for sub-progress, update % after UAT completion (not before)
- sync-all-counties: same progress fix + rebuild trigger at end
- geoportal monitor: use shared helper instead of raw fetch
- weekend-deep-sync + auto-refresh: consolidate webhook code via helper
- docker-compose: default N8N_WEBHOOK_URL to pmtiles-webhook on satra:9876

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:59:18 +03:00
Claude VM b356e70148 fix(session-store): rename globalThis key collision between session-store and eterra-client
Both session-store.ts and eterra-client.ts used globalThis.__eterraSessionStore
but for completely different purposes (EterraSession vs Map<string, SessionEntry>).
The Map from eterra-client made getSessionStatus() report connected: true on
server start (Map is truthy), while getSessionCredentials() returned undefined
username/password — causing "Credentiale lipsa" on sync attempts despite the
UI showing a green "Conectat" dot.

Renamed eterra-client's global keys to __eterraClientCache and
__eterraClientCleanupTimer to eliminate the collision.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:36:33 +03:00
Claude VM 708e550d06 fix(parcel-sync): allow DB download regardless of layer freshness
The top export buttons required all primary layers to be "fresh" (<7 days)
before using the DB download path. When stale, they fell through to live
eTerra sync which requires credentials — blocking users who just want to
download existing data. Now any UAT with data in DB uses the local export
path directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:16:41 +03:00
Claude VM 0cce1c8170 feat(sync-management): rule-based sync scheduling page + API
Phase 1 of unified sync scheduler:

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:03:50 +03:00
Claude VM 34be6c58bc feat(monitor): add Sync All Romania + live GIS stats
- /api/eterra/stats: lightweight polling endpoint (8 parallel Prisma queries, 30s poll)
- /api/eterra/sync-all-counties: iterates all counties in DB sequentially,
  syncs TERENURI + CLADIRI + INTRAVILAN + enrichment (magic mode) per UAT
- Monitor page: live stat cards (UATs, parcels, buildings, DB size),
  Sync All Romania button with progress tracking at county+UAT level
- Concurrency guard: blocks county sync while all-Romania sync runs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:42:01 +03:00
Claude VM 7bc9e67e96 feat(monitor): add eTerra session indicator + login form
The eTerra connect/disconnect UI and session status were missing from the
deployed monitor page. Also, since ETERRA env vars are empty in the
container, the connect flow now accepts username/password from the UI.
This unblocks county sync which requires an active eTerra session.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:40:31 +03:00
Claude VM 93b3904755 fix(sync-county): use eTerra session credentials, not just env vars
Same pattern as sync-background: session credentials from eTerra login
take priority, env vars are fallback only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:47:16 +03:00
Claude VM f44d57629f feat: county sync on monitor page + in-app notification system
- GET /api/eterra/counties — distinct county list from GisUat
- POST /api/eterra/sync-county — background sync all UATs in a county
  (TERENURI + CLADIRI + INTRAVILAN), magic mode for enriched UATs,
  concurrency guard, creates notification on completion
- In-app notification service (KeyValueStore, CRUD, unread count)
- GET/PATCH /api/notifications/app — list and mark-read endpoints
- NotificationBell component in header with popover + polling
- Monitor page: county select dropdown + SyncTestButton with customBody

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:56:59 +03:00
Claude VM 8222be2f0e fix(geoportal): search input text invisible in dark mode
Changed bg-background/95 to bg-background (opaque) and added
text-foreground to ensure input text is visible on dark theme.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:11:46 +03:00
Claude VM 177f2104c1 fix(geoportal): show UAT name in search results + fix map snap-back
Search results now JOIN GisUat to display UAT name prominently instead
of just SIRUTA codes. Map flyTo uses imperative handle instead of
stateful props that re-triggered on re-renders.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 21:21:09 +03:00
AI Assistant f106a2bb02 feat(auto-refresh): upgrade nightly scheduler to delta sync all UATs
Replace old approach (5 stale UATs, 7-day freshness gate, no enrichment)
with new delta engine on ALL UATs:
- Quick-count + VALID_FROM delta for sync (~7s/UAT)
- Rolling doc check + early bailout for magic UATs (~10s/UAT)
- All 43 UATs in ~5-7 minutes instead of 5 UATs in 30+ minutes
- Runs Mon-Fri 1:00-5:00 AM, weekend deep sync unchanged
- Small delays (3-10s) between UATs to be gentle on eTerra

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 08:08:39 +03:00
AI Assistant 27960c9a43 fix(monitor): increase refresh-all timeout to 3h
First-run magic enrichment on partially-enriched UATs can take
30+ minutes per UAT. After first complete run, subsequent runs
will be seconds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 07:01:36 +03:00
AI Assistant fc7a1f9787 feat(monitor): add Refresh ALL UATs button with delta sync
New endpoint POST /api/eterra/refresh-all processes all 43 UATs
sequentially. UATs with >30% enrichment get magic mode, others
get base sync only. Each UAT uses the new delta engine (quick-count
+ VALID_FROM + rolling doc check). Progress tracked via progress store.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 01:02:47 +03:00
AI Assistant ef3719187d perf(enrich): rolling doc check resolves changes in-place, always returns early
Instead of marking features enrichedAt=null and falling through to the
full enrichment flow (which downloads the entire immovable list ~5min),
the rolling doc check now merges updated PROPRIETARI/DATA_CERERE directly
into existing enrichment and returns immediately.

Also touches enrichedAt on all checked features to rotate the batch,
ensuring different features are checked on each daily run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 00:53:25 +03:00
AI Assistant 7a93a28055 fix(parcel-sync): always run syncLayer for delta detection + no-geom freshness
- Always call syncLayer for TERENURI/CLADIRI (not gated by isFresh)
  so that quick-count + VALID_FROM delta actually run on daily syncs
- syncLayer handles efficiency internally via quick-count match
- Add 48h freshness check for no-geom import (skip if recent)
- Admin layers: skip if synced within 24h
- Log sync summary (new features, updated features)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 23:25:23 +03:00
AI Assistant f822509169 feat(monitor): separate delta test buttons for Cluj-Napoca and Feleacu
- Cluj-Napoca (54975): base mode, parcele+cladiri only (no magic)
- Feleacu (57582): magic + no-geom (full enrichment test)
- Both with elapsed timer and phase-change logging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 23:02:37 +03:00
AI Assistant d76c49fb9e feat(monitor): add delta sync test button for Cluj-Napoca
Quick test button on /monitor page to trigger smart delta sync
(magic mode) on Cluj-Napoca and track progress via polling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:59:04 +03:00
AI Assistant 9e7abfafc8 feat(parcel-sync): smart delta sync + fix HAS_BUILDING bug
- Fix: geoportal/enrich endpoint now looks up CLADIRI_ACTIVE from DB
  instead of hardcoding HAS_BUILDING=0, BUILD_LEGAL=0
- Quick-count check: skip OBJECTID comparison when remote==local count
- VALID_FROM delta: detect attribute changes on existing parcels and
  mark them for re-enrichment (catches spatial validity changes)
- Early bailout: skip all eTerra API calls when 0 features need enrichment
- Rolling doc check: probe 200 oldest-enriched parcels for new
  documentation activity (catches ownership/CF changes VALID_FROM misses)
- Targeted doc fetch: only fetch documentation for immovable PKs that
  actually need enrichment instead of all 10k+

Daily sync cost reduced from ~300+ API calls / 1-2h to ~6-10 calls / 10-15s.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:57:02 +03:00
AI Assistant 4d1883b459 feat(registratura): add manual toggle for monitoring (Opreste/Reactiveaza)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:52:59 +03:00
AI Assistant 5bcf65ff02 feat(registratura): auto-close monitoring on resolved, inline check, edit tracking
- Auto-deactivate monitoring when status is 'solutionat' or 'respins'
- 'Verifica acum' shows result inline (no page reload/close)
- 'Modifica' button opens edit dialog to fix petitioner/regNumber/date
- Show monitoring section even when inactive (final status visible)
- Display tracking config info (nr, date, petitioner) in detail view

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:17:18 +03:00
AI Assistant 89e7d08d19 feat(parcel-sync): add Monitor link next to WDS on export tab
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:07:30 +03:00
AI Assistant 126a121056 feat(auto-refresh): trigger PMTiles rebuild via N8N after nightly sync
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:04:22 +03:00
AI Assistant 31877fde9e feat(wds): add 'Descarca parcele' button for quick terenuri+cladiri sync
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 08:58:18 +03:00
AI Assistant 0a38b2c374 fix(wds): restore full-step manual trigger button
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 08:23:57 +03:00
AI Assistant b8061ae31f feat(wds): limit force sync to terenuri + cladiri only
Manual trigger now only processes sync_terenuri and sync_cladiri steps.
import_nogeom and enrich are left for the regular weekend window.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 07:36:35 +03:00
AI Assistant 145aa11c55 fix(wds): remove time window restriction for manual force sync
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 07:30:49 +03:00
AI Assistant 730eee6c8a feat(wds): add manual sync trigger button with force-run mode
- triggerForceSync() resets error steps, clears lastSessionDate, starts sync immediately
- Force mode uses extended night window (22:00-05:00) instead of weekend-only
- API action 'trigger' starts sync in background, returns immediately
- 'Porneste sync' button in header (hidden when already running)
- Respects __parcelSyncRunning guard to prevent concurrent runs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 01:59:07 +03:00
AI Assistant 4410e968db feat(wds): live status banner, auto-poll, and instant error emails
- Auto-poll every 15s when sync is running, 60s when idle
- Live status banner: running (with city/step), error list, weekend window waiting, connection error
- Highlight active city card and currently-running step with pulse animation
- Send immediate error email per failed step (not just at session end)
- Expose syncStatus/currentActivity/inWeekendWindow in API response
- Stop silently swallowing fetch/action errors — show them in the UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 01:41:55 +03:00
AI Assistant 82a225de67 fix(tippecanoe): remove --simplification=10 — distorts buildings at z18
Simplification with 10px tolerance makes rectangular buildings look
jagged/trapezoidal at high zoom. Without it, tippecanoe still simplifies
at lower zooms via --drop-densest-as-needed but preserves full geometry
at the base zoom (z18).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:07:51 +03:00
AI Assistant adc0b0a0d0 fix(monitor): resolve relative PMTILES_URL for server-side health check
Server-side fetch() cannot resolve relative URLs like /tiles/pmtiles/...
Route through internal tile-cache proxy (http://tile-cache:80) instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:01:47 +03:00
AI Assistant 9bf79a15ed fix(geoportal): proxy PMTiles through HTTPS + fix click/selection + optimize rebuild
PMTiles was loaded via HTTP from MinIO (10.10.10.166:9002) on an HTTPS page,
causing browser mixed-content blocking — parcels invisible on geoportal.

Fixes:
- tile-cache nginx proxies /pmtiles/ → MinIO with Range header support
- PMTILES_URL changed to relative path (resolves to HTTPS automatically)
- clickableLayers includes PMTiles fill layers (click on parcels works)
- Selection highlight uses PMTiles source at z13+ (was Martin z17+ only)
- tippecanoe per-layer zoom ranges (terenuri z13-z18, cladiri z14-z18)
  skips processing millions of features at z0-z12 — faster rebuild

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:56:49 +03:00
AI Assistant b46eb7a70f feat(parcel-sync): add building status layer to Harta tab (gis_cladiri_status)
Buildings now color-coded on legal status in ParcelSync map view:
- Blue fill: building has legal documents (build_legal = 1)
- Red fill: building without legal documents (build_legal = 0)

Previously only parcels had status coloring; buildings were plain blue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:53:12 +02:00
AI Assistant ee86af6183 docs: update tile evaluation + monitoring + add geoportal improvement mega prompt
- TILE-SERVER-EVALUATION.md: updated to reflect current architecture (PMTiles z0-z18)
- MODULE-MAP.md: added PMTiles + tile-cache to Geoportal section
- Monitor: timeout increased to 90 min for z18 builds, description updated
- Added PROMPT-GEOPORTAL-IMPROVE.md with mega prompt for future sessions
  (includes MLT check, mvt-rs evaluation prompt, operational commands)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:38:53 +02:00
AI Assistant 870e1bd4c2 perf(geoportal): extend PMTiles to z18 — eliminate Martin for terenuri/cladiri entirely
PMTiles now covers z0-z18 (full zoom range). Martin sources kept only for
selection highlight and fallback when PMTiles not configured.
All terenuri/cladiri fill/line/label layers served from PMTiles when active.
Zero PostGIS load for tile rendering at any zoom level.

File will be ~1-2 GB but eliminates all cold-load latency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:23:04 +02:00
AI Assistant c269d8b296 fix(docker): declare PMTILES_URL + MARTIN_URL as ARG+ENV in Dockerfile
Without ARG/ENV declarations in the build stage, docker-compose build args
are silently ignored. webpack never sees the values → NEXT_PUBLIC_ vars
are empty → PMTiles disabled → all tiles go through Martin → PostGIS at 90% CPU.

This was the root cause of slow tile loading all along.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:41:24 +02:00
AI Assistant aac93678bb fix(geoportal): move PMTILES_URL + MARTIN_URL to build args (NEXT_PUBLIC_ requires build time)
NEXT_PUBLIC_ env vars are inlined by webpack at build time in client components.
Setting them only in environment (runtime) has no effect — the map-viewer
was falling back to Martin for ALL tiles, causing 90% PostgreSQL CPU.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:30:26 +02:00
AI Assistant c00d4fe157 fix(monitor): increase rebuild timeout to 30min + fix sample tile z14→z17
Martin now starts at z17, so z14 sample tile returned 404.
Rebuild timeout increased from 15 to 30 min for z16 builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:21:34 +02:00
AI Assistant f5c8cf5fdc perf(geoportal): extend PMTiles to z16 — near-zero PostGIS load for tile serving
- PMTiles now covers z0-z16 (was z0-z14) for terenuri + cladiri
- Martin only serves z17-z18 (very close zoom, few features per tile)
- map-viewer: PMTiles layers serve z13-z16 for terenuri, z14-z16 for cladiri
- Labels at z16 now from PMTiles (cadastral_ref included in tiles)
- Remove failed compound index from postgis-setup.sql

This eliminates PostgreSQL as bottleneck for 99% of tile requests.
PMTiles file will be ~300-500MB (vs 104MB at z14).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:40:03 +02:00
AI Assistant b33fe35c4b perf(martin+postgres): connection pool limit + compound indexes + minzoom alignment
- Martin: pool_size=8 (prevents overwhelming PostgreSQL with concurrent queries)
- Martin: gis_terenuri minzoom 10→14, gis_cladiri minzoom 12→15
  (PMTiles serves z0-z14, no point in Martin generating those)
- PostGIS: add compound index layerId+geom for Martin view queries
- PostGIS: add B-tree index on layerId for LIKE filtering in views

Fixes 90% CPU on PostgreSQL during cold tile loads at detail zoom levels.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:15:13 +02:00
AI Assistant 73456c1424 feat(monitor): activity log with rebuild polling + warm cache details
- Rebuild: shows webhook status, then polls every 15s until PMTiles
  last-modified changes, then shows success with new size/timestamp
- Warm cache: shows HIT/MISS/error breakdown after completion
- Activity log panel with timestamps, color-coded status, scrollable
- 15-minute timeout on rebuild polling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:59:35 +02:00
AI Assistant 9eb2b12fea fix(parcel-sync): safety check prevents mass deletion on stale remote data
If >80% of local features would be deleted (and >100 exist), skip deletion
and log a warning. This protects against session expiry returning empty
remote results, which previously caused the sync to mark ALL local
features as "removed" and attempt to delete them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 10:19:47 +02:00
AI Assistant dfb5ceb926 fix(parcel-sync): batch deleteMany to avoid PostgreSQL 32767 bind variable limit
Cluj-Napoca sync failed with 62,307 removed features exceeding the limit.
Batch deletions in chunks of 30,000 in both sync-service and no-geom-sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 10:17:57 +02:00
AI Assistant 91fb23bc53 feat(geoportal): live tile infrastructure monitor at /monitor
Dashboard page showing:
- nginx tile-cache status (connections, requests)
- Martin tile server sources
- PMTiles file info (size, last modified)
- Cache HIT/MISS test on sample tiles
- Configuration summary

Action buttons:
- Rebuild PMTiles (triggers N8N webhook)
- Warm Cache (fetches common tiles from container)

Auto-refreshes every 30 seconds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 10:14:28 +02:00
AI Assistant 58442da355 fix(parcel-sync): fix session expiry during long pagination (Cluj 0 features bug)
Three bugs caused sync to return 0 features after 37 minutes:

1. reloginAttempted was instance-level flag — once set to true after first
   401, all subsequent 401s threw immediately without retry. Moved to
   per-request scope so each request can independently relogin on 401.

2. Session lastUsed never updated during pagination — after ~10 min of
   paginating, the session store considered it expired and cleanup could
   evict it. Added touchSession() call before every request.

3. Single eTerra client shared across all cities/steps for hours — now
   creates a fresh client per city/step (session cache still avoids
   unnecessary logins when session is valid).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 10:05:06 +02:00
AI Assistant 9bab9db4df feat(geoportal): N8N webhook on sync completion + tile cache monitoring
- weekend-deep-sync.ts: fire webhook to N8N_WEBHOOK_URL after each sync cycle
  (N8N triggers tippecanoe PMTiles rebuild via SSH on host)
- nginx tile-cache: add stub_status at /status, custom log format with cache status
- Add tile-cache-stats.sh: shows HIT/MISS ratio, cache size, slow tiles
- docker-compose: add N8N_WEBHOOK_URL env var

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 09:44:38 +02:00
AI Assistant c82e234d6c perf(tile-cache): fix compression passthrough + 7d TTL + browser caching
- Remove Accept-Encoding stripping — Martin gzip passes through to client
  (was sending uncompressed tiles, wasting bandwidth and cache space)
- Increase cache TTL from 1h to 7d (tiles change only on weekly sync)
- Increase inactive eviction from 24h to 7d
- Add Cache-Control header for browser caching (24h + stale-while-revalidate 7d)
- 204 (empty tiles) cached 1h instead of 1m (they don't change either)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:47:39 +02:00
AI Assistant ecf61e7e1d fix(tippecanoe): remove cache warming from Docker container (no host network access)
Cache warming must run from host, not from Docker container.
Use scripts/warm-tile-cache.sh standalone instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:36:37 +02:00
AI Assistant dafb3555d7 fix(tippecanoe): fix empty terenuri/cladiri export — remove ST_Simplify from ogr2ogr
ogr2ogr doesn't auto-detect geometry from ST_SimplifyPreserveTopology().
Export raw geometry instead, let tippecanoe handle simplification (--simplification=10).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:59:01 +02:00
AI Assistant 0d5fcf909c feat(geoportal): PMTiles for terenuri/cladiri overview + cache warming + cleanup
- Extend PMTiles to include simplified terenuri (5m tolerance) and cladiri (3m)
- map-viewer: terenuri z13 from PMTiles, z14+ from Martin (live detail)
- map-viewer: cladiri z14 from PMTiles, z15+ from Martin
- Martin sources start at higher minzoom when PMTiles active (less DB load)
- Add warm-tile-cache.sh: pre-populate nginx cache for major cities
- Rebuild script now includes cache warming step after PMTiles upload
- Remove deprecated docker-compose version: "3.8"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:46:47 +02:00
AI Assistant 236635fbf4 fix(geoportal): show only building body suffix (C1, C2) instead of full cadastral_ref
Strip parcel number prefix from building labels — "291479-C1" now displays as "C1".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:32:38 +02:00
AI Assistant 0572097fb2 feat(geoportal): activate PMTiles overview tiles from MinIO
UAT + administrativ layers now served from pre-generated PMTiles (~5ms)
instead of Martin/PostGIS (~200-2000ms) for zoom levels 0-14.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:51:57 +02:00
AI Assistant 938aa2c6d3 fix(tippecanoe): use GHCR registry for GDAL image (migrated from Docker Hub)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:44:02 +02:00
AI Assistant 8ebd7e4ee2 fix(tippecanoe): build from source instead of unavailable ghcr.io image
ghcr.io/felt/tippecanoe:latest returns 403 — no public Docker image.
Build tippecanoe from GitHub source in a multi-stage Alpine build.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:41:37 +02:00
AI Assistant 536b3659bb feat(geoportal): nginx tile cache + PMTiles overview layers + tippecanoe pipeline
- Add nginx reverse proxy cache in front of Martin (2GB, 1h TTL, stale serving, CORS)
- Martin no longer exposes host port — all traffic routed through tile-cache on :3010
- Add PMTiles support in map-viewer.tsx (conditional: NEXT_PUBLIC_PMTILES_URL env var)
  - When set: single PMTiles source for UAT + administrativ layers (z0-z14, ~5ms/tile)
  - When empty: fallback to Martin tile sources (existing behavior, zero breaking change)
- Add tippecanoe Docker service (profiles: tools) for on-demand PMTiles generation
- Add rebuild-overview-tiles.sh: ogr2ogr export → tippecanoe → MinIO atomic upload
- Install pmtiles npm package for MapLibre protocol registration

Performance impact:
- nginx cache: 10-100x faster on repeat tile requests, zero PostGIS load on cache hit
- PMTiles: sub-10ms overview tiles, zero PostGIS load for z0-z14

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:28:49 +02:00
AI Assistant 67f3237761 docs(geoportal): update evaluation + skills with deployment lessons learned
- Portainer CE volume mount pitfall (silent empty directory creation)
- Martin Docker tag format change at v1.0 (v prefix dropped)
- UNKNOWN GEOMETRY TYPE log is normal for views
- Bake-into-image pattern for config files in Portainer deployments
- Updated all implementation prompts with Portainer-safe instructions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:41:54 +02:00
AI Assistant 675b1e51dd fix(martin): bake config into image via Dockerfile (Portainer volume mount fix)
Portainer CE deploys only docker-compose.yml — ./martin.yaml not present on host,
so Docker creates an empty directory instead of mounting the file. Solution: COPY
martin.yaml into the image at build time, eliminating the volume dependency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:18:30 +02:00
AI Assistant a83f9e63b9 fix(martin): correct Docker image tag to 1.4.0 (no v prefix for v1.x+)
Martin changed tag format at v1.0: v0.15.0 → 1.4.0 (dropped the v prefix).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:06:59 +02:00
AI Assistant a75d0e1adc fix(geoportal): mount Martin config + upgrade v1.4 + enable building labels
Root cause: martin.yaml was never mounted in docker-compose.yml — Martin ran
in auto-discovery mode which dropped cadastral_ref from gis_cladiri tiles.

Changes:
- docker-compose: mount martin.yaml, upgrade Martin v0.15→v1.4.0, use --config
- map-viewer: add cladiriLabel layer (cadastral_ref at z16+), wire into visibility
- martin.yaml: update version comment
- geoportal/: tile server evaluation doc + 3 skill files (vector tiles, PMTiles, MapLibre perf)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:28:20 +02:00
AI Assistant e42eeb6324 feat(parcel-sync): extended enrichment fields from existing API data
New fields extracted from already-fetched documentation/GIS data
(zero extra API calls, no performance impact):

- TIP_INSCRIERE: "Intabulare, drept de PROPRIETATE, dobandit prin..."
- ACT_PROPRIETATE: "hotarare judecatoreasca nr... / contract vanzare..."
- COTA_PROPRIETATE: "1/1" or fractional
- DATA_CERERE: date of registration application
- NR_CORPURI: number of building bodies on parcel
- CORPURI_DETALII: "C1:352mp, C2:248mp, C3:104mp"
- IS_CONDOMINIUM: condominium flag
- DATA_CREARE: parcel creation date in eTerra

Also fixed HAS_BUILDING: now also uses NR_CORPURI count as fallback
(was 0 for parcels where buildingMap cross-ref missed matches).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:57:13 +02:00
AI Assistant 9d45799900 revert: disable building labels + remove debug endpoints
Building labels (C1/C2/C3) disabled — Martin MVT tiles don't include
cadastral_ref as a property despite the PostgreSQL view exposing it.
Root cause needs investigation (Martin config or alternative tile server).

Removed temporary debug endpoints:
- /api/eterra/debug-tile-props
- /api/eterra/debug-tile-sample

Kept /api/eterra/debug-fields (useful long-term diagnostic).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:43:20 +02:00
AI Assistant 946723197e debug: red building labels with template string syntax
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:30:13 +02:00
AI Assistant 3ea57f00b6 debug: try cladiri labels at minzoom 15
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:23:48 +02:00
AI Assistant 311f63e812 debug: add /api/eterra/debug-tile-sample for Martin tile diagnostics
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:22:26 +02:00
AI Assistant 1d233fdc19 fix(geoportal): building labels — inline addLayer like terenuriLabel
Removed wrapper function/setTimeout approach. Now uses exact same
inline addLayer pattern as terenuriLabel which is proven to work.
Same source, same font, same coalesce pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:13:40 +02:00
AI Assistant c6eb1a9450 fix(geoportal): building labels — force overlap + delayed init
Labels were hidden by MapLibre collision detection with terrain
labels. Now using text-allow-overlap + text-ignore-placement to
force visibility. Also added retry with setTimeout in case source
isn't ready when layer is first added.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:07:38 +02:00
AI Assistant 49a239006d fix(geoportal): simplify building labels — show full cadastral_ref
Previous index-of/slice expression wasn't rendering. Simplified to
just show the full cadastral_ref (e.g. "77102-C1") as-is. MapLibre
auto-hides overlapping labels. This is a diagnostic step to verify
the tile property is accessible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:57:45 +02:00
AI Assistant 6c5aa61f09 debug: add /api/eterra/debug-tile-props to check Martin tile columns
Temporary diagnostic to verify what columns gis_cladiri view exposes
for Martin vector tiles. Needed to debug missing C1/C2 labels.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:39:21 +02:00
AI Assistant 4c1ffe3d01 fix(geoportal): building labels C1/C2 — simpler expression + minzoom 16
Previous index-of expression wasn't rendering. Simplified to use
filter with index-of on dash + slice from dash position.
Also lowered minzoom from 17 to 16.

Added diagnostic log in enrichment for building cross-ref count
to debug HAS_BUILDING=0 cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:11:56 +02:00
AI Assistant 4e67c29267 feat(parcel-sync): add /api/eterra/debug-fields diagnostic endpoint
Shows all available eTerra fields for a parcel + buildings:
- GIS layer attributes (raw from ArcGIS)
- Immovable parcel details (intravilan, categories)
- Immovable list entry (address, areas)
- Documentation data (owners, registrations)
- Local DB state (enrichment, sync dates)

Usage: /api/eterra/debug-fields?siruta=161829&cadRef=77102

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 06:59:35 +02:00
AI Assistant acb9be8345 feat(geoportal): building body labels (C1, C2, C3...) on map at zoom 17+
Extracts body suffix from cadastral_ref (e.g. "77102-C1" → "C1") and
displays as centered label on each building polygon. Only visible at
zoom 17+ to avoid clutter at lower zooms.

Applied to both geoportal map-viewer and parcel-sync map tab.
Uses siruta filter in parcel-sync tab.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 06:53:45 +02:00
AI Assistant 189e9a218a fix(parcel-sync): fix [object Object] in address field + re-enrich corrupted
The eTerra API returns street and locality as objects ({name: "..."})
not strings. formatAddress now extracts .name correctly.

Also added:
- streetNumber fallback (alongside buildingNo)
- String() safety on addressDescription
- Corruption check: any enrichment containing "[object Object]" is
  automatically re-enriched on next cycle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 06:43:04 +02:00
AI Assistant c4516c6f23 fix: set TZ=Europe/Bucharest in Docker + scheduler diagnostic logs
The container was running on UTC by default — the 1-5 AM window was
actually 4-8 AM Romania time, missing the intended night window.

- Add TZ=Europe/Bucharest + tzdata package to Dockerfile
- Add startup diagnostic logs: server time, timezone, ETERRA creds check

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 06:35:23 +02:00
AI Assistant 798b3e4f6b feat(wds): replace 3-field form with UAT autocomplete search
Same search pattern as parcel-sync module: type name or SIRUTA code,
pick from dropdown, city is added instantly. Already-queued cities
are filtered out from results.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:57:07 +02:00
AI Assistant a6d7e1d87f fix(wds): auto-initialize queue with default cities on first access
The /wds page was showing 0 cities because the KeyValueStore was empty
until the scheduler ran for the first time. Now the GET endpoint
initializes the queue with the 9 default cities on first access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:46:50 +02:00
AI Assistant 54d9a36686 fix(parcel-sync): enrichment robustness — 5 fixes for better coverage
1. Completeness check with real values: features with all "-" values
   are now re-enriched instead of being considered "complete"

2. Age-based re-enrichment: features older than 30 days are re-enriched
   on next run (catches eTerra data updates)

3. Per-feature try-catch: one feature failing no longer aborts the
   entire UAT enrichment — logs warning and continues

4. fetchParcelFolosinte wrapped in try-catch: was a hard failure that
   killed the whole enrichment process

5. Workspace resolution logging: warns when immovable list is empty
   (wrong workspace), warns on fallback to PK=65

These fixes should progressively improve enrichment coverage toward
100% with each weekend sync cycle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:39:32 +02:00
AI Assistant 24b565f5ea feat(parcel-sync): DXF export in ZIP + detailed tooltips on hero buttons
DXF Export:
- Add gpkgToDxf() helper using ogr2ogr -f DXF (non-fatal fallback)
- export-local: terenuri.dxf, cladiri.dxf, terenuri_magic.dxf in ZIP
- export-bundle: same DXF files alongside GPKGs
- Zero overhead — conversion runs locally from DB data, no eTerra calls

Hero Button Tooltips:
- Hover shows ZIP contents: layer names, entity counts, sync dates
- Base tooltip: "GPKG + DXF per layer"
- Magic tooltip: "GPKG + DXF + CSV complet + Raport calitate"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:24:35 +02:00
AI Assistant bde25d8d84 feat(parcel-sync): add LIMITE_UAT to sync package everywhere
All sync paths now include both admin layers (LIMITE_INTRAV_DYNAMIC +
LIMITE_UAT) as best-effort alongside terenuri + cladiri:
- export-bundle (hero buttons)
- sync-background (fire-and-forget)
- auto-refresh scheduler (weekday nights)
- weekend deep sync (weekend nights)
- freshness check (export tab badge)

LIMITE_UAT rarely changes so incremental sync will skip it almost
every time, but it stays fresh in the DB freshness check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:13:29 +02:00
AI Assistant 8b6d6ba1d0 fix(parcel-sync): add intravilan to primary layers + tooltip on stale badge
- LIMITE_INTRAV_DYNAMIC added to primary layers checked for freshness
- Auto-refresh scheduler and weekend sync now also sync intravilan
- "X vechi" badge shows tooltip with exact layer names and dates
- "Proaspete" badge also shows tooltip with layer details

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:10:26 +02:00
AI Assistant e5da0301de fix(parcel-sync): freshness check only on primary layers (terenuri + cladiri)
Secondary layers (LIMITE_INTRAV, LIMITE_UAT) are synced once and rarely
change. They were causing permanent "1 vechi" badge even after fresh
sync of terenuri+cladiri.

Now canExportLocal and the freshness badge only consider TERENURI_ACTIVE
and CLADIRI_ACTIVE — the layers that actually matter for export.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:07:15 +02:00
AI Assistant 318cb6037e fix(parcel-sync): fix unicode escapes in JSX + refresh on bg sync complete
- Replace \u00ce with actual Î character in JSX text (was rendering as literal \u00cenchide)
- Add onSyncRefresh + onDbRefresh calls when closing bg sync card
- Ensures DB freshness badge updates after background sync completes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:10:34 +02:00
AI Assistant 3b456eb481 feat(parcel-sync): incremental sync, smart export, auto-refresh + weekend deep sync
Sync Incremental:
- Add fetchObjectIds (returnIdsOnly) to eterra-client — fetches only OBJECTIDs in 1 request
- Add fetchFeaturesByObjectIds — downloads only delta features by OBJECTID IN (...)
- Rewrite syncLayer: compare remote IDs vs local, download only new features
- Fallback to full sync for first sync, forceFullSync, or delta > 50%
- Reduces sync time from ~10 min to ~5-10s for typical updates

Smart Export Tab:
- Hero buttons detect DB freshness — use export-local (instant) when data is fresh
- Dynamic subtitles: "Din DB (sync acum Xh)" / "Sync incremental" / "Sync complet"
- Re-sync link when data is fresh but user wants forced refresh
- Removed duplicate "Descarca din DB" buttons from background section

Auto-Refresh Scheduler:
- Self-contained timer via instrumentation.ts (Next.js startup hook)
- Weekday 1-5 AM: incremental refresh for existing UATs in DB
- Staggered processing with random delays between UATs
- Health check before processing, respects eTerra maintenance

Weekend Deep Sync:
- Full Magic processing for 9 large municipalities (Cluj, Bistrita, TgMures, etc.)
- Runs Fri/Sat/Sun 23:00-04:00, round-robin intercalated between cities
- 4 steps per city: sync terenuri, sync cladiri, import no-geom, enrichment
- State persisted in KeyValueStore — survives restarts, continues across nights
- Email status report at end of each session via Brevo SMTP
- Admin page at /wds: add/remove cities, view progress, reset
- Hint link on export tab pointing to /wds

API endpoints:
- POST /api/eterra/auto-refresh — N8N-compatible cron endpoint (Bearer token auth)
- GET/POST /api/eterra/weekend-sync — queue management for /wds page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:50:34 +02:00
AI Assistant 8f65efd5d1 feat: add /prompts page — Claude Code prompt library
Personal prompt library at /prompts with:
- 6 categories: Module Work, API & Backend, Quality & Security,
  Session & Continue, Documentation & Meta, Quick Actions
- 22 optimized prompt templates for ArchiTools development
- Copy-to-clipboard on every prompt
- One-time prompts with checkbox persistence (localStorage)
- Search/filter across all prompts
- Best practices sidebar (10 tips from Claude Code research)
- Module name quick-copy badges
- Variable placeholders highlighted ({MODULE_NAME}, etc.)
- Deploy prep checklist, debug unknown errors, and more

Not registered as a module — accessible only via direct URL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:14:59 +02:00
AI Assistant eab465b8c3 chore: add STIRLING_PDF_URL, STIRLING_PDF_API_KEY, PORTAL_ONLY_USERS to docker-compose
These env vars were previously hardcoded in source code and removed during
the production audit. Now properly configured in docker-compose.yml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 06:46:38 +02:00
AI Assistant 0c4b91707f audit: production safety fixes, cleanup, and documentation overhaul
CRITICAL fixes:
- Fix SQL injection in geoportal search (template literal in $queryRaw)
- Preserve enrichment data during GIS re-sync (upsert update explicit fields only)
- Fix ePay version race condition (advisory lock in transaction)
- Add requireAuth() to compress-pdf and unlock routes (were unauthenticated)
- Remove hardcoded Stirling PDF API key (env vars now required)

IMPORTANT fixes:
- Add admin role check on registratura debug-sequences endpoint
- Fix reserved slot race condition with advisory lock in transaction
- Use SSO identity in close-guard-dialog instead of hardcoded "Utilizator"
- Storage DELETE catches only P2025 (not found), re-throws real errors
- Add onDelete: SetNull for GisFeature → GisSyncRun relation
- Move portal-only users to PORTAL_ONLY_USERS env var
- Add security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy)
- Add periodic cleanup for eTerra/ePay session caches and progress store
- Log warning when ePay dataDocument is missing (expiry fallback)

Cleanup:
- Delete orphaned rgi-test page (1086 lines, unregistered, inaccessible)
- Delete legacy/ folder (5 files, unreferenced from src/)
- Remove unused ensureBucketExists() from minio-client.ts

Documentation:
- Optimize CLAUDE.md: 464 → 197 lines (moved per-module details to docs/)
- Create docs/ARCHITECTURE-QUICK.md (80 lines: data flow, deps, env vars)
- Create docs/MODULE-MAP.md (140 lines: entry points, API routes, cross-deps)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 06:40:34 +02:00
AI Assistant c012adaa77 fix: export buttons direct (no dropdown), compact mobile cards
Export fix:
- Replaced DropdownMenu with direct DXF/GPKG buttons in SelectionToolbar.
  Radix dropdown portals don't work inside fixed z-[110] containers.
  Direct buttons work reliably on all platforms.

Mobile RGI cards:
- Single-row compact layout: icon + nr cerere + solicitant + termen + status
- Smaller icons (3.5), tighter spacing, shorter status labels
- No Card wrapper — lightweight border div for less visual weight

Mobile filters:
- Tighter spacing, smaller labels

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:03:24 +02:00
AI Assistant d82b873552 fix(portal): mobile toolbar as fixed viewport element + layout fixes
Selection toolbar:
- Moved OUTSIDE map container div to a fixed viewport position
  (bottom-4, z-[110]). iOS Safari clips absolute elements inside
  calc(100vh) containers — fixed positioning solves this.
- Only shown when UAT selected and has data.

Mobile top layout:
- UAT card takes full width (right-2 not right-[140px])
- Basemap switcher at top-[52px] left-2 on mobile (below UAT card)
- Desktop: unchanged (top-right offset from zoom controls)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:06:48 +02:00
AI Assistant 12ff629fbf feat: ZIP download, mobile fixes, click centering, tooltip
ZIP download:
- Both portal and RGI test page now create a single ZIP archive
  (Documente_eliberate_{appNo}.zip) instead of sequential downloads
- Uses JSZip (already in project dependencies)

Portal mobile:
- Basemap switcher drops below UAT card on mobile (top-14 sm:top-2)
- Selection toolbar at bottom-3 with z-30 (always visible)
- Click on feature centers map on that parcel (flyTo)

Tooltips:
- Green download icon: "Descarca arhiva ZIP cu documentele cererii X"
- Updated on both portal and RGI test page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:17:29 +02:00
AI Assistant 8acafe958b fix: freehand drawing, click highlight, mobile toolbar visibility
Freehand drawing fix:
- Disable dragPan when in freehand mode (was only disabling dblclick
  zoom). Without this, clicks were interpreted as pan gestures.
- Re-enable dragPan when exiting freehand mode.

Click highlight:
- Clicking a parcel in "off" mode now highlights it with the selection
  layer (amber fill + orange outline). Clicking empty space clears it.
- Provides visual feedback for which parcel was clicked.

Mobile toolbar:
- Moved selection toolbar higher (bottom-12 on mobile) with z-20
  to ensure it's above MapLibre attribution bar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 07:11:16 +02:00
AI Assistant 45d4d1bf40 fix: hide enrichment in portal, CF disabled button, no UAT flash, fix overlaps
SelectionToolbar: new hideEnrichment prop hides the Enrichment button.
Portal uses it to show only Export + Clear in selection toolbar.

Portal feature panel: added disabled "Solicita extras CF" button with
tooltip "Sectiune platita — contacteaza administratorul".

Initial map zoom: starts at zoom 15 (close-up) instead of 7 (Romania
overview). Prevents the UAT boundaries flash before fitBounds runs.
Applied to both ParcelSync Harta tab and Portal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 06:58:01 +02:00
AI Assistant 6f46a85ff3 fix(rgi+portal): default sort desc by termen, fix overlaps, tooltip
RGI (both pages):
- Default sort: Termen descrescator (cel mai in viitor sus)

Portal map:
- Basemap switcher moved left (right-12) to avoid zoom controls overlap
- Selection toolbar moved up (bottom-8) to avoid attribution overlap
- Download button has title tooltip on mobile cards

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 06:52:22 +02:00
AI Assistant 2cd35c790d fix(portal): mobile responsive — card view for RGI, visible map controls
Mobile (< 640px):
- RGI: card-based layout instead of table (shows nr cerere, status,
  solicitant, termen, rezolutie, UAT in compact card)
- Header: compact "Portal" title, smaller tab buttons
- Map: selection toolbar centered at bottom (always visible)
- UAT info card: smaller text, truncated, doesn't overlap basemap switcher
- Feature panel: narrower (w-56 vs w-64)
- Filter buttons: smaller text

Desktop (>= 640px):
- Same table view as before (hidden on mobile)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 06:25:12 +02:00
AI Assistant 7a36f0b613 fix(portal): build error + simple feature panel without enrichment/CF
Fixed:
- Added setLayoutProperty to MapLike type (was missing, broke build)
- Replaced FeatureInfoPanel with simple inline panel showing only:
  SIRUTA, Nr. cadastral, Suprafata (no enrichment, no CF extract,
  no "Actualizeaza" button)
- Fixed unknown type errors in JSX property access
- Hidden basemap boundaries + UAT layers for cleaner map

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:23:37 +02:00
AI Assistant 1919155d41 perf: hide basemap boundaries, remove UAT layers, optimize Martin minzoom
Basemap cleanup (after map load):
- Hide OpenFreeMap boundary/admin layers (redundant with our UATs)
- Keep city/town place labels, remove village-level
- Hide our Martin UAT layers (not needed in ParcelSync — filtered by siruta)

Martin tile optimization:
- gis_terenuri_status minzoom: 10 → 13 (parcels not visible below 13)
- gis_cladiri_status minzoom: 12 → 14 (buildings not visible below 14)
- Prevents unnecessary tile fetches at low zoom levels

Applied to both ParcelSync Harta tab and Portal map.
Requires docker restart martin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:15:00 +02:00
AI Assistant 1a5487f0f7 fix: zoom no longer resets after manual pan/zoom (fitBounds once per siruta)
The fitBounds effect was re-triggered every time mapReady toggled
(which happened frequently due to the source-checking polling interval).
Now uses boundsFittedForSirutaRef to ensure fitBounds runs only ONCE
per siruta selection — changing UAT still zooms correctly, but manual
zoom/pan is preserved afterwards.

Fixed in both ParcelSync Harta tab and Portal map.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:08:33 +02:00
AI Assistant daca222427 fix: portal user match for dtiurbe / d.tiurbe@beletage.ro
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:55:57 +02:00
AI Assistant f1f4dc097e fix(portal): full-screen overlay + redirect portal-only users
Portal layout: removed conflicting (portal)/layout.tsx that had
duplicate html/body tags. Portal page now uses fixed overlay
(z-[100]) that covers the entire screen including sidebar.

Middleware: portal-only users (dan.tiurbe) are automatically
redirected from any non-portal route to /portal. They can still
access /api/ and /auth/ routes normally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:49:44 +02:00
AI Assistant e420cd4609 feat: standalone portal page for Dan Tiurbe at /portal
Dedicated external portal combining RGI documents + cadastral map,
without ArchiTools sidebar/navigation.

Layout: (portal) route group with minimal layout (no AppShell).

Tab "Documente RGI":
- Full RGI functionality (county selector, sortable/filterable table,
  expandable docs with download, batch download)

Tab "Harta":
- UAT autocomplete selector
- Sync button when UAT has no data
- Map with basic parcel/building styling (no enrichment overlay)
- Only 3 basemaps: Harta (liberty), Noapte (dark), Google satellite
- Selection tools (click/rect/freehand) + DXF/GPKG export
- Feature info panel on click
- No CF extract ordering (hidden)

URL: https://tools.beletage.ro/portal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:29:29 +02:00
AI Assistant 9df6c9f542 fix(rgi): default columns, date sort, clean filenames, green icon downloads all
Default columns: Nr. cerere, Solicitant, Termen, Status, Rezolutie, UAT
(matching user's preferred view). Obiect, Identificatori, Deponent,
Data depunere now off by default.

Date sort: dueDate and appDate columns now sort by raw timestamp
(not by DD.MM.YYYY string which sorted incorrectly).

Filenames: removed long documentPk from filename. Now uses
DocType_AppNo.pdf (e.g. Receptie_tehnica_66903.pdf). Duplicate
types get suffix: Receptie_tehnica_66903_2.pdf.

Green icon: click downloads ALL documents from that application
sequentially. Shows spinner while downloading. Tooltip shows
"Nr. 66903 — click descarca toate" + details.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:03:39 +02:00
AI Assistant aebe1d521c feat(rgi): download all docs button + tooltips on status icon
Download all:
- "Descarca toate" button in expanded docs panel
- Downloads each document sequentially with correct filename
  (e.g. Receptie_tehnica_66903_10183217654.pdf)
- Progress indicator: "2/5: Harti & planuri..."
- Skips blocked docs, shows summary "3 descarcate, 2 indisponibile"
- 300ms delay between downloads to avoid browser blocking

Status icon tooltip:
- Hover on green/clock icon shows: Nr cerere, Obiect, Status,
  Rezolutie, Termen, Identificatori

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:41:51 +02:00
AI Assistant 2f114d47de feat(rgi): sortable/filterable table, county selector, smart filenames, soft blocked msg
Page improvements:
- County dropdown with all 41 Romanian counties (default Cluj)
- orgUnitId auto-computed (countyId * 1000 + 2)
- Sortable columns: click header to sort asc/desc with arrow indicators
- Search input: filters across all visible columns (diacritics-insensitive)
- Soft blocked message: amber toast "Documentul nu este inca disponibil"
  auto-hides after 5s (no more redirect errors)

Download improvements:
- Meaningful filenames: {docType}_{appNo}.pdf (e.g. Harti_planuri_66903.pdf)
- Romanian diacritics stripped from filenames
- Returns { blocked: true } JSON instead of redirect when unavailable

Bug fix: replaced incorrect useState() side-effect with proper useEffect()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:37:00 +02:00
AI Assistant c1006f395c fix(rgi): remove wrong dueDate lock — always show download button
The dueDate-based lock was incorrect: some documents with future
dueDate ARE downloadable. The availability depends on eTerra internal
rules, not predictably on dueDate.

Now all documents show a download button. If server-side download
fails (fileVisibility 404), it redirects to eTerra direct URL
which works in the user's browser session.

Filters changed to: Solutionate / Confirmate / Toate

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:23:48 +02:00
AI Assistant a191a684b2 feat(rgi): filter by downloadable/pending + locked document indicator
eTerra blocks document downloads until dueDate passes (new rule).
Now the page shows:

Filter modes:
- "Descarcabile acum" (default) — solved + dueDate passed
- "In asteptare" — solved + dueDate future (documents locked)
- "Toate" — no filter

UI indicators:
- Green download icon: ready to download
- Amber clock icon: solved but locked until dueDate
- Documents panel shows "Disponibile de la DD.MM.YYYY" badge when locked
- Download button replaced with date badge for locked documents

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:12:48 +02:00
AI Assistant 3614c2fc4a fix(rgi): set application context before download attempt
Before downloading, now calls:
1. verifyCurrentActorAuthenticated — sets actor context in session
2. appdetail/details — loads application context

Then tries download regardless of fileVisibility result.
The session context might be what enables downloads that previously
returned 404.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:50:27 +02:00
AI Assistant 4beac959c8 fix(rgi): redirect to eTerra when server-side download unavailable
When fileVisibility returns OK → download server-side (fast).
When not available → HTTP 302 redirect to eTerra direct URL.
User's browser session handles authentication automatically.

This means: if logged into eTerra in browser, ALL documents download.
If not logged in, eTerra shows its own login page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:48:01 +02:00
AI Assistant b0a5918bd7 fix(rgi): fast download with fileVisibility gate + clear error message
Download route simplified:
1. fileVisibility check — if 404, returns "indisponibil" + eTerra URL
2. Single download pattern (the one that works)

When document not available server-side, response includes direct
eTerra URL as fallback. No more 7 pattern attempts = much faster.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:39:53 +02:00
AI Assistant 5966a11f7e fix(rgi): download via user's eTerra browser session (not server-side)
Server credentials can list RGI applications and docs but can't download
files (confirmOnView returns false — only the current actor/deponent
has download permission).

Download now opens the eTerra URL directly in the user's browser,
which uses their existing eTerra session cookie. Flow:
1. Hidden iframe calls confirmOnView
2. After 500ms, opens downloadFile URL in new tab

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:32:09 +02:00
AI Assistant 0e5c01839d fix(rgi): exhaustive download debug — tries 7 URL patterns + GET/POST confirmOnView
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:25:14 +02:00
AI Assistant d780c3c973 fix(rgi): diagnostic download route — tries multiple URL patterns
Download route now:
- Calls fileVisibility with documentTypeId (if provided)
- Calls confirmOnView with documentPk
- Tries 3 different download URL patterns until one works
- Add &debug=1 to see diagnostic results instead of downloading
- Page now passes documentTypeId in download link

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:13:34 +02:00
AI Assistant 7a28d3ad33 fix(rgi): proper table layout with td per column for alignment
Replaced single colSpan td with flex layout → proper td per column.
Headers and data cells now align correctly. Expanded docs row uses
colSpan only for the detail panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:03:41 +02:00
AI Assistant 4707c6444e fix(rgi): rgiDownload handles session expiry + re-login on 401/302/404
eTerra returns 404 (not 401) when session expires during file download
because it redirects to login page. Now rgiDownload:
- Uses validateStatus to catch all statuses
- Re-logins and retries on 401/302/404
- Sets Accept: */* header for binary downloads

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:59:50 +02:00
AI Assistant e5e2fabb1d fix(rgi): correct download flow — confirmOnView + downloadFile by documentPk
The download uses documentPk (not documentTypeId) as the file identifier:
1. confirmOnView/{wid}/{appId}/{documentPk} — confirm view
2. loadDocument/downloadFile/{wid}/{documentPk} — actual download

Removed fileVisibility step (not needed, was causing 404).
Updated page download links to pass documentPk.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:46:37 +02:00
AI Assistant 227c363e13 fix(rgi): correct field mapping + configurable columns + download fix
Mapped all eTerra RGI fields correctly:
- App: appNo, applicationPk, appDate, dueDate, deponent, requester,
  applicationObject, identifiers, statusName, resolutionName, hasSolution
- Docs: docType, documentPk (fileId), documentTypeId (docId),
  fileExtension, digitallySigned, startDate

Features:
- Configurable columns: click "Coloane" to toggle 17 available columns
- Table layout with proper column rendering
- Click row to expand issued documents
- Documents show type name, date, extension, digital signature badge
- Download button with correct fileId/docId mapping
- Filter: hasSolution===1 && dueDate > now (not string matching)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:33:10 +02:00
AI Assistant 64f10a63ff fix(rgi): user-friendly page + 3-step download flow
Rewrote RGI test page:
- Clean card-based UI with status icons (green=solved, amber=pending)
- Click row to expand and see issued documents
- Each document has a direct "Descarca" download button
- Filter toggle "Doar solutionate cu termen viitor"
- No more raw JSON tables

Download route now follows eTerra's 3-step flow:
1. fileVisibility — check access, get fileId
2. confirmOnView — confirm document view
3. loadDocument/downloadFile — actual file download

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:18:32 +02:00
AI Assistant aa11ca389e feat(eterra): RGI API routes + test page for issued documents
New eTerra RGI (Registrul General de Intrare) integration:

API routes (/api/eterra/rgi/):
- POST /applications — list applications with workspace/year filters
- GET /details?applicationId=X — application details
- GET /issued-docs?applicationId=X&workspaceId=Y — issued documents list
- GET /download-doc?wid=X&aid=Y&did=Z — download issued document

EterraClient: added rgiPost, rgiGet, rgiDownload methods for RGI API.

Test page (/rgi-test):
- Filters: workspace, orgUnit, year
- Toggle: "Doar solutionate cu termen viitor"
- Table with application list, expandable issued docs, download links
- Raw JSON debug sections (collapsible)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 20:59:49 +02:00
AI Assistant 1dac5206e4 fix(parcel-sync): re-apply custom layers after basemap switch
MapViewer destroys and recreates the map when basemap changes. The
readiness polling now detects when custom sources are missing (new map
instance) and resets appliedSirutaRef + prevCheckSirutaRef, which
triggers all effects to re-run: siruta filter, enrichment overlay,
boundary mismatch GeoJSON, and fitBounds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:10:13 +02:00
AI Assistant 3da45a4cab feat(parcel-sync): sync button on empty Harta tab + intravilan in base sync
Map tab: when UAT has no local data, shows a "Sincronizează terenuri,
clădiri și intravilan" button that triggers background base sync.

Sync background (base mode): now also syncs LIMITE_INTRAV_DYNAMIC layer
(intravilan boundaries) alongside TERENURI_ACTIVE + CLADIRI_ACTIVE.
Non-critical — if intravilan fails, the rest continues.

Also fixed remaining \u2192 unicode escapes in export/layers/epay tabs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:04:09 +02:00
AI Assistant b1fc7c84a7 fix(parcel-sync): mismatch parcels visible from zoom 13, labels from 16
Mismatch fill/line layers now have minzoom: 13 (same as normal parcels).
Labels have minzoom: 16 with text-size: 10 and text-allow-overlap: false
(same settings as the regular parcel cadastral labels).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:57:03 +02:00
AI Assistant b87c908415 fix(parcel-sync): static connection dots, legend position, mismatch labels
- ePay + eTerra pills: removed animate-ping, now show static green dot
  when connected (no more spinning appearance)
- Legend moved to top-left, hides when FeatureInfoPanel is open
  (no more overlap)
- Boundary mismatch parcels now show cadastral numbers as labels
  (orange for foreign, purple for edge parcels)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:39:01 +02:00
AI Assistant ab35fc4df7 fix(parcel-sync): red parcel fill for buildings without legal docs
Instead of trying to color buildings directly (which requires an
unreliable cadastralRef join), the parcel itself gets a strong red fill
(opacity 0.45) when has_building=1 AND build_legal=0. Buildings sitting
on these parcels are visually on a red background.

Color scheme:
- Red fill: building without legal docs
- Light blue fill: building with legal
- Green fill: enriched, no building
- Yellow/amber fill: no enrichment

Removed broken gis_cladiri_status overlay. Simplified legend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:33:57 +02:00
AI Assistant 3f5eed25f4 fix(geoportal): DROP enrichment views before recreate (column change)
PostgreSQL CREATE OR REPLACE VIEW fails when column structure changes.
Now drops views first, then recreates them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:21:57 +02:00
AI Assistant 0dc5e58b55 fix(geoportal): use subquery instead of JOIN for gis_cladiri_status view
LEFT JOIN caused duplicate rows and column conflicts. Replaced with a
correlated subquery (LIMIT 1) to safely look up BUILD_LEGAL from the
parent parcel's enrichment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:16:06 +02:00
AI Assistant ba71ca3ef5 feat(parcel-sync): fix click, color styling, UAT boundary cross-check
Click fix:
- Keep l-terenuri-fill visible but transparent (opacity 0) so it still
  catches click events for FeatureInfoPanel. Enrichment overlay renders
  underneath.

Color changes:
- No enrichment: amber/yellow fill (was light green)
- With enrichment: green fill
- Buildings: red fill = no legal docs, blue = legal, gray = unknown
- Parcel outline: red = building no legal, blue = building legal

Boundary cross-check (/api/geoportal/boundary-check?siruta=X):
- Finds "foreign" parcels: registered in other UATs but geometrically
  within this UAT boundary (orange dashed)
- Finds "edge" parcels: registered here but centroid outside boundary
  (purple dashed)
- Alert banner shows count, legend updated with mismatch indicators

Martin config: added gis_cladiri_status source with build_legal property.
Enrichment views: gis_cladiri_status now JOINs parent parcel's BUILD_LEGAL.

Requires: docker restart martin + POST /api/geoportal/setup-enrichment-views

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:05:12 +02:00
AI Assistant 2848868263 fix(parcel-sync): fitBounds zoom + Martin config for enrichment tiles
- Map tab now uses fitBounds (not flyTo with fixed zoom) to show entire
  UAT extent when selected. Bounds are fetched and applied after map ready.
- Added gis_terenuri_status to martin.yaml so Martin serves enrichment
  tiles (has_enrichment, has_building, build_legal properties).
- Removed center/zoom props from MapViewer — use fitBounds via handle.
- Requires `docker restart martin` on server for Martin to reload config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:38:15 +02:00
AI Assistant 2b8d144924 fix(parcel-sync): replace Unicode escapes with actual Romanian diacritics
The \u0103, \u00ee etc. escape sequences were rendering literally in JSX
text nodes instead of displaying ă, î, ț, ș characters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:24:11 +02:00
AI Assistant d48a2bbf5d refactor(parcel-sync): split 4800-line module into 9 files + Harta tab + enrichment views
Split parcel-sync-module.tsx (4800 lines) into modular files:
- Orchestrator (452 lines): shared state (session, UAT, sync) + tab routing
- Types + helpers, ConnectionPill, 6 tab components (search, layers, export, database, cf, map)

New ParcelSync Harta tab:
- UAT-scoped map: zoom to extent, filter parcels/buildings by siruta
- Data-driven styling via gis_terenuri_status enrichment overlay
  (green=no enrichment, dark green=enriched, blue outline=building, red=no legal docs)
- Reuses Geoportal components (MapViewer, SelectionToolbar, FeatureInfoPanel, BasemapSwitcher)
- Export DXF/GPKG for selection, legend

New PostGIS views (gis_terenuri_status, gis_cladiri_status):
- has_enrichment, has_building, build_legal columns from enrichment JSON
- Auto-created via /api/geoportal/setup-enrichment-views
- Does not modify existing Geoportal views

New API: /api/geoportal/uat-bounds (WGS84 bbox from PostGIS geometry)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:02:01 +02:00
AI Assistant 3fcf7e2a67 fix(geoportal): Google satellite, ESC/right-click exit, no UAT fill, ANCPI bbox fix
Basemaps: added Google Satellite option
ANCPI ortofoto: fixed bbox conversion (all 4 corners, not just SW/NE)
Selection: ESC key and right-click exit selection mode, tooltips updated
UAT layers: removed fill (only lines + labels), less visual clutter
Proprietari vechi: greyed out (opacity-50) so current owners stand out

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:19:02 +02:00
AI Assistant 1cc73a3033 fix(geoportal): enrichment now calls proven /api/eterra/search internally
Instead of reimplementing eTerra search logic (which missed most fields),
now calls the existing /api/eterra/search endpoint that already works
perfectly in ParcelSync. Same data, same format: proprietari, CF, CFvechi,
topo, intravilan, categorie, adresa, solicitant.

Per-parcel, 2-5 seconds, persists in DB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:05:07 +02:00
AI Assistant 024ee0f21a fix(geoportal): layer toggle + enrichment update + refresh button
1. Layer toggle fix: removed isStyleLoaded() check that silently blocked
   visibility changes when OpenFreeMap style has pending sprite/font loads
2. Enrichment: "Actualizeaza" button always visible (re-fetch from eTerra)
   replaces "Enrichment" button when data already exists
3. Panel updates with returned enrichment data immediately

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:53:18 +02:00
AI Assistant 19bed6724b fix(geoportal): enrichment panel update + force-hide all layers + boundary filter
1. Enrichment: panel now updates immediately with returned data (was only showing message)
2. Layers: ALL data layers set to visibility:none immediately after creation,
   then only enabled ones are shown. Fixes cladiri appearing when only terenuri toggled.
3. OpenFreeMap boundaries: also filter by source-layer="boundary" (more reliable)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:45:29 +02:00
AI Assistant 48fe47d2c0 fix(geoportal): per-parcel enrichment via searchImmovableByIdentifier
Replaces background UAT-wide enrichment with instant per-parcel search.
Uses eTerra searchImmovableByIdentifier (cadastral number lookup) which
returns in 1-3 seconds instead of minutes.

Extracts: NR_CF, proprietari (with shares), intravilan, categorie,
adresa, has_building, build_legal. Persists in DB immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:35:03 +02:00
AI Assistant 5ff7d4cdd7 fix(geoportal): hide oneway arrows from OpenFreeMap basemap
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:24:41 +02:00
AI Assistant 91034c41ee fix(geoportal): background enrichment using proven enrichFeatures()
Previous single-parcel enrichment wrote empty data (couldn't match in eTerra).
Now uses the original enrichFeatures() which properly fetches owners, CF, etc.

Changes:
- Enrichment runs in BACKGROUND (returns immediately with message)
- Clears bad enrichment data before re-running
- Tracks running enrichments to avoid duplicates
- GET /api/geoportal/enrich?siruta=... checks if enrichment is running
- Panel: hasRealEnrichment checks for CF/PROPRIETARI/CATEGORIE (not just NR_CAD)
- Enrichment button stays visible until real data exists
- Message: "Enrichment pornit in background. Datele vor aparea in 1-3 minute."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:23:44 +02:00
AI Assistant d9c247fee2 fix(geoportal): force all layers hidden on map load (fixes terenuri/cladiri showing when toggled off)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:20:13 +02:00
AI Assistant 7ae23aebf4 fix(geoportal): hide OpenFreeMap built-in boundary layers on load
OpenFreeMap Liberty/Dark styles include admin boundary layers that show
even when our UAT toggle is off. Now hides all boundary/admin/border
layers from the basemap style on map load.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:18:44 +02:00
AI Assistant d2b69d5ec6 fix(geoportal): all layers OFF by default + full enrichment display
Layers:
- ALL layers OFF by default (just basemap on load)
- User activates what they need

Feature panel:
- Shows ALL enrichment fields: proprietari (full text, wrapping),
  CF vechi, nr topo, adresa, solicitant, intravilan, categorie
- Building info with icon (cu acte / fara acte warning)
- hasEnrichment check relaxed (any non-empty field counts)
- Panel scrollable (max-h 60vh) for long data
- WrapRow for multi-line text (proprietari, adresa)
- Enrichment button visible when no enrichment data
- Enrichment auto-updates panel on success

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:09:41 +02:00
AI Assistant dfa4815d75 fix(geoportal): layers off by default + bulk enrichment feedback
- UAT + Intravilan layers OFF by default (user activates when needed)
- Terenuri/Cladiri listed first in panel (most used)
- Bulk enrichment: per-feature with progress counter (3/10), success summary
- Progress text shown in toolbar during enrichment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:58:24 +02:00
AI Assistant 903dc67ac4 fix: drop views before recreating slim versions (cannot drop columns from view)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:43:56 +02:00
AI Assistant 60919122d9 feat(geoportal): one-click optimize-tiles + unified setup banner
New endpoint POST /api/geoportal/optimize-tiles:
- Slims gis_features view (drops attributes, enrichment, timestamps)
- Cascades to gis_terenuri, gis_cladiri, gis_administrativ, gis_documentatii
- Makes vector tiles dramatically smaller

Setup banner now checks 3 optimizations:
1. UAT zoom views (gis_uats_z0/z5/z8/z12)
2. Pre-computed geometry (geom_z0/z5/z8 columns)
3. Slim tile views (no JSON columns)

One "Aplica toate" button runs all pending steps sequentially.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:26:08 +02:00
AI Assistant 32d3f30f9d fix(geoportal): auto-refresh panel after enrichment + Comanda CF always visible
- After enrichment: panel updates immediately with returned data (no reload needed)
- "Comanda CF" button visible on any parcel with cadastral ref (not just enriched ones)
- "Descarca CF" shown when CF extract already exists in DB

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:11:59 +02:00
AI Assistant 8ead985c7e perf(geoportal): single-parcel enrichment instead of full UAT
Previous enrichment tried to enrich ALL parcels in a UAT (minutes).
Now enriches just the clicked parcel (seconds):
1. Finds the GisFeature by ID or objectId+siruta
2. Fetches immovable data from eTerra for that specific parcel
3. Persists enrichment in DB
4. Skips if enriched < 7 days ago

Auto-uses env credentials (ETERRA_USERNAME/PASSWORD) — no manual login needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:56:04 +02:00
AI Assistant 566d7c4bb1 fix(geoportal): better enrichment error messages + login retry
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:25:55 +02:00
AI Assistant 3ffb617970 fix(geoportal): font 404s + slim tile views for performance
- Labels: add text-font ["Noto Sans Regular"] (OpenFreeMap compatible)
- Optimize views: gis_features/terenuri/cladiri/administrativ now exclude
  attributes, enrichment, timestamps (huge JSON columns that made tiles slow)
- Views only include: id, layer_id, siruta, object_id, cadastral_ref,
  area_value, is_active, geom

Run optimize-views again after deploy to apply slimmer views.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:23:05 +02:00
AI Assistant 8362e3fd84 fix(geoportal): drop views before adding columns (fixes geometry type conflict)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:01:11 +02:00
AI Assistant 53c241c20f perf(geoportal): materialize simplified UAT geometries (fixes 90% CPU on PostgreSQL)
ST_SimplifyPreserveTopology in views runs on every Martin tile request,
causing constant CPU load. Fix: pre-compute simplified geometries into
dedicated columns (geom_z0, geom_z5, geom_z8) on the GisUat table.

POST /api/geoportal/optimize-views:
1. Adds geom_z0/z5/z8 columns to GisUat
2. Backfills with pre-computed simplifications (one-time cost)
3. Creates GiST spatial indexes on each
4. Replaces views to use pre-computed columns (zero CPU reads)
5. Updates trigger to auto-compute on INSERT/UPDATE

Setup banner: now checks optimization status and shows "Optimizeaza"
button if needed. One-click, then docker restart martin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:51:17 +02:00
AI Assistant c4122cea01 feat(geoportal): enrichment API + CF download + bulk enrichment
New API endpoints:
- POST /api/geoportal/enrich — enriches all parcels for a SIRUTA,
  skips already-enriched, persists in GisFeature.enrichment column
- GET /api/geoportal/cf-status?nrCad=... — checks if CF extract exists,
  returns download URL if available

Feature panel:
- No enrichment: "Enrichment" button (triggers eTerra sync for UAT)
- Has enrichment + CF available: "Descarca CF" button (direct download)
- Has enrichment + no CF: "Comanda CF" button (link to ePay tab)
- Copy button always visible
- After enrichment completes, panel auto-reloads data

Selection toolbar:
- Bulk "Enrichment" button for selected parcels (per unique SIRUTA)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:48:08 +02:00
AI Assistant 800c45916e feat(geoportal): rectangle + freehand polygon selection drawing on map
Rectangle mode (Dreptunghi):
- Mousedown starts drawing, mousemove shows amber overlay, mouseup selects
- All terenuri features in the drawn bbox are added to selection
- Map panning disabled during draw, re-enabled after
- Minimum 5px size to prevent accidental micro-selections

Freehand mode (Desen):
- Each click adds a point, polygon drawn with GeoJSON source
- Double-click closes polygon, selects features whose centroid is inside
- Ray-casting point-in-polygon algorithm for spatial filtering
- Double-click zoom disabled during freehand mode

Draw state clears when switching selection modes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:19:20 +02:00
AI Assistant 3a2262edd0 feat(geoportal): feature panel with Enrichment + Extras CF buttons
- Parcele fara enrichment: buton "Enrichment" (sync magic de la eTerra)
- Parcele cu enrichment: date complete + buton "Extras CF" (link ePay)
- Buton "Copiaza" (clipboard cu NR_CAD, CF, suprafata, proprietari)
- ExportFormat: removed geojson (only dxf + gpkg)
- Tooltips pe fiecare buton

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:17:15 +02:00
AI Assistant 836d60b72f feat(geoportal): one-time setup banner for PostGIS views
- GET /api/geoportal/setup-views checks if zoom views exist
- POST creates them (idempotent)
- SetupBanner component: auto-checks on mount, shows amber banner if
  views missing, button to create them, success message with docker
  restart reminder, auto-hides when everything is ready

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:05:22 +02:00
AI Assistant 7d2fe4ade0 feat(geoportal): selection modes (click/rectangle/freehand) + export DXF/GPKG only
- Selection toolbar: 3 modes — Click (individual), Dreptunghi (area), Desen (freehand)
- Each mode has tooltip explaining usage
- Export: removed GeoJSON, only DXF + GPKG. GPKG labeled "cu metadata"
- DXF export fix: -s_srs + -t_srs (was -a_srs + -t_srs)

Note: rectangle and freehand drawing on map not yet implemented (UI ready,
map interaction coming next session).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 07:57:34 +02:00
AI Assistant 78625d6415 fix(geoportal): simplified info panel, preserve basemap zoom, DXF export, intravilan outline
- Feature panel: simplified (NR_CAD/NR_CF/SIRUTA/Suprafata/Proprietari),
  aligned top-right under basemap switcher, click empty space to close
- Basemap switch: preserves zoom+center via viewStateRef + moveend listener
- DXF export: use -s_srs + -t_srs (not -a_srs + -t_srs which ogr2ogr rejects)
- Intravilan: double line (black outer + orange inner), z13+, no fill
- Parcel labels: cadastral_ref shown at z16+
- UAT z12: original geometry (no simplification)
- Removed MapLibre popup (only side panel)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 07:43:11 +02:00
AI Assistant b38916229e fix(martin): add --default-srid=3844 (ST_Simplify views lose SRID metadata)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:05:19 +02:00
AI Assistant 1b679098ab fix(martin): revert to auto-discovery (Docker bind mount creates directories on Portainer)
Views already select only needed columns, so Martin auto-discovery
serves the same result without needing a config file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:55:59 +02:00
AI Assistant ba3edc3321 fix(martin): mount config as /martin-config.yaml (Docker created /config/martin.yaml as directory)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:34:28 +02:00
AI Assistant 0af3e16a2b feat(geoportal): add /api/geoportal/setup-views endpoint for creating UAT zoom views
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:08:14 +02:00
AI Assistant 4f694d4458 perf(geoportal): 4-level UAT simplification + intravilan layer + preserve view on basemap switch
UAT zoom-dependent views (read-only, original geom NEVER modified):
- gis_uats_z0 (z0-5): 2000m simplification — country outlines
- gis_uats_z5 (z5-8): 500m — regional overview
- gis_uats_z8 (z8-12): 50m — county/city level with labels
- gis_uats_z12 (z12+): 10m — near-original precision

New layers:
- gis_administrativ (intravilan, arii speciale) — orange dashed, no simplification
- Toggle in layer panel (off by default)

Basemap switching:
- Now preserves current center + zoom when switching between basemaps

Parcels + buildings: NO simplification (exact geometry needed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:34:15 +02:00
AI Assistant 76c19449f3 perf(geoportal): zoom-dependent UAT simplification + Martin config + tile cache
PostGIS:
- gis_uats view: ST_SimplifyPreserveTopology(geom, 50) + only name/county/siruta
- gis_uats_simple view: ST_SimplifyPreserveTopology(geom, 500) for z0-z9

Martin config (martin.yaml):
- Explicit source definitions (auto_publish: false)
- gis_uats_simple (z0-9): only name+siruta, 500m simplified geometry
- gis_uats (z0-14): name+siruta+county, 50m simplified
- gis_terenuri (z10-18): object_id+siruta+cadastral_ref+area_value+layer_id
- gis_cladiri (z12-18): same properties
- 24h cache headers on all tiles

MapViewer:
- Dual UAT sources: simplified (z0-9) + detailed (z9+) with seamless handoff
- Zoom-interpolated paint: thin lines at z5, thicker at z12
- UAT labels only z9+, fill opacity z-interpolated (0.03→0.08)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:24:38 +02:00
AI Assistant 6c55264fa3 feat(geoportal): OpenFreeMap vector basemaps + eTerra ORTO 2024 ortophoto
Basemap options:
- Liberty (OpenFreeMap vector) — default, sharp vector tiles
- Dark (OpenFreeMap) — dark theme, auto-styled
- Satellite (ESRI World Imagery) — raster
- ANCPI Ortofoto 2024 — proxied via /api/eterra/tiles/orto, converts
  Web Mercator z/x/y to EPSG:3844 bbox, authenticates with eTerra
  session, caches 24h. Requires ETERRA_USERNAME/PASSWORD env vars.

Replaces old raster OSM/OpenTopoMap with vector styles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:43:21 +02:00
AI Assistant 06932b5ddc fix(geoportal): remove .pbf extension from Martin tile URLs
Martin v0.15.0 serves tiles at /{source}/{z}/{x}/{y} without .pbf
extension. Requests with .pbf returned 404.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:30:50 +02:00
AI Assistant 2248ecc5d3 fix(geoportal): fix basemap switching + OpenTopoMap maxzoom 17
- Basemap switching now recreates the map (basemap in useEffect deps)
  instead of buggy remove/re-add that swallowed errors
- OpenTopoMap maxzoom set to 17 (was requesting z19 → 400 errors)
- Basemap maxzoom applied to both source and map maxZoom

Note: Martin vector tiles return 404 — PostGIS views or Martin container
need to be checked on the server.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:24:47 +02:00
AI Assistant fff20e0cb9 fix(geoportal): use w-full h-full on map container (MapLibre overrides position to relative)
MapLibre sets position:relative on its container element, which overrides
our absolute inset-0 and causes height to collapse to 0. Using w-full h-full
instead works because the parent (absolute inset-0) has a definite computed
height from positioning.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:13:55 +02:00
AI Assistant b13a038eb1 fix(geoportal): use absolute inset-0 on MapViewer wrapper (fixes 0-height canvas)
h-full (height:100%) doesn't propagate through absolutely positioned
parents. The MapLibre container had width=1065 but height=0. Using
absolute inset-0 on the wrapper fills the parent directly via positioning
instead of percentage height.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:07:48 +02:00
AI Assistant 1a9ed1ef76 fix(geoportal): use absolute positioning to fill main (fixes h-full/flex-1 chain)
height: 100% doesn't work when the parent gets its height from flex-1
(flexbox-computed, not explicit height). Fixed by adding position:relative
to main in fullscreen mode and using absolute inset-0 on the geoportal
container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:23:55 +02:00
AI Assistant 2278226ff1 fix(geoportal): fullscreen route + local CSS + proper layout
- Add /geoportal to FULLSCREEN_ROUTES (overflow-hidden, no padding)
- Copy maplibre-gl.css to public/ and load from same origin (avoids CDN/CSP)
- Simplify layout: fill parent via h-full w-full (no negative margin hack)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:14:09 +02:00
AI Assistant 437d734df6 fix(geoportal): load MapLibre CSS via CDN link injection + fullscreen layout
- Static CSS import doesn't work with next/dynamic + standalone output.
  Now injects a <link> tag to unpkg CDN at module load time (bulletproof).
- Geoportal is now fullscreen: map fills entire viewport below the header,
  no duplicate title/description, negative margins bleed to edges.
- Removed page-level CSS imports (no longer needed).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:01:48 +02:00
AI Assistant 3346ec709d fix(geoportal): import MapLibre CSS at page level (fixes blank map with next/dynamic)
CSS imports inside dynamically loaded components (ssr: false) don't get
included in the production bundle. Importing maplibre-gl CSS at the page
level ensures it's always available. Applied to both geoportal and
parcel-sync pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:49:40 +02:00
AI Assistant 1b5876524a feat(geoportal): add search, basemap switcher, feature info panel, selection + export
Major geoportal enhancements:
- Basemap switcher (OSM/Satellite/Terrain) with ESRI + OpenTopoMap tiles
- Search bar with debounced lookup (UATs by name, parcels by cadastral ref, owners by name)
- Feature info panel showing enrichment data from ParcelSync (cadastru, proprietari, suprafata, folosinta)
- Parcel selection mode with amber highlight + export (GeoJSON/DXF/GPKG via ogr2ogr)
- Next.js /tiles rewrite proxying to Martin (fixes dev + avoids mixed content)
- Fixed MapLibre web worker relative URL resolution (window.location.origin)

API routes: /api/geoportal/search, /api/geoportal/feature, /api/geoportal/export

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:43:01 +02:00
AI Assistant 4ea7c6dbd6 fix(geoportal): use relative /tiles URL for Martin (avoids mixed content + build-time env) 2026-03-23 15:49:37 +02:00
AI Assistant 4a144fc397 fix(geoportal): use HTTPS Martin URL via Traefik /tiles proxy 2026-03-23 15:35:40 +02:00
AI Assistant 00a691debd chore: add NEXT_PUBLIC_MARTIN_URL env var for geoportal 2026-03-23 14:52:40 +02:00
AI Assistant c297a2c5f7 feat: add Geoportal module with MapLibre GL JS + Martin vector tiles
Phase 1 of the geoportal implementation:

Infrastructure:
- Martin vector tile server in docker-compose (port 3010)
- PostGIS setup SQL for GisUat: native geom column, Esri→PostGIS
  trigger, GiST index, gis_uats view for Martin auto-discovery

Geoportal module (src/modules/geoportal/):
- map-viewer.tsx: MapLibre GL JS canvas with OSM base, Martin MVT
  sources (gis_uats, gis_terenuri, gis_cladiri), click-to-inspect,
  zoom-level-aware layer visibility, layer styling
- layer-panel.tsx: collapsible sidebar with layer toggles
- geoportal-module.tsx: standalone page wrapper
- Module registered in config/modules.ts, flags.ts, i18n

ParcelSync integration:
- 6th tab "Harta" with lazy-loaded MapViewer (ssr: false)
- Centered on selected UAT

Dependencies: maplibre-gl v5.21.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:21:37 +02:00
AI Assistant 53595fdf94 docs: add ANCPI ePay env vars to CONFIGURATION.md, bump ParcelSync to 0.6.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:02:30 +02:00
AI Assistant a52f9e7586 feat(parcel-sync): redesign PostGIS/QGIS section with clear instructions
- Renamed "Setup PostGIS" to "Activeaza compatibilitate QGIS"
- Tooltip: "Operatie sigura, reversibila. Nu modifica datele existente."
- After setup: shows step-by-step QGIS connection instructions
  (host, port, database, views, SRID)
- Button hidden after successful setup (shows instructions instead)
- Clear explanation for non-technical users

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:01:35 +02:00
AI Assistant 88754250a8 docs: update CLAUDE.md + SKILLS.md with ePay integration, performance fixes
- ParcelSync version 0.6.0 with ePay CF extract ordering
- ANCPI ePay in Current Integrations table
- Static WORKSPACE_TO_COUNTY mapping documented
- GisUat geometry select optimization documented
- Feature count cache (5-min TTL) documented
- ePay endpoint gotchas, auth flow, order flow
- Cleaned outdated info, focused on actionable gotchas

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:57:42 +02:00
AI Assistant 14a77dd6f7 perf: cache GisFeature counts in memory (5min TTL, stale-while-revalidate)
Feature count groupBy query is expensive but data changes rarely.
First request waits for query, subsequent ones return cached instantly.
After 5min, stale cache is returned immediately while background
refresh runs. Badge "N local" is back on UAT dropdown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:45:33 +02:00
AI Assistant d0c1b5d48e perf: select only needed columns from GisUat, skip geometry (~100MB) 2026-03-23 13:23:23 +02:00
AI Assistant ad4c72f527 perf(parcel-sync): make GisFeature groupBy opt-in on /api/eterra/uats
The groupBy query scanning the entire GisFeature table (~30k+ rows)
was blocking the UAT list API for 25+ seconds on every page load.
Feature counts are now opt-in via ?features=true query param.
Default response is instant (just GisUat table, no joins).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:44:09 +02:00
AI Assistant 2886703d0f perf(parcel-sync): use useDeferredValue for UAT search input
React's useDeferredValue lets the input update immediately while
deferring the expensive filter (3186 items) to a lower priority.
Removes the setTimeout debounce in favor of React's built-in
concurrent rendering scheduler. Input stays responsive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:30:47 +02:00
AI Assistant 62777e9778 fix(ancpi): UAT debounce + list tooltips + expired download + ePay retry
1. UAT search: 150ms debounce prevents slow re-renders on keystroke
2. Lista mea tooltips: "Scoate Extrase CF" shows exact credit cost,
   status badges show expiry dates and clear instructions
3. Expired extracts: both Descarcă (old version) + Actualizează shown
4. ePay auto-connect: retry 2x with 3s delay, check session before
   connect, re-attempt on disconnect detection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:14:34 +02:00
AI Assistant 5a6ab36aa7 feat(ancpi): selectable extracts with numbered ZIP download
- Checkbox on each row (ordered selection → numbered files in ZIP)
- "Descarcă selecție (N)" button appears when items selected
- Tooltip shows position in ZIP: "#1 in ZIP", "#2 in ZIP"
- Select-all checkbox in header
- Tooltips on Descarcă tot + Descarcă selecție buttons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:37:03 +02:00
AI Assistant 87281bc690 fix(ancpi): make Actualizeaza button prominent + add tooltips in tab
- Actualizeaza button: orange bg, white text, clearly clickable
- Tooltip: "Comandă extras CF nou (1 credit) / Extrasul actual a expirat"
- Descarca button: tooltip "Descarcă extras CF (nrCadastral)"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:23:54 +02:00
AI Assistant 7d30e28fdc fix(ancpi): parse CF numbers and solutii separately, zip by position
The nested JSON in ePay HTML breaks [^}]* regex. New approach:
find all CF.stringValues independently, find all solutii independently,
then zip them by position (they appear in same order in HTML).
This correctly maps CF number → document for batch orders.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:06:20 +02:00
AI Assistant a826f45b24 feat(ancpi): re-download with CF matching + tooltips + animations
Re-download: all 7 orders re-downloaded using documentsByCadastral
for correct CF→document matching. No more hardcoded order→parcel map.

Tooltips on all CF extract UI elements:
- No extract: "Comandă extras CF (1 credit)"
- Valid: "Valid până la DD.MM.YYYY" + "Descarcă extras CF"
- Expired: "Expirat pe DD.MM.YYYY" + "Comandă extras CF nou (1 credit)"
- Processing: "Comanda în curs de procesare"

Animations: Loader2 spinner while ordering, transition to green check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:38:23 +02:00
AI Assistant 0c94af75d3 fix(ancpi): correct PDF-to-parcel matching + UAT search priority
Critical fix: batch order documents are now matched by CF number
from parsed metadateCereri (documentsByCadastral), not by index.
Prevents PDF content mismatch when ePay returns docs in different order.

UAT search: name matches shown first, county-only matches after.
Typing "cluj" now shows CLUJ-NAPOCA before county "Cluj" matches.

Cleaned MinIO + DB of incorrectly mapped old test data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:29:11 +02:00
AI Assistant a59d9bc923 feat(ancpi): complete ePay UI redesign + ZIP download + smart batch ordering
UI Redesign:
- ePay auto-connect when UAT is selected (no manual button)
- Credit badge with tooltip ("N credite ePay disponibile")
- Search result cards show CF status: Valid (green), Expirat (orange),
  Lipsă (gray), Se proceseaza (yellow pulse)
- Action buttons on each card: download/update/order CF extract
- "Lista mea" numbered rows + CF Status column + smart batch button
  "Scoate Extrase CF": skips valid, re-orders expired, orders new
- "Descarca Extrase CF" button → ZIP archive with numbered files
- Extrase CF tab simplified: clean table, filters (Toate/Valabile/
  Expirate/In procesare), search, download-all ZIP

Backend:
- GET /api/ancpi/download-zip?ids=... → JSZip streaming
- GET /api/ancpi/orders: multi-cadastral status check with statusMap
  (valid/expired/none/processing) + latestById

Data:
- Simulated expired extract for 328611 (Cluj-Napoca, expired 2026-03-17)
- Cleaned old error records from DB

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:13:51 +02:00
AI Assistant b7302d274a docs: update SKILLS.md with complete ANCPI ePay documentation 2026-03-23 04:20:37 +02:00
AI Assistant c9ecd284c7 feat(ancpi): complete ePay UI + dedup protection
UI Components (Phase 4):
- epay-connect.tsx: connection widget with credit badge, auto-connect
- epay-order-button.tsx: per-parcel "Extras CF" button with status
- epay-tab.tsx: full "Extrase CF" tab with orders table, filters,
  download/refresh actions, new order form
- Minimal changes to parcel-sync-module.tsx: 5th tab + button on
  search results + ePay connect widget

Dedup Protection:
- epay-queue.ts: batch-level dedup (60s window, canonical key from
  sorted cadastral numbers)
- order/route.ts: request nonce idempotency (60s cache)
- test/route.ts: refresh protection (30s cache)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 04:19:19 +02:00
AI Assistant fcc6f8cc20 fix(ancpi): strip diacritics from MinIO metadata headers (ASCII only) 2026-03-23 03:51:10 +02:00
AI Assistant af30088ee6 fix(ancpi): simplify document parsing, avoid catastrophic regex backtracking 2026-03-23 03:43:44 +02:00
AI Assistant 6185defa8b fix(ancpi): decode HTML entities before parsing document info from OrderDetails 2026-03-23 03:38:36 +02:00
AI Assistant e63ec4c6c8 fix(ancpi): parse Angular ng-click downloadFile pattern for document IDs 2026-03-23 03:32:21 +02:00
AI Assistant 84b862471c fix(ancpi): add multiple document parsing patterns + debug logging 2026-03-23 03:26:40 +02:00
AI Assistant 8488a53e3b feat(ancpi): batch ordering + download existing orders
Major rewrite:
- Queue now processes batches: addToCart×N → saveMetadata×N → ONE
  submitOrder → poll → download ALL documents → store in MinIO
- Removed unique constraint on orderId (shared across batch items)
- Added step=download to test endpoint: downloads PDFs from 5
  existing orders (9685480-9685484) and stores in MinIO
- step=order now uses enqueueBatch for 2 test parcels (61904, 309952)
  as ONE ePay order instead of separate orders

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 03:20:36 +02:00
AI Assistant 08cd7164cb fix(ancpi): GET CheckoutConfirmationSubmit after EditCartSubmit
EditCartSubmit returns 200 (not redirect) — Angular does client-side
redirect to CheckoutConfirmationSubmit.action. Added this step to
actually confirm the order before looking for the orderId.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 02:49:57 +02:00
AI Assistant 6c60572a3e fix(ancpi): find NEW orderId after submit, track known IDs in queue
submitOrder now captures the previous orderId BEFORE submitting, then
searches for a NEW orderId that isn't in the knownOrderIds set. Queue
passes knownOrderIds between sequential items to prevent duplicate
orderId assignment (unique constraint violation).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 02:43:21 +02:00
AI Assistant c452bd9fb7 fix(ancpi): use form-data multipart for saveProductMetadataForBasketItem
Angular uses doPostAsFormMultipart — the save endpoint requires
multipart/form-data, not application/x-www-form-urlencoded.
Install form-data package and restore multipart upload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 02:33:30 +02:00
AI Assistant fd86910ae3 fix(ancpi): remove form-data dependency, use URLSearchParams for save
form-data package not installed — crashes at runtime. Use
URLSearchParams instead (the Angular source uses doPostAsForm
which is form-urlencoded, so this should work too).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 02:24:45 +02:00
AI Assistant bcb7aeac64 fix(ancpi): accept SAVE_OK as success code from saveMetadata 2026-03-23 02:19:28 +02:00
AI Assistant 7fc46f75bd fix(ancpi): ePay county IDs = WORKSPACE_IDs, UAT IDs = SIRUTA codes
Zero discovery calls needed! ePay internal county IDs are identical
to eTerra WORKSPACE_IDs (CLUJ=127, ALBA=10, etc.) and ePay UAT IDs
are SIRUTA codes (Cluj-Napoca=54975, Florești=57706). Queue now
uses workspacePk + siruta directly from GisUat DB.
Flow: AddToCart → saveMetadata → EditCartSubmit → Poll+Download.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 02:11:41 +02:00
AI Assistant e13a9351be fix(ancpi): complete rewrite based on Angular source code analysis
All endpoints and payloads verified against epaymentAngularApp.js:
- EpayJsonInterceptor: form-urlencoded (not JSON), uses reqType param
- County IDs: internal ANCPI IDs from judeteNom (NOT 0-41 indices)
- UAT lookup: reqType=nomenclatorUAT&countyId=<internal_ID>
- Save metadata: reqType=saveProductMetadataForBasketItem (multipart)
  with productMetadataJSON using stringValues[] arrays
- SearchEstate: field names are identificator/judet/uat (not identifier/countyId/uatId)
- Download PDF: Content-Type: application/pdf in request header
- Queue resolves county+UAT IDs dynamically via getCountyList+getUatList

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 02:01:39 +02:00
AI Assistant eb8cd18210 fix(ancpi): use JSON body for EpayJsonInterceptor + EditCartItemJson
Root cause from ePay Angular analysis:
- EpayJsonInterceptor needs Content-Type: application/json + {"judet": N}
- EditCartItemJson needs JSON with bigDecimalValue/stringValue structure
- SearchEstate needs basketId in body for JSON response
- Queue skips SearchEstate (data already from eTerra), uses
  configureCartItem → submitOrder flow directly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 01:55:30 +02:00
AI Assistant 23bddf6752 feat(ancpi): test EditCartItemJson + SearchEstate with AJAX headers 2026-03-23 01:42:43 +02:00
AI Assistant 665a51d794 feat(ancpi): extract Angular AJAX endpoints from ShowCartItems page 2026-03-23 01:37:45 +02:00
AI Assistant d367b5f736 fix(ancpi): add SearchEstate debug logging, try without uatId, add cart first
SearchEstate might need active cart and/or different headers.
Add X-Requested-With: XMLHttpRequest, make uatId optional, log raw
response (type, length, sample), and add-to-cart before searching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 01:27:30 +02:00
AI Assistant f92fcfd86b fix(ancpi): test SearchEstate with various uatId values 2026-03-23 01:18:58 +02:00
AI Assistant 0447908007 fix(ancpi): GET login page before POST to establish form tokens
OpenAM requires an initial GET to set session cookies before the
credentials POST. Without it, POST returns 500 and only sets
AMAuthCookie (intermediate) instead of iPlanetDirectoryPro (final SSO).
Then navigate to ePay goto URL to establish JSESSIONID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 01:06:42 +02:00
AI Assistant 887e3f423e fix(ancpi): try HTTP URL for ePay session establishment
OpenAM goto URL is http://epay.ancpi.ro:80 (HTTP, not HTTPS).
Try multiple URL variants to establish JSESSIONID after OpenAM auth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:56:24 +02:00
AI Assistant 04c74c78e4 fix(ancpi): add credit parsing debug logging 2026-03-23 00:43:37 +02:00
AI Assistant e35b50e5c2 fix(ancpi): recognize AMAuthCookie as valid OpenAM session cookie
ANCPI's OpenAM uses AMAuthCookie instead of iPlanetDirectoryPro.
Accept AMAuthCookie, iPlanetDirectoryPro, or JSESSIONID as valid
session indicators. Navigate to ePay after auth to establish JSESSIONID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:38:11 +02:00
AI Assistant b9993f0573 fix(ancpi): follow full redirect chain for OpenAM login, add cookie debug
Let axios follow all redirects (maxRedirects=10) so cookie jar captures
iPlanetDirectoryPro from the chain. Explicitly navigate to ePay after
login to ensure JSESSIONID. Log all cookies for debugging. Last resort:
verify login by checking if credit info is visible on ePay page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:32:30 +02:00
AI Assistant 259f56396b fix(ancpi): use full OpenAM login URL with module + goto params
OpenAM requires module=SelfRegistration and goto= redirect URL.
Also handle 302 manually to capture iPlanetDirectoryPro cookie,
then follow redirect to ePay for JSESSIONID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:27:54 +02:00
AI Assistant b61cd71044 feat(ancpi): add test endpoint for step-by-step ePay verification
GET /api/ancpi/test?step=login|uats|search|order
Temporary diagnostic route to test ePay integration before building UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:20:11 +02:00
AI Assistant 336c46ff8e chore: hardcode ANCPI ePay credentials in docker-compose
Portainer CE can't inject env vars, so credentials must be hardcoded
in the compose file (same pattern as all other secrets in the project).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:18:27 +02:00
AI Assistant 3921852eb5 feat(parcel-sync): add ANCPI ePay CF extract ordering backend
Foundation (Phase 1):
- CfExtract Prisma model with version tracking, expiry, MinIO path
- epay-types.ts: all ePay API response types
- epay-counties.ts: WORKSPACE_ID → ePay county index mapping (42 counties)
- epay-storage.ts: MinIO helpers (bucket, naming, upload, download)
- docker-compose.yml: ANCPI env vars

ePay Client (Phase 2):
- epay-client.ts: full HTTP client (login, credits, cart, search estate,
  submit order, poll status, download PDF) with cookie jar + auto-relogin
- epay-session-store.ts: separate session from eTerra

Queue + API (Phase 3):
- epay-queue.ts: sequential FIFO queue (global cart constraint),
  10-step workflow per order with DB status updates at each step
- POST /api/ancpi/session: connect/disconnect
- POST /api/ancpi/order: create single or bulk orders
- GET /api/ancpi/orders: list all extracts
- GET /api/ancpi/credits: live credit balance
- GET /api/ancpi/download: stream PDF from MinIO

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:09:52 +02:00
AI Assistant f6781ab851 feat(parcel-sync): store UAT geometries from LIMITE_UAT in local DB
- Add geometry (Json), areaValue (Float), lastUpdatedDtm (String) to
  GisUat model for local caching of UAT boundaries
- County refresh now fetches LIMITE_UAT with returnGeometry=true and
  stores EsriGeometry rings per UAT in EPSG:3844
- Uses LAST_UPDATED_DTM from eTerra for future incremental sync
- Skips geometry fetch if >50% already have geometry stored

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:14:52 +02:00
AI Assistant 86e43cecae fix(parcel-sync): show 'jud.' prefix before county name in UAT dropdown
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:03:05 +02:00
AI Assistant 7b10f1e533 fix(parcel-sync): use verified WORKSPACE_ID → county mapping from eTerra
LIMITE_UAT provides SIRUTA + WORKSPACE_ID for all 3186 UATs across 42
workspaces. eTerra nomenclature APIs all return 404, and immovable list
returns empty for small communes. Use verified workspace→county mapping
derived from eTerra data (cross-referenced sample UATs + DB confirmations).
Logs unknown workspaces if eTerra ever adds new ones.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:52:46 +02:00
AI Assistant 61a44525bf fix(parcel-sync): resolve county names 100% from eTerra, zero hardcoding
LIMITE_UAT gives SIRUTA + WORKSPACE_ID for all 3186 UATs. For each of
the 42 unique workspaces, fetch 1 immovable via fetchImmovableListBy
AdminUnit — the response includes workspace.name = county name.
No static mappings, no nomenclature endpoints (they 404).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:39:02 +02:00
AI Assistant ce49b9e536 fix(parcel-sync): resolve counties via LIMITE_UAT WORKSPACE_ID + known UAT seats
eTerra nomenclature endpoints (fetchCounties, fetchNomenByPk) return
404. New approach: LIMITE_UAT gives ADMIN_UNIT_ID + WORKSPACE_ID for
all 3186 UATs across 42 workspaces. Use a static mapping of county
seat SIRUTAs to identify which workspace belongs to which county.
Logs unresolved workspaces for debugging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:36:29 +02:00
AI Assistant f9a2f6f82a fix(parcel-sync): use LIMITE_UAT + fetchNomenByPk for county data
fetchCounties() returns 404 — endpoint doesn't exist on eTerra.
New approach: query LIMITE_UAT layer for all features (no geometry)
to discover SIRUTA + WORKSPACE_ID per UAT, then resolve each unique
WORKSPACE_ID to county name via fetchNomenByPk(). Fallback: resolve
county for UATs that already have workspacePk in DB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:29:56 +02:00
AI Assistant 899b5c4cf7 fix(parcel-sync): populate county data during login, not via PATCH
Root cause: PATCH endpoint created a new EterraClient which tried
to re-login with expired session → 401. Now county refresh runs
immediately after successful login in the session route, using the
same authenticated client (fire-and-forget). Component reloads UAT
data 5s after connection to pick up fresh county info.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:23:46 +02:00
AI Assistant 379e7e4d3f feat(parcel-sync): add diagnostic endpoint for county debugging
GET /api/eterra/uats/test-counties returns raw eTerra nomenclature
response structure — shows exact field names and data format for
fetchCounties() and fetchAdminUnitsByCounty(). Temporary diagnostic
to fix county population issue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:04:37 +02:00
AI Assistant 8fa89a7675 fix(parcel-sync): restore SIRUTA in dropdown, add county debug output
- Restore SIRUTA code display in parentheses next to UAT name
- PATCH response now includes debug samples (sampleUat keys, county
  raw data) visible in browser console for diagnosing matching issues
- POST endpoint now supports resync (upsert mode, safe to call again)
- Client logs full PATCH result to browser console for debugging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:34:29 +02:00
AI Assistant 431291c410 fix(parcel-sync): robust county population + local feature count in dropdown
- PATCH /api/eterra/uats: handle nested responses (unwrapArray), try
  multiple field names (extractName/extractCode), log sample UAT for
  debugging, match by code first then by name
- GET /api/eterra/uats: include localFeatures count per SIRUTA via
  GisFeature groupBy query
- Dropdown: show green badge with local feature count, county with dash
- Add SKILLS.md for ParcelSync/eTerra/GIS module context

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:18:22 +02:00
AI Assistant 79750b2a4a fix(parcel-sync): use eTerra nomenclature API for county population
LIMITE_UAT layer lacks WORKSPACE_ID field, so the previous approach
failed silently. Now uses fetchCounties() + fetchAdminUnitsByCounty()
nomenclature API: Phase 1 fills county for UATs with existing
workspacePk, Phase 2 enumerates counties and matches by name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:56:43 +02:00
AI Assistant 86c39473a5 feat(parcel-sync): show county in UAT search dropdown via eTerra data
PATCH /api/eterra/uats fetches counties from eTerra nomenclature and
LIMITE_UAT layer, then batch-updates GisUat records with county name
and workspacePk. Auto-triggers on first eTerra connection when county
data is missing. Helps distinguish same-name UATs in different counties.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:46:13 +02:00
AI Assistant 2a25e4b160 fix(registratura): replace parentheses with en-dash in contact display
"Name – Company" instead of "Name (Company)" for sender/recipient.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 04:15:37 +02:00
AI Assistant c8aee1b58e fix(registratura): remove parentheses from institution-only contacts, add live contact sync
- Fix display format: institutions without a person name no longer show
  as "(Company)" — now shows just "Company"
- Three-case formatting: name+company → "Name (Company)", only company
  → "Company", only name → "Name"
- Registry table now resolves sender/recipient live from address book
  via contactMap — edits in address book reflect immediately in registry
- New contacts created via quick-contact are added to contactMap on the fly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 04:04:17 +02:00
AI Assistant a3ab539197 feat: add read-only /api/projects endpoint for external tools
Returns project tags from tag-manager (category=project).
Supports search (?q=), company filter (?company=), single by ID (?id=).
Same Bearer token auth as address-book API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 01:08:59 +02:00
AI Assistant aab38d909c feat: add dedicated /api/address-book REST endpoint for inter-service access
Bearer token auth (ADDRESSBOOK_API_KEY) for external tools like avizare.
Supports GET (list/search/filter/by-id), POST (create), PUT (update), DELETE.
Middleware exclusion so it bypasses NextAuth session requirement.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 22:33:00 +02:00
AI Assistant de52b5dced docs: update CLAUDE.md with infrastructure, PDF compression, address book changes
- Two-server architecture: satra (app) + proxy (Traefik)
- Traefik config details (timeouts, dynamic config paths)
- Portainer CE deploy workflow (manual Pull and redeploy)
- PDF compression dual-mode docs (qpdf local + iLovePDF cloud)
- Streaming upload architecture (zero-memory parsing)
- Middleware exclusion pattern for large upload routes
- Address Book flexible contact model (name OR company)
- ContactPerson department field
- Updated module versions and integration table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:23:09 +02:00
AI Assistant 8e2534ebe3 feat: quick contact dialog from registratura supports name OR company
- QuickContactDialog now has Company/Organization field
- Either name or company is required (same logic as address book)
- Auto-sets type to "institution" when only company is provided
- Display name in registry form uses company as fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:10:34 +02:00
AI Assistant 9d73697fb0 feat: address book - require name OR company, add department to contact persons
- Either name or company/organization is now required (not just name)
- When only company is set, it shows as primary display name
- Added department field to ContactPerson sub-entities
- Department shown as badge in card and detail views
- Updated vCard export to handle nameless contacts and department field
- Sort contacts by name or company (whichever is set)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:07:06 +02:00
AI Assistant 87ac81c6c9 fix: encode unicode filenames in Content-Disposition headers
Filenames with Romanian characters (Ș, Ț, etc.) caused ByteString errors.
Also pass original filename through to extreme mode response.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 20:50:37 +02:00
AI Assistant 4b5d3bd498 fix(pdf-compress): bypass middleware body buffering for upload routes
Next.js middleware buffers the entire request body (10MB default limit)
before the route handler runs. middlewareClientMaxBodySize experimental
flag doesn't work reliably with standalone output.

Solution: exclude api/compress-pdf from middleware matcher so the body
streams directly to the route handler. Auth check moved to a shared
helper (auth-check.ts) called at the start of each route.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:54:28 +02:00
AI Assistant 003a2821fd fix(pdf-compress): zero-memory multipart parsing + streamed response
Previous approach loaded entire raw body (287MB) into RAM via readFile,
then extracted PDF (another 287MB), then read output (287MB) = ~860MB peak.
Docker container OOM killed silently -> 500.

New approach:
- parse-upload.ts: scan raw file on disk using 64KB buffer reads (findInFile),
  then stream-copy just the PDF portion. Peak memory: ~64KB.
- extreme/route.ts: stream qpdf output directly from disk via Readable.toWeb.
  Never loads result into memory.

Total peak memory: ~64KB + qpdf process memory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:44:06 +02:00
AI Assistant e070aedae5 fix: increase middleware body size limit to 500MB for PDF uploads
Next.js 16 truncates request bodies at 10MB in middleware layer,
causing ECONNRESET for large PDF uploads. Set middlewareClientMaxBodySize
to 500mb to allow large file uploads to reach the route handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:23:06 +02:00
AI Assistant f032cf0e4a fix(pdf-compress): replace busboy with manual multipart parsing
Busboy's file event never fires in Next.js Turbopack despite the
stream being read correctly (CJS/ESM interop issue). Replace with
manual boundary parsing: stream body to disk chunk-by-chunk, then
extract the PDF part using simple boundary scanning. Tested working
with 1MB+ payloads — streams to disk so memory usage stays constant
regardless of file size.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:47:37 +02:00
AI Assistant 5a7de39f6a fix(pdf-compress): stream large uploads via busboy instead of arrayBuffer
req.arrayBuffer() fails with 502 on files >100MB because it tries to
buffer the entire body in memory before the route handler runs.

New approach: busboy streams the multipart body directly to a temp file
on disk — never buffers the whole request in memory. Works for any size.

Shared helper: parse-upload.ts (busboy streaming, 500MB limit, fields).
Both local (qpdf) and cloud (iLovePDF) routes refactored to use it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:07:16 +02:00
AI Assistant f5deccd8ea refactor(pdf-compress): replace Ghostscript with qpdf + iLovePDF API
Ghostscript -sDEVICE=pdfwrite fundamentally re-encodes fonts, causing
garbled text regardless of parameters. This cannot be fixed.

New approach:
- Local: qpdf-only lossless structural optimization (5-30% savings,
  zero corruption risk — fonts and images completely untouched)
- Cloud: iLovePDF API integration (auth → start → upload → process →
  download) with 3 levels (recommended/extreme/low), proper image
  recompression without font corruption

Frontend: 3 modes (cloud recommended, cloud extreme, local lossless).
Docker: ILOVEPDF_PUBLIC_KEY env var added.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:50:46 +02:00
AI Assistant d75fcb1d1c fix(pdf-compress): remove /screen preset that destroys font encoding
The -dPDFSETTINGS=/screen GS preset overwrites font encoding tables,
producing garbled text in output PDFs. Replace with individual params
that ONLY compress images while preserving fonts intact.

Three quality levels via GS (no Stirling dependency):
- extreme: 100 DPI, QFactor 1.2 (~quality 35)
- high: 150 DPI, QFactor 0.76 (~quality 50)
- balanced: 200 DPI, QFactor 0.4 (~quality 70)

Route all UI modes through the GS endpoint with level parameter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:19:42 +02:00
AI Assistant 9e73dc3cb9 fix(pdf-compress): use arrayBuffer() instead of formData() for large files
formData() fails with "Failed to parse body as FormData" on large PDFs
in Next.js route handlers. Switch to req.arrayBuffer() which reliably
reads the full body, then manually extract the PDF from multipart.

Extreme mode: arrayBuffer + multipart extraction + GS + qpdf pipeline.
Stirling mode: arrayBuffer forwarding to Stirling with proper headers.

Revert serverActions.bodySizeLimit (doesn't apply to route handlers).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:32:05 +02:00
AI Assistant 194ddf0849 fix(pdf-compress): fix broken multipart parsing + add body size limit
Extreme mode: replace fragile manual multipart boundary parsing (which
extracted only a fraction of large files, producing empty PDFs) with
standard req.formData(). Add GS output validation + stderr capture.

Stirling mode: parse formData first then build fresh FormData for
Stirling instead of raw body passthrough (which lost data on large
files). Add 5min timeout + original/compressed size headers.

next.config: add 250MB body size limit for server actions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:18:34 +02:00
AI Assistant 81c61d8411 fix(registratura): show expiry/AC alerts regardless of entry status
Status "inchis" means the correspondence is resolved, not that the
document validity stopped mattering. Remove status filters from both
ImminentActions dashboard and detail panel Remindere section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:49:48 +02:00
AI Assistant 0cd28de733 refactor(registratura): focus imminent actions on expiry/AC only, remove deadlines
Strip institutional legal deadlines from the dashboard — show only documents
expiring and AC validity within 60-day horizon. Rename to "De reînnoit".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:47:19 +02:00
AI Assistant 8275ed1d95 feat(registratura): add reminders section, expiry helpers, imminent actions dashboard
- Add "Remindere & Alerte" section in entry detail panel showing AC validity,
  expiry date, and tracked deadline status with color-coded indicators
- Add quick expiry date buttons (6/12/24 months from document date) in entry form
- Default dosare view to show only active threads
- Add ImminentActions dashboard component showing urgent items (expired docs,
  AC validity warnings, overdue/imminent deadlines) sorted by urgency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:42:54 +02:00
AI Assistant 22eb9a4383 feat(scale): add mm/cm/m/km unit switcher for real dimensions
Scale calculator now supports all 4 real-world units:
- mm, cm, m, km — toggle buttons next to mode selector
- Formula adapts via unit→mm multiplier (mm=1, cm=10, m=1000, km=1M)
- Real→Desen: input in chosen unit, output always mm on drawing
- Desen→Real: output in chosen unit, secondary line shows all other units
- Switching unit clears input to avoid confusion
- step attribute adapts per unit (km=0.001, m=0.01, others=0.5)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 23:57:28 +02:00
AI Assistant 75a7ab91ca fix(scale)+docs: fix scale calculator units (cm not m), update CLAUDE.md+ROADMAP.md
Scale calculator:
- Real input now in cm (not m) — more natural for architects
- Drawing output in mm (unchanged)
- Formula: drawing_mm = real_cm * 10 / scale (was val/scale, wrong)
- Reverse: real_cm = drawing_mm * scale / 10 (was val*scale, wrong)
- Secondary display: real in m; drawing in cm; reverse also shows mm
- Added 1:20 preset (useful for detail drawings)
- Removed Camere tab (not useful)

Docs:
- CLAUDE.md: update Mini Utilities 0.3.0 + Password Vault 0.4.0 descriptions
- ROADMAP.md: add task 8.04 (this session), bump module versions, renumber 8.05-8.08

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 23:52:54 +02:00
AI Assistant e583fdecc9 fix(utilities): fix scale calculator logic, remove rooms tab
Scale calculator:
- Real→Desen: val(m) × 1000 ÷ scale = mm pe desen (ex: 5m la 1:100 → 50mm)
- Desen→Real: val(mm) × scale ÷ 1000 = m real (ex: 50mm la 1:100 → 5m)
- Previously both formulas were wrong (missing ×1000/÷1000 unit conversion)

Remove RoomAreaCalculator (not useful) and its tab/imports

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 22:38:23 +02:00
AI Assistant 06b3a820de feat(utilities+vault): TVA configurable rate, scale/rooms calculators, multi-user vault
Mini Utilities:
- TVA calculator: rate now configurable (5%/9%/19%/21% presets + custom input)
  replaces hardcoded 19% constant; displays effective rate in results
- New tab: Calculator scară desen — real↔drawing conversion with 6 scale presets
  (1:50..1:5000) + custom, shows cm equivalent for drawing→real
- New tab: Calculator suprafețe camere — multi-room area accumulator
  (name + W×L×H), live m²/m³ per room, running total, copy-all

Password Vault:
- New VaultUser type: { username, password, email?, notes? }
- VaultEntry.additionalUsers: VaultUser[] — backward compat (defaults to [])
- VaultForm: collapsible "Utilizatori suplimentari" section, add/remove rows
- Card list: badge showing count of additional users when present
- useVault: normalizes legacy entries (additionalUsers ?? [])

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 20:51:34 +02:00
AI Assistant b0f27053ae feat(registratura): AC validity 12/24 months + reminder config in Ghid termene
- Add ACValidityPeriod type (12 | 24) and validityMonths field to ACValidityTracking
- Replace hardcoded 12-month validity with configurable dropdown (12/24 luni)
- Update computed dates, reminder counter, and tooltip to use selected period
- Add "Configurare remindere si alerte" section in Ghid termene with:
  - Threshold table (urgent 5z, depasit 0z, CU alert 30z, AC monthly, prelungire 45z lucr, anuntare 10z cal, transmitere 1z)
  - Pause/resume explanation for clarification requests
- Update AC expiry description to mention configurable 12/24 month validity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:38:15 +02:00
AI Assistant 55c807dd1b feat: inline resolve for sub-deadlines + milestone date tooltips
- DeadlineTimeline gains onResolveInline callback prop
- Milestone labels show small green checkmark button for resolvable items
- Clicking opens inline text input (motiv + OK/Cancel, Enter/Escape)
- RegistryEntryDetail wires resolve via onResolveDeadline prop
- Milestone date labels show "Data maximă: ..." on hover
- Auto-refreshes viewed entry after inline resolve

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:27:03 +02:00
AI Assistant 0f928b08e9 fix: dashboard stats exclude closed entries + auto-tracked deadlines
- aggregateDeadlines() now skips entries with status "inchis"
- Auto-tracked/background deadlines excluded from active/urgent/overdue counts
- Only user-created deadlines affect badge numbers
- Milestone dots vertically centered on progress bar (top-[5px], h-2.5)
- Milestone tooltips now show full date ("Data maximă: 15 februarie 2026")
- Countdown text shows date on hover too

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:13:49 +02:00
AI Assistant c892e8d820 feat: deadline pause/resume on clarifications + enhanced timeline UX
- TrackedDeadline gains pausedAt + totalPausedDays fields
- pauseDeadline() / resumeDeadline() in deadline-service with audit log
- Auto-pause: when incoming conex linked to parent with active deadlines
- Auto-resume: when outgoing conex linked to parent with paused deadlines
  (shifts dueDate forward by paused days)
- Timeline shows "Suspendat" state with blue pulsing progress bar
- Milestone tooltips now show exact dates (hover: "Data maximă: ...")
- ISC warning text on expired emission deadlines
- Verification expired text matches PDF spec
- DeadlineAuditEntry gains "paused" | "resumed" action types
- getDeadlineDisplayStatus returns "Suspendat" (blue) for paused deadlines

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:54:19 +02:00
AI Assistant 1361534c98 fix: closed entries no longer show in deadline dashboard
- groupDeadlinesByEntry skips entries with status "inchis"
- closeEntry auto-resolves all pending deadlines on close (main + linked)
- Fixes S-2026-00001 showing as overdue despite being closed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:26:20 +02:00
AI Assistant d8a10fadc0 fix: React error #310 — useMemo after early return in detail panel
Move threadChain useMemo before the `if (!entry) return null` early
return to keep hook call order stable between renders. When entry was
null, the hook was skipped, causing "Rendered more hooks than during
the previous render" crash on subsequent renders with entry set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:23:15 +02:00
AI Assistant 0cc14a96e9 feat: milestone dots on dashboard progress bar with legend
- Progress bar now shows auto-tracked sub-deadline milestones as dots
- Passed milestones: grayed out dot + strikethrough label
- Active milestones: amber dot with descriptive label
- Each milestone has a tooltip with context-aware text (e.g.,
  "Verificare expirata — nu se mai pot solicita clarificari")
- Legend below progress bar shows all milestone labels
- Wider progress bar (w-32) to accommodate milestone dots

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:12:26 +02:00
AI Assistant f6fc63a40c feat: add deadline system guide/overview page (Ghid termene)
- New DeadlineConfigOverview component: read-only reference page showing all
  18 deadline types organized by category with chain flow diagrams
- Accessible via "Ghid termene" toggle in the "Termene legale" tab
- Shows: summary stats, color legend, collapsible category sections,
  chain flow diagrams (fan-in + sequential), notification overview,
  document expiry settings, and legal reference index
- All data derived from existing catalog (zero API calls)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:10:23 +02:00
AI Assistant c5112dbb3d feat: timeline milestones for deadlines, auto-close reply entries, cleanup
- Entry created via "Inchide" flow now gets status "inchis" with closureInfo
- New DeadlineTimeline component: main deadlines as cards with progress bar,
  auto-tracked sub-deadlines as milestone dots on horizontal timeline
- Auto-tracked deadlines hidden from dashboard when user deadlines exist
- Verification milestone shows "Expirat — nu se mai pot solicita clarificari"
- Parent closureInfo now includes linkedEntryId/Number of the closing act
- Removed orphaned deadline-table.tsx and use-deadline-filters.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 23:51:27 +02:00
AI Assistant 5b18cce5a3 feat: simplify deadline dashboard + add flow diagrams for document chains
Major UX overhaul of the "Termene legale" and thread tabs:

Deadline dashboard:
- Replace 6 KPI cards with simple summary bar (active/urgent/overdue)
- Replace flat table with grouped list by entry (cards with progress bars)
- Chain deadlines collapsed by default with expand toggle
- Auto-tracked/background deadlines hidden from main list

Flow diagram (new component):
- CSS-only horizontal flow diagram showing document chains
- Nodes with direction bar (blue=intrat, orange=iesit), number, subject, status
- Solid arrows for thread links, dashed for conex/linked entries
- Used in both "Dosare" tab (full) and detail panel (compact, max 5 nodes)

Thread explorer → Dosare:
- Renamed tab "Fire conversatie" → "Dosare"
- Each thread shown as a card with flow diagram inside
- Simplified stats (just active/finalized count)

Background tracking:
- comunicare-aviz-beneficiar marked as backgroundOnly (not shown in dashboard)
- Transmission status computed and shown in detail panel (on-time/late)

Auto-resolution:
- When closing entry via reply, matching parent deadlines auto-resolve
- Resolution note includes the reply entry number

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 23:25:08 +02:00
AI Assistant 34024404a5 fix: prevent deleting registry entries that would create sequence gaps
Only the last entry in a company+year sequence can be deleted. Trying
to delete an earlier number (e.g. #2 when #3 exists) returns a 409
error with a Romanian message explaining the restriction.

Also routes UI deletes through the API (like create/update) so they
get proper audit logging and sequence recalculation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:38:51 +02:00
AI Assistant 5cb438ef67 fix: JSONB space-after-colon in all registry LIKE/regex patterns
PostgreSQL JSONB value::text serializes JSON with spaces after colons
("number": "B-2026-00001") but all LIKE patterns searched for the
no-space format ("number":"B-2026-00001"), causing zero matches and
every new entry getting sequence #1.

Fixed in allocateSequenceNumber, recalculateSequence, and debug-sequences.
Added PATCH handler to migrate old-format entries (BTG/SDT/USW/GRP)
to new single-letter format (B/S/U/G).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:22:33 +02:00
AI Assistant 4b61d07ffd debug: add raw value snippet to debug-sequences for regex diagnostics
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:04:25 +02:00
AI Assistant 39d64b033e fix: replace \d with [0-9] in all PostgreSQL regex patterns
PostgreSQL POSIX regex on the server does not support \d shorthand,
causing SUBSTRING to return NULL and every entry to get sequence 1.

Replaced all \d with [0-9] in:
- allocateSequenceNumber (new + old format queries)
- recalculateSequence (new + old format queries)
- debug-sequences endpoint (GET + POST queries)

Also added samples field to debug GET for raw number diagnostics,
and POST now handles old-format entries (BTG→B mapping) with
ON CONFLICT GREATEST for proper counter merging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:59:04 +02:00
AI Assistant 0f555c55ee feat: simplify registry number format to B-2026-00001
New format: single-letter prefix + year + 5-digit sequence.
No direction code (IN/OUT) in the number — shown via arrow icon.
Sequence is shared across directions within the same company+year.

Changes:
- REGISTRY_COMPANY_PREFIX: BTG→B, USW→U, SDT→S, GRP→G
- OLD_COMPANY_PREFIX map for backward compat with existing entries
- allocateSequenceNumber: searches both old and new format entries
  to find the actual max sequence (backward compat)
- recalculateSequence: same dual-format search
- parseRegistryNumber: supports 3 formats (current, v1, legacy)
- isNewFormat: updated regex for B-2026-00001
- CompactNumber: already used single-letter badges, just updated comment
- debug-sequences endpoint: updated for new format
- Notification test data: updated to new format
- RegistrySequence.type: now "SEQ" (shared) instead of "IN"/"OUT"

After deploy: POST /api/registratura/debug-sequences to clean up
old counters, then recreate test entries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:49:35 +02:00
AI Assistant eb39024548 fix: debug-sequences endpoint regex escaping with $queryRawUnsafe
Prisma tagged template literals were mangling regex backslashes.
Switch to $queryRawUnsafe for the complex regex queries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:38:40 +02:00
AI Assistant f7190bb98e perf: upgrade to Node 22 + BuildKit cache mount for faster Docker builds
- Node 22 (from 20): ~15% faster TypeScript compilation
- BuildKit cache mount on /root/.npm: npm ci reuses cached packages
  between builds instead of re-downloading from registry
- NODE_OPTIONS --max-old-space-size=2048: prevents OOM on VMs with
  limited RAM during Next.js build

Requires BuildKit enabled (default in Docker 23+, or set
DOCKER_BUILDKIT=1). Portainer CE uses BuildKit by default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:23:56 +02:00
AI Assistant 46de088423 fix: use only actual entries as source of truth for registry numbers
The previous fix still used MAX(actualMax, counterVal) which meant a
stale counter (from entries deleted before the fix was deployed) would
override the actual entry count. Changed to use ONLY actualMax + 1.

The RegistrySequence counter is now just a cache that gets synced —
it never overrides the actual entries count.

Also added /api/registratura/debug-sequences endpoint:
- GET: shows all counters vs actual entry max (for diagnostics)
- POST: resets all counters to match actual entries (one-time fix)

After deploy, call POST /api/registratura/debug-sequences to reset
the stale counters, then delete the BTG-2026-OUT-00004 entry and
recreate it — it will get 00001.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:20:58 +02:00
AI Assistant 28bb395b06 perf: optimize Docker build for faster deployments
- Move prisma generate before COPY . . so it's cached when only
  source files change (saves ~10-15s per build)
- Use npm ci --ignore-scripts in deps stage (postinstall scripts
  not needed, prisma generate runs explicitly)
- Combine apk add + addgroup + adduser into single RUN (fewer layers)
- Expand .dockerignore to exclude .claude/, .vscode/, logs, tsbuildinfo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:15:41 +02:00
AI Assistant dbed7105b7 fix: bulletproof registry number allocation using actual DB entries as source of truth
The old allocateSequenceNumber blindly incremented a counter in
RegistrySequence, which drifted out of sync when entries were deleted
or moved between companies — producing wrong numbers (e.g., #6 for
the first entry of a company).

New approach:
- Uses pg_advisory_xact_lock inside a Prisma interactive transaction
  to serialize concurrent allocations
- Always queries the actual MAX sequence from KeyValueStore entries
  (the source of truth) before allocating the next number
- Takes MAX(actual entries, counter) + 1 so the counter can never
  produce a stale/duplicate number
- Upserts the counter to the new value for consistency
- Also adds recalculateSequence to DELETE handler so the counter
  stays in sync after entry deletions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:11:52 +02:00
AI Assistant 8e56aa7b89 fix: detail panel scroll and missing TooltipProvider in status badge
- Add min-h-0 + overflow-hidden on ScrollArea to enable scrolling
  in the detail side panel (flex child needs bounded height)
- Wrap external status badge Tooltip in TooltipProvider to fix
  "Tooltip must be used within TooltipProvider" runtime crash

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:55:15 +02:00
AI Assistant 2739c6af6f fix: extreme PDF compression producing empty output on large files
The multipart body parser was using the first \r\n\r\n as the file
content start, but this could miss the actual file part. Now properly
iterates through parts to find the one with filename= header, and
uses lastIndexOf for the closing boundary to avoid false matches
inside PDF binary data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:48:36 +02:00
AI Assistant d7bd1a7f5d feat: external status monitor for registratura (Primaria Cluj-Napoca)
- Add ExternalStatusTracking types + ExternalDocStatus semantic states
- Authority catalog with Primaria Cluj-Napoca (POST scraper + HTML parser)
- Status check service: batch + single entry, change detection via hash
- API routes: cron-triggered batch (/api/registratura/status-check) +
  user-triggered single (/api/registratura/status-check/single)
- Add "status-change" notification type with instant email on change
- Table badge: Radio icon color-coded by status (amber/blue/green/red)
- Detail panel: full monitoring section with status, history, manual check
- Auto-detection: prompt when recipient matches known authority
- Activation dialog: configure petitioner name + confirm registration data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:42:21 +02:00
AI Assistant 1c51236c31 fix: registry number re-allocation on company change + extreme PDF large file support
- Registratura: re-allocate number when company/direction changes on update,
  recalculate old company's sequence counter from actual entries
- Extreme PDF: stream body to temp file instead of req.formData() to support large files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:05:13 +02:00
AI Assistant ed504bd1de fix: stream PDF body to Stirling to avoid FormData parse failure on large files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:48:05 +02:00
AI Assistant 0958238b25 Update docs: compact numbers, Alerte Termene sender, notifications in repo structure
- CLAUDE.md: add compact registry numbers feature, sender name, test mode
- ROADMAP.md: expand 8.03 with compact numbers, icon-only toolbar, test mode
- REPO-STRUCTURE.md: add src/core/notifications/ directory + description
- SYSTEM-ARCHITECTURE.md: add sender name, test mode, group company behavior
- CONFIGURATION.md + DOCKER-DEPLOYMENT.md: NOTIFICATION_FROM_NAME=Alerte Termene

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:14:39 +02:00
AI Assistant dff0bbe97c Shorten company badges to single letters and remove intern direction
Company badges: US→U, SDT→S (B and G already single letter).
Direction: only intrat (↓ green) and iesit (↑ orange), removed intern since no longer used.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:10:50 +02:00
AI Assistant 98c6fcb619 ui: compact registry numbers with company badge + direction icon
Replace full "BTG-0042/2026" with compact [B] ↓ 0042/2026 format:
- Colored company badge (B=blue, US=violet, SDT=green, G=gray)
- Direction arrow icon (↓ green=intrat, ↑ orange=iesit, ↔ gray=intern)
- Plain number without prefix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:07:12 +02:00
AI Assistant b079683a46 ui: rename email sender and subject to 'Alerte Termene' instead of ArchiTools
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:59:19 +02:00
AI Assistant f10a112de6 ui: make toolbar buttons icon-only with title tooltip (Bune practici, Notificari)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:52:12 +02:00
AI Assistant 9d58f1b705 feat: add test digest mode (?test=true) + group company sees all entries
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:47:17 +02:00
AI Assistant 479afb1039 fix: exclude /api/notifications/digest from auth middleware (N8N cron uses Bearer token)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:34:23 +02:00
AI Assistant d07d8a8381 fix: replace em dash with ASCII dash in email subject to fix SMTP header error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:29:52 +02:00
AI Assistant 1cbdf13145 chore: add Brevo SMTP credentials and cron secret to docker-compose
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:22:06 +02:00
AI Assistant 974d06fff8 feat: add email notification system (Brevo SMTP + N8N daily digest)
- Add core notification service: types, email-service (nodemailer/Brevo SMTP), notification-service (digest builder, preference CRUD, HTML renderer)
- Add API routes: POST /api/notifications/digest (N8N cron, Bearer auth), GET/PUT /api/notifications/preferences (session auth)
- Add NotificationPreferences UI component (Bell button + dialog with per-type toggles) in Registratura toolbar
- Add 7 Brevo SMTP env vars to docker-compose.yml
- Update CLAUDE.md, ROADMAP.md, DATA-MODEL.md, SYSTEM-ARCHITECTURE.md, CONFIGURATION.md, DOCKER-DEPLOYMENT.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 01:12:36 +02:00
AI Assistant 6941074106 fix: copy button uses plain number format (nr. 42 din 11.03.2026)
Strips company prefix and leading zeros from registry number.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 00:37:53 +02:00
AI Assistant 8e9753fd29 feat: add copy button next to registry number in table
Copies "nr. BTG-0042/2026 din 11.03.2026" to clipboard on click.
Small icon, subtle until hover, green check feedback on copy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 00:35:12 +02:00
AI Assistant 7094114c36 fix: hooks order violation in DeadlineResolveDialog causing crash
useMemo was called after early return (when deadline=null), violating
React Rules of Hooks. Moved all hooks before the conditional return.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 00:30:38 +02:00
AI Assistant 959590acfe feat: add Convocare CTATU document type
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 00:21:36 +02:00
AI Assistant 1c5ad7c988 feat: restructure Completari + rename Contestatie → Litigii + remove ac-prelungire
- Remove ac-prelungire backward deadline (redundant with AC validity tracker)
- Completari: now 2 beneficiary reminders (L350=60z, L50=3luni) instead of 4 mixed entries
- Rename contestatie → litigii ("Litigii / Sanctiuni / Contestatii")
- Add new litigii deadlines: prescriptie contraventie (3 ani), plangere PV (15z), CNSC (10z)
- Update existing: plangere prealabila, actiune instanta, atacare urbanism labels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 00:08:24 +02:00
AI Assistant a2b9ff75b5 feat(registratura): restructure Autorizare deadlines — no tacit approval
- ac-verificare (5z lucr.) now auto-track, created automatically with
  any AC emitere type. Informational: authority notifies if incomplete.
- ac-emitere (30z cal.) now chains to ac-emitere-dupa-completari when
  interrupted — term recalculates from completion submission date.
- ac-emitere-urgenta (7z lucr.) and ac-emitere-anexe (15z cal.) kept.
- New: ac-prelungire-emitere (15z lucr.) — authority communicates
  decision on AC extension within 15 working days.
- Info box in DeadlineAddDialog for autorizare category explaining
  auto-tracked verification + interruption mechanism.
- None of the autorizare deadlines have tacit approval.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:55:39 +02:00
AI Assistant a96dce56a2 feat(registratura): revert intrat full categories + add urbanism deadlines
- Revert: only iesit+cerere/aviz gets full permitting categories
- Urbanism: furnizare date retele (15z cal, doar autoritati publice)
- Urbanism: aviz oportunitate PUZ — verificare (30z) + convocare CTATU
  (30z, chain) + emitere dupa comisie (15z, auto-track)
- Urbanism: aviz arhitect sef PUD/PUZ — convocare CTATU (30z, chain)
  + emitere (15z, auto-track)
- Urbanism: promovare CL (30z) + vot CL (45z dupa dezbatere publica)
- None of the urbanism deadlines have tacit approval

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:45:25 +02:00
AI Assistant 8bcb0bcc81 fix: show all permitting deadline categories for cerere/aviz regardless of direction
Previously DIRECTION_CATEGORIES limited intrat to only completari+contestatie,
so cerere/aviz on intrat direction lost certificat/avize/urbanism/autorizare.
Now cerere/aviz doc types unlock all 6 categories on both directions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:24:02 +02:00
AI Assistant 4467e70973 fix: tooltip {proiect} hint visibility — use amber-300 on dark tooltip bg
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:00:10 +02:00
AI Assistant f50ad5e020 feat(registratura): auto-detect {proiect} placeholder in subject and switch to template mode
Typing {proiect}, {nr}, {an}, {detalii} or {text} in the subject field
now auto-transforms to template mode with the appropriate input widgets
(project dropdown, number fields, etc.).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:52:31 +02:00
AI Assistant 442a1565fd feat: avize deadline restructure with interruption mechanism + comisie toggle
- Add "Necesita analiza in comisie" toggle for avize category (mirrors CJ toggle)
  - When OFF: auto-creates 5-day working limit for completari requests
  - When ON: no limit (institution can request completions anytime)
- Add interruption mechanism: resolve aviz as "intrerupt" when institution
  requests completions → auto-creates new 15-day deadline from completions date
- New resolution type "intrerupt" with yellow badge + chain support
- Restructure avize catalog entries:
  - aviz-ac-15 (L50) and aviz-urbanism-30 (L350) now have chain to
    aviz-emitere-dupa-completari for interruption flow
  - aviz-mediu: updated hints about procedure closure prerequisite
  - aviz-cultura-comisie: 2-phase with auto-track depunere la comisie (30 days)
  - aeronautica, ISU, transport-eu: all get interruption chain
- 3 new auto-track entries: aviz-completari-limit (5zl), aviz-emitere-dupa-completari
  (15zc), aviz-cultura-depunere-comisie (30zc)
- New document type: "Convocare sedinta"
- Info boxes in dialog explaining auto-track behavior + interruption mechanism

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:41:14 +02:00
AI Assistant 31565b418a fix: doc type persistence on edit + filter deadlines by document type
- Fix doc type showing "altele" after edit: preserve initial documentType
  in allDocTypes map even if not in defaults or Tag Manager
- Filter deadline categories by document type: only cerere/aviz unlock
  full permitting categories (CU, avize, urbanism, autorizare)
- Other doc types (scrisoare, notificare, etc.) only get completari +
  contestatie as deadline categories
- Add completari to intrat direction (was missing)
- Pass documentType to DeadlineAddDialog for category filtering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:58:06 +02:00
AI Assistant 4ac4a48cad fix: differentiate Conex (linkedEntryIds) vs Inchide (threadParentId) semantics
- Conex button now adds to linkedEntryIds (for clarificari/solicitari)
  instead of setting threadParentId
- Inchide button sets threadParentId (direct reply) + auto-closes parent
- Fix Sterge button persistence bug: threadParentId now saves as empty
  string instead of undefined (which was stripped in JSON serialization)
- Card headers: "Conex cu X" (amber) vs "Raspuns la X" (blue + green)
- Add conexTo prop to RegistryEntryForm for linked entry pre-fill

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:29:56 +02:00
AI Assistant 85077251f3 feat(registratura): fix thread clear, close via conex entry
- Fix threadParentId clear: always show "Sterge" button even when parent not found in allEntries
- Bigger clear button with text label instead of tiny X icon
- Fallback display when parent not in current list (shows truncated ID)
- Close via conex: table/detail "Inchide" now creates a new reply entry that closes the original
- Header shows "Conex la BTG-XXX" + "Inchide originala" badge when closing via conex
- After saving the new conex entry, parent is auto-closed with resolution "finalizat"
- onClose signature changed from (id: string) to (entry: RegistryEntry) across table + detail

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:15:40 +02:00
AI Assistant f5e19ce3d1 feat(registratura): add Conex (reply) + Inchide buttons, reorder completari last
- Conex button on table rows (Reply icon, blue) — opens new entry with threadParentId pre-set + flipped direction
- Conex button on detail panel — same behavior
- Inchide button on table rows (CheckCircle2 icon, green) — only for open entries
- replyTo prop on RegistryEntryForm: pre-sets threadParentId + direction flip (intrat→iesit, iesit→intrat)
- Card header shows "Conex la BTG-0042/2026" with blue badge when replying
- Completari moved to last position in deadline category order

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:40:12 +02:00
AI Assistant f01fe47af4 feat(registratura): remove publicitate category, auto-track comunicare, late receipt badge, AC validity conditional
- Remove publicitate/comunicare category entirely (AC publicity handled by AC validity tracker)
- comunicare-aviz-beneficiar moved to auto-track: created alongside any iesit deadline
- Late receipt badge on incoming aviz entries: shows "Primit cu X zile intarziere" when document date < today
- Valabilitate document + AC Validity Tracker visible only when documentType is "aviz" (act administrativ)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:26:05 +02:00
AI Assistant b2519a3b9c feat(registratura): redesign CU deadline tracking — direction filtering, CJ toggle, auto-track, verification badge
- CU has NO tacit approval on any entry
- Direction-dependent categories: iesiri (CU, Avize, Completari, Urbanism, Autorizare), intrari (Contestatie)
- Rename: Analiza → Urbanism (PUD/PUZ/PUG), Autorizare (AC) → Autorizare (AD/AC)
- Auto-track deadlines: cu-verificare (10zl) created automatically with CU emitere
- CJ toggle: auto-creates arhitect-sef solicita aviz (3zc) + primar emite aviz (5zc)
- Verification badge: after 10 days shows "Nu mai pot returna documentatia"
- Prelungire helper: CU issue date + 6/12/24 month calculator
- cu-prelungire-emitere changed to 30zc (practica administrativa)
- New DeadlineTypeDef fields: autoTrack, directionFilter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 18:58:50 +02:00
AI Assistant f5ffce2e23 feat(registratura): restructure legal deadline catalog — 35 deadlines from Legea 50/1991 & 350/2001
Complete rewrite of deadline-catalog.ts based on comprehensive legislative extracts:

Certificat de Urbanism (6): verificare 10zl, emitere L50 15zl, emitere L350 30zc,
  suport tehnic 10zl, prelungire depunere 15zc backward, prelungire emitere 15zc

Avize (15): AC standard 15zc, urbanism 30zc, Mediu 15zc, Cultura comisie 30zc,
  Min.Culturii 30zl, Aeronautica 30zc, ISU 15zc, transport EU 10zc, comisie
  agenda 30zc, comisie emitere 15zc, oportunitate analiza/emitere (fara tacit!),
  reconfirmare 5zl, primar 5zc, monument fara AC 30zc

Completari (4): notificare 5zl, beneficiar 60zc, emitere 15zc, AC beneficiar 90zc

Autorizare (5): verificare 5zl, emitere 30zc, urgenta 7zl, agricol 15zc,
  prelungire 45zl backward

Publicitate (2): AC 30zc, comunicare aviz 1zc

Contestatie (4): plangere prealabila 30zc, contestare AC 60zc,
  contestare urbanism 5 ani, plangere contraventionala 15zc

Each deadline now includes legalReference field displayed in the dialog.
Dialog shows legal reference, scroll for long lists, contestatie category added.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:24:02 +02:00
AI Assistant eb7c28ca14 feat(registratura): smart defaults per direction, new doc types, expanded cerere templates
- Default doc type: aviz (intrat) / cerere (iesit); auto-switches on direction toggle
- New default doc types: Proces verbal, Notificare, Comunicare (with full seed templates)
- Cerere templates rewritten: emiterea CU/AC, prelungirea valabilitatii, completare
  documentatie, indreptarea erorilor materiale, inaintare dispozitie de santier,
  eliberarea certificatului, aviz, racordare
- Aviz label renamed to "Aviz / Act administrativ"
- Scrisoare label renamed to "Scrisoare / Adresa", raport to "Raport / Studiu"
- Moved PV/notificare/comunicare templates from scrisoare/altele to their own types
- Cleaned up duplicate templates across categories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:02:46 +02:00
AI Assistant b62e01b153 feat(registratura): expand seed templates — memoriu justificativ, comunicare, deviz, borderou
- Add Comunicare, Instiintare under scrisoare
- Add Memoriu justificativ, Studiu de fezabilitate, Audit energetic, Caiet de sarcini under raport
- Add Aviz racordare, Acord unic under aviz
- Add Contract proiectare, PV predare-primire under contract
- Add Borderou, Fisa tehnica, Tema de proiectare, Deviz general, Conventie under altele
- Total seed templates: ~55 across 11 document types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:31:36 +02:00
AI Assistant 8cec9646c3 feat(registratura): add administrative acts seed templates (CU, AC, avize concrete)
- Add CU/AC/Prelungire CU/AC templates under aviz type for received acts
- Add Aviz ISU/DSP/Mediu/APM concrete templates
- Add PV receptie, Proces verbal, Referat verificare, Expertiza tehnica, RTE, Memoriu tehnic
- Add Cerere completari, Raspuns completari, Somatie templates
- Update dynamic placeholders for intrat (CU/AC examples) and iesit (Cerere CU)
- Update tooltip examples: intrat shows CU/AC/Aviz, iesit shows Cerere/Solicitare

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:05:45 +02:00
AI Assistant e30b437dce feat(registratura): smart subject autocomplete v2 — seed templates, project linking, dynamic placeholders
- Add SEED_TEMPLATES catalog (11 doc types x 2-4 templates = ~30 predefined patterns)
- Add {proiect} field type with mini-autocomplete from Tag Manager projects
- Pre-fill {an} fields with current year on template selection
- Dynamic placeholder changes based on documentType + direction (22 combinations)
- Dropdown on empty focus: "Sabloane recomandate" + "Recente" sections
- Direction-aware tooltip on Subiect field (intrat vs iesit examples)
- getRecommendedTemplates() merges seeds + DB-learned, DB takes priority

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:46:05 +02:00
AI Assistant 3a3db3f366 fix(registratura): lower subject template min length from 8 to 3 chars
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 08:48:34 +02:00
AI Assistant b3b585e7c8 feat(registratura): subject autocomplete with inline template fields
- New subject-template-service: extracts reusable templates from existing
  subjects by detecting variable parts (numbers, years, text after separators)
- Template input component: inline editable fields within static text
  (e.g., "Cerere CU nr. [___]/[____] — [___________]")
- Two-tier autocomplete dropdown: templates sorted by frequency (top) +
  matching existing subjects (bottom)
- Learns from database: more entries = better suggestions
- Follows existing contact autocomplete pattern (focus/blur, onMouseDown)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 08:40:37 +02:00
AI Assistant eb96af3e4b feat(registratura): add best practices popover + contextual tooltips on form fields
- "Bune practici" button in registry header opens a popover with internal rules
  (numerotare, completare, termene, atașamente, închidere)
- Info tooltips on form labels: Direcție, Tip document, Subiect, Expeditor,
  Destinatar, Atașamente (consistent with existing pattern on Data document)
- Install shadcn/ui Popover component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 08:21:54 +02:00
AI Assistant 6786ac07d1 fix(registratura): remove intern direction — only intrat/iesit are valid
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 08:01:29 +02:00
AI Assistant a0dd35a066 feat(registratura): atomic numbering, reserved slots, audit trail, API endpoints + theme toggle animation
Registratura module:
- Atomic sequence numbering (BTG-2026-IN-00125 format) via PostgreSQL upsert
- Reserved monthly slots (2/company/month) for late registrations
- Append-only audit trail with diff tracking
- REST API: /api/registratura (CRUD), /api/registratura/reserved, /api/registratura/audit
- Auth: NextAuth session + Bearer API key support
- New "intern" direction type with UI support (form, filters, table, detail panel)
- Prisma models: RegistrySequence, RegistryAudit

Theme toggle:
- SVG mask-based sun/moon morph with 360° spin animation
- Inverted logic (sun in dark mode, moon in light mode)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 07:54:32 +02:00
AI Assistant f94529c380 Merge branch 'claude/elastic-chaplygin' 2026-03-09 13:16:45 +02:00
AI Assistant 179dc306bb fix(auth): replace client-side signin page with server-side route handler
The client page rendered inside AppShell layout, causing a flash of the
full app UI before redirecting to Authentik. The new route handler
initiates the OAuth flow server-side (CSRF token + POST to NextAuth
provider signin) and redirects instantly — no visible page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:16:45 +02:00
AI Assistant f1ab165139 Merge branch 'claude/elastic-chaplygin' 2026-03-09 12:48:03 +02:00
AI Assistant acfec5abe5 fix(auth): move loading check after hooks to fix Rules of Hooks violation
Early return before useCallback/useMemo caused React error #310
(different hook count between renders). Loading spinner now renders
after all hooks are called unconditionally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:48:03 +02:00
AI Assistant c09598f93d Merge branch 'claude/elastic-chaplygin' 2026-03-09 12:39:57 +02:00
AI Assistant bb3673b4aa fix(auth): correct callbackUrl and auto-redirect to Authentik
- Use NEXTAUTH_URL instead of request.url for callbackUrl (was 0.0.0.0:3000)
- Add custom /auth/signin page that auto-calls signIn("authentik")
- Skip the intermediate "Sign in with Authentik" button page
- Exclude /auth/signin from middleware matcher

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:39:50 +02:00
AI Assistant 6cf809d985 Merge branch 'claude/elastic-chaplygin' 2026-03-09 12:26:16 +02:00
AI Assistant ca4d7b5d8d feat(auth): force Authentik login on first visit, fix ManicTime sync
Auth:
- Add middleware.ts that redirects unauthenticated users to Authentik SSO
- Extract authOptions to shared auth-options.ts
- Add getAuthSession() helper for API route protection
- Add loading spinner during session validation
- Dev mode bypasses auth (stub user still works)

ManicTime:
- Fix hardcoded companyId="beletage" — now uses group context from Tags.txt
- Fix extended project format label parsing (extracts name after year)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:26:08 +02:00
AI Assistant ca5856829b fix(dwg): correct ODA download URL + dynamic binary path lookup
- Use www.opendesign.com/guestfiles/get URL (no auth required)
- Auto-find and symlink ODA binary after dpkg install
- app.py searches multiple common install paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:24:56 +02:00
AI Assistant d84106e1b4 feat(dwg): switch to ODA File Converter (libredwg too unstable)
ODA File Converter handles all DWG versions reliably.
Uses xvfb for headless Qt operation in Docker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:18:22 +02:00
AI Assistant a23215a66e fix(dwg): build libredwg from source (not in any apt repo)
Multi-stage build: compile libredwg 0.13.3 in builder stage,
copy only dwg2dxf binary + libs to final slim image.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:58:34 +02:00
AI Assistant cc652dc5af fix(dwg): enable universe repo for libredwg-tools in Ubuntu 24.04
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:55:39 +02:00
AI Assistant 1b27f111a9 fix(dwg): use Ubuntu base for sidecar (libredwg-tools not in Debian repos)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:53:04 +02:00
AI Assistant 5209fd5dd0 feat(dwg): DWG→DXF via sidecar microservice (libredwg)
Add dedicated dwg2dxf container (Debian slim + libredwg-tools + Flask)
instead of modifying the Alpine base image. The ArchiTools API route
proxies to the sidecar over Docker internal network.

- dwg2dxf-api/: Dockerfile + Flask app (POST /convert, GET /health)
- docker-compose.yml: dwg2dxf service, healthcheck, depends_on
- route.ts: rewritten from local exec to HTTP proxy
- .dockerignore: exclude sidecar from main build context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:51:27 +02:00
AI Assistant 7ed653eaec rollback(docker): restore Alpine base for stability, DWG→DXF disabled 2026-03-08 22:15:39 +02:00
AI Assistant f1f40d093b feat(docker): switch to Ubuntu base, enable DWG→DXF conversion via libredwg-tools 2026-03-08 22:08:39 +02:00
AI Assistant 893daea485 fix(docker): remove libredwg (not in Alpine repos), DWG→DXF gracefully disabled 2026-03-08 21:47:44 +02:00
AI Assistant 12b7bca990 Mini Utilities v0.2.0: extreme PDF compression (GS+qpdf), DWG→DXF, paste support, drag-drop layers
- Extreme PDF compression via direct Ghostscript + qpdf pipeline
  (PassThroughJPEGImages=false, QFactor 1.5, 72 DPI downsample)
- DWG→DXF converter via libredwg (Docker only)
- PDF unlock in-app via Stirling PDF proxy
- Removed PDF/A tab (unused)
- Paste (Ctrl+V) on all file drop zones
- Mouse drag-drop reordering on thermal layers
- Tabs reorganized into 2 visual rows
- Dockerfile: added ghostscript, qpdf, libredwg
2026-03-08 21:44:43 +02:00
AI Assistant 94b342e5ce Hot Desk 0.2.0: room layout proportions, name quick-select, remove notes 2026-03-08 19:33:25 +02:00
AI Assistant 4d2f924537 pre-launch hardening: Address Book type sort, Hot Desk proportions, TVA calculator, ROADMAP Phase 4B
- Address Book: type dropdown always sorted alphabetically (ro locale), including custom types
- Hot Desk: window ~half height (top-[35%] bottom-[35%]), door ~double height (h-16)
- Mini Utilities: TVA calculator (19%) with add/extract modes, RON formatting, copy buttons
- ROADMAP: new Phase 4B Pre-Launch Hardening with 10 structured tasks
- CLAUDE.md: bumped versions (Address Book 0.1.1, Mini Utilities 0.1.1, Hot Desk 0.1.1), Visual Copilot separate repo note
2026-03-08 14:08:48 +02:00
AI Assistant a6fa94deec docs + fix: eTerra health check keywords from real maintenance page
- Added real eTerra maintenance keywords observed 2026-03-08:
  'serviciu indisponibil', 'activități de mentenanță sunt în desfășurare'
- Extract actual maintenance message from HTML response for UI display
- Updated CLAUDE.md: ParcelSync module #15, Visual Copilot #16,
  eTerra/PostGIS integrations, TS strict gotchas, eTerra API rules
- Updated ROADMAP.md: Phase 7B (ParcelSync) with 5 completed tasks
- Updated SESSION-LOG.md: full session entry with bugs/learnings
2026-03-08 13:04:11 +02:00
AI Assistant b7a236c45a feat(parcel-sync): eTerra health check + maintenance detection
- New eterra-health.ts service: pings eTerra periodically (3min),
  detects maintenance (503, keywords), tracks consecutive failures
- New /api/eterra/health endpoint for explicit health queries
- Session route blocks login when eTerra is in maintenance (503 response)
- GET /api/eterra/session now includes eterraAvailable/eterraMaintenance
- ConnectionPill shows amber 'Mentenanță' state with AlertTriangle icon
  instead of confusing red error when eTerra is down
- Auto-connect skips when maintenance detected, retries when back online
- 30s session poll auto-detects recovery and re-enables auto-connect
2026-03-08 10:28:30 +02:00
AI Assistant 6557cd5374 feat(parcel-sync): per-UAT analytics dashboard in Database tab
- New API route /api/eterra/uat-dashboard with SQL aggregates
  (area stats, intravilan/extravilan split, land use, top owners, fun facts)
- CSS-only dashboard component: KPI cards, donut ring, bar charts
- Dashboard button on each UAT card in DB tab, expands panel below
2026-03-08 10:18:34 +02:00
AI Assistant 6558c690f5 feat(parcel-sync): owner name search (proprietar) in Search tab
- New search mode toggle: Nr. Cadastral / Proprietar
- Owner search queries:
  1. Local DB first (enrichment PROPRIETARI/PROPRIETARI_VECHI ILIKE)
  2. eTerra API fallback (tries personName/titularName/ownerName filter keys)
- DB search works offline (no eTerra connection needed) — uses enriched data
- New API route: POST /api/eterra/search-owner
- New eterra-client method: searchImmovableByOwnerName()
- Owner results show source badge (DB local / eTerra online)
- Results can be added to saved list and exported as CSV
- Relaxed search tab guard: only requires UAT selection (not eTerra connection)
- Cadastral search still requires eTerra connection (shows hint when offline)
2026-03-08 03:48:23 +02:00
AI Assistant 8bb4a47ac5 fix(eterra): increase default timeout 40s -> 120s for large geometry pages
- DEFAULT_TIMEOUT_MS: 40_000 -> 120_000 (1000 features with full geometry
  from Feleacu regularly exceed 40s on the eTerra server)
- Add timeoutMs option to syncLayer() for caller override
- syncLayer now passes timeoutMs through to EterraClient.create()

Fixes 'timeout of 40000ms exceeded' on TERENURI_ACTIVE sync.
2026-03-08 03:31:18 +02:00
AI Assistant d7d78c0cc1 fix(eterra-client): reduce default pageSize to 1000 + retry on ArcGIS errors
- DEFAULT_PAGE_SIZE: 2000 -> 1000 (matches eTerra maxRecordCount, avoids
  requesting more than the server supports on first try)
- PAGE_SIZE_FALLBACKS: [500, 200] (removed 1000 since it's now the default)
- Add retry-once logic for 'Error performing query operation':
  Wait 2s and retry same page before falling to smaller sizes.
  These errors are often transient server-side timeouts.
- Longer delay (1s vs 0.5s) between page size fallback attempts

Fixes Feleacu (7951 features) background sync failure.
2026-03-08 03:06:44 +02:00
AI Assistant 041bfd4138 fix(parcel-sync): fix ArcGIS 1000 server cap pagination + scan improvements
- eterra-client: detect server maxRecordCount cap in fetchAllLayerByWhere
  When server returns exactly 1000 (or other round cap) but we asked for 2000,
  recognize this as a server limit, adjust pageSize, and CONTINUE paginating.
  Previously: 1000 < 2000 -> break (lost all data beyond page 1).

- no-geom-sync: count layers first, pass total to fetchAllLayer
  Belt-and-suspenders: even if cap detection misses, known total prevents
  early termination. Also use pageSize 1000 to match typical server cap.
  Clădiri count uses countLayer instead of fetching all OBJECTIDs.

- UI: add include-no-geom checkbox in background sync section
  Users can toggle it independently of scan status.
  Shows '(scanare in curs)' hint when scan is still running.
2026-03-08 02:37:39 +02:00
AI Assistant d12f01fc02 fix(parcel-sync): add 2min timeout to no-geom scan, non-blocking UI
- Server: Promise.race with 120s timeout on no-geom-scan API route
- Client: AbortController with 120s timeout on scan fetch
- UI: show 'max 2 min' during scanning + hint that buttons work without scan
- UI: timeout state shows retry button + explains no-geom won't be available
- Prevents indefinitely stuck 'Se scanează...' on slow eTerra responses
2026-03-08 02:28:51 +02:00
AI Assistant e57ca88e7e fix: increase background job progress retention to 6h, localStorage recovery to 8h 2026-03-08 01:59:09 +02:00
AI Assistant c43082baee feat(parcel-sync): background sync + download from DB
- New POST /api/eterra/sync-background: fire-and-forget server-side processing
  Starts sync + optional enrichment in background, returns 202 immediately.
  Progress tracked via existing /api/eterra/progress polling.
  Work continues in Node.js event loop even if browser is closed.
  Progress persists 1 hour for background jobs (vs 60s for normal).

- Enhanced POST /api/eterra/export-local: base/magic mode support
  mode=base: ZIP with terenuri.gpkg + cladiri.gpkg from local DB
  mode=magic: adds terenuri_magic.gpkg (enrichment merged, includes no-geom),
  terenuri_complet.csv, raport_calitate.txt, export_report.json
  All from PostgreSQL — zero eTerra API calls, instant download.

- UI: background sync section in Export tab
  'Sync fundal Baza/Magic' buttons: start background processing
  'Descarc─â din DB Baza/Magic' buttons: instant download from local DB
  Background job progress card with indigo theme (distinct from export)
  localStorage job recovery: resume polling after page refresh
  'Descarc─â din DB' button shown on completion
2026-03-08 01:53:24 +02:00
AI Assistant bcc7a54325 perf: reverse enrichment order — direct parcel details first, skip immApps
- fetchImmovableParcelDetails called FIRST (1 call, no applicationId needed)
- app-based fetchParcelFolosinte only as fallback when direct returns nothing
- SOLICITANT skipped entirely (was always '-' for old CF records)
- Remove unused pickApplication helper
- Net savings: ~500+ API calls per UAT enrichment (50-65% reduction)
- copycf/get returns same data as list (no enrichment value, kept as utility)
2026-03-08 01:15:28 +02:00
AI Assistant aee28b6768 feat: filter no-geom by IE status (hasLandbook), add checkIfIsIE + CF PDF APIs
QUALITY GATE TIGHTENED:
No-geometry import now requires hasLandbook=1 (imobil electronic).
This filters out immovables without carte funciara — they have no
CF data, no owners, no parcel details to extract. For Cosbuc this
reduces useful no-geom from ~1916 to ~468 (only IEs with real data).

Three-tier quality gate:
1. Active (status=1)
2. Has landbook (hasLandbook=1) — is electronic immovable  [NEW]
3. Has identification (cadRef/paperLbNo/paperCadNo) OR area

CLEANUP also updated: DB cleanup now removes stale no-geom records
that don't pass the tightened gate (existing non-IE records will be
cleaned on next import run).

NEW API METHODS (eterra-client):
- checkIfIsIE(adminUnitId, paperCadNo, topNo, paperCfNo) → boolean
  Calls /api/immovable/checkIfIsIE — verifies IE status per-parcel
  Available for future per-item verification if needed
- getCfExtractUrl(immovablePk, workspaceId) → string
  Returns URL for /api/cf/landbook/copycf/get/{pk}/{ws}/0/true
  Downloads the CF extract as PDF blob (future enrichment)

UI updated: 'Filtrate' label now says 'fara CF/inactive/fara date'
to reflect the new hasLandbook filter.
2026-03-08 00:57:16 +02:00
AI Assistant f09eaaad7c feat: enrichment fallback via direct parcel details endpoint
PROBLEM:
For no-geometry parcels (and many geometry parcels without application
IDs), CATEGORIE_FOLOSINTA was always '-' because:
1. fetchImmAppsByImmovable returned no apps (no applicationId)
2. Without appId, fetchParcelFolosinte was skipped entirely
3. No fallback existed

DISCOVERY (from eTerra UI investigation):
The endpoint /api/immovable/details/parcels/list/{wp}/{pk}/{page}/{size}
returns parcel use categories DIRECTLY — no applicationId needed.
Example: [{useCategory:'arabil', intravilan:'Necunoscut', parcelPk:17753903}]

FIX:
- After the app-based CATEGORIE_FOLOSINTA attempt, if result is still '-',
  fall back to fetchImmovableParcelDetails (the direct endpoint)
- formatCategories now handles both API formats:
  - App-based: categorieFolosinta + suprafata fields
  - Direct: useCategory field (no area — shows category name only)
- When direct endpoint provides area=0, format shows just the category
  name without ':0' (e.g. 'arabil; faneata' instead of 'arabil:0; faneata:0')
- Also picks up intravilan from direct endpoint if app-based was empty
- Fixed fetchImmovableParcelDetails default size: 1 → 20 (one immovable
  can have multiple parcels, e.g. IE 25332 has 2: arabil + faneata)
- Results are cached in folCache to avoid duplicate requests
2026-03-08 00:46:02 +02:00
AI Assistant a7c9e8a6cc fix: robust layer fetch (multi-fallback page sizes, error cause), neutral 505 color
LAYER FETCH:
- fetchAllLayerByWhere now falls back through 2000 → 1000 → 500 → 200
  instead of just 2000 → 1000 before giving up
- 500ms delay between fallback attempts to let eTerra recover
- Error message now includes the original cause:
  'Failed to fetch layer TERENURI_ACTIVE: Session expired (401)'
  instead of just 'Failed to fetch layer TERENURI_ACTIVE'

DISPLAY:
- 505 terenuri count no longer green (was emerald-600, now neutral semibold)
  It's just a data value, not a status indicator
2026-03-07 22:01:17 +02:00
AI Assistant b287b4c34b fix: stable scan display, accurate workflow preview, cladiri count
ROOT CAUSE: The cross-reference between immovable list and GIS layer
produces wildly different matchedCount on each scan (320, 430, 629, 433)
because the eTerra immovable/list API with inscrisCF=-1 returns
inconsistent results across calls. The GIS layer count (505) is stable.

SCAN DISPLAY — now uses only stable numbers:
- Header shows 'Layer GIS: 505 terenuri + X cladiri' (stable ArcGIS count)
- Shows 'Lista imobile: 2.717 (estimat ~2.212 fara geometrie)' using
  simple subtraction totalImmovables - remoteGisCount
- Cross-ref matchedCount kept internally for import logic, but NOT shown
  as the primary number — eliminates visual instability
- hasNoGeomParcels now uses estimated count (stable)

WORKFLOW PREVIEW — now accurate:
- Step 1: 'Sync GIS — descarca 505 terenuri + X cladiri' (separate counts)
  or 'skip (date proaspete in DB)' when fresh
- Step 2 (enrichment): Fixed 'deja imbogatite' bug when DB is empty.
  Now correctly computes what WILL be in DB after sync completes:
  geoAfterSync + noGeomAfterImport - localDbEnrichedComplete
- Steps 3-4 unchanged

CLADIRI COUNT:
- Scan now also fetches CLADIRI_ACTIVE layer count (lightweight, OBJECTID only)
- New field remoteCladiriCount in NoGeomScanResult
- Displayed in header and workflow step 1
- Non-fatal: if CLADIRI fetch fails, just shows 0
2026-03-07 21:40:38 +02:00
AI Assistant 531c3b0858 fix: scan numbers always add up, match quality tracking, pipeline audit
SCAN DISPLAY:
- Use matchedCount (withGeometry) for 'cu geometrie' — ALWAYS adds up
  with noGeomCount to equal totalImmovables (ground truth arithmetic)
- Show remoteGisCount separately as 'Layer GIS: N features (se descarca toate)'
- When remoteGisCount != matchedCount, show matching detail with breakdown
  (X potrivite + cadRef/ID split) so mismatches are transparent
- Workflow preview step 1 still uses remoteGisCount (correct: all GIS
  features get downloaded regardless of matching)

MATCH QUALITY TRACKING:
- New fields: matchedByRef, matchedById in NoGeomScanResult
- Track how many immovables matched by cadastral ref vs by IMMOVABLE_ID
- Console log match quality for server-side debugging
- scannedAt timestamp for audit trail

PIPELINE AUDIT (export report):
- New 'pipeline' section in export_report.json with full trace:
  syncedGis, noGeometry (imported/cleaned/skipped), enriched, finalDb
- raport_calitate.txt now has PIPELINE section before quality analysis
  showing exactly what happened at each step
- Capture noGeomCleaned + noGeomSkipped in addition to noGeomImported
2026-03-07 21:22:29 +02:00
AI Assistant 1e6888a32a fix: show remoteGisCount (505) as cu geometrie, add no-geom cleanup step
- UI: scan card now shows remoteGisCount instead of matchedCount (withGeometry)
  as the primary 'cu geometrie' number — this is the true GIS layer feature count
- UI: workflow preview step 1 shows remoteGisCount for download count
- UI: mismatch note reworded as secondary detail about cross-reference matching
- Import: automatic cleanup step at start of syncNoGeometryParcels
  - Builds valid immovablePk set from fresh list (active + identification/area)
  - Deletes stale NO_GEOMETRY records not in the valid set
  - Reports cleaned count in result + progress note
- NoGeomSyncResult type: added 'cleaned' field
- Gitignore: temp-db-check.cjs
2026-03-07 21:00:43 +02:00
AI Assistant f9594fff71 fix(parcel-sync): paperCfNo bug, status filter, enrichment robustness
BUGS FIXED:
- paperCfNo does NOT exist in eTerra API — field is paperLbNo
  Renamed withPaperCf → withPaperLb everywhere (type, scan, UI)
- Area fields: only measuredArea and legalArea exist on immovable/list
  Removed phantom area/areaValue/suprafata checks from import filter

FILTERING TIGHTENED:
- Quality gate now requires status=1 (active) in eTerra
- Items with status≠1 are filtered out before import
- Quality breakdown adds: withActiveStatus, withLandbook counters
- Import attributes now store MEASURED_AREA, LEGAL_AREA, HAS_LANDBOOK
- workspace.nomenPk used instead of workspacePk for accuracy

ENRICHMENT ROBUSTNESS:
- Area fallback: when AREA_VALUE is missing (no-geom), enrichment
  now falls back to listItem.measuredArea/legalArea from immovable list
- Post-enrichment verification: logs 100% coverage or warns about gaps
- EnrichResult type extended with totalFeatures + unenrichedCount

UI UPDATES:
- Quality grid shows 6 stats: cadRef, CF/LB, paperCad, area, active, landbook
- Filter explanation updated: 'inactive sau fără date' instead of old text
2026-03-07 20:25:05 +02:00
AI Assistant af2631920f feat(parcel-sync): quality gate filter for no-geom import + diagnostic endpoint
- Filter no-geom items before import: must have identification (cadRef/CF/paperCad/paperLb) OR area
- Multi-field area extraction: area, measuredArea, areaValue, suprafata
- Scan quality breakdown: withCadRef, withPaperCf, withPaperCad, withArea, useful, empty
- Added paperLbNo to quality analysis and samples
- UI: quality breakdown grid in scan card
- UI: filtered count in workflow preview (shows useful, not total)
- UI: enrichment estimate uses useful count
- New diagnostic endpoint /api/eterra/no-geom-debug for field inspection
2026-03-07 19:58:43 +02:00
AI Assistant 681b52e816 feat: quality analysis for no-geom parcels + raport_calitate.txt
Scan phase:
- qualityBreakdown on NoGeomScanResult: withCadRef, withPaperCad,
  withPaperCf, withArea, useful vs empty counts
- UI scan card shows quality grid before deciding to export

Export phase:
- Comprehensive enrichment quality analysis: owners, CF, address,
  area, category, building — split by with-geom vs no-geom
- raport_calitate.txt in ZIP: human-readable Romanian report with
  per-category breakdowns and percentage stats
- export_report.json includes full qualityAnalysis object
- Progress completion note shows quality summary inline
2026-03-07 19:23:57 +02:00
AI Assistant 53914c7fc3 fix: scan math consistency + stale enrichment detection + re-enrichment
- withGeometry = matched immovable count (not GIS feature count) — numbers always add up
- Added remoteGisCount to show raw GIS layer count separately
- Enrichment completeness check: ENRICHMENT_REQUIRED_KEYS 7-field schema
- localDbEnrichedComplete vs localDbEnriched detects stale enrichment
- UI: orange warning when enrichment incomplete (missing PROPRIETARI_VECHI)
- UI: workflow preview uses enrichedComplete for accurate time estimate
- UI: note when GIS feature count differs from matched immovable count
- enrich-service: re-enriches features with incomplete schema instead of skipping
2026-03-07 18:29:03 +02:00
AI Assistant ba579d75c1 feat(parcel-sync): include no-geometry rows in Magic GPKG + HAS_GEOMETRY column
- Magic GPKG (terenuri_magic.gpkg) now contains ALL records:
  rows with geometry render as polygons, rows without have null geom
  but still carry all attribute/enrichment data (QGIS shows them fine)
- Added HAS_GEOMETRY column to Magic GPKG fields (0 or 1)
- GPKG builder now supports includeNullGeometry option: splits features
  into spatial-first (creates table), then appends null-geom rows
- Base terenuri.gpkg / cladiri.gpkg unchanged (spatial only)
- CSV still has all records as before
- GeoJsonFeature type now allows null geometry
- Reproject: null geometry guard added
- UI text updated: no longer says 'Nu apar in GPKG'
2026-03-07 18:06:28 +02:00
AI Assistant 96859dde4f feat(parcel-sync): scan shows local DB context + Magic workflow preview
- NoGeomScanResult now includes: localDbTotal, localDbWithGeom, localDbNoGeom,
  localDbEnriched, localSyncFresh (parallel DB queries, fast)
- Scan card shows 'Baza de date locala: X cu geometrie + Y fara + Z imbogatite'
- Workflow preview shows numbered steps with smart estimates:
  step 1 shows 'skip (date proaspete)' when sync is fresh
  step 2 shows '~N noi de importat' or 'deja importate' for no-geom
  step 3 shows '~N de procesat (~M min)' or 'deja imbogatite' for enrichment
- All-geometry card also shows local DB summary
- User can see exactly what will happen before pressing Magic
2026-03-07 17:50:34 +02:00
AI Assistant b01ea9fc37 fix(parcel-sync): scan uses remote GIS layer instead of empty local DB
- scanNoGeometryParcels now fetches TERENURI_ACTIVE features from remote
  ArcGIS (lightweight, no geometry) to cross-reference with eTerra immovable list
- Cross-references by both NATIONAL_CADASTRAL_REFERENCE and IMMOVABLE_ID
- Works correctly regardless of whether user has synced to local DB
- Renamed totalInDb -> withGeometry in NoGeomScanResult, UI, and API
- Extended fetchAllLayer() to forward outFields/returnGeometry options
2026-03-07 17:32:49 +02:00
AI Assistant 40b9522e12 fix: remove all hardcoded workspaceId=65 + add robustness for large UATs
- enrich-service: resolve workspacePk from feature attrs / GisUat DB / ArcGIS
  (was hardcoded 65, broke enrichment for non-BN counties)
- enrich-service: skip already-enriched features (resume after crash)
- no-geom-sync: use resolved wsPk in synthetic attributes
- no-geom-sync: batched DB inserts (50/batch) with retry + exponential backoff
- Fixes: Magic export for Cluj/other counties getting empty enrichment
2026-03-07 17:17:55 +02:00
AI Assistant db6ac5d3a3 fix: dynamic workspaceId for no-geometry scan (was hardcoded 65)
- resolveWorkspacePk chain: explicit param -> GisUat DB -> ArcGIS layer query
- UI passes workspacePk from UAT selection to scan API
- Fixes: FELEACU (Cluj, workspace!=65) returning 0 immovables
- Better messaging: shows X total, Y with geometry, Z without
- Shows warning when 0 immovables found (workspace resolution failed)
2026-03-07 16:52:20 +02:00
AI Assistant ddde2db900 fix: auto-scan race condition for no-geometry scan
- handleNoGeomScan accepts optional targetSiruta parameter
- useRef tracks last auto-scanned siruta to prevent duplicate scans
- Show zero result on error instead of hiding card (null)
- Fixes: FELEACU scan disappearing after 2s while COSBUC worked
2026-03-07 16:35:26 +02:00
AI Assistant 5861e06ddb fix(parcel-sync): auto-scan no-geometry + redesign UI card
- Auto-scan triggers when UAT selected + connected (no manual click needed)
- Three states: scanning spinner, found N parcels (amber alert card), all OK (green check)
- Checkbox more prominent: only shown when no-geom parcels exist
- Re-scan button available, scan result cached per siruta
- AlertTriangle icon for visual warning
2026-03-07 13:06:45 +02:00
AI Assistant 30915e8628 feat(parcel-sync): import eTerra immovables without geometry
- Add geometrySource field to GisFeature (NO_GEOMETRY marker)
- New no-geom-sync service: scan + import parcels missing from GIS layer
- Uses negative immovablePk as objectId to avoid @@unique collision
- New /api/eterra/no-geom-scan endpoint for counting
- Export-bundle: includeNoGeometry flag, imports before enrich
- CSV export: new HAS_GEOMETRY column (0/1)
- GPKG: still geometry-only (unchanged)
- UI: checkbox + scan button on Export tab
- Baza de Date tab: shows no-geometry counts per UAT
- db-summary API: includes noGeomCount per layer
2026-03-07 12:58:10 +02:00
AI Assistant d50b9ea0e2 ParcelSync: PROPRIETARI_VECHI in enrichment + global DB summary tab (all UATs without login) 2026-03-07 12:16:34 +02:00
AI Assistant abd00aecfb ParcelSync: DB status card on Export tab + Baza de Date tab with per-category sync 2026-03-07 12:00:20 +02:00
AI Assistant de1e779770 fix(parcel-sync): progress display stuck + numbers jumping during sync
2 bugs:
1. After Magic/base download completes, progress bar stayed stuck at
   77% because exportProgress was never updated to 'done' client-side.
   Fix: set progress to 'Finalizat' + 100% after successful blob download.

2. syncLayer overwrote the export route's weighted percentages (0-100)
   with raw feature counts (50/200), causing progress bar to jump.
   Fix: when isSubStep=true, sync writes phase/note/phaseCurrent/phaseTotal
   but preserves the parent route's downloaded/total weighted values.
2026-03-07 11:45:52 +02:00
AI Assistant 097d010b5d fix(parcel-sync): sync progress visible during GPKG/bundle export
3 bugs fixed:
- syncLayer was called without jobId -> user saw no progress duringSync
- syncLayer set status:'done' prematurely -> client stopped polling before GPKG phase
- syncLayer errors were silently ignored -> confusing 'no features in DB' error

Added isSubStep option to syncLayer: when true, keeps status as 'running'
and doesn't schedule clearProgress. Export routes now pass jobId + isSubStep
so the real sync progress (Descărcare features 50/200) is visible in the UI.
2026-03-07 11:23:36 +02:00
AI Assistant b0927ee075 feat(parcel-sync): sync-first architecture — DB as ground truth
- Rewrite export-bundle to sync-first: check freshness -> sync layers -> enrich (magic) -> build GPKG/CSV from local DB
- Rewrite export-layer-gpkg to sync-first: sync if stale -> export from DB
- Create enrich-service.ts: extracted magic enrichment logic (CF, owners, addresses) with DB storage
- Add enrichment + enrichedAt columns to GisFeature schema
- Update PostGIS views to include enrichment data
- UI: update button labels for sync-first semantics, refresh sync status after exports
- Smart caching: skip sync if data is fresh (168h / 1 week default)
2026-03-07 11:12:54 +02:00
AI Assistant 0d0b1f8c9f feat(parcel-sync): native PostGIS geometry support for QGIS
- Remove postgresqlExtensions/postgis from Prisma schema (PostGIS not yet installed)
- Add prisma/postgis-setup.sql: trigger auto-converts GeoJSON→native geometry,
  GiST spatial index, QGIS-friendly views (gis_terenuri, gis_cladiri, etc.)
- Add POST /api/eterra/setup-postgis endpoint (idempotent, runs all SQL setup)
- Add safety-net raw SQL in sync-service: backfills geom after upsert phase
- Add QGIS/PostGIS setup card in layer catalog UI with connection info
- Schema comment documents the trigger-managed 'geom' column approach
2026-03-07 10:25:30 +02:00
AI Assistant b0c4bf91d7 feat(parcel-sync): sync-to-DB + local export + layer catalog enhancements
Layer catalog now has 3 actions per layer:
- Sync: downloads from eTerra, stores in PostgreSQL (GisFeature table),
  incremental — only new OBJECTIDs fetched, removed ones deleted
- GPKG: direct download from eTerra (existing behavior)
- Local export: generates GPKG from local DB (no eTerra needed)

New features:
- /api/eterra/export-local endpoint — builds GPKG from DB, ZIP for multi-layer
- /api/eterra/sync now uses session-based auth (no credentials in request)
- Category headers show both remote + local feature counts
- Each layer shows local DB count (violet badge) + last sync timestamp
- 'Export local' button in action bar when any layer has local data
- Sync progress message with auto-dismiss

DB schema already had GisFeature + GisSyncRun tables from prior work.
2026-03-07 10:05:39 +02:00
AI Assistant f73e639e4f fix(parcel-sync): quote all CSV fields + layer feature counts + drumul de azi
- CSV export: all fields properly quoted to prevent column misalignment
  when values contain commas (e.g. nrTopo with multiple topo numbers)
- Layer catalog: 'Numara toate' button fetches feature count per layer
  via /api/eterra/layers/summary (now supports session auth)
- Feature counts displayed as badges on each layer and category total
- 'Drumul de azi' section: persists today's layer counts in localStorage
  grouped by SIRUTA with timestamps
2026-03-06 23:19:58 +02:00
AI Assistant 0b049274b1 fix(search): robust address from all structured fields, multi-address support
- Always build from structured fields first (street, postalNo, building, locality)
- Fall back to addressDescription ONLY when no structured fields exist
- Support multiple addresses per immovable (joined with |)
- Deduplicate identical addresses
- Handle addressDescription as last-resort fallback
2026-03-06 22:57:11 +02:00
AI Assistant 742acb2d74 fix(search): proper address from all fields, parcel details endpoint, remove strikethrough
- Address: use street.dictionaryItem.name (Strada/Alee/etc) + street.name,
  postalNo as house number, buildingEntryNo/FloorNo/UnitNo/SectionNo
  for apartment details, locality.name, county.name
- Area+intravilan: fetch from /api/immovable/details/parcels/list (direct
  endpoint with area, intravilan, useCategory) before trying immApps
- Owners: remove strikethrough, use smaller neutral font (text-[11px]
  text-muted-foreground/80), rename label to 'Proprietari anteriori'
2026-03-06 22:50:57 +02:00
AI Assistant 4aa8e6c324 fix(search): use legalArea/measuredArea + nodeStatus=-1 for radiated owners
- Area: use measuredArea/legalArea from immovable list and documentation
  (actual fields from eTerra API, not area/areaValue which don't exist)
- Owners: detect radiated via nodeStatus === -1 on ancestor I (inscription)
  nodes. Walk up parentId tree from P (person)  I  A  C.
  nodeStatus: -1=radiated, 0=active, 2=pending
- Remove debug logging (data structure now understood)
2026-03-06 22:16:14 +02:00
AI Assistant 79c45adc37 fix(search): address [object Object], suprafata from folosinte, owner tree separation
- Address: handle street/locality/county as objects (extract .name)
  Fixes 'Str. [object Object], Feleacu'  'Str. X, Feleacu'
- Suprafata: fallback to total area from folosinte endpoint when
  immovable list and documentation APIs return null
- Owners: use tree traversal (nodeId/parentNodeId) to detect radiated
  inscriptions. Walk up parent chain to check radiationDate/cancelled/
  isActive/closed/status on ancestor inscription nodes.
- Enhanced logging: first/last 3 partTwoRegs entries + node types
  for debugging owner structure in Dozzle
2026-03-06 22:06:28 +02:00
AI Assistant 6eae4fa1c5 feat(search): separate active/old owners, improve address format, debug area fields
- Proprietari split into proprietariActuali + proprietariVechi (radiati)
  based on cancelled/isActive/radiat/status/radiationDate fields
- UI shows owners separated: actuali bold, vechi strikethrough
- CSV export has separate PROPRIETARI_ACTUALI / PROPRIETARI_VECHI columns
- Address: use addressDescription directly when present (>3 chars)
- Add county to address fallback
- Try area/areaValue/areaMP/suprafata fields for surface
- Debug logging: log immovable item keys + partTwoRegs sample on first search
2026-03-06 21:53:18 +02:00
AI Assistant 6b8feb9075 fix: workspace resolution via ArcGIS listLayer + seed UATs from uat.json
- resolveWorkspace: use listLayer() instead of listLayerByWhere() with
  hardcoded field names. Auto-discovers admin field (ADMIN_UNIT_ID/SIRUTA)
  from ArcGIS layer metadata via buildWhere().
- resolveWorkspace: persist WORKSPACE_ID to DB on first resolution for
  fast subsequent lookups.
- UATs POST: seed from uat.json (correct SIRUTA codes) instead of eTerra
  nomenclature API (nomenPk != SIRUTA, county nomenPk != WORKSPACE_ID).
- Remove eTerra nomenclature dependency from UATs endpoint.
- Fix activeJobs Set iteration error on container restart.
- Remove unused enrichedUatsFetched ref.
2026-03-06 21:24:51 +02:00
AI Assistant 1b72d641cd fix(parcel-sync): robust workspace resolution with direct nomen lookup
- Add fetchNomenByPk() to EterraClient  single nomen entry lookup
- resolveWorkspace() now tries fast path first: direct nomen lookup for
  SIRUTA  walk parentNomenPk chain to find COUNTY (1-3 calls vs 42+)
- Falls back to full county scan only if direct lookup fails
- Search route: DB lookup as middle fallback between workspacePk and resolve
- Debug logging to trace workspace resolution on production
- Fix: try all possible UAT identifier fields (nomenPk, siruta, code, pk)
2026-03-06 21:09:22 +02:00
AI Assistant ec5a866673 feat(parcel-sync): store UATs in PostgreSQL, eliminate repeated eTerra calls
- GisUat table now includes workspacePk column (created via raw SQL)
- GET /api/eterra/uats serves from PostgreSQL  instant, no eTerra login needed
- POST /api/eterra/uats triggers sync check: compares county count with DB,
  only does full eTerra fetch if data differs or DB is empty
- Frontend loads UATs from DB on mount (fast), falls back to uat.json if empty
- On eTerra connect, fires POST to sync-check; if data changed, reloads from DB
- Workspace cache populated from DB on GET for search route performance
2026-03-06 20:56:12 +02:00
AI Assistant d948e5c1cf feat(parcel-sync): county-aware UAT autocomplete with workspace resolution
- New /api/eterra/uats endpoint fetches all counties + UATs from eTerra,
  caches server-side for 1 hour, returns enriched data with county name
  and workspacePk for each UAT
- When eTerra is connected, auto-fetches enriched UAT list (replaces
  static uat.json fallback)  shows 'FELEACU (57582), CLUJ' format
- UAT autocomplete now searches both UAT name and county name
- Selected UAT stores workspacePk in state, passes it directly to
  /api/eterra/search  eliminates slow per-search county resolution
- Search route accepts optional workspacePk, falls back to resolveWorkspace()
- Dropdown shows UAT name, SIRUTA code, and county prominently
- Increased autocomplete results from 8 to 12 items
2026-03-06 20:46:44 +02:00
AI Assistant 540b02d8d2 feat(parcel-sync): search by cadastral number with full details
Search tab now uses eTerra application API (same as the web UI):

- POST /api/eterra/search queries /api/immovable/list with exact
  identifierDetails filter + /api/documentation/data for full details
- Returns: nr cad, nr CF, CF vechi, nr topo, suprafata, intravilan,
  categorii folosinta, adresa, proprietari, solicitant
- Automatic workspace (county) resolution from SIRUTA with cache
- Support for multiple cadastral numbers (comma separated)

UI changes:
- Detail cards instead of flat ArcGIS feature table
- Copy details to clipboard button per parcel
- Add parcels to list + CSV export
- Search list with summary table + CSV download
- No more layer filter or pagination (not needed for app API)

New EterraClient methods:
- searchImmovableByIdentifier (exact cadaster lookup)
- fetchCounties / fetchAdminUnitsByCounty (workspace resolution)
2026-03-06 19:58:33 +02:00
AI Assistant c98ce81cb7 feat(parcel-sync): live eTerra search by cadastral number
- Add /api/eterra/search  queries eTerra ArcGIS REST API directly
  by NATIONAL_CADASTRAL_REFERENCE, NATIONAL_CADNR, or INSPIRE_ID
  across TERENURI_ACTIVE + CLADIRI_ACTIVE layers
- Search tab now queries eTerra live (not local DB) with 600ms debounce
- Requires session connected + UAT selected to search
- Updated placeholder and empty state messages in Romanian
2026-03-06 19:18:18 +02:00
AI Assistant bd90c4e30f feat(parcel-sync): server-side eTerra session + auto-connect on UAT typing
- Add session-store.ts: global singleton for shared eTerra session state
  with job tracking (registerJob/unregisterJob/getRunningJobs)
- Add GET/POST /api/eterra/session: connect/disconnect with job-running guard
- Export routes: credential fallback chain (body > session > env vars),
  register/unregister active jobs for disconnect protection
- Login route: also creates server-side session
- ConnectionPill: session-aware display with job count, no credential form
- Auto-connect: triggers on first UAT keystroke via autoConnectAttempted ref
- Session polling: all clients poll GET /api/eterra/session every 30s
- Multi-client: any browser sees shared connection state
2026-03-06 19:06:39 +02:00
AI Assistant 129b62758c refactor(parcel-sync): global UAT bar, connection pill, reorder tabs
- UAT autocomplete always visible above tabs (all tabs share it)
- Connection status pill in top-right: breathing green dot when connected,
  dropdown with credentials form / disconnect button
- Tab order: Cautare Parcele (1st) -> Catalog Layere -> Export (last)
- Renamed 'Butonul Magic' to just 'Magic'
- Removed connection/UAT cards from inside Export tab
2026-03-06 18:41:11 +02:00
AI Assistant 86edbdf44e chore(docker): add gdal-tools to runner for real GeoPackage export 2026-03-06 07:06:37 +02:00
AI Assistant 09a24233bb feat(parcel-sync): full GPKG export workflow with UAT autocomplete, hero buttons, layer catalog
- Fix login button (return success instead of ok)
- Add UAT autocomplete with NFD-normalized search (3186 entries)
- Add export-bundle API: base mode (terenuri+cladiri) + magic mode (enriched parcels)
- Add export-layer-gpkg API: individual layer GPKG download
- Add gpkg-export service: ogr2ogr with GeoJSON fallback
- Add reproject service: EPSG:3844 projection support
- Add magic-mode methods to eterra-client (immApps, folosinte, immovableList, docs, parcelDetails)
- Rewrite UI: 3-tab layout (Export/Catalog/Search), progress tracking, phase trail
2026-03-06 06:53:49 +02:00
AI Assistant 7cdea66fa2 feat: add parcel-sync module (eTerra ANCPI integration with PostGIS)
- 31 eTerra layer catalog (terenuri, cladiri, documentatii, administrativ)
- Incremental sync engine (OBJECTID comparison, only downloads new features)
- PostGIS-ready Prisma schema (GisFeature, GisSyncRun, GisUat models)
- 7 API routes (/api/eterra/login, count, sync, features, layers/summary, progress, sync-status)
- Full UI with 3 tabs (Sincronizare, Parcele, Istoric)
- Env var auth (ETERRA_USERNAME / ETERRA_PASSWORD)
- Real-time sync progress tracking with polling
2026-03-06 00:36:29 +02:00
Marius Tarau 51dbfcb2bd feat: append ?embedded=1 to VIM iframe URL 2026-03-01 04:19:06 +02:00
Marius Tarau 24a5ba0598 fix: force-dynamic on visual-copilot page so VIM_URL is read at runtime 2026-03-01 04:13:01 +02:00
Marius Tarau 4d094ffd1b fix: pass VIM_URL env var to container in docker-compose 2026-03-01 04:08:00 +02:00
Marius Tarau 31cc71677e fix: VIM_URL as runtime server-side env var (not build-time NEXT_PUBLIC_) 2026-03-01 03:56:11 +02:00
Marius Tarau afdd349631 feat: Visual CoPilot module + collapsible sidebar
- Add visual-copilot module (iframe embed, env: NEXT_PUBLIC_VIM_URL)
- Sidebar collapse to icon-only with localStorage persistence
- Tooltips on collapsed nav items
- Full-viewport layout for canvas routes (/visual-copilot)
- Register module in modules.ts + feature flag in flags.ts
2026-03-01 03:52:43 +02:00
AI Assistant 5ca276fb26 docs: update CLAUDE.md, ROADMAP.md, SESSION-LOG.md
- Registratura v0.4.0 with QuickLook preview description
- New task 3.03d: QuickLook + bug fixes (NAS links, overflow, vault filter)
- Session log updated with all fixes and new features
- attachment-preview.tsx added to key files list
2026-02-28 19:47:28 +02:00
AI Assistant dcce341b8a feat(registratura): QuickLook-style attachment preview
New fullscreen preview modal for document attachments:
- Images: zoomable (scroll wheel + buttons), pannable when zoomed,
  zoom percentage display, reset with '0' key
- PDFs: native browser PDF viewer via blob URL iframe
- Navigation: left/right arrows (keyboard + buttons), bottom
  thumbnail strip when multiple attachments
- Actions: download, print, close (Esc)
- Dark overlay with smooth animations
- Preview button (eye icon) shown for images AND PDFs
- Replaced old inline image-only preview with new QuickLook modal

New file: attachment-preview.tsx (~450 lines)
Modified: registry-entry-detail.tsx (integrated preview)
2026-02-28 19:33:40 +02:00
AI Assistant 08b7485646 fix(registratura): redesign NAS attachment card to prevent overflow
Replaced two-row layout (short path + full UNC path) with compact
single-row: filename only + NAS badge + Copiaza/Copiat badge.
Full path shown in native tooltip on hover, copied on click.
Uses <button> instead of <div> for proper block formatting.
Removed font-mono (caused no-break overflow), removed shortDisplayPath
import, added pathFileName import, removed unused FolderOpen icon.
2026-02-28 19:20:13 +02:00
AI Assistant ea4f245467 fix(registratura): disable horizontal scroll on detail sheet ScrollArea
Radix ScrollArea Viewport allows horizontal scroll by default, causing
NAS attachment cards to extend beyond the sheet edge. Override viewport
overflow-x to hidden and move px-6 padding inside the content div so
the viewport constrains content width properly.
2026-02-28 19:07:27 +02:00
AI Assistant 26f0033c60 fix: NAS attachment overflow in detail sheet + vault filter reset
- Detail sheet: add overflow-hidden to ScrollArea content wrapper so
  NAS attachment cards with long paths don't push badges/copy icon
  beyond the sheet boundary
- Password vault: reset category filter to 'all' and clear search
  after adding/editing an entry so user sees the new entry immediately
  instead of landing on a filtered empty view
2026-02-28 18:55:04 +02:00
AI Assistant 7a1aee15fd fix(registratura): NAS attachment card overflow in detail sheet
Add overflow-hidden to card container, min-w-0 to flex row, and
truncate to path text div so long NAS paths don't push badges
and copy icon outside the visible area.
2026-02-28 18:04:54 +02:00
AI Assistant 05efd525e3 fix(registratura): NAS links copy to clipboard instead of broken file:///
Browsers block file:/// URLs from web pages for security. Changed:
- Detail sheet: click on NAS attachment copies path to clipboard with
  'Copiat!' green badge feedback. Full UNC path shown below.
- Entry form: NAS link click = copy path (removed window.open fallback)
- Removed unused toFileUrl/toFileUrlByIp imports from form
- User pastes in Explorer address bar to open the file
2026-02-28 17:55:27 +02:00
AI Assistant 4dae06be44 feat(registratura): detail sheet side panel + configurable column visibility
- New registry-entry-detail.tsx: full entry visualization in Sheet (side panel)
  - Status badges, document info, parties, dates, thread links
  - Attachment preview: images inline, NAS paths with IP fallback
  - Legal deadlines, external tracking, tags, notes sections
  - Action buttons: Editează, Închide, Șterge
- Registry table rewrite:
  - 10 column defs with Romanian tooltip explanations on each header
  - Column visibility dropdown (Settings icon) with checkboxes
  - Default: Nr/Data/Dir/Subiect/Exped./Dest./Status (7/10)
  - Persisted in localStorage (registratura:visible-columns)
  - Row click opens detail sheet, actions reduced to Eye + Pencil
- Docs updated: CLAUDE.md, ROADMAP.md (3.03c), SESSION-LOG.md
2026-02-28 17:45:18 +02:00
AI Assistant f4b1d4b8dd feat(registratura): all 4 NAS drives (A/O/P/T) + hostnameIP fallback
- nas-paths.ts: A:\=Arhiva, O:\=Organizare, P:\=Proiecte, T:\=Transfer
- toUncPathByIp() / toFileUrlByIp() helpers for DNS failure fallback
- shareLabelFor() returns human-readable share name for badges
- UI: 'IP' fallback link on hover, badge shows share label
- Validation hints updated to show all 4 drive letters
- Docs updated: CLAUDE.md, ROADMAP.md, SESSION-LOG.md
2026-02-28 17:23:38 +02:00
AI Assistant 4f00cb2de8 feat(registratura): NAS network path attachments (\\newamun / P:\)
- New nas-paths.ts config: drive mappings, UNC normalization, file:/// URL builder
- RegistryAttachment type extended with optional networkPath field
- 'Link NAS' button in attachment section opens inline path input
- Network path entries shown with blue HardDrive icon + NAS badge
- Click opens in Explorer via file:/// URL, copy path button on hover
- P:\ auto-converted to \\newamun\Proiecte UNC path
- Short display path shows share + last 2 segments
- Validation: warns if path doesn't match known NAS mappings
2026-02-28 17:13:26 +02:00
AI Assistant eaaec49eb1 fix: branding uses possessive form 'Mihai''s Tools' 2026-02-28 17:03:48 +02:00
AI Assistant 58fa46ced2 feat: personalized branding - show '{FirstName} Tools' in sidebar when logged in 2026-02-28 16:58:34 +02:00
AI Assistant d0b51be50a docs: update SESSION-LOG, ROADMAP, CLAUDE.md with Parole Uzuale v0.3.0 + WiFi QR 2026-02-28 16:49:04 +02:00
AI Assistant f7a2372e56 feat(vault): real QR code generation for WiFi entries
Replace placeholder WiFi string display with actual QR code rendered
on canvas via 'qrcode' library. Dialog now shows scannable QR image
with copy-image and download-PNG buttons.
2026-02-28 16:47:21 +02:00
AI Assistant 6295508f99 docs: update SESSION-LOG with session 2026-02-28b 2026-02-28 16:34:20 +02:00
AI Assistant 3abf0d189c feat: Registratura thread explorer, AC validity tracker, interactive I/O toggle + Password Vault rework
Registratura improvements:
- Thread Explorer: new 'Fire conversatie' tab with timeline view, search, stats, gap tracking (la noi/la institutie), export to text report
- Interactive I/O toggle: replaced direction dropdown with visual blue/orange button group (Intrat/Iesit with icons)
- Doc type UX: alphabetical sort + immediate selection after adding custom type
- AC Validity Tracker: full Autorizatie de Construire lifecycle workflow (12mo validity, execution phases, extension request, required docs checklist, monthly reminders, abandonment/expiry tracking)

Password Vault rework (renamed to 'Parole Uzuale' v0.3.0):
- New categories: WiFi, Portale Primarii, Avize Online, PIN Semnatura, Software, Hardware (replaced server/database/api)
- Category icons (lucide-react) throughout list and form
- WiFi QR code dialog with connection string copy
- Context-aware form (PIN vs password label, hide email for WiFi/PIN, hide URL for WiFi, hide generator for PIN)
- Dynamic stat cards showing top 3 categories by count
- Removed encryption banner
- Updated i18n, flags, config
2026-02-28 16:33:36 +02:00
AI Assistant 25338ea4d8 docs: QA checklist + full documentation update for Phase 3 completion
- QA-CHECKLIST.md: ~120 test items covering all Phase 3 features
- CLAUDE.md: modules table with versions, integrations (AI Chat, Vault, ManicTime)
- ROADMAP.md: status table updated (all 14 COMPLETE), Phase 5 updated
- SESSION-LOG.md: session entry for 2026-02-28
- SESSION-GUIDE.md: added QA Bug Fix prompt (4B), QA-CHECKLIST raw URL
2026-02-28 05:06:00 +02:00
AI Assistant a25cc40d8a docs: update ROADMAP.md mark 3.15 complete 2026-02-28 04:52:09 +02:00
AI Assistant d34c722167 feat(3.15): AI Tools extindere si integrare
Prompt Generator:
- Search bar cu cautare in name/description/tags/category
- Filtru target type (text/image) cu toggle rapid 'Imagine'
- 4 template-uri noi imagine: Midjourney Exterior, SD Interior,
  Midjourney Infographic, SD Material Texture (18 total)
- Config v0.2.0

AI Chat  Real API Integration:
- /api/ai-chat route: multi-provider (OpenAI, Anthropic, Ollama, demo)
- System prompt default in romana pt context arhitectura
- GET: config status, POST: message routing
- use-chat.ts: sendMessage() cu fetch real, sending state,
  providerConfig fetch, updateSession() pt project linking
- UI: provider status badge (Wifi/WifiOff), Bot icon pe mesaje,
  loading spinner la generare, disable input while sending
- Config banner cu detalii provider/model/stare

AI Chat + Tag Manager:
- Project selector dropdown in chat header (useTags project)
- Session linking: projectTagId + projectName on ChatSession
- Project name display in session sidebar
- Project context injected in system prompt

Docker:
- AI env vars: AI_PROVIDER, AI_API_KEY, AI_MODEL, AI_BASE_URL, AI_MAX_TOKENS
2026-02-28 04:51:36 +02:00
AI Assistant 11b35c750f 3.13 Tag Manager ManicTime sync bidirectional sync, backup, hierarchy validation
- ManicTime parser service: parse/serialize Tags.txt format, classify lines into project/phase/activity
- API route /api/manictime: GET (read + sync plan), POST (pull/push/both with backup versioning)
- ManicTimeSyncPanel component: connection check, stats grid, import/export/full sync with confirmation dialog
- Integrated into Tag Manager module with live sync status
- Docker: MANICTIME_TAGS_PATH env var, SMB volume mount /mnt/manictime
- Hierarchy validation: project codes, company association, duplicate detection
- Version bump to 0.2.0
2026-02-28 04:38:57 +02:00
AI Assistant 99fbdddb68 3.03 Registratura Termene Legale recipient registration, audit log, expiry tracking
- Added recipientRegNumber/recipientRegDate fields for outgoing docs (deadline triggers from recipient registration date)
- Added prelungire-CU deadline type in catalog (15 calendar days, tacit approval)
- CU category already first in catalog  verified
- DeadlineAuditEntry interface + audit log on TrackedDeadline (created/resolved entries)
- Document expiry tracking: expiryDate + expiryAlertDays with live countdown
- Web scraping prep fields: externalStatusUrl + externalTrackingId
- Dashboard: 6 stat cards (added missing recipient + expiring soon)
- Alert banners for missing recipient data and expiring documents
- Version bump to 0.2.0
2026-02-28 04:31:32 +02:00
AI Assistant 85bdb59da4 3.14 Password Vault encryption AES-256-GCM server-side
- Created src/core/crypto/ with AES-256-GCM encrypt/decrypt (PBKDF2 key derivation)
- Created /api/vault route: CRUD with server-side password encryption
- PATCH /api/vault migration endpoint to re-encrypt legacy plaintext passwords
- Rewrote use-vault hook to use dedicated /api/vault instead of generic storage
- Updated UI: amber 'not encrypted' warning  green 'encrypted' badge
- Added ENCRYPTION_SECRET env var to docker-compose.yml and stack.env
- Module version bumped to 0.2.0
2026-02-28 04:12:44 +02:00
AI Assistant f0b3659247 ROADMAP: mark 3.06 Template Library as done 2026-02-28 02:34:29 +02:00
AI Assistant 5992fc867d 3.06 Template Library Redenumire, Versionare, Multi-format
- Renamed from 'Sabloane Word' to 'Biblioteca Sabloane' (Template Library)
- Multi-format support: Word, Excel, PDF, DWG, Archicad with auto-detection
- Auto versioning: 'Revizie Noua' button archives current version, bumps semver
- Version history dialog: browse and download any previous version
- Simplified UX: file upload vs external link toggle, auto-detect placeholders
  silently for .docx, hide placeholders section for non-Word formats
- File type icons: distinct icons for docx, xlsx, archicad, dwg, pdf
- Updated stats cards: Word/Excel count, DWG/Archicad count, versioned count
- Backward compatible: old entries without fileType/versionHistory get defaults
2026-02-28 02:33:57 +02:00
AI Assistant 4f6964ac41 ROADMAP: mark 3.05 Email Signature as done 2026-02-28 02:02:32 +02:00
AI Assistant b4338571cc 3.05 Email Signature Automatizare si Branding
- AD prefill: 'Din cont' button pre-fills name + company from Authentik session
- Logo size slider: 50%-200% scale control in Stil & Aranjare section
- Promotional banner: configurable image+link below signature with preview
- US/SDT custom graphics: dedicated dash (US) and dot (SDT) decorative icons
  replacing Beletage's grey/accent slashes for company-specific branding
2026-02-28 02:02:04 +02:00
AI Assistant 0a939417d8 ROADMAP: mark 3.12 Mini Utilities as done 2026-02-28 01:55:12 +02:00
AI Assistant 989a9908ba 3.12 Mini Utilities Extindere si Fix-uri
- NumberToText: Romanian number-to-words converter (lei/bani, compact, spaced)
- ColorPaletteExtractor: image upload -> top 8 color swatches with hex copy
- AreaConverter: bidirectional (mp, ari, ha, km2, sq ft)
- UValueConverter: bidirectional U->R and R->U toggle
- MDLPA: replaced broken iframe with 3 styled external link cards
- PdfReducer: drag-and-drop, simplified to 2 levels, Deblocare PDF + PDF/A links
- DWG-to-DXF skipped (needs backend service)
2026-02-28 01:54:40 +02:00
AI Assistant 6535c8ce7f docs: mark 3.07 Digital Signatures as done 2026-02-28 00:18:57 +02:00
AI Assistant 7774a3b622 feat(digital-signatures): simplify form, add TIFF support, subcategories, download options
- Remove 'initials' type, expirationDate, legalStatus, usageNotes fields
- Add subcategory field with creatable input + 6 default categories
- Add TIFF upload support with client-side utif2 decode for preview
- Store original TIFF data separately for faithful downloads
- Add download dropdown: Original file, Word (.docx), PDF (.pdf)
- Group assets by subcategory in list view
- Add subcategory filter in search bar
- Install docx, jspdf, utif2 packages
- Closes 3.07
2026-02-28 00:18:29 +02:00
AI Assistant a0ec4aed3f fix: move blob migration server-side, restore lightweight list loading
The client-side migration was downloading 25-50MB of base64 data to the
browser before showing anything. getAllEntries also lost its lightweight flag.

Fix:
- New POST /api/storage/migrate-blobs endpoint runs entirely server-side
  (loads entries one-at-a-time from PostgreSQL, never sends heavy data to browser)
- Restore lightweight:true on getAllEntries (strips remaining base64 in API)
- Migration fires on mount (fire-and-forget) while list loads independently
- Remove client-side migrateEntryBlobs function
2026-02-28 00:03:26 +02:00
AI Assistant 578f6580a4 fix: remove raw SQL query that may cause Docker build/runtime issues
Replace complex prisma.\ with simple Prisma findMany + JS stripping.
Now that entries are inherently small (base64 in separate blob namespace),
JS-based stripping is instant. Also fix migration to check flag before loading.
2026-02-27 23:53:37 +02:00
AI Assistant f8c19bb5b4 perf: separate blob storage for registratura attachments
Root cause: even with SQL-level stripping, PostgreSQL must TOAST-decompress
entire multi-MB JSONB values from disk before any processing. For 5 entries
with PDF attachments (25-50MB total), this takes several seconds.

Fix: store base64 attachment data in separate namespace 'registratura-blobs'.
Main entries are inherently small (~1-2KB). List queries never touch heavy data.

Changes:
- registry-service.ts: extractBlobs/mergeBlobs split base64 on save/load,
  migrateEntryBlobs() one-time migration for existing entries
- use-registry.ts: dual namespace (registratura + registratura-blobs),
  migration runs on first mount
- registratura-module.tsx: removed useContacts/useTags hooks that triggered
  2 unnecessary API fetches on page load (write-only ops use direct storage)

Before: 3 API calls on mount, one reading 25-50MB from PostgreSQL
After: 1 API call on mount, reading ~5-10KB total
2026-02-27 23:35:04 +02:00
AI Assistant 8385041bb0 perf: strip heavy base64 data at PostgreSQL level using raw SQL
Previous fix stripped data in Node.js AFTER Prisma loaded the full JSON
from PostgreSQL. For 5 entries with PDF attachments, this still meant
25-50MB transferring from DB to Node.js on every page load.

Now uses prisma.\ with nested jsonb_each/jsonb_object_agg to
strip data/fileData/imageUrl strings >1KB inside the database itself.
Heavy base64 never leaves PostgreSQL when lightweight=true.
2026-02-27 23:23:38 +02:00
AI Assistant 962d2a0229 docs: add base64 payload finding to SESSION-LOG and CLAUDE.md rules 2026-02-27 22:38:33 +02:00
AI Assistant c22848b471 perf(registratura): lightweight API mode strips base64 attachments from list
ROOT CAUSE: RegistryEntry stores file attachments as base64 strings in JSON.
A single 5MB PDF becomes ~6.7MB of base64. With 6 entries, the exportAll()
endpoint was sending 30-60MB of JSON on every page load  taking 2+ minutes.

Fix: Added ?lightweight=true parameter to /api/storage GET endpoint.
When enabled, stripHeavyFields() recursively removes large 'data' and
'fileData' string fields (>1KB) from JSON values, replacing with '__stripped__'.

Changes:
- /api/storage route.ts: stripHeavyFields() + lightweight query param
- StorageService.export(): accepts { lightweight?: boolean } option
- DatabaseStorageAdapter.export(): passes lightweight flag to API
- LocalStorageAdapter.export(): accepts option (no-op, localStorage is fast)
- useStorage.exportAll(): passes options through
- registry-service.ts: getAllEntries() uses lightweight=true by default
- registry-service.ts: new getFullEntry() loads single entry with full data
- use-registry.ts: exports loadFullEntry() for on-demand full loading
- registratura-module.tsx: handleEdit/handleNavigateEntry load full entry

Result: List loading transfers ~100KB instead of 30-60MB. Editing loads
full data for a single entry on demand (~5-10MB for one entry vs all).
2026-02-27 22:37:39 +02:00
AI Assistant db9bcd7192 docs: document N+1 performance bug findings and prevention rules 2026-02-27 22:27:07 +02:00
AI Assistant c45a30ec14 perf: fix N+1 query pattern across all modules + rack numbering
CRITICAL PERF BUG: Every hook did storage.list() (1 HTTP call fetching ALL
items with values, discarding values, returning only keys) then storage.get()
for EACH key (N individual HTTP calls re-fetching values one by one).

With 6 entries + contacts + tags, Registratura page fired ~40 sequential
HTTP requests on load, where 3 would suffice.

Fix: Replace list()+N*get() with single exportAll() call in ALL hooks:
- registratura/registry-service.ts (added exportAll to RegistryStorage interface)
- address-book/use-contacts.ts
- it-inventory/use-inventory.ts
- password-vault/use-vault.ts
- word-templates/use-templates.ts
- prompt-generator/use-prompt-generator.ts
- hot-desk/use-reservations.ts
- email-signature/use-saved-signatures.ts
- digital-signatures/use-signatures.ts
- ai-chat/use-chat.ts
- core/tagging/tag-service.ts (uses storage.export())

Additional fixes:
- registratura/use-registry.ts: addEntry uses optimistic local state update
  instead of double-refresh; closeEntry batches saves with Promise.all +
  single refresh
- server-rack.tsx: reversed slot rendering so U1 is at bottom (standard
  rack numbering, per user's physical rack)

Performance impact: ~90% reduction in HTTP requests on page load for all modules
2026-02-27 22:26:11 +02:00
AI Assistant 4cd793fbbc docs: update ROADMAP.md and SESSION-LOG.md for task 3.08 2026-02-27 22:05:56 +02:00
AI Assistant 346e40d788 feat(it-inventory): dynamic types, rented status, rack visualization, simplified form
- Rewrite types.ts: dynamic InventoryItemType (string-based), DEFAULT_EQUIPMENT_TYPES with server/switch/ups/patch-panel, RACK_MOUNTABLE_TYPES set, new 'rented' status with STATUS_LABELS export
- Remove deprecated fields: assignedTo, assignedToContactId, purchaseDate, purchaseCost, warrantyExpiry
- Add rack fields: rackPosition (1-42), rackSize (1-4U) on InventoryItem
- New server-rack.tsx: 42U rack visualization with color-coded status slots, tooltips, occupied/empty rendering
- Rewrite it-inventory-module.tsx: tabbed UI (Inventar + Rack 42U), 5 stat cards with purple pulse for rented count, inline custom type creation, conditional rack position fields for mountable types, simplified form
- Fix search filter in use-inventory.ts: remove assignedTo reference, search rackLocation/location

Task 3.08 complete
2026-02-27 22:04:47 +02:00
AI Assistant 8042df481f fix(registratura): prevent duplicate numbers, add upload progress, submission lock, unified close/resolve, backdating support
- generateRegistryNumber: parse max existing number instead of counting entries
- addEntry: fetch fresh entries before generating number (race condition fix)
- Form: isSubmitting lock prevents double-click submission
- Form: uploadingCount tracks FileReader progress, blocks submit while uploading
- Form: submit button shows Loader2 spinner during save/upload
- CloseGuardDialog: added ClosureResolution selector (finalizat/aprobat-tacit/respins/retras/altele)
- ClosureBanner: displays resolution badge
- Types: ClosureResolution type, registrationDate field on RegistryEntry
- Date field renamed 'Data document' with tooltip explaining backdating
- Registry table shows '(înr. DATE)' when registrationDate differs from document date
2026-02-27 21:56:47 +02:00
AI Assistant db6662be39 feat(registratura): structured ClosureInfo who/when/why/attachment for every close
- Added ClosureInfo type with reason, closedBy, closedAt, linkedEntry, hadActiveDeadlines, attachment
- Rewrote close-guard-dialog into universal close dialog (always shown on close)
  - Reason field (always required)
  - Optional continuation entry search+link
  - Optional closing document attachment (file upload)
  - Active deadlines shown as warning banner when present
- Created ClosureBanner component (read-only, shown at top of closed entry edit)
  - Shows who, when, why, linked entry (clickable), attached doc (downloadable)
- All closes now go through the dialog  no more silent closeEntry
- Linked-entries sub-dialog preserved as second step
2026-02-27 17:06:03 +02:00
AI Assistant 5b99ad0400 fix(registratura): add 'certificat' to deadline-add-dialog CATEGORIES array 2026-02-27 16:53:06 +02:00
AI Assistant 80e41d4842 fix(registratura): 5 post-3.02 fixes - QuickContact pre-fill (useEffect sync), form close on contact create (stopPropagation), Switch label 'Inchis' -> 'Status', CU moved from Avize to own category, close guard for active deadlines 2026-02-27 16:02:10 +02:00
AI Assistant 2be0462e0d feat(registratura): 3.02 bidirectional integration, simplified status, threads
- Dynamic document types: string-based DocumentType synced with Tag Manager
  (new types auto-create tags under 'document-type' category)
- Added default types: 'Apel telefonic', 'Videoconferinta'
- Bidirectional Address Book: quick-create contacts from sender/recipient/
  assignee fields via QuickContactDialog popup
- Simplified status: Switch toggle replaces dropdown (default open)
- Responsabil (Assignee) field with contact autocomplete (ERP-ready)
- Entry threads: threadParentId links entries as replies, ThreadView shows
  parent/current/children tree with branching support
- Info tooltips on deadline, status, and assignee fields
- New Resp. column and thread icon in registry table
- All changes backward-compatible with existing data
2026-02-27 15:33:29 +02:00
AI Assistant b2618c041d docs: update CLAUDE.md, ROADMAP.md, SESSION-LOG.md with all session findings 2026-02-27 13:26:45 +02:00
AI Assistant f6f7cf5982 fix: logo hydration + overflow, redesign theme toggle with Sun/Moon icons 2026-02-27 12:49:45 +02:00
AI Assistant ed9bbfe60a feat: redesign sidebar logos (wider, theme-aware) + animated sun/moon theme toggle 2026-02-27 12:36:39 +02:00
AI Assistant daa38420f7 fix: dark mode logo variants + increase logo size to 40px 2026-02-27 12:20:54 +02:00
AI Assistant dafbc1c69a feat: add Beletage logo + interactive 3-logo mini-game with secret combo confetti 2026-02-27 12:12:49 +02:00
AI Assistant 042e4e1108 feat: 3.01 header logos+nav, 3.09 dynamic contact types, 3.10 hot-desk window, 3.11 vault email+link 2026-02-27 11:33:20 +02:00
AI Assistant 100e52222e fix(deploy): hardcode env vars in docker-compose to ensure they reach the container 2026-02-27 11:01:06 +02:00
AI Assistant 041e2f00cb chore: add stack.env for Portainer Git deployment 2026-02-27 10:48:16 +02:00
AI Assistant ccba14a05e fix(deploy): add env vars to docker-compose, prisma generate in Dockerfile, move @prisma/client to deps 2026-02-27 10:43:32 +02:00
AI Assistant 0ad7e835bd feat(core): setup postgres, minio, and authentik next-auth 2026-02-27 10:29:54 +02:00
AI Assistant 3b1ba589f0 feat: add Hot Desk module (Phase 2) 4-desk booking with 2-week window, room layout, calendar, subtle unbooked-day alerts 2026-02-19 08:10:50 +02:00
AI Assistant 6cb655a79f docs: mark Phase 1 tasks 1.12 and 1.13 complete 2026-02-19 07:12:53 +02:00
AI Assistant eaca24aa58 feat(word-xml): remove POT/CUT auto-calculation toggle 2026-02-19 07:12:21 +02:00
AI Assistant cd4b0de1e9 feat(registratura): linked-entry search filter, remove 20-item cap 2026-02-19 07:08:59 +02:00
AI Assistant 1f2af98f51 feat(dashboard): activity feed and KPI panels 2026-02-19 07:05:41 +02:00
AI Assistant 713a66bcd9 feat(word-templates): placeholder auto-detection from .docx via JSZip 2026-02-19 07:02:12 +02:00
AI Assistant 67fd88813a docs: mark task 1.09 complete in ROADMAP and SESSION-LOG 2026-02-19 06:58:11 +02:00
AI Assistant da33dc9b81 feat(address-book): vCard export and Registratura reverse lookup 2026-02-19 06:57:40 +02:00
AI Assistant 35305e4389 docs: update SESSION-LOG and ROADMAP for tasks 1.07 and 1.08 2026-02-19 06:44:21 +02:00
332 changed files with 84218 additions and 6957 deletions
+6
View File
@@ -8,4 +8,10 @@ node_modules
*.md
docs/
legacy/
dwg2dxf-api/
.DS_Store
.claude/
.vscode/
.idea/
*.log
*.tsbuildinfo
+24 -14
View File
@@ -8,7 +8,7 @@
NEXT_PUBLIC_APP_NAME=ArchiTools
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Storage adapter: 'localStorage' (default) | 'api' | 'minio'
# Storage adapter: 'localStorage' (default) | 'database'
NEXT_PUBLIC_STORAGE_ADAPTER=localStorage
# Feature flag overrides (set to 'true' or 'false')
@@ -25,22 +25,32 @@ NEXT_PUBLIC_STORAGE_ADAPTER=localStorage
# NEXT_PUBLIC_FLAG_MODULE_MINI_UTILITIES=false
# NEXT_PUBLIC_FLAG_MODULE_AI_CHAT=false
# Future: API storage backend
# STORAGE_API_URL=http://api.internal/storage
# =============================================================================
# PostgreSQL Database (required when STORAGE_ADAPTER=database)
# =============================================================================
DATABASE_URL=postgresql://USER:PASSWORD@10.10.10.166:5432/architools_db?schema=public
# Future: MinIO object storage
# MINIO_ENDPOINT=10.10.10.166:9003
# MINIO_ACCESS_KEY=
# MINIO_SECRET_KEY=
# MINIO_BUCKET=architools
# =============================================================================
# MinIO Object Storage
# =============================================================================
MINIO_ENDPOINT=10.10.10.166
MINIO_PORT=9002
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=admin
MINIO_SECRET_KEY=your-minio-secret
MINIO_BUCKET_NAME=tools
# Future: Authentik SSO
# AUTHENTIK_URL=http://10.10.10.166:9100
# AUTHENTIK_CLIENT_ID=
# AUTHENTIK_CLIENT_SECRET=
# =============================================================================
# Authentication (Authentik OIDC)
# =============================================================================
NEXTAUTH_URL=https://tools.beletage.ro
NEXTAUTH_SECRET=generate-with-openssl-rand-base64-32
AUTHENTIK_CLIENT_ID=your-authentik-client-id
AUTHENTIK_CLIENT_SECRET=your-authentik-client-secret
AUTHENTIK_ISSUER=https://auth.beletage.ro/application/o/architools/
# Future: N8N automation
# N8N_WEBHOOK_URL=http://10.10.10.166:5678/webhook
# PMTiles rebuild webhook (pmtiles-webhook systemd service on satra)
N8N_WEBHOOK_URL=http://10.10.10.166:9876
# External tool URLs (displayed in dashboard)
NEXT_PUBLIC_GITEA_URL=http://10.10.10.166:3002
BIN
View File
Binary file not shown.
+197 -266
View File
@@ -1,266 +1,197 @@
# 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
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 an architecture/engineering office group of 3 companies:
- **Beletage** (architecture)
- **Urban Switch** (urbanism)
- **Studii de Teren** (geotechnics)
It runs on an on-premise Ubuntu server at `10.10.10.166`, containerized with Docker, managed via Portainer, served by Nginx Proxy Manager.
### Stack
| Layer | Technology |
|---|---|
| Framework | Next.js 16.x, App Router, TypeScript (strict) |
| Styling | Tailwind CSS v4, shadcn/ui |
| State | localStorage (via StorageService abstraction) |
| Deploy | Docker multi-stage, Portainer, Nginx Proxy Manager |
| Repo | Gitea at `http://10.10.10.166:3002/gitadmin/ArchiTools` |
| Language | Code in **English**, UI in **Romanian** |
### Architecture Principles
- **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 (localStorage default, designed for future DB/MinIO)
- **Cross-module tagging system** as shared service
- **Auth stub** designed for future Authentik SSO integration
- **All entities** include `visibility` / `createdBy` fields from day one
---
## Repository Structure
```
src/
├── app/ # Routing only (thin wrappers)
│ ├── (modules)/ # Module route pages
── layout.tsx # App shell
├── core/ # Platform services
│ ├── module-registry/ # Module registration + types
── feature-flags/ # Flag evaluation + env override
├── storage/ # StorageService + adapters
│ │ └── adapters/ # localStorage adapter (+ future DB/MinIO)
│ ├── tagging/ # Cross-module tag service
│ ├── i18n/ # Romanian translations
│ ├── theme/ # Light/dark theme
│ └── auth/ # Auth types + stub (future Authentik)
├── modules/ # Module business logic
│ ├── <module-name>/
│ │ ├── components/ # Module UI components
│ │ ├── hooks/ # Module-specific hooks
│ │ ├── services/ # Module business logic
│ │ ├── types.ts # Module types
│ │ ├── config.ts # Module metadata
│ │ └── index.ts # Public exports
│ └── ...
├── shared/ # Shared UI
│ ├── components/
│ │ ├── ui/ # shadcn/ui primitives
│ │ ├── layout/ # Sidebar, Header
│ │ └── common/ # Reusable app components
│ ├── hooks/ # Shared hooks
│ └── lib/ # Utils (cn, etc.)
├── config/ # Global config
│ ├── modules.ts # Module registry entries
│ ├── flags.ts # Default feature flags
│ ├── navigation.ts # Sidebar nav structure
│ └── companies.ts # Company definitions
docs/ # 16 internal technical docs
legacy/ # Original HTML tools for reference
```
---
## Implemented Modules (13/13 — zero placeholders)
| # | Module | Route | Key Features |
|---|---|---|---|
| 1 | **Dashboard** | `/` | Stats cards, module grid, external tools by category |
| 2 | **Email Signature** | `/email-signature` | Multi-company branding, live preview, zoom/copy/download |
| 3 | **Word XML Generator** | `/word-xml` | Category-based XML gen, simple/advanced mode, ZIP export |
| 4 | **Registratura** | `/registratura` | CRUD registry, stats, filters, **legal deadline tracking** |
| 5 | **Tag Manager** | `/tag-manager` | CRUD tags, category/scope/color, grouped display |
| 6 | **IT Inventory** | `/it-inventory` | Equipment tracking, type/status/company filters |
| 7 | **Address Book** | `/address-book` | CRUD contacts, card grid, search/type filter |
| 8 | **Password Vault** | `/password-vault` | CRUD credentials, show/hide/copy, category filter |
| 9 | **Mini Utilities** | `/mini-utilities` | Text case, char counter, percentage calc, area converter |
| 10 | **Prompt Generator** | `/prompt-generator` | Template-driven prompt builder, 4 builtin templates |
| 11 | **Digital Signatures** | `/digital-signatures` | CRUD signature/stamp/initials assets |
| 12 | **Word Templates** | `/word-templates` | Template library, 8 categories, version tracking |
| 13 | **AI Chat** | `/ai-chat` | Session-based chat UI, demo mode (no API keys yet) |
### Registratura — Legal Deadline Tracking (Termene Legale)
The Registratura module includes a full legal deadline tracking engine for Romanian construction permitting:
- **16 deadline types** across 5 categories (Avize, Completări, Analiză, Autorizare, Publicitate)
- **Working days vs calendar days** with Romanian public holiday support (including Orthodox Easter via Meeus algorithm)
- **Backward deadlines** (e.g., AC extension: 45 working days BEFORE expiry)
- **Chain deadlines** (resolving one prompts adding the next)
- **Tacit approval** (auto-detected when overdue + applicable type)
- **Tabbed UI**: "Registru" tab (existing registry) + "Termene legale" tab (deadline dashboard)
Key files:
- `services/working-days.ts` — Romanian holidays, `addWorkingDays()`, `isWorkingDay()`
- `services/deadline-catalog.ts` — 16 `DeadlineTypeDef` entries
- `services/deadline-service.ts``createTrackedDeadline()`, `resolveDeadline()`, `aggregateDeadlines()`
- `components/deadline-dashboard.tsx` — Stats + filters + table
- `components/deadline-add-dialog.tsx` — 3-step wizard (category → type → date preview)
---
## Infrastructure
### Server: `10.10.10.166` (Ubuntu)
| Service | Port | Purpose |
|---|---|---|
| **ArchiTools** | 3000 | This app |
| **Gitea** | 3002 | Git hosting (`gitadmin/ArchiTools`) |
| **Portainer** | 9000 | Docker management, auto-deploy on push |
| **Nginx Proxy Manager** | 81 (admin) | Reverse proxy + SSL termination |
| **Uptime Kuma** | 3001 | Service monitoring |
| **MinIO** | 9003 | Object storage (future) |
| **N8N** | 5678 | Workflow automation (future) |
| **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 |
| **Authentik** | 9100 | SSO (future) |
### Deployment Pipeline
```
git push origin main
→ Gitea webhook fires
→ Portainer auto-redeploys stack
→ Docker multi-stage build (~1-2 min)
→ Container starts on :3000
→ Nginx Proxy Manager routes traffic
```
### Docker
- `Dockerfile`: 3-stage build (deps → builder → runner), `node:20-alpine`, non-root user
- `docker-compose.yml`: single service, port 3000, watchtower label
- `output: 'standalone'` in `next.config.ts` is **required**
---
## 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 }>`
### 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
### 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
```
### 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 |
---
## Future Integrations (not yet implemented)
| Feature | Status | Notes |
|---|---|---|
| **Authentik SSO** | Auth stub exists | `src/core/auth/` has types + provider shell |
| **MinIO storage** | Adapter pattern ready | Switch `NEXT_PUBLIC_STORAGE_ADAPTER` to `minio` |
| **API backend** | Adapter pattern ready | Switch to `api` adapter when backend exists |
| **AI Chat API** | UI complete, demo mode | No API keys yet; supports Claude/GPT/Ollama |
| **N8N automations** | Webhook URL configured | For notifications, backups, workflows |
---
## 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 |
# ArchiTools — Project Context for AI Assistants
## Quick Start
```bash
npm install
npm run dev # http://localhost:3000
npx next build # verify zero errors before pushing
git push origin main # manual redeploy via Portainer UI
```
---
## Project Overview
**ArchiTools** is a modular internal web dashboard for 3 architecture/engineering companies:
**Beletage** (architecture), **Urban Switch** (urbanism), **Studii de Teren** (geotechnics).
Production: `tools.beletage.ro` — Docker on-premise, Portainer CE, Traefik v3 proxy.
### Stack
| Layer | Technology |
| ---------- | ------------------------------------------------------- |
| Framework | Next.js 16.x, App Router, TypeScript (strict) |
| Styling | Tailwind CSS v4, shadcn/ui |
| Database | PostgreSQL + PostGIS via Prisma v6 ORM |
| Storage | `DatabaseStorageAdapter` (Prisma), localStorage fallback |
| Files | MinIO (S3-compatible object storage) |
| Auth | NextAuth v4 + Authentik OIDC (auth.beletage.ro) |
| Deploy | Docker multi-stage → Portainer CE → Traefik v3 + SSL |
| Repo | Gitea at `git.beletage.ro/gitadmin/ArchiTools` |
| Language | Code: **English**, UI: **Romanian** |
### Architecture Principles
- **Module platform** — each module isolated: own types/services/hooks/components
- **Feature flags** gate loading (disabled = zero bundle cost)
- **Storage abstraction** via `StorageService` interface + adapters
- **Auth via Authentik SSO** — group → role/company mapping
- **All entities** include `visibility` / `createdBy` from day one
---
## Repository Structure
```
src/
├── app/(modules)/ # Route pages (thin wrappers)
├── core/ # Platform: auth, storage, flags, tagging, i18n, theme
├── modules/<name>/ # Module business logic (see MODULE-MAP.md)
│ ├── components/ # UI components
│ ├── hooks/ # Module hooks
── services/ # Business logic
│ ├── types.ts # Interfaces
│ ├── config.ts # Module metadata
── index.ts # Public exports
├── shared/components/ # ui/ (shadcn), layout/ (sidebar/header), common/
├── config/ # modules.ts, flags.ts, navigation.ts, companies.ts
docs/ # Architecture, guides, module deep-dives
```
---
## Modules (17 total)
| Module | Route | Key Features |
| ------------------ | ------------------- | --------------------------------------------------- |
| Dashboard | `/` | KPI cards, activity feed, module grid |
| Email Signature | `/email-signature` | Multi-company, live preview, copy/download |
| Word XML | `/word-xml` | Category-based XML, simple/advanced, ZIP export |
| Registratura | `/registratura` | Registry CRUD, legal deadlines, notifications, NAS |
| Tag Manager | `/tag-manager` | Tags CRUD, ManicTime sync |
| IT Inventory | `/it-inventory` | Equipment, rack visualization, filters |
| Address Book | `/address-book` | Contacts, vCard, Registratura integration |
| Password Vault | `/password-vault` | AES-256-GCM encrypted, WiFi QR, multi-user |
| Mini Utilities | `/mini-utilities` | 12+ tools: PDF compress, OCR, converters, calc |
| Prompt Generator | `/prompt-generator` | 18 templates, text + image targets |
| Digital Signatures | `/digital-signatures` | Assets CRUD, file upload, tags |
| Word Templates | `/word-templates` | Template library, .docx placeholder detection |
| AI Chat | `/ai-chat` | Multi-provider (OpenAI/Claude/Ollama) |
| Hot Desk | `/hot-desk` | 4 desks, week calendar, room layout |
| ParcelSync | `/parcel-sync` | eTerra ANCPI, PostGIS, enrichment, ePay ordering |
| Geoportal | `/geoportal` | MapLibre viewer, parcel search, UAT layers |
| Visual CoPilot | `/visual-copilot` | Placeholder — separate repo |
See `docs/MODULE-MAP.md` for entry points, API routes, and cross-module deps.
---
## Development Rules
### TypeScript Strict Mode Gotchas
- `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 }>`
- Prisma `$queryRaw` returns `unknown[]` — cast with `as Array<{ field: type }>`
- `?? ""` on `{}` field produces `{}` not `string` — use `typeof` check
### Conventions
- **Code**: English | **UI text**: Romanian | **IDs**: uuid v4
- **Dates**: ISO strings (`YYYY-MM-DD` display, full ISO timestamps)
- **Components**: functional, `'use client'` where needed
- **No emojis** in code or UI
### Storage Performance (CRITICAL)
- **NEVER** `storage.list()` + `storage.get()` in loop — N+1 bug
- **ALWAYS** use `storage.exportAll()` or `storage.export(namespace)` for batch-load
- **NEVER** store base64 files in entity JSON — use `lightweight: true` for listing
- After mutations: optimistic update OR single `refresh()` — never both
### Middleware & Large Uploads
- 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`
- Excluded routes use `requireAuth()` from `auth-check.ts` instead
- To add new upload route: (1) exclude from middleware, (2) add `requireAuth()`
### eTerra / ANCPI Rules
- ArcGIS: paginate with `resultOffset`/`resultRecordCount` (max 1000)
- Sessions expire ~10min — cache TTL 9min, auto-relogin on 401
- Health check detects maintenance — block login when down
- `WORKSPACE_TO_COUNTY` (42 entries in `county-refresh.ts`) is authoritative
- `GisUat.geometry` is huge — always `select` to exclude in list queries
- Feature counts cached 5-min TTL
- ePay: form-urlencoded body, OpenAM auth, MinIO metadata must be ASCII
### Before Pushing
1. `npx next build` — zero errors
2. Test on `localhost:3000`
3. Commit with descriptive message
4. `git push origin main` → manual Portainer redeploy
---
## Common Pitfalls (Top 10)
1. **Middleware body buffering** — upload routes >10MB must be excluded from matcher
2. **N+1 storage queries** — use `exportAll()`, never `list()` + `get()` loop
3. **GisUat geometry in queries** — exclude with `select`, or 50ms → 5+ seconds
4. **Enrichment data loss on re-sync** — upsert must preserve enrichment field
5. **Ghostscript corrupts fonts** — use qpdf for PDF compression, never GS
6. **eTerra timeout too low** — geometry pages need 60-90s; default 120s
7. **Traefik 60s readTimeout** — must set 600s in static config for uploads
8. **Portainer CE can't inject env vars** — all env in docker-compose.yml
9. **`@prisma/client` in dependencies** (not devDeps) — runtime requirement
10. **`output: 'standalone'`** in next.config.ts — required for Docker
---
## Infrastructure Quick Reference
| Service | Address | Purpose |
| ----------- | ------------------------ | -------------------------- |
| App | 10.10.10.166:3000 | ArchiTools (tools.beletage.ro) |
| PostgreSQL | 10.10.10.166:5432 | Database (Prisma) |
| MinIO | 10.10.10.166:9002/9003 | Object storage |
| Authentik | 10.10.10.166:9100 | SSO (auth.beletage.ro) |
| Portainer | 10.10.10.166:9000 | Docker management |
| Gitea | 10.10.10.166:3002 | Git (git.beletage.ro) |
| Traefik | 10.10.10.199 | Reverse proxy + SSL |
| N8N | 10.10.10.166:5678 | Workflow automation |
| Stirling PDF | 10.10.10.166:8087 | PDF tools (needs env vars!) |
## Company IDs
| ID | Name | Prefix |
| ----------------- | --------------- | ------ |
| `beletage` | Beletage | B |
| `urban-switch` | Urban Switch | US |
| `studii-de-teren` | Studii de Teren | SDT |
| `group` | Grup | G |
---
## Documentation
| Doc | Path |
| ------------------- | ------------------------------------------ |
| Module Map | `docs/MODULE-MAP.md` |
| Architecture Quick | `docs/ARCHITECTURE-QUICK.md` |
| System Architecture | `docs/architecture/SYSTEM-ARCHITECTURE.md` |
| Module System | `docs/architecture/MODULE-SYSTEM.md` |
| Feature Flags | `docs/architecture/FEATURE-FLAGS.md` |
| Storage Layer | `docs/architecture/STORAGE-LAYER.md` |
| Security & Roles | `docs/architecture/SECURITY-AND-ROLES.md` |
| Module Dev Guide | `docs/guides/MODULE-DEVELOPMENT.md` |
| Docker Deployment | `docs/guides/DOCKER-DEPLOYMENT.md` |
| Coding Standards | `docs/guides/CODING-STANDARDS.md` |
| Data Model | `docs/DATA-MODEL.md` |
For module-specific deep dives, see `docs/modules/`.
+39 -9
View File
@@ -1,24 +1,54 @@
# Stage 1: Dependencies
FROM node:20-alpine AS deps
# syntax=docker/dockerfile:1
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# BuildKit cache mount keeps npm's global cache between builds —
# subsequent npm ci only downloads changed/new packages instead of
# re-fetching everything from the registry (~30-60s saving).
RUN --mount=type=cache,target=/root/.npm \
npm ci --ignore-scripts
# Stage 2: Build
FROM node:20-alpine AS builder
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
# Copy prisma schema first — cached layer for prisma generate
COPY prisma ./prisma
RUN npx prisma generate
# Now copy the rest of the source
COPY . .
# Build args for NEXT_PUBLIC_* vars (inlined at build time)
ARG NEXT_PUBLIC_STORAGE_ADAPTER=database
ARG NEXT_PUBLIC_APP_NAME=ArchiTools
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_APP_NAME=${NEXT_PUBLIC_APP_NAME}
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
ENV NODE_OPTIONS="--max-old-space-size=2048"
RUN npm run build
# Stage 3: Runner
FROM node:20-alpine AS runner
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV TZ=Europe/Bucharest
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Install system deps + create user in a single layer
RUN apk add --no-cache gdal gdal-tools ghostscript qpdf tzdata \
&& addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# Note: DWG→DXF conversion handled by dwg2dxf sidecar container (see docker-compose.yml)
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+302
View File
@@ -0,0 +1,302 @@
# ArchiTools — QA Checklist (Phase 3 Features)
> Test all features implemented in Phase 3 (sessions 2026-02-27 through 2026-02-28).
> Open each URL on http://10.10.10.166:3000 and check every item.
> Mark with [x] when verified, note bugs inline.
---
## 1. Registratura `/registratura`
### 1.1 Core Registry (Tab "Registru")
- [ ] Create new entry — form loads, all fields visible
- [ ] Registration number auto-generated correctly (no duplicates)
- [ ] Direction toggle (Intrat/Ieșit) works
- [ ] Dynamic document types in dropdown (Cerere, Adresă, Aviz, Apel telefonic, Videoconferință, etc.)
- [ ] "Tip nou" inline input creates a new document type + Tag Manager tag
- [ ] Sender/Recipient autocomplete searches Address Book contacts
- [ ] "Creează contact" button appears when typed name doesn't match
- [ ] Quick contact creation popup works (Name, Phone, Email)
- [ ] Responsabil (Assignee) field with autocomplete
- [ ] Backdating support: "Data document" field accepts past dates, registrationDate shows in table if different
- [ ] Thread parent selector: can search and link to another entry
- [ ] Thread view (GitBranch icon) shows parent → current → children tree
- [ ] File attachment upload with loading indicator ("Se încarcă X fișiere...")
- [ ] Submit button disabled + spinner during upload/save (no double-click)
- [ ] "Actualizează" button feedback (spinner when saving edits)
- [ ] Filters work: direction, status, document type, search
- [ ] Close entry: resolution selector (Finalizat/Aprobat tacit/Respins/Retras/Altele)
- [ ] Closure banner shows resolution badge
### 1.2 Recipient Registration Fields (Ieșit only)
- [ ] "Nr. înregistrare destinatar" and "Data înregistrare destinatar" visible only for Ieșit entries
- [ ] Fields save correctly
### 1.3 Document Expiry
- [ ] Expiry date field visible and saves
- [ ] Expiry alert days field visible and saves
### 1.4 Legal Deadlines (Tab "Termene legale")
- [ ] Deadline dashboard loads with stats cards
- [ ] 6 stat cards visible (total, active, resolved, overdue, tacit, chain)
- [ ] Add deadline wizard: category → type → date preview (3 steps)
- [ ] 16+ deadline types across 5 categories load correctly
- [ ] Deadline card shows expandable audit log ("Istoric modificări")
- [ ] Resolve deadline works
- [ ] Alert banners show in registry tab when deadlines are overdue/approaching
- [ ] Alert count badge on tab header
---
## 2. Tag Manager `/tag-manager`
### 2.1 Core Tags
- [ ] Tag list loads (project, phase, activity, document-type, custom)
- [ ] Create tag with all fields (label, category, color, scope, company)
- [ ] Mandatory validation: project tags require projectCode + companyId
- [ ] Edit tag works
- [ ] Delete tag works
- [ ] US/SDT project seeds present (US-001...US-010, SDT-001...SDT-010)
### 2.2 ManicTime Sync Panel
- [ ] ManicTime sync panel visible below stats
- [ ] "Verifică conexiunea" button works (expect error in dev if no file mounted)
- [ ] Stats grid shows 4 cards (ArchiTools tags, ManicTime tags, new, modified)
- [ ] Import button shows confirmation dialog
- [ ] Export button shows confirmation dialog
- [ ] Full sync button shows confirmation dialog
- [ ] Success/error messages display correctly
- [ ] On production: verify Tags.txt file path resolves (requires SMB mount)
---
## 3. IT Inventory `/it-inventory`
### 3.1 Inventory Tab
- [ ] Equipment list loads
- [ ] Dynamic equipment types (Server, Switch, UPS, Patch Panel, + custom)
- [ ] "Tip nou" inline input creates new equipment type
- [ ] Status includes "Închiriat" (rented) with purple pulse animation
- [ ] Filter by type, status, company works
- [ ] Search filter works
- [ ] Rack position fields (U position, size) show only for RACK_MOUNTABLE_TYPES
### 3.2 Rack 42U Tab
- [ ] Rack visualization renders with 42 slots
- [ ] U1 at bottom, U42 at top
- [ ] Color-coded status slots (active=green, maintenance=amber, decommissioned=red, rented=purple)
- [ ] Tooltips on hover showing equipment details
- [ ] Empty slots shown correctly
---
## 4. Address Book `/address-book`
### 4.1 Dynamic Types
- [ ] Contact type dropdown shows default types + custom types
- [ ] Can add new type via "Tip nou" input
- [ ] Custom types persist and appear for future contacts
### 4.2 vCard Export
- [ ] Download icon on contact card hover → .vcf file downloads
- [ ] vCard contains correct data (name, phone, email, company)
### 4.3 Registratura Reverse Lookup
- [ ] Detail view (FileText icon) shows Registratura entries where contact is sender/recipient
---
## 5. Password Vault `/password-vault`
### 5.1 Core
- [ ] CRUD credentials works
- [ ] Email field visible and saves
- [ ] URLs rendered as clickable links (open in new tab)
- [ ] Password strength meter updates live (slabă/medie/puternică/foarte puternică)
- [ ] Company scope selector works
### 5.2 Encryption
- [ ] Passwords stored encrypted (check via direct DB query if possible)
- [ ] Passwords decrypt correctly for display
- [ ] Legacy plaintext passwords auto-detected on read
---
## 6. Prompt Generator `/prompt-generator`
### 6.1 Search & Filters
- [ ] Search bar visible above templates
- [ ] Searching by template name works
- [ ] Searching by description works
- [ ] Searching by tags works
- [ ] Category dropdown filters correctly
- [ ] Target type dropdown (text/image) filters correctly
- [ ] Quick "Imagine" toggle button filters to image templates only
- [ ] Empty state shows when no results match
- [ ] Clearing search shows all templates
### 6.2 Image Templates (4 new)
- [ ] "Midjourney — Randare Exterior Arhitectural" template loads and composes
- [ ] "Stable Diffusion — Design Interior" template loads and composes
- [ ] "Midjourney — Infografic Arhitectural" template loads and composes
- [ ] "Stable Diffusion — Textură Material Arhitectural" template loads and composes
- [ ] All 4 have targetAiType = "image"
- [ ] Total template count = 18
### 6.3 Compose & History
- [ ] Select template → compose view shows variables
- [ ] Fill variables → output preview updates
- [ ] Copy to clipboard works
- [ ] History tab shows past compositions
---
## 7. AI Chat `/ai-chat`
### 7.1 Session Management
- [ ] "Conversație nouă" creates a session
- [ ] Session appears in sidebar
- [ ] Click session in sidebar to switch
- [ ] Delete session works (trash icon on hover)
- [ ] Session title shows date by default
### 7.2 Provider Status
- [ ] Provider badge visible in header (shows "Demo" if no API key)
- [ ] Badge shows WifiOff icon when demo mode
- [ ] Badge shows Wifi icon + green when API configured
- [ ] Settings button toggles config banner
- [ ] Config banner shows: Provider, Model, Max tokens, Stare
- [ ] Amber warning when not configured (env var instructions)
### 7.3 Chat Flow
- [ ] Type message + Enter sends
- [ ] User message appears right-aligned (primary color)
- [ ] Send button disabled while empty
- [ ] Input disabled + "Se generează..." placeholder while sending
- [ ] Loading spinner replaces Send button while sending
- [ ] "Se generează răspunsul..." indicator in messages area while waiting
- [ ] Bot icon on assistant messages
- [ ] Timestamps shown on messages (HH:MM format)
- [ ] Demo mode: response comes from /api/ai-chat (should return demo text)
- [ ] Error messages show gracefully (connection errors, API errors)
### 7.4 Project Linking (Tag Manager Integration)
- [ ] "Proiect" button visible in chat header when session active
- [ ] Click shows dropdown with project tags from Tag Manager
- [ ] Project tags show color dot + project code + label
- [ ] Selecting a project links it to session
- [ ] Project name badge appears in header after linking
- [ ] X button on badge removes project link
- [ ] Project name shows under session title in sidebar
- [ ] Linked project context injected in system prompt (verify in API logs)
### 7.5 API Route
- [ ] GET /api/ai-chat returns provider config JSON
- [ ] POST /api/ai-chat with demo provider returns demo response
- [ ] POST with configured provider (if API key set) returns real AI response
- [ ] System prompt includes Romanian arch office context
---
## 8. Hot Desk `/hot-desk`
- [ ] Room layout shows with window (left) and door (right)
- [ ] 4 desks visible
- [ ] Week-ahead calendar works
- [ ] Reserve desk works
- [ ] Cancel reservation works
---
## 9. Email Signature `/email-signature`
- [ ] Address toggle works for all 3 companies (BTG, US, SDT)
- [ ] Live preview renders correctly
- [ ] Copy/download works
---
## 10. Dashboard `/`
- [ ] KPI cards (6) show real data
- [ ] Activity feed shows last 20 items
- [ ] Module grid links work
- [ ] External tools links work
---
## 11. Cross-Cutting
### 11.1 Auth
- [ ] Login via Authentik works (auth.beletage.ro)
- [ ] User name/email shown in header
- [ ] Logout works
### 11.2 Performance
- [ ] Registratura page loads in < 3 seconds (was 2+ minutes before N+1 fix)
- [ ] No excessive network requests in DevTools (should be ~1 per namespace, not N+1)
- [ ] Lightweight mode strips base64 from list loading
### 11.3 Theme
- [ ] Light/dark toggle works
- [ ] Company logos switch variants correctly
- [ ] All modules readable in both themes
### 11.4 Navigation
- [ ] All 14 modules reachable from sidebar
- [ ] Logo mini-game: BTG→US→SDT combo triggers confetti
- [ ] "ArchiTools" text links to dashboard
---
## Bug Report Template
```
### BUG: [Short description]
**Module:** [module name]
**URL:** [full URL]
**Steps:**
1. ...
2. ...
3. ...
**Expected:** ...
**Actual:** ...
**Screenshot:** [if applicable]
**Severity:** Critical / High / Medium / Low
```
---
## Notes
- Test on production (10.10.10.166:3000 or tools.beletage.ro) after Portainer redeploy
- Test in both Chrome and Firefox
- Test in both light and dark mode
- ManicTime sync requires SMB mount on Docker host — may fail in dev
- AI Chat requires env vars (AI_PROVIDER, AI_API_KEY) for real responses — demo mode otherwise
- Password Vault encryption requires ENCRYPTION_SECRET env var
+546 -159
View File
@@ -28,24 +28,28 @@
---
## Current Module Status vs. XLSX Spec
## Current Module Status (after Phase 3 completion)
| # | Module | Core Done | Gaps Remaining | New Features Needed |
| --- | ------------------ | ----------- | -------------------------------------------------------------------------------- | ------------------------------------------- |
| 1 | Registratura | YES | Linked-entry selector capped at 20 | Workflow automation, email integration, OCR |
| 2 | Email Signature | YES | US/SDT logo files may be missing from `/public/logos/`; US/SDT no address toggle | AD sync, branding packs |
| 3 | Word XML | YES | POT/CUT toggle exists (spec says remove) | Schema validator, visual mapper |
| 4 | Digital Signatures | YES | No file upload (URL only); tags not editable in form | Permission layers, document insertion |
| 5 | Password Vault | YES | Unencrypted storage; no strength meter; no company scope | Hardware key, rotation reminders |
| 6 | IT Inventory | YES | assignedTo not linked to contacts; no maintenance log | Network scan import |
| 7 | Address Book | YES | No vCard export; no reverse Registratura lookup | Email sync, deduplication |
| 8 | Prompt Generator | YES | Missing architecture viz templates (sketch→render, photorealism) | Prompt scoring |
| 9 | Word Templates | YES | No clause library; placeholders manual only; no Word generation | Diff compare, document generator |
| 10 | Tag Manager | YES | No US/SDT project seeds; no mandatory-category enforcement | Server tag sync, smart suggestions |
| 11 | Mini Utilities | PARTIAL | Missing: U→R value, AI artifact cleaner, MDLPA validator, PDF reducer, OCR | More converters |
| 12 | Dashboard | BASIC | No activity feed, no notifications, no KPI panels | Custom dashboards per role |
| 13 | AI Chat | DEMO ONLY | No API integration, no key config, no streaming | Conversation templates |
| 14 | Hot Desk | NOT STARTED | Entire module missing | — |
| # | Module | Version | Status | Remaining Gaps | Future Enhancements |
| --- | ------------------ | ------- | --------- | --------------------------------------------------- | ------------------------------------------------- |
| 1 | Registratura | 0.5.0 | HARDENING | — | Workflow automation, OCR, print/PDF export |
| 2 | Email Signature | 0.1.0 | COMPLETE | US/SDT addresses may need update | AD sync, branding packs, promo banners |
| 3 | Word XML | 0.1.0 | COMPLETE | — | Schema validator, visual mapper |
| 4 | Digital Signatures | 0.1.0 | COMPLETE | — | Permission layers, document insertion |
| 5 | Password Vault | 0.4.0 | COMPLETE | — | Hardware key, rotation reminders, Passbolt |
| 6 | IT Inventory | 0.2.0 | COMPLETE | — | Network scan import |
| 7 | Address Book | 0.1.1 | COMPLETE | — | Email sync, deduplication |
| 8 | Prompt Generator | 0.2.0 | HARDENING | Bug fixes, new idea TBD | Prompt scoring, more image templates |
| 9 | Word Templates | 0.1.0 | COMPLETE | No clause library; no Word generation | Diff compare, document generator |
| 10 | Tag Manager | 0.2.0 | HARDENING | Logic/workflow fix, ERP API exposure needed | Smart suggestions |
| 11 | Mini Utilities | 0.3.0 | COMPLETE | — | More converters, more tools TBD |
| 12 | Dashboard | 0.1.0 | COMPLETE | — | Custom dashboards per role |
| 13 | AI Chat | 0.2.0 | COMPLETE | Needs API key env vars for real AI | Streaming, model selector, conversation templates |
| 14 | Hot Desk | 0.1.1 | COMPLETE | — | — |
| 15 | ParcelSync | 0.5.0 | COMPLETE | Needs real-world UAT testing at scale | Map visualization, batch enrichment, export tools |
| 16 | Visual Copilot | 0.1.0 | SEPARATE | Dev in separate repo (git.beletage.ro/gitadmin/vim) | AI image analysis, merge into ArchiTools later |
**Phases 13 COMPLETE (all 42 tasks).** Phase 7B (ParcelSync) COMPLETE. Phase 4B (Pre-Launch Hardening) IN PROGRESS.
---
@@ -157,7 +161,7 @@
**Status:** ✅ Done. Renamed `encryptedPassword``password` in type. Added `company: CompanyId` field. Form now has company selector (Beletage/Urban Switch/Studii/Grup). Password strength meter with 4 levels (slabă/medie/puternică/foarte puternică) based on length + character diversity (upper/lower/digit/symbol). Meter updates live. Build ok, pushat.
### 1.08 `[LIGHT]` IT Inventory — Link assignedTo to Address Book
### 1.08 `[LIGHT]` IT Inventory — Link assignedTo to Address Book
**What:** Change `assignedTo` from free text to an autocomplete that links to Address Book contacts (same pattern as Registratura sender/recipient).
**Files to modify:**
@@ -165,9 +169,11 @@
- `src/modules/it-inventory/components/` — equipment form
- `src/modules/it-inventory/types.ts` — Add `assignedToContactId?: string`
**Status:** ✅ Done. Added `assignedToContactId?: string` field to InventoryItem type. Form now shows autocomplete dropdown filtering Address Book contacts (searches by name or company). Up to 5 suggestions shown. Clicking a contact pre-fills both display name and contact ID. Placeholder "Caută după nume..." guides users. Build ok, pushed.
---
### 1.09 `[STANDARD]` Address Book — vCard Export + Registratura Reverse Lookup
### 1.09 `[STANDARD]` Address Book — vCard Export + Registratura Reverse Lookup
**What:**
@@ -180,9 +186,9 @@
**Files to create:**
- `src/modules/address-book/services/vcard-export.ts`
---
**Status:** ✅ Done. Created `vcard-export.ts` generating vCard 3.0 (.vcf) with name, org, title, phones, emails, address, website, notes, contact persons. Added Download icon button on card hover. Added detail dialog (FileText icon) showing full contact info + scrollable table of all Registratura entries where this contact appears as sender or recipient (uses `allEntries` to bypass filters). Build ok, pushed.
### 1.10 `[STANDARD]` Word Templates — Placeholder Auto-Detection
### 1.10 `[STANDARD]` Word Templates — Placeholder Auto-Detection
**What:** When a template file URL points to a `.docx`, parse it client-side to extract `{{placeholder}}` patterns and auto-populate the `placeholders[]` field. Use JSZip (already installed) to read the docx XML.
**Files to modify:**
@@ -191,9 +197,9 @@
**Files to create:**
- `src/modules/word-templates/services/placeholder-parser.ts`
---
**Status:** ✅ Done. `placeholder-parser.ts` uses JSZip to read all `word/*.xml` files from the .docx ZIP, searches for `{{...}}` patterns in both raw XML and stripped text (handles Words split-run encoding). Form now has: “Alege fișier .docx” button (local file picker, most reliable — no CORS) and a Wand icon on the URL field for URL-based detection (may fail on CORS). Parsing spinner shown during detection. Detected placeholders auto-populate the field. Build ok, pushed.
### 1.11 `[STANDARD]` Dashboard — Activity Feed + KPI Panels
### 1.11 `[STANDARD]` Dashboard — Activity Feed + KPI Panels
**What:**
@@ -203,72 +209,283 @@
**Files to modify:** `src/modules/dashboard/components/` or `src/app/(modules)/page.tsx`
---
### 1.12 `[LIGHT]` Registratura — Increase Linked-Entry Selector Limit
**What:** The linked-entry selector in `registry-entry-form.tsx` shows only first 20 entries (`.slice(0, 20)`). Add a search/filter field to find entries by number or subject, and remove the 20 limit.
**Files to modify:** `src/modules/registratura/components/registry-entry-form.tsx`
**Status:** ✅ Done. Created `src/modules/dashboard/hooks/use-dashboard-data.ts` — scans all `architools:*` localStorage keys directly, extracts entities with timestamps, builds activity feed (last 20, sorted by `updatedAt`) and KPI counters. Updated `src/app/page.tsx`: KPI grid (6 cards: registratura this week, open dosare, deadlines this week, overdue in red, new contacts this month, active IT equipment), activity feed with module icon + label + action + relative time (Romanian locale). Build ok, pushed.
---
### 1.13 `[LIGHT]` Word XML — Remove POT/CUT Auto-Calculation
### 1.12 `[LIGHT]` Registratura — Increase Linked-Entry Selector Limit
**What:** The xlsx says POT/CUT auto-calculation is "not needed". The toggle exists but the auto-injection code in `xml-generator.ts` should be removed. Keep the fields, just remove the auto-compute logic.
**Files to modify:** `src/modules/word-xml/services/xml-generator.ts`
**User action needed:** Confirm this should be removed.
**What:** Added search/filter input (by number, subject, sender) to the linked-entry selector. Removed `.slice(0, 20)` cap. Also improved chip labels to show truncated subject.
**Commit:** `cd4b0de`
**Status:** Done ✅
---
## PHASE 2 — New Module: Hot Desk Management
### ✅ 1.13 `[LIGHT]` Word XML — Remove POT/CUT Auto-Calculation
**What:** Removed `computeMetrics` entirely from `XmlGeneratorConfig`, `generateCategoryXml`, `generateAllCategories`, `downloadZipAll`, `useXmlConfig`, `XmlSettings`, and `WordXmlModule`. Fields kept; auto-injection removed.
**Commit:** `eaca24a`
**Status:** Done ✅
---
## PHASE 2 — New Module: Hot Desk Management ✅
> Module 14 from xlsx. Entirely new.
### 2.01 `[HEAVY]` Hot Desk Module — Full Implementation
### 2.01 `[HEAVY]` Hot Desk Module — Full Implementation (2026-02-26)
**What:** Build Module 14 from scratch per xlsx spec:
- 4 desks in a shared room
- Users reserve desks 1 week ahead
- Calendar view showing desk availability per day
- Reserve/cancel actions
- History of past reservations
- Visual room layout showing which desks are booked
**Module structure:**
```
src/modules/hot-desk/
├── components/
│ ├── hot-desk-module.tsx # Main view with calendar + room layout
│ ├── desk-calendar.tsx # Week view with 4 desk columns
│ ├── desk-room-layout.tsx # Visual 4-desk room diagram
│ └── reservation-dialog.tsx # Book/cancel dialog
├── hooks/
│ └── use-reservations.ts # CRUD + conflict detection
├── services/
│ └── reservation-service.ts # Business logic, overlap check
├── types.ts # DeskReservation, DeskId
├── config.ts # Module metadata
└── index.ts
```
**Files to also create/modify:**
- `src/app/(modules)/hot-desk/page.tsx` — Route
- `src/config/modules.ts` — Register module
- `src/config/navigation.ts` — Add sidebar entry
- `src/config/flags.ts` — Add feature flag
**User approval required** before moving to Phase 3.
**What:** Build Module 14 from scratch per xlsx spec.
**Status:** ✅ Done. Full implementation: 4 desks, week-ahead calendar, reserve/cancel, room layout with window (left) + door (right) landmarks, history. Registered in modules/navigation/flags.
---
## PHASE 3 — Quality & Testing
## PHASE 3 — Replanificare Detaliată (Ideation & Requirements)
> Fază adăugată pentru rafinarea cerințelor, UX și logicii de business înainte de implementarea tehnică.
### 3.01 ✅ `[UI/UX]` Header & Navigare Globală (2026-02-27)
**Cerințe noi:**
- **Logo-uri:** Mărirea dimensiunii logo-urilor în header.
- **Mini-joc Logo:** Click pe logo-uri le animează (spin/bounce/ping), combo secret BTG→US→SDT declanșează confetti.
- **Navigare:** Click pe titlul aplicației redirecționează către Dashboard.
- **Theme Toggle:** Slider animat sun/moon cu gradient zi/noapte, stele, nori.
- **Dark mode logos:** Variante corecte light/dark pe baza temei, cu fallback montat.
**Status:** ✅ Done. Sidebar redesigned: logos in centered row (flex-1, theme-aware, dual-render light+dark), "ArchiTools" text below, animated theme toggle (Sun/Moon lucide icons, gradient background, stars/clouds).
### 3.02 ✅ `[BUSINESS]` Registratura — Integrare și UX (2026-02-27)
**Cerințe noi:**
- **Tipuri de documente (Bidirecțional):** Câmpul de tip document comunică cu Tag Manager. Dacă se introduce un tip nou, se salvează automat ca etichetă nouă sub o categorie fixă "Registratura" (pentru a acoperi și apeluri telefonice, video-uri, nu doar documente fizice).
- **Expeditor/Destinatar (Bidirecțional):** Autocomplete legat de Address Book. Dacă un contact nu există, se creează automat (minim Nume) și apare un popup rapid pentru a adăuga opțional Telefon/Email.
- **Status Simplificat:** Eliminarea statusului "Deschis". Înlocuire cu un simplu checkbox "Închis" (implicit totul e deschis).
- **Termen Limită Intern:** Clarificarea funcției (ex: termen pentru a răspunde la o intrare). Adăugare tooltip/info box explicativ pentru a maximiza eficiența echipei.
- **Pregătire Integrare ERP (Responsabil):** Adăugarea unui câmp "Responsabil" (Assignee) pentru a aloca sarcini interne, gândit arhitectural pentru a putea fi expus/sincronizat ușor printr-un API/Webhook viitor către un sistem ERP extern.
- **Legături între intrări/ieșiri (Thread-uri & Branches):** Posibilitatea de a lega o ieșire de o intrare specifică (ex: "Răspuns la adresa nr. X"), creând un "fir" (thread) vizual al conversației instituționale. Trebuie să suporte și "branching" (ex: o intrare generează mai multe ieșiri conexe către instituții diferite). UI-ul trebuie să rămână extrem de simplu și intuitiv (ex: vizualizare tip arbore simplificat sau listă indentată în detaliile documentului).
**Status:** ✅ Done. All 6 sub-features implemented:
1. **Dynamic doc types** — Select + inline "Tip nou" input. New types auto-created as Tag Manager tags (category: document-type). Added "Apel telefonic" and "Videoconferință" as defaults.
2. **Bidirectional Address Book** — Autocomplete shows "Creează contact" button when no match. QuickContactDialog popup creates contact in Address Book with minimal data (Name required, Phone/Email optional).
3. **Simplified status** — Replaced Status dropdown with Switch toggle "Închis/Deschis". Default is open.
4. **Internal deadline tooltip** — Added Info tooltip explaining "Termen limită intern" vs legal deadlines. Also added tooltips on "Închis" and "Responsabil" fields.
5. **Responsabil (Assignee)** — New field with contact autocomplete + quick-create. ERP-ready with separate assigneeContactId. Shown in registry table as "Resp." column.
6. **Threads & Branching**`threadParentId` field links entries as reply-to. Thread search with direction badges. ThreadView component shows parent, current entry, siblings (branches), and child replies as indented tree. Thread icon in table. Click to navigate between threaded entries.
### 3.03 ✅ `[BUSINESS]` Registratura — Termene Legale (Flux Nou) (99fbddd)
**Implementat:**
-**Declanșare Termen:** Câmpuri `recipientRegNumber` + `recipientRegDate` pe RegistryEntry — termenul legal curge de la data înregistrării la destinatar
-**Sistem de Alerte:** Alert banners (amber/red) în Dashboard + tab Termene legale pentru ieșiri fără date destinatar și documente expirând
-**Categorii Termene:** CU deja pe prima categorie; adăugat `prelungire-cu` (15 zile calendaristice, acord tacit aplicabil)
-**Acord Tacit:** Deja implementat în Phase 2 — funcționează automat
-**Istoric Modificări (Audit Log):** `DeadlineAuditEntry` interface, audit log pe fiecare `TrackedDeadline` (created/resolved), expandabil pe deadline card
-**Valabilitate Documente:** `expiryDate` + `expiryAlertDays` cu countdown live color-coded (red=expirat, amber=aproape)
-**Pregătire Web Scraping:** Câmpuri `externalStatusUrl` + `externalTrackingId` pe RegistryEntry
-**Dashboard Stats:** 6 carduri (adăugat "Lipsă nr. dest." + "Expiră curând")
**Neimplementat (necesită integrare complexă):**
-**Generare Raport/Declarație:** Necesită integrare cross-module Registratura ↔ Word Templates
### 3.03b ✅ `[STANDARD]` Registratura — NAS Network Path Attachments (2026-02-28)
**Implementat:**
-**NAS config** (`src/config/nas-paths.ts`): 4 drive mappings (A:\=Arhiva, O:\=Organizare, P:\=Proiecte, T:\=Transfer)
-**Hostname + IP fallback**: toate funcțiile au variante `...ByIp()``toUncPathByIp()`, `toFileUrlByIp()` — pentru când DNS nu rezolvă `newamun`
-**Link NAS button** în secțiunea atașamente: input inline cu validare, preview cale scurtă
-**Visual distinct**: border albastru, HardDrive icon, badge cu numele share-ului (Proiecte/Arhiva/etc.)
-**Click deschide Explorer** via `file:///` URL, buton "IP" fallback pe hover
-**Copy path** button pe hover
-**Drive letter → UNC** normalization automată (P:\ → \\newamun\Proiecte)
-**`shareLabelFor()`** helper returns human-readable share name
### 3.03c ✅ `[STANDARD]` Registratura — Detail Sheet + Column Manager (2026-02-28)
**Implementat:**
-**Entry Detail Sheet**: panou lateral (Sheet) cu vizualizare completă a înregistrării — status badges, date, părți, thread-uri, atașamente cu preview inline, termene legale, etichete, note
-**Attachment Preview**: imagini afișate inline, fișiere cu download, NAS paths cu UNC complet + IP fallback
-**Column Visibility Manager**: dropdown cu checkbox-uri pentru 10 coloane, persistat în localStorage
-**Default columns**: Nr., Data, Dir., Subiect, Exped., Dest., Status (7/10 vizibile implicit)
-**Tooltip naming convention**: fiecare header de coloană are tooltip cu explicație completă în română
-**Table UX cleanup**: click pe rând deschide detail sheet, acțiuni reduse la View + Edit, Close/Delete mutate în sheet
-**Row click navigation**: cursor-pointer pe rânduri, Eye icon + Pencil icon în acțiuni
### 3.03d ✅ `[STANDARD]` Registratura — QuickLook Attachment Preview + Bug Fixes (2026-02-28)
**Implementat:**
-**QuickLook-style preview modal** (`attachment-preview.tsx`, ~480 lines): fullscreen dark overlay cu animații
-**Image preview**: zoom with scroll wheel + buttons (25%500%), pan/drag when zoomed, reset cu tasta 0
-**PDF preview**: native browser PDF viewer via blob URL iframe (scroll, zoom, print built-in)
-**Multi-file navigation**: săgeți stânga/dreapta (keyboard + butoane), thumbnail strip în josul ecranului
-**Actions**: download, print, close (Esc)
-**NAS link fix**: `file:///` URLs blocked by browsers — replaced with copy-to-clipboard + "Copiat!" feedback
-**NAS card overflow fix**: disabled horizontal scroll on Radix ScrollArea viewport, redesigned card to single-row compact
-**Password Vault filter reset**: category/search reset to "all" after add/edit
### 3.04 ✅ `[ARCHITECTURE]` Autentificare & Identitate (2026-02-27)
**Cerințe noi:**
- **Devansare Prioritate Authentik:** Integrarea cu Authentik (legat la Domain Controller/Active Directory).
- **Sincronizare Useri:** Sistemul poate citi lista de utilizatori din Authentik/AD.
**Status:** ✅ Done. NextAuth v4 + Authentik OIDC fully configured. Group→role mapping (admin/manager/user) and group→company mapping. Header user menu with login/logout. Env vars hardcoded in docker-compose.yml (Portainer CE limitation). See also Phase 6 below.
### 3.05 `[BUSINESS]` Email Signature — Automatizare și Branding ✅ DONE (b433857)
**Implementat:**
-**Precompletare din cont (AD):** Buton "Din cont" completează automat numele și compania din sesiunea Authentik
-**Slider Dimensiune Logo:** Control 50%200% în secțiunea Stil & Aranjare, aplicat în HTML-ul generat
-**Bannere Promoționale:** Secțiune configurabilă cu URL imagine, URL link, text alternativ, dimensiuni; previzualizare live; salvabil în preseturile existente
-**Elemente Grafice US/SDT:** Iconițe decorative distincte — liniuță (Urban Switch) și punct (Studii de Teren) — înlocuiesc slash-urile generice Beletage
### 3.06 `[BUSINESS]` Template Library (Fostul Word Templates) ✅ DONE (5992fc8)
**Implementat:**
-**Redenumire:** "Șabloane Word" → "Bibliotecă Șabloane" (Template Library) în config, navigație, i18n
-**Multi-format:** Suport Word (.docx), Excel (.xlsx), PDF, DWG, Archicad (.tpl/.pln) cu auto-detecție tip din extensie
-**Versionare Automată:** Buton "Revizie Nouă" — arhivează versiunea curentă în istoric, incrementează automat semver (1.0 → 1.1)
-**Istoric Versiuni:** Dialog cu toate versiunile anterioare, fiecare cu link descărcare și note revizie
-**Clarificare UX:** Toggle "Link extern" / "Încarcă fișier", placeholder-e detectate automat și silențios doar pentru .docx, secțiune ascunsă pentru alte formate
-**Backward compatible:** Entități vechi fără fileType/versionHistory primesc defaults automat
### 3.07 ✅ `[BUSINESS]` Digital Signatures — Simplificare și Suport TIFF (2026-02-28)
**Cerințe noi:**
- **Simplificare Formular:** Eliminarea câmpurilor inutile pentru fluxul actual: "inițiale", "data expirare", "statut legal" și "note utilizare".
- **Suport Nativ TIFF:** Permiterea încărcării fișierelor `.tiff` (formatul principal folosit pentru semnături/ștampile). _Notă tehnică: Deoarece browserele nu randează nativ TIFF, se va implementa o conversie client-side (ex: via `tiff.js` sau un canvas) doar pentru preview-ul vizual, dar sistemul va stoca și va folosi fișierul TIFF original._
- **Organizare pe Subcategorii:** Adăugarea posibilității de a grupa semnăturile/ștampilele pe subcategorii clare (ex: "Colaboratori firma X", "Experți tehnici", "Verificatori de proiect").
- **Opțiuni de Descărcare Avansate:**
- Descărcare fișier original (`.tiff` / `.png`).
- Generare și descărcare document Word (`.docx`) gol care conține imaginea inserată automat.
- Generare și descărcare document PDF gol care conține imaginea inserată automat.
**Status:** ✅ Done. Removed `initials` type, `expirationDate`, `legalStatus`, `usageNotes` fields. Added `subcategory` field with creatable input (6 defaults: Colaboratori, Experți tehnici, Verificatori de proiect, Proiectanți, Diriginți de șantier, Responsabili tehnici). TIFF upload via `utif2` client-side decode → PNG preview, original TIFF stored in `originalFileData`. Download dropdown on each card: Original, Word (.docx via `docx` lib), PDF (.pdf via `jsPDF`). Assets grouped by subcategory in list view with subcategory filter.
### 3.08 ✅ `[BUSINESS]` IT Inventory — Simplificare și Status Nou (2026-02-28)
**Cerințe noi:**
- **Eliminare Câmpuri Inutile:** Se vor șterge câmpurile "Atribuit către" (assignedTo), "Data achiziție", "Cost achiziție" și "Expirare garanție".
- **Tipuri Dinamice (Bidirecțional):** Câmpul "Tip echipament" (ex: Laptop, Monitor) trebuie să permită adăugarea interactivă de noi tipuri direct din formular (similar cu logica de la Registratură/Tag Manager).
- **Status Nou "Închiriat":** Adăugarea statusului "Închiriat" în lista de statusuri.
- **Atenționare Vizuală (Animație):** Când un echipament are statusul "Închiriat", badge-ul/rândul respectiv trebuie să aibă o animație subtilă (ex: un puls de culoare, un glow) pentru a "sări în ochi" în tabelul principal.
- **Vizualizare Rack (Server Rack 42U):**
- Adăugarea unei vizualizări grafice (schematice) a unui rack de servere standard de 42U.
- În formularul de echipament, dacă tipul este compatibil (ex: Server, Switch, UPS, Patch Panel), se vor adăuga două câmpuri noi: "Poziție Rack (U)" (ex: 12) și "Dimensiune (U)" (ex: 1U, 2U).
- Echipamentele care au aceste date completate vor fi randate automat în vizualizarea grafică a rack-ului.
- La hover pe un echipament din rack, va apărea un tooltip cu detaliile de bază (Nume, IP, Status).
**Status:** ✅ Done. Rewrote types.ts: dynamic `InventoryItemType` (string-based) with `DEFAULT_EQUIPMENT_TYPES` (added server/switch/ups/patch-panel), `RACK_MOUNTABLE_TYPES` set, new "rented" status with `STATUS_LABELS` export. Removed deprecated fields (assignedTo, purchaseDate, purchaseCost, warrantyExpiry). Added `rackPosition`/`rackSize` fields. New `server-rack.tsx` — 42U rack visualization with color-coded status slots and tooltips. Rewrote module component: tabbed UI (Inventar + Rack 42U), 5 stat cards with purple pulse animation for rented count, inline custom type creation, conditional rack position fields for mountable types.
### 3.09 ✅ `[BUSINESS]` Address Book — Tipuri Dinamice (2026-02-27)
**Cerințe noi:**
- **Tip Contact Dinamic:** Câmpul "Tip" devine "creatable select" (dropdown + text input) cu salvare automată.
**Status:** ✅ Done. `CreatableTypeSelect` component (input + "+" button), `DEFAULT_TYPE_LABELS` + dynamic types from existing contacts, `ContactType` allows `| string` for custom types.
### 3.10 ✅ `[UI/UX]` Hot Desk — Orientare Vizuală (2026-02-27)
**Cerințe noi:**
- **Punct de Reper (Fereastră):** Fereastră pe stânga, ușă pe dreapta.
**Status:** ✅ Done. Window on left wall (sky-blue, "Fereastră"), door on right wall (amber, "Ușă").
### 3.11 ✅ `[BUSINESS]` Password Vault — Câmpuri Noi + Rework v0.3.0 (2026-02-28)
**Cerințe noi:**
- **Câmp Email:** Adăugarea unui câmp distinct pentru "Email", separat de "Username".
- **Link Clickabil:** URL afișat ca link clickabil (tab nou) din lista de parole.
- **Redenumire:** "Seif Parole" → "Parole Uzuale"
- **Categorii noi:** WiFi, Portale Primării, Avize Online, PIN Semnătură, Software, Echipament HW (înlocuiesc server/database/api)
- **Iconițe categorie:** lucide-react icons per categorie (Globe, Mail, Wifi, Building2, etc.)
- **WiFi QR Code:** QR generat real pe canvas (`qrcode` lib) — scanabil direct, cu butoane Copiază imaginea / Descarcă PNG
- **Form context-aware:** PIN vs Parolă, ascunde email/URL/generator pentru WiFi/PIN
- **Stat cards dinamice:** top 3 categorii după nr. intrări
- **Eliminat banner criptare**
**Status:** ✅ Done. v0.3.0.
### 3.12 `[BUSINESS]` Mini Utilities — Extindere și Fix-uri ✅ DONE (989a990)
**Implementat:**
-**Transformare Numere în Litere:** Convertor complet RO — lei/bani, variante compact și cu spații, suport până la 1 miliard
-**Convertor Suprafețe (Bidirecțional):** 5 câmpuri editabile (mp, ari, ha, km², sq ft), orice câmp actualizează restul
-**Convertor U ↔ R (Bidirecțional):** Toggle U→R / R→U, calcul cu Rsi/Rse/lambda
-**Fix MDLPA:** Iframe înlocuit cu 3 carduri link extern (portal, tutoriale, regulament)
-**PDF Reducer:** Drag & drop, 2 nivele (Echilibrat/Maximă), linkuri Deblocare PDF + PDF/A pe Stirling PDF
-**Extractor Paletă Culori:** Upload imagine → canvas downscale → top 8 culori ca swatches hex clickabile
- ⏭️ **DWG to DXF:** Amânat — necesită serviciu backend (ODA File Converter sau similar)
### 3.13 ✅ `[BUSINESS]` Tag Manager — Sincronizare ManicTime (11b35c7)
**Implementat:**
-**Sincronizare Bidirecțională:** Parser service (`manictime-service.ts`) parsează/serializează Tags.txt; API route `/api/manictime` cu GET (citire + plan sync) + POST (pull/push/both); UI panel cu buton verificare, import, export, sincronizare completă
-**Versionare Fișier (Backup):** Backup automat `Tags_backup_YYYYMMDD_HHMMSS.txt` la fiecare scriere (push/both)
-**Validare Ierarhie:** Verificare cod proiect, asociere companie, detectare duplicate; avertizări afișate în UI
-**Docker config:** `MANICTIME_TAGS_PATH` env var, volume mount `/mnt/manictime`; necesită configurare SMB pe host
**Infrastructură necesară (pe server):**
- Montare SMB share: `//time/tags → /mnt/manictime` pe host-ul Ubuntu (cifs-utils)
### 3.14 `[ARCHITECTURE]` Storage & Securitate ✅
**Cerințe noi:**
- **Migrare Storage (Prioritate):** ✅ PostgreSQL via Prisma — realizat anterior (DatabaseStorageAdapter).
- **Criptare Parole:** ✅ AES-256-GCM server-side encryption. Dedicated `/api/vault` route, `src/core/crypto/` service, ENCRYPTION_SECRET env var. Legacy plaintext auto-detected at decrypt. PATCH migration endpoint.
- **Integrare Passbolt (Wishlist):** Studierea posibilității de a lega Password Vault-ul din ArchiTools direct de instanța voastră de Passbolt (via API), pentru a avea un singur "source of truth" securizat pentru parole.
### 3.15 `[BUSINESS]` AI Tools — Extindere și Integrare ✅
**Implementat (commit d34c722):**
- **Prompt Generator v0.2.0:**
- Search bar cu căutare în name/description/tags/category labels
- Filtru target type (text/image) cu dropdown + toggle rapid "Imagine"
- 4 template-uri noi imagine (18 total): Midjourney Exterior, SD Interior Design, Midjourney Infographic, SD Material Texture
- **AI Chat v0.2.0 — Real API Integration:**
- `/api/ai-chat` route: multi-provider (OpenAI gpt-4o-mini, Anthropic claude-sonnet-4-20250514, Ollama llama3.2, demo)
- System prompt default în română pt context arhitectură (Legea 50/1991, norme P118, DTAC/PT)
- `use-chat.ts`: `sendMessage()` cu fetch real, `sending` state, `providerConfig` la mount, `updateSession()`
- UI: provider badge (Wifi/WifiOff + label), Bot icon pe mesaje assistant, spinner la generare, config banner cu detalii tech
- **AI Chat + Tag Manager:**
- Project selector dropdown în chat header via `useTags('project')`
- `ChatSession.projectTagId` + `projectName` — context injectat în system prompt
- Project name afișat în sidebar sesiuni
- **Docker:** env vars AI_PROVIDER, AI_API_KEY, AI_MODEL, AI_BASE_URL, AI_MAX_TOKENS
**Neimplementat (wishlist):**
- Node-based Canvas (infinite canvas) — necesită bibliotecă React Flow, complexitate mare
- Nod Interpretare 3D — necesită Three.js, model ML, out of scope curent
---
## PHASE 4 — Quality & Testing
> Foundation work: tests, CI, docs, data safety.
### 3.01 `[STANDARD]` Install Testing Framework (Vitest)
### 4.01 `[STANDARD]` Install Testing Framework (Vitest)
**What:** Install and configure Vitest with React Testing Library.
@@ -281,7 +498,7 @@ npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vi
---
### 3.02 `[STANDARD]` Unit Tests — Critical Services
### 4.02 `[STANDARD]` Unit Tests — Critical Services
**What:** Write tests for the most critical business logic:
@@ -295,7 +512,7 @@ npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vi
---
### 3.03 `[STANDARD]` Data Export/Import for All Modules
### 4.03 `[STANDARD]` Data Export/Import for All Modules
**What:** Create a shared utility for backing up localStorage data:
@@ -308,7 +525,7 @@ npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vi
---
### 3.04 `[LIGHT]` Update Stale Documentation
### 4.04 `[LIGHT]` Update Stale Documentation
**What:** Update docs to reflect current state:
@@ -318,41 +535,30 @@ npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vi
---
### 3.05 `[LIGHT]` Wire External Tool URLs to Env Vars
### 4.05 `[LIGHT]` Wire External Tool URLs to Env Vars
**What:** `src/config/external-tools.ts` has hardcoded IPs. Wire to `process.env.NEXT_PUBLIC_*_URL` with fallback.
---
## PHASE 4 — AI Chat Integration
## PHASE 5 — AI Chat Enhancements
> Make Module 13 functional.
> Remaining AI Chat features not covered by task 3.15.
### 4.01 `[HEAVY]` AI Chat — Real API Integration
### 5.01 `[STANDARD]` AI Chat — Response Streaming
**What:** Replace demo mode with actual AI provider calls:
**What:** Add streaming via ReadableStream for real-time token display (currently waits for full response).
**Note:** Basic multi-provider API integration already done in 3.15 (`/api/ai-chat`).
**Remaining:**
- Create `/api/ai/chat` server-side route (API keys never exposed to browser)
- Provider abstraction: Anthropic Claude, OpenAI GPT, Ollama (local)
- Response streaming via ReadableStream
- Model selector in the UI
- ReadableStream SSE response
- Model selector dropdown in UI
- Token usage display
**Env vars:**
```
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...
OLLAMA_BASE_URL=http://10.10.10.166:11434
AI_DEFAULT_PROVIDER=anthropic
AI_DEFAULT_MODEL=claude-sonnet-4-6-20261001
```
**User action needed:** Provide API keys when ready.
- Provider switching without restart
---
### 4.02 `[STANDARD]` AI Chat — Domain-Specific System Prompts
### 5.02 `[STANDARD]` AI Chat — Domain-Specific System Prompts
**What:** Architecture office-focused conversation modes:
@@ -365,166 +571,347 @@ AI_DEFAULT_MODEL=claude-sonnet-4-6-20261001
---
### 4.03 `[LIGHT]` Enable AI Chat Feature Flag
### 5.03 `[LIGHT]` Enable AI Chat Feature Flag
**What:** Set `module.ai-chat` enabled in `flags.ts` + production `.env`.
---
## PHASE 5 — Authentication (Authentik SSO)
## PHASE 6 — Authentication (Authentik SSO)
> Real users, real permissions. Requires server admin access.
> Real users, real permissions. Core auth done, access control pending.
### 5.01 `[HEAVY]` Authentik OIDC Integration
### 6.01 `[HEAVY]` Authentik OIDC Integration (2026-02-27)
**What:** Replace stub user with real Authentik SSO.
**Status:** ✅ Done. NextAuth v4 + Authentik OIDC provider configured. Group→role mapping (authentik groups → admin/manager/user). Group→company mapping (beletage/urban-switch/studii-de-teren). Cookie-based session. `useAuth()` returns real user. Header shows user name/email + logout. Sign in with Authentik page works.
- NextAuth.js / Auth.js route handler
- OIDC token → user profile resolution
- Cookie-based session
- `useAuth()` returns real user
Env vars (hardcoded in docker-compose.yml for Portainer CE):
**Server setup required:**
1. Create OAuth2 app in Authentik (http://10.10.10.166:9100)
2. Set redirect URI: `http://10.10.10.166:3000/api/auth/callback/authentik`
3. Set env vars: `AUTHENTIK_URL`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET`, `NEXTAUTH_SECRET`
**User action needed:** Authentik admin credentials.
- `NEXTAUTH_URL=https://tools.beletage.ro`
- `NEXTAUTH_SECRET`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET`, `AUTHENTIK_ISSUER`
---
### 5.02 `[STANDARD]` Module-Level Access Control
### 6.02 `[STANDARD]` Module-Level Access Control
**What:** Implement `canAccessModule()` with role-based rules. FeatureGate checks flag + permission.
**Depends on:** 5.01
**Depends on:** 6.01
---
### 5.03 `[STANDARD]` Data Visibility Enforcement
### 6.03 `[STANDARD]` Data Visibility Enforcement
**What:** Filter storage results by `visibility` and `createdBy` fields (already stored on every entity, never enforced).
**Depends on:** 5.01
**Depends on:** 6.01
---
### 5.04 `[LIGHT]` Audit Logging
### 6.04 `[LIGHT]` Audit Logging
**What:** Log create/update/delete actions with user ID + timestamp. Console initially, later storage/N8N.
**Depends on:** 5.01
**Depends on:** 6.01
---
## PHASE 6 — Storage Migration (localStorage → Database)
## PHASE 7 — Storage Migration (localStorage → Database) — PARTIALLY DONE
> Multi-user shared data. Requires PostgreSQL + infrastructure changes.
> Multi-user shared data. PostgreSQL + Prisma + MinIO client configured.
### 6.01 `[HEAVY]` PostgreSQL + Prisma Setup
### 7.01 `[HEAVY]` PostgreSQL + Prisma Setup (2026-02-27)
**What:** Add PostgreSQL container, create Prisma schema for all entities, run migrations.
**Infrastructure:** New `postgres` service in `docker-compose.yml`.
**What:** PostgreSQL + Prisma ORM setup.
**Status:** ✅ Done. Prisma v6.19.2 with PostgreSQL on 10.10.10.166:5432. `KeyValueStore` model. `npx prisma generate` in Dockerfile. `@prisma/client` in dependencies.
---
### 6.02 `[HEAVY]` API Storage Adapter
### 7.02 `[HEAVY]` API Storage Adapter (2026-02-27)
**What:** Create `ApiStorageAdapter` implementing `StorageService`. Use Next.js API routes + Prisma.
**Depends on:** 6.01
**What:** `DatabaseStorageAdapter` implementing `StorageService` via `/api/storage` route + Prisma.
**Status:** ✅ Done. Set `NEXT_PUBLIC_STORAGE_ADAPTER=database` to activate. All 14 modules instantly get DB persistence.
---
### 6.03 `[STANDARD]` Data Migration Tool
### 7.03 `[STANDARD]` Data Migration Tool
**What:** One-time export from localStorage → import to PostgreSQL. Preserve IDs and timestamps.
**Depends on:** 6.02
**Depends on:** 7.02
---
### 6.04 `[HEAVY]` MinIO File Storage
### 7.04 `[HEAVY]` MinIO File Storage (2026-02-27)
**What:** Create `MinioAdapter` for file uploads. Migrate base64 attachments to MinIO objects.
**MinIO already running** at http://10.10.10.166:9003.
**User action needed:** MinIO access key + secret key.
**What:** MinIO client configured and tested.
**Status:** ✅ Done. MinIO client installed (`minio` npm package). Connected to 10.10.10.166:9002 (API) / 9003 (UI). Bucket `tools` exists. File upload adapter not yet wired to StorageService (MinioAdapter pending).
---
## PHASE 7 — Advanced Features
## PHASE 7BParcelSync / eTerra Module (NEW — 2026-03)
> GIS integration with Romania's national eTerra/ANCPI cadastral system.
### 7B.01 ✅ `[HEAVY]` ParcelSync Core — eTerra Client + Layer Sync (2026-03)
**What:** eTerra API client with form-post auth, JSESSIONID cookie jar, session caching, paginated layer fetching. 23-layer catalog. Background sync with progress polling. PostGIS storage with GisFeature model.
**Status:** ✅ Done. `eterra-client.ts` (~1000 lines), `sync-service.ts`, `eterra-layers.ts`, `session-store.ts`. Pagination with `maxRecordCount=1000` + page size fallbacks (500, 200). Default timeout 120s. Background sync via server singleton.
---
### 7B.02 ✅ `[HEAVY]` ParcelSync — Enrichment Pipeline (2026-03)
**What:** Per-parcel enrichment via eTerra `/api/immovable/list`. Extracts NR_CAD, NR_CF, PROPRIETARI, SUPRAFATA, INTRAVILAN, CATEGORIE_FOLOSINTA, HAS_BUILDING, etc.
**Status:** ✅ Done. `enrich-service.ts` with `FeatureEnrichment` type. JSONB storage in `enrichment` column.
---
### 7B.03 ✅ `[STANDARD]` ParcelSync — Owner Search (2026-03)
**What:** Search by owner name. DB-first (ILIKE on enrichment JSON PROPRIETARI) with eTerra API fallback.
**Status:** ✅ Done. `/api/eterra/search-owner`, `searchImmovableByOwnerName()` in client. Mode toggle in Search tab UI.
---
### 7B.04 ✅ `[STANDARD]` ParcelSync — Per-UAT Analytics Dashboard (2026-03)
**What:** Visual dashboard per UAT with KPIs, area stats, intravilan/extravilan donut, land use bars, top owners, fun facts. CSS-only (no chart libraries).
**Status:** ✅ Done. `/api/eterra/uat-dashboard` with SQL aggregates. `uat-dashboard.tsx` component. Dashboard button on each UAT card in DB tab.
---
### 7B.05 ✅ `[STANDARD]` ParcelSync — Health Check + Maintenance Detection (2026-03)
**What:** Ping eTerra every 3min, detect maintenance by keywords in HTML, block login when down, show amber "Mentenanță" state.
**Status:** ✅ Done. `eterra-health.ts` singleton, `/api/eterra/health` endpoint, session route blocks login, UI shows amber pill with message extraction.
**Bugs found & fixed during ParcelSync development:**
- Timeout 40s too low for geometry pages → increased to 120s
- `arr[0]` access fails TS strict even after length check → assign to const
- `?? ""` on `{}` typed field produces `{}` → use `typeof` check
- Prisma `$queryRaw` result needs explicit cast + guard
---
## PHASE 4B — Pre-Launch Hardening (2026-03 — OFFICE TESTING)
> Final fixes and polish before rolling out to the office for daily use.
> Official testing start: **2026-03-09**. All critical items must be resolved.
### 4B.01 ✅ `[LIGHT]` Address Book — Type Dropdown Alphabetical Sort (2026-03-08)
**What:** Type dropdown (filter + creation form) always sorted alphabetically by Romanian label, including newly created custom types.
**Files:** `src/modules/address-book/components/address-book-module.tsx`
**Status:** ✅ Done. `allTypes` memo sorted with `localeCompare('ro')`. `CreatableTypeSelect` entries merged and sorted.
---
### 4B.02 ✅ `[LIGHT]` Hot Desk — Window/Door Proportions (2026-03-08)
**What:** Room layout: window ~half current height, door ~double current height. Window indicator dots reduced from 6 to 3.
**Files:** `src/modules/hot-desk/components/desk-room-layout.tsx`
**Status:** ✅ Done. Window: `top-[35%] bottom-[35%]` (was `top-4 bottom-4`). Door: `h-16` (was `h-8`).
---
### 4B.03 ✅ `[STANDARD]` Mini Utilities — TVA Calculator (2026-03-08)
**What:** Quick VAT calculator with 19% Romanian rate. Two modes: "Adaugă TVA" (add to net) and "Extrage TVA" (extract from gross). Copy buttons, formatted RON output.
**Files:** `src/modules/mini-utilities/components/mini-utilities-module.tsx`
**Status:** ✅ Done. New `TvaCalculator` component with mode toggle, RON formatting, copy-to-clipboard.
---
### 4B.04 `[STANDARD]` Registratura — Legal Deadline Workflow Fixes
**What:** Fix gaps in the legal deadline tracking logic:
- Chain deadline workflow (resolving one → prompt to add next in sequence)
- Backward deadline edge cases (e.g., AC extension 45 working days BEFORE expiry)
- Tacit approval auto-detection when overdue + applicable type
- UI polish for deadline dashboard (filters, sorting, edge states)
**Files:** `src/modules/registratura/services/deadline-service.ts`, `components/deadline-dashboard.tsx`, `components/deadline-add-dialog.tsx`
**Status:** TODO
---
### 4B.05 `[STANDARD]` Tag Manager — Logic/Workflow Fix + ERP API
**What:**
- Fix tag assignment and filtering logic/workflow issues
- Expose tags via API for external ERP integration (read-only endpoint for tag list + project assignments)
- Verify ManicTime bidirectional sync still works
**Files:** `src/modules/tag-manager/`, `src/app/api/` (new tags API route)
**Status:** TODO
---
### 4B.06 `[STANDARD]` Prompt Generator — Bug Fixes + New Features
**What:** Address known bugs and implement new ideas (details TBD from user testing):
- Fix any template rendering issues
- Add new templates as requested
- Implement user-suggested feature improvements
**Files:** `src/modules/prompt-generator/`
**Status:** TODO — awaiting user feedback from testing
---
### 4B.07 `[HEAVY]` Authentik SSO — Verify & Fix
**What:** End-to-end verification that Authentik OIDC login works:
- Verify auth.beletage.ro accessibility
- Test login flow (redirect → auth → callback → session)
- Verify group→role/company mapping
- Fix any issues with NextAuth v4 + Authentik provider config
- Ensure session persistence and token refresh
**Files:** `src/core/auth/`, `src/app/api/auth/`, `.env` / `docker-compose.yml`
**Status:** TODO — critical for multi-user testing
---
### 4B.08 `[STANDARD]` DB/Storage — End-to-End Verification
**What:** Verify all 14 modules correctly persist to PostgreSQL:
- Test CRUD operations for each module
- Verify data survives container restart
- Check storage adapter fallback behavior
- Validate MinIO file storage connection (adapter pending)
**Files:** `src/core/storage/`, `src/app/api/storage/`, `prisma/schema.prisma`
**Status:** TODO
---
### 4B.09 ✅ `[STANDARD]` Mini Utilities v0.2.0 — Extreme Compression, DWG, UX (2025-07-21)
**What:** Major Mini Utilities upgrade:
- **Extreme PDF compression** via direct Ghostscript + qpdf pipeline (rivaling iLovePDF — `PassThroughJPEGImages=false`, QFactor 1.5, 72 DPI downsample)
- **DWG→DXF converter** via libredwg (Docker only)
- **PDF Unlock** in-app via Stirling PDF proxy
- **Removed PDF/A** tab (unused)
- **Paste support** (Ctrl+V) on all file drop zones
- **Mouse drag-drop reordering** on thermal comparison layers
- **Tabs reorganized** into 2 visual rows
- Dockerfile updated: `apk add ghostscript qpdf libredwg`
**Files:** `Dockerfile`, `src/modules/mini-utilities/components/mini-utilities-module.tsx`, `src/app/api/compress-pdf/extreme/route.ts` (rewritten), `src/app/api/compress-pdf/unlock/route.ts` (new), `src/app/api/dwg-convert/route.ts` (new)
**Status:** ✅ DONE
---
### 4B.10 `[LIGHT]` Visual Copilot — Separate Repo Documentation
**What:** Visual Copilot is being developed in a **separate repository**: `https://git.beletage.ro/gitadmin/vim`. Current ArchiTools placeholder stays as-is. Module will be merged back as a proper module when ready.
**Status:** DOCUMENTED — no code changes needed in ArchiTools
---
## PHASE 8 — Advanced Features
> Cross-cutting features that enhance the entire platform.
### 7.01 `[HEAVY]` Project Entity & Cross-Module Linking
### 8.01 `[HEAVY]` Project Entity & Cross-Module Linking
**What:** New module: Projects. Central entity linking Registratura entries, Tags, Contacts, Templates.
**Reference:** `docs/DATA-MODEL.md` lines 566-582.
---
### 7.02 `[STANDARD]` Global Search (Cmd+K)
### 8.02 `[STANDARD]` Global Search (Cmd+K)
**What:** Search across all modules. Each module registers a search provider. Header bar integration.
---
### 7.03 `[STANDARD]` Notification System
### 8.03 `[STANDARD]` Notification System + Registratura UI Polish (2026-03-11)
**What:** Bell icon in header. Deadline alerts, overdue warnings, tacit approval triggers.
**What:** Email notification system with daily digest via Brevo SMTP + N8N cron. Plus Registratura toolbar and registry number UX improvements.
**Implemented:**
- Brevo SMTP relay (nodemailer, port 587 STARTTLS), sender "Alerte Termene" &lt;noreply@beletage.ro&gt;
- Daily digest email: urgent deadlines, overdue deadlines, expiring documents
- Per-user notification preferences (3 types + global opt-out) stored in KeyValueStore
- API routes: POST `/api/notifications/digest` (N8N Bearer auth), GET/PUT `/api/notifications/preferences` (session auth)
- Test mode via `?test=true` query param on digest endpoint
- "group" company users see ALL entries across companies in digest
- UI: Bell icon button "Notificari" in Registratura toolbar → dialog with toggles
- Icon-only toolbar buttons (Bune practici + Notificari) with native `title` tooltips
- HTML email: inline-styled tables, color-coded rows (red/yellow/blue), per-company grouping
- N8N cron: `0 8 * * 1-5` (weekdays 8:00)
- **Compact registry numbers**: single-letter company badge (B=blue, U=violet, S=green, G=gray) + direction arrow (↓ green=intrat, ↑ orange=iesit) + plain number — `CompactNumber` component in `registry-table.tsx`
**Files:** `src/core/notifications/`, `src/app/api/notifications/`, `components/notification-preferences.tsx`, `components/registry-table.tsx`
---
### 7.04 `[STANDARD]` Registratura — Print/PDF Export
### 8.04 `[STANDARD]` Mini Utilities + Password Vault — UX Improvements (2026-03-12)
**What:** TVA calculator with configurable rate, new scale calculator for drawings, multi-user support in Password Vault.
**Implemented:**
- **TVA cotă configurabilă**: preseturi 5%/9%/19%/21% + câmp custom; rata efectivă afișată în rezultate; titlu card actualizat dinamic
- **Calculator scară desen** (tab nou "Scară"): modul Real→Desen (input cm, output mm) și Desen→Real (input mm, output cm); 7 preseturi 1:20..1:5000 + scară custom 1:X; afișare secundară în m/cm; copy button pe rezultat
- Logică: `drawing_mm = real_cm × 10 / scale` / `real_cm = drawing_mm × scale / 10`
- **Password Vault — utilizatori multipli**: tip `VaultUser { username, password, email?, notes? }`; câmp `additionalUsers: VaultUser[]` pe `VaultEntry`; secțiune colapsibilă "Utilizatori suplimentari" în form; badge cu numărul de utilizatori în lista de intrări; normalizare backward-compat în `useVault` hook
**Files:** `src/modules/mini-utilities/components/mini-utilities-module.tsx`, `src/modules/password-vault/types.ts`, `src/modules/password-vault/components/password-vault-module.tsx`, `src/modules/password-vault/hooks/use-vault.ts`
**Versions:** Mini Utilities 0.2.0→0.3.0, Password Vault 0.3.0→0.4.0
---
### 8.05 `[STANDARD]` Registratura — Print/PDF Export
**What:** Export registry as formatted PDF. Options: full registry, single entry, deadline summary.
---
### 7.05 `[STANDARD]` Word Templates — Clause Library + Document Generator
### 8.06 `[STANDARD]` Word Templates — Clause Library + Document Generator
**What:** In-app clause composition, template preview, simple Word generation from templates.
---
### 7.06 `[STANDARD]` N8N Webhook Integration
### 8.07 `[STANDARD]` N8N Webhook Integration
**What:** Fire webhooks on events (new entry, deadline approaching, status change). N8N at http://10.10.10.166:5678.
---
### 7.07 `[STANDARD]` Mobile Responsiveness Audit
### 8.08 `[STANDARD]` Mobile Responsiveness Audit
**What:** Test all modules on 375px/768px. Fix overflowing tables, forms, sidebar.
---
## PHASE 8 — Security & External Access
## PHASE 9 — Security & External Access
### 8.01 `[HEAVY]` Guest/External Access Role
### 9.01 `[HEAVY]` Guest/External Access Role
**What:** Read-only guest role, time-limited share links. Depends on Authentik (Phase 5).
**What:** Read-only guest role, time-limited share links. Depends on Authentik (Phase 6).
---
### 8.02 `[STANDARD]` CrowdSec Integration
### 9.02 `[STANDARD]` CrowdSec Integration
**What:** IP banning for brute force. CrowdSec at http://10.10.10.166:8088.
---
### 8.03 `[LIGHT]` SSL/TLS via Let's Encrypt
### 9.03 `[LIGHT]` SSL/TLS via Let's Encrypt
**What:** When public domain ready, configure in Nginx Proxy Manager.
---
## PHASE 9 — CI/CD
## PHASE 10 — CI/CD
### 9.01 `[STANDARD]` Gitea Actions CI Pipeline
### 10.01 `[STANDARD]` Gitea Actions CI Pipeline
**What:** `.gitea/workflows/ci.yml` — lint, typecheck, test, build on push.
**Check first:** Is Gitea Actions runner installed on server?
---
### 9.02 `[STANDARD]` E2E Tests (Playwright)
### 10.02 `[STANDARD]` E2E Tests (Playwright)
**What:** End-to-end tests for critical flows: navigation, Registratura CRUD, email signature, tag management.
@@ -532,16 +919,16 @@ AI_DEFAULT_MODEL=claude-sonnet-4-6-20261001
## Infrastructure Credentials Needed
| Service | What | When Needed |
| ------------------------ | --------------------------------------- | ------------------- |
| **US/SDT Logos** | SVG/PNG logo files | Phase 1 (task 1.01) |
| **US/SDT Addresses** | Office addresses for email signature | Phase 1 (task 1.02) |
| **Anthropic API Key** | `sk-ant-...` from console.anthropic.com | Phase 4 (task 4.01) |
| **OpenAI API Key** | `sk-...` from platform.openai.com | Phase 4 (task 4.01) |
| **Authentik Admin** | Login to create OAuth app at :9100 | Phase 5 (task 5.01) |
| **MinIO Credentials** | Access key + secret key for :9003 | Phase 6 (task 6.04) |
| **PostgreSQL** | New container + password | Phase 6 (task 6.01) |
| **Gitea Actions Runner** | Registration token from Gitea admin | Phase 9 (task 9.01) |
| Service | What | When Needed | Status |
| ------------------------ | --------------------------------------- | --------------------- | --------------------- |
| **US/SDT Logos** | SVG/PNG logo files | Phase 1 (task 1.01) | ✅ Done |
| **US/SDT Addresses** | Office addresses for email signature | Phase 1 (task 1.02) | ✅ Done (placeholder) |
| **Anthropic API Key** | `sk-ant-...` from console.anthropic.com | Phase 5 (task 5.01) | Pending |
| **OpenAI API Key** | `sk-...` from platform.openai.com | Phase 5 (task 5.01) | Pending |
| **Authentik Admin** | Login to create OAuth app at :9100 | Phase 6 (task 6.01) | ✅ Done |
| **MinIO Credentials** | Access key + secret key for :9003 | Phase 7 (task 7.04) | ✅ Done |
| **PostgreSQL** | Database + password | Phase 7 (task 7.01) | ✅ Done |
| **Gitea Actions Runner** | Registration token from Gitea admin | Phase 10 (task 10.01) | Pending |
---
+59 -9
View File
@@ -6,23 +6,25 @@
## Repository URLs
| Access | Git Clone URL | Web UI |
|---|---|---|
| **Internal (office)** | `http://10.10.10.166:3002/gitadmin/ArchiTools.git` | http://10.10.10.166:3002/gitadmin/ArchiTools |
| **External (internet)** | `https://git.beletage.ro/gitadmin/ArchiTools.git` | https://git.beletage.ro/gitadmin/ArchiTools |
| Access | Git Clone URL | Web UI |
| ----------------------- | -------------------------------------------------- | -------------------------------------------- |
| **Internal (office)** | `http://10.10.10.166:3002/gitadmin/ArchiTools.git` | http://10.10.10.166:3002/gitadmin/ArchiTools |
| **External (internet)** | `https://git.beletage.ro/gitadmin/ArchiTools.git` | https://git.beletage.ro/gitadmin/ArchiTools |
### Raw File URLs (for AI tools that can fetch URLs)
Replace `{GITEA}` with whichever base works for you:
- Internal: `http://10.10.10.166:3002`
- External: `https://git.beletage.ro`
| File | URL |
|---|---|
| CLAUDE.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/CLAUDE.md` |
| ROADMAP.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/ROADMAP.md` |
| SESSION-LOG.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/SESSION-LOG.md` |
| File | URL |
| ---------------- | -------------------------------------------------------------- |
| CLAUDE.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/CLAUDE.md` |
| ROADMAP.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/ROADMAP.md` |
| SESSION-LOG.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/SESSION-LOG.md` |
| SESSION-GUIDE.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/SESSION-GUIDE.md` |
| QA-CHECKLIST.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/QA-CHECKLIST.md` |
**Production app:** http://10.10.10.166:3000
@@ -111,6 +113,37 @@ Don't change anything unrelated. Update SESSION-LOG.md.
---
## PROMPT 4B: QA Bug Fix Session (post-Phase 3)
```
I'm working on ArchiTools.
Repository: https://git.beletage.ro/gitadmin/ArchiTools.git (branch: main)
Read these files from the repo:
- CLAUDE.md (project context, stack, architecture)
- QA-CHECKLIST.md (testing checklist — look for items marked with bugs)
- SESSION-LOG.md (previous sessions for context)
Pull latest, npm install.
I tested the app and found bugs. Here is my bug list:
[PASTE YOUR BUG LIST HERE]
For each bug:
1. Investigate root cause
2. Fix it
3. Run `npx next build` — must pass
4. Commit with descriptive message
After fixing all bugs:
- Push to main
- Update SESSION-LOG.md with bug fix details
- Notify me for re-testing
```
---
## PROMPT 5: Code Review (no changes)
```
@@ -155,25 +188,32 @@ Run `npx next build`, push to main, update ROADMAP.md + SESSION-LOG.md, notify m
## Tool-Specific Notes
### Claude Code (CLI)
Works natively. Clone/pull, read files, edit, build, push — all built in.
### ChatGPT Codex
Give it the repo URL. It can clone via git, read files, and push.
Use the external URL: `https://git.beletage.ro/gitadmin/ArchiTools.git`
### VS Code + Copilot / Cursor / Windsurf
Clone the repo locally first, then open in the IDE. The AI agent reads files from disk.
```bash
git clone https://git.beletage.ro/gitadmin/ArchiTools.git
cd ArchiTools && npm install && code .
```
### Google Antigravity
Give it the repo URL. It can clone and work autonomously.
Use: `https://git.beletage.ro/gitadmin/ArchiTools.git`
### Phone (ChatGPT app, Claude app)
Can't run code directly, but can read files via raw URLs and give guidance:
```
Read these URLs and help me plan the next task:
https://git.beletage.ro/gitadmin/ArchiTools/raw/branch/main/CLAUDE.md
@@ -186,6 +226,7 @@ https://git.beletage.ro/gitadmin/ArchiTools/raw/branch/main/SESSION-LOG.md
## Git Workflow
### First time (any device)
```bash
git clone https://git.beletage.ro/gitadmin/ArchiTools.git
cd ArchiTools
@@ -194,12 +235,14 @@ npm run dev
```
### Session start (pull latest)
```bash
git pull origin main
npm install
```
### Session end (push)
```bash
npx next build
git add <specific-files>
@@ -214,26 +257,33 @@ git push origin main
## Files to Update After Every Session
### 1. `ROADMAP.md` — Mark done tasks
```markdown
### 1.01 ✅ (2026-02-18) `[LIGHT]` Verify Email Signature Logo Files
```
### 2. `SESSION-LOG.md` — Add entry at the TOP
```markdown
## Session — 2026-02-18 (Sonnet 4.6)
### Completed
- 1.01: Verified logo files
- 1.02: Added address toggle
### In Progress
- 1.03: Prompt templates — 4 of 10 done
### Blockers
- Need logo files from user
### Notes
- Build passes, commit abc1234
---
```
+723
View File
@@ -4,6 +4,729 @@
---
## Session — 2026-03-08 (GitHub Copilot - Claude Opus 4.6)
### Context
ParcelSync module development continuation — owner search, UAT dashboard, eTerra health check/maintenance detection. Documentation update.
### Completed
- **ParcelSync — Owner Name Search:**
- New `searchImmovableByOwnerName()` method in `eterra-client.ts` — tries personName/titularName/ownerName filter keys
- New `/api/eterra/search-owner` API route — DB search first (enrichment ILIKE with `unaccent()`), eTerra API fallback
- UI: mode toggle (Nr. Cadastral / Proprietar) in Search tab, owner results with source badges (BD/eTerra)
- Relaxed search tab guard: only requires UAT selection (not eTerra connection)
- Commit: `6558c69`
- **ParcelSync — Per-UAT Analytics Dashboard:**
- New `/api/eterra/uat-dashboard` API route with SQL aggregates (area stats, intravilan/extravilan, land use, top owners, fun facts, median area via PERCENTILE_CONT)
- New `uat-dashboard.tsx` component — CSS-only visualizations: 6 KPI cards, donut ring (conic-gradient), horizontal bar charts (width-based), top 10 owners list, fun facts
- Dashboard button (BarChart3 icon) on each UAT card in DB tab, expands panel below
- Zero chart library dependencies
- Commit: `6557cd5`
- **ParcelSync — eTerra Health Check + Maintenance Detection:**
- New `eterra-health.ts` service: pings eTerra every 3min, 10s timeout, detects maintenance by keywords in HTML response (includes real keywords from 2026-03-08 outage: "serviciu indisponibil", "activități de mentenanță sunt în desfășurare")
- Extracts actual maintenance message from page for display in UI
- New `/api/eterra/health` endpoint (GET cached, POST force-check)
- Session route blocks login when eTerra is in maintenance (returns 503 + `{maintenance: true}`)
- `GET /api/eterra/session` enriched with `eterraAvailable`, `eterraMaintenance`, `eterraHealthMessage`
- ConnectionPill shows amber "Mentenanță" state with AlertTriangle icon instead of red "Eroare"
- Auto-connect skips during maintenance, retries when back online (30s poll detects recovery)
- Commit: `b7a236c`
- **eTerra Timeout Fix:**
- `DEFAULT_TIMEOUT_MS` 40000→120000 (eTerra 1000-feature geometry pages need 60-90s)
- Added `timeoutMs` option to `syncLayer()`, passed through to `EterraClient.create()`
- Commit: `8bb4a47`
- **Documentation Update:**
- CLAUDE.md: Added ParcelSync module (#15) + Visual Copilot (#16), eTerra integration, PostGIS, new TS strict mode gotchas, eTerra/external API rules
- ROADMAP.md: Added Phase 7B with 5 completed tasks, updated module status table
- SESSION-LOG.md: This session entry
### Bugs Found & Fixed
| Bug | Root Cause | Fix |
| -------------------------------------------------------- | ------------------------------------------------------------- | ----------------------------------------------------- |
| `timeout of 40000ms exceeded` on TERENURI_ACTIVE | Default 40s too short for 1000-feature geometry pages | Increased to 120s |
| `suprafata` type `{}` not assignable to `string\|number` | `?? ""` on enrichment field typed `{}` produces `{}` | `typeof x === 'number'` explicit check |
| `Object is possibly 'undefined'` on `topOwners[0]` | TS strict: `arr[0]` is `T\|undefined` even after `length > 0` | Assign to const, `if (const)` guard |
| eTerra maintenance shows as "Eroare conectare" | No health check, login attempt fails with generic error | Health check + maintenance detection + amber UI state |
### Learnings
- **eTerra goes down for maintenance regularly** — need pre-check before any login attempt
- **eTerra login page shows**: "Serviciu indisponibil" + "activități de mentenanță sunt în desfășurare" during outages
- **CSS-only charts work great**: conic-gradient donut rings, width-based horizontal bars — zero library overhead
- **TS strict `arr[0]`**: ALWAYS assign to const even after length check — the TS compiler doesn't narrow
- **Prisma `$queryRaw`**: always cast explicitly and guard access — it returns `unknown[]`
### Files Changed
- **New:** `src/modules/parcel-sync/services/eterra-health.ts`
- **New:** `src/modules/parcel-sync/components/uat-dashboard.tsx`
- **New:** `src/app/api/eterra/health/route.ts`
- **New:** `src/app/api/eterra/uat-dashboard/route.ts`
- **New:** `src/app/api/eterra/search-owner/route.ts`
- **Modified:** `src/modules/parcel-sync/services/eterra-client.ts` (timeout 120s, `searchImmovableByOwnerName()`)
- **Modified:** `src/modules/parcel-sync/services/sync-service.ts` (timeoutMs option)
- **Modified:** `src/modules/parcel-sync/services/session-store.ts` (health fields in SessionStatus)
- **Modified:** `src/modules/parcel-sync/components/parcel-sync-module.tsx` (owner search, dashboard, maintenance UI)
- **Modified:** `src/app/api/eterra/session/route.ts` (health check integration, login blocking)
- **Modified:** `CLAUDE.md`, `ROADMAP.md`, `SESSION-LOG.md`
### Build
- `npx next build` — zero errors
- Commits: `8bb4a47``6558c69``6557cd5``b7a236c`
- Pushed to main → Portainer auto-deploy
---
## Session — 2026-02-28c (GitHub Copilot - Claude Opus 4.6)
### Context
Continuation of QA improvements. NAS path enhancement: all 4 drives + DNS→IP fallback. Detail sheet, column visibility, QuickLook attachment preview, multiple bug fixes.
### Completed
- **NAS Network Path Attachments (enhanced):**
- 4 drive mappings: A:\=Arhiva, O:\=Organizare, P:\=Proiecte, T:\=Transfer
- `NAS_HOST` / `NAS_IP` constants used consistently across all UNC templates
- New helpers: `toUncPathByIp()`, `toFileUrlByIp()`, `shareLabelFor()`
- IP fallback: every network attachment shows an "IP" button on hover that opens via `\\10.10.10.10\...` when DNS fails
- Badge now shows share label (Proiecte/Arhiva/Organizare/Transfer) instead of generic "NAS"
- Validation hint updated to show all 4 drive letters
- **Registratura — Entry Detail Sheet (Side Panel):**
- New `registry-entry-detail.tsx` component (~700 lines)
- Side panel (Sheet) slides in from the right on Eye icon click or row click
- Full entry visualization: status badges, document info, parties, dates, thread links, legal deadlines, attachments with inline image preview, NAS path links with IP fallback, external tracking, tags, notes
- Action buttons inside sheet: Edită, Închide, Șterge
- **Registratura — Column Visibility Manager:**
- Configurable columns via Settings dropdown in table header
- 10 columns defined with Romanian tooltips explaining each abbreviation
- Default visible: Nr., Data, Dir., Subiect, Exped., Dest., Status (7/10)
- Hidden by default: Tip, Resp., Termen (can be toggled on)
- Persisted in localStorage per user (`registratura:visible-columns`)
- Reset button restores defaults
- **Registratura — Table UX Cleanup:**
- Row click opens detail sheet (cursor-pointer)
- Actions reduced from 3 buttons (close/edit/delete) to 2 (view/edit)
- Close and Delete moved into detail sheet
- Column headers have tooltips explaining naming convention
- **Bug Fix: NAS `file:///` links don't open Explorer:**
- Root cause: all modern browsers block `file:///` URLs from web pages (hard security restriction)
- Solution: click-to-copy-to-clipboard with green "Copiat!" badge feedback (2s timeout)
- Applied to both detail sheet and entry form
- Removed broken `window.open(toFileUrl(...))` calls
- **Learning:** `file:///` cannot be opened from web pages — only clipboard copy works
- **Bug Fix: NAS attachment card overflows Sheet boundary:**
- Root cause: Radix ScrollArea Viewport has `overflow: scroll` on both axes by default
- Fix 1: `overflow-x-hidden` on ScrollArea Viewport via CSS selector override
- Fix 2: Redesigned NAS card from two-row layout to compact single-row (filename + NAS badge + Copiază button)
- Removed `font-mono` on paths (prevents word-break), removed `shortDisplayPath` (replaced with `pathFileName`)
- **Bug Fix: Password Vault — new entry lands on filtered empty view:**
- Root cause: after adding, the module returns to list view but keeps the active category/search filter
- Solution: `handleSubmit` now resets `filters.category` to `"all"` and `filters.search` to `""` after save
- **QuickLook-style Attachment Preview (macOS Quick Look inspired):**
- New `attachment-preview.tsx` component (~480 lines)
- Fullscreen dark overlay (`z-[100]`, `bg-black/90`, `backdrop-blur-sm`) with smooth animations
- **Images**: zoomable (scroll wheel + ±buttons, 25%500%), pannable when zoomed (grab/grabbing cursor), zoom % display, reset with 0 key
- **PDFs**: browser native viewer via blob URL iframe (base64→Blob→createObjectURL), with scroll/zoom/print built-in
- **Navigation**: left/right arrows (keyboard + circular buttons), bottom thumbnail strip when multiple
- **Actions**: download (createElement('a') + click), print (PDF: hidden iframe, image: new window), close (Esc)
- **Fallback**: unsupported types show icon + "Download" button
- Preview button (Eye icon) shown for images AND PDFs in detail sheet
- Replaced old inline image-only preview with fullscreen modal
- React 19 compatible: no `setState` in effects, uses `key` prop for remount-based state reset
- **Documentation updated:**
- CLAUDE.md: Registratura v0.4.0 with QuickLook description
- ROADMAP.md: New task 3.03d with QuickLook + bug fixes
- SESSION-LOG.md: This session entry
### Files Changed
- **New:** `src/modules/registratura/components/registry-entry-detail.tsx`
- **New:** `src/modules/registratura/components/attachment-preview.tsx`
- **Modified:** `src/config/nas-paths.ts` (4 drives, IP fallback helpers, shareLabelFor)
- **Modified:** `src/modules/registratura/components/registry-table.tsx` (column visibility, tooltips, view button, row click)
- **Modified:** `src/modules/registratura/components/registratura-module.tsx` (detail sheet integration, handleView)
- **Modified:** `src/modules/registratura/components/registry-entry-form.tsx` (NAS link copy-to-clipboard, removed broken file:/// links)
- **Modified:** `src/modules/password-vault/components/password-vault-module.tsx` (filter reset after add/edit)
- **Modified:** `CLAUDE.md`, `ROADMAP.md`, `SESSION-LOG.md`
### Build
- `npx next build` — zero errors
- Pushed to main → Portainer auto-deploy
---
## Session — 2026-02-28b (GitHub Copilot - Claude Opus 4.6)
### Context
User-requested improvements from QA testing: Registratura UX enhancements + Password Vault category rework.
### Completed
- **Registratura — Thread Explorer ("Fire conversație" tab):**
- New `thread-explorer.tsx` component with full thread timeline view
- Thread building via BFS from threadParentId chains
- Stats cards (total/active/completed/average duration)
- Search + status filter (toate/active/finalizate)
- Expandable timeline with directional nodes (blue=intrat, orange=ieșit)
- Gap day indicators showing "la noi" vs "la instituție" between entries
- Export to text report per thread with downloadReport()
- Integrated as new tab in registratura-module.tsx
- **Registratura — Interactive I/O Toggle:**
- Replaced direction dropdown with visual button group (blue Intrat / orange Ieșit)
- Uses ArrowDownToLine/ArrowUpFromLine icons from lucide-react
- Full mutual-exclusive toggle with cn() conditional styling
- **Registratura — Doc Type UX Fix:**
- Document types now sorted alphabetically by label
- After adding custom type, it's immediately selected (no manual re-selection needed)
- Uses localCustomTypes state to track session-added types
- **Registratura — AC Validity Tracker:**
- New `ACValidityTracking` type system (phases, execution duration, required docs, reminders)
- New `ac-validity-tracker.tsx` component (~400 lines)
- Full AC lifecycle: issuance → validity (12mo) → execution → extension/abandonment/expiry
- Required documents checklist (CF notation, newspaper, site panel) with progress bar
- Extension request workflow (45 working days before expiry using addWorkingDays)
- Monthly reminders with snooze capability
- Pre-expiry warnings (30/60/90 days)
- Integrated into registry-entry-form.tsx
- **Password Vault → "Parole Uzuale" (v0.3.0):**
- Renamed module from "Seif Parole" to "Parole Uzuale" everywhere (config, i18n, flags)
- New categories: WiFi, Portale Primării, Avize Online, PIN Semnătură, Software, Echipament HW (replaced server/database/api)
- Category icons (Globe, Mail, Wifi, Building2, FileCheck2, Fingerprint, Monitor, HardDrive) throughout
- WiFi QR code: real QR generated on canvas via `qrcode` lib, scanabil direct cu telefonul, cu butoane Copiază imaginea + Descarcă PNG
- Context-aware form: PIN vs password label, hides email for WiFi/PIN, hides URL for WiFi, hides generator for PIN
- Dynamic stat cards showing top 3 categories by entry count
- Removed encryption banner per user request
- Category descriptions as helper text in form
### Files Changed
- **New:** `src/modules/registratura/components/thread-explorer.tsx`, `src/modules/registratura/components/ac-validity-tracker.tsx`
- **Modified:** `src/modules/registratura/types.ts`, `src/modules/registratura/components/registratura-module.tsx`, `src/modules/registratura/components/registry-entry-form.tsx`
- **Modified:** `src/modules/password-vault/types.ts`, `src/modules/password-vault/config.ts`, `src/modules/password-vault/components/password-vault-module.tsx`
- **Modified:** `src/config/flags.ts`, `src/core/i18n/locales/ro.ts`
### Build
- `npx next build` — zero errors, all 23 routes OK
- Commit: `3abf0d1`
- Pushed to main → Portainer auto-deploy
---
## Session — 2026-02-28 (GitHub Copilot - Claude Opus 4.6)
### Context
Final Phase 3 implementation (tasks 3.03, 3.13, 3.15) + QA preparation + documentation update.
### Completed
- **Task 3.03 ✅ Registratura — Termene Legale Îmbunătățiri:**
- Deadline audit log (DeadlineAuditEntry) on create/resolve
- Expandable "Istoric modificări" on deadline cards
- Recipient registration fields (nr + date, ieșit only)
- Document expiry tracking (expiryDate + expiryAlertDays)
- Web scraping prep fields (externalStatusUrl, externalTrackingId)
- 6 stat cards + amber/red alert banners
- Alert count badge on tab header
- New deadline type: prelungire-cu (15 calendar days, tacit approval)
- Commit: `99fbddd`
- **Task 3.13 ✅ Tag Manager — ManicTime Sync:**
- `manictime-service.ts`: Parser/serializer for Tags.txt format, line classifier, sync planner
- `/api/manictime` route: GET (read + sync plan), POST (pull/push/both), Prisma integration
- `manictime-sync-panel.tsx`: Connection check, stats grid, import/export/full sync buttons with confirmation
- Docker: MANICTIME_TAGS_PATH env var, `/mnt/manictime` volume mount
- Tag Manager v0.2.0
- Commit: `11b35c7`
- **Task 3.15 ✅ AI Tools — Extindere și Integrare:**
- Prompt Generator v0.2.0: search bar + target type filter + 4 new image templates (18 total)
- `/api/ai-chat` route: multi-provider (OpenAI/Claude/Ollama/demo), Romanian arch office system prompt
- `use-chat.ts`: sendMessage() with real API, sending state, providerConfig, updateSession()
- AI Chat UI: provider badge (Wifi/WifiOff), Bot icon, spinner, config banner
- AI Chat + Tag Manager: project selector via useTags('project'), session linking
- Docker: AI_PROVIDER, AI_API_KEY, AI_MODEL, AI_BASE_URL, AI_MAX_TOKENS env vars
- AI Chat v0.2.0, Prompt Generator v0.2.0
- Commit: `d34c722`
- **QA Preparation:**
- Created `QA-CHECKLIST.md` — comprehensive testing checklist for all Phase 3 features (~120 items)
- Covers: Registratura, Tag Manager, IT Inventory, Address Book, Password Vault, Prompt Generator, AI Chat, Hot Desk, Email Signature, Dashboard, cross-cutting (auth, performance, theme)
- **Documentation Update:**
- CLAUDE.md: updated module table (versions, new features), updated integrations table (AI Chat, Vault encryption, ManicTime)
- ROADMAP.md: updated status table (all 14 modules COMPLETE), marked 3.03/3.13/3.15 done, updated Phase 5 to reflect 3.15 overlap
- SESSION-LOG.md: this entry
- SESSION-GUIDE.md: added QA/Bug Fix session prompt
### Commits
- `99fbddd` feat(3.03): Registratura Termene Legale improvements
- `11b35c7` feat(3.13): Tag Manager ManicTime bidirectional sync
- `d34c722` feat(3.15): AI Tools — extindere si integrare
- `a25cc40` docs: update ROADMAP.md — mark 3.15 complete
- (this session) docs: QA checklist + full documentation update
### Phase Status
- **Phase 1:** 13/13 ✅
- **Phase 2:** 1/1 ✅ (Hot Desk)
- **Phase 3:** 15/15 ✅ (all requirements implemented)
- **Phase 6:** 1/4 done (Authentik SSO)
- **Phase 7:** 3/4 done (PostgreSQL + Prisma + MinIO client)
- **Next:** User QA testing → bug fixes → Phase 4 (Quality & Testing)
---
## Session — 2026-02-27 late night #2 (GitHub Copilot - Claude Opus 4.6)
### Context
Performance investigation: Registratura loading extremely slowly with only 6 entries. Rack numbering inverted.
### Root Cause Analysis — Findings
**CRITICAL BUG: N+1 Query Pattern in ALL Storage Hooks**
The `DatabaseStorageAdapter.list()` method fetches ALL items (keys + values) from PostgreSQL in one HTTP request, but **discards the values** and returns only the key names. Then every hook calls `storage.get(key)` for EACH key individually — making a separate HTTP request + DB query per item.
With 6 registry entries + ~10 contacts + ~20 tags, the Registratura page fired **~40 sequential HTTP requests** on load (**1 list + N gets per hook × 3 hooks**). Each request goes through: browser → Next.js API route → Prisma → PostgreSQL → back. This is a textbook N+1 query problem.
**Additional issues found:**
- `addEntry()` called `getAllEntries()` for number generation, then `refresh()` called `getAllEntries()` again → double-fetch
- `closeEntry()` called `updateEntry()` (which refreshes), then manually called `refresh()` again → double-refresh
- Every single module hook had the same pattern (11 hooks total)
- Rack visualization rendered U1 at top instead of bottom
### Fix Applied
**Strategy:** Replace `list()` + N × `get()` with a single `exportAll()` call that fetches all items in one HTTP request and filters client-side.
**Files fixed (13 total):**
- `src/modules/registratura/services/registry-service.ts` — added `exportAll` to `RegistryStorage` interface, rewrote `getAllEntries`
- `src/modules/registratura/hooks/use-registry.ts``addEntry` uses optimistic local state update instead of double-refresh; `closeEntry` batches saves with `Promise.all` + single refresh
- `src/modules/address-book/hooks/use-contacts.ts``exportAll` batch load
- `src/modules/it-inventory/hooks/use-inventory.ts``exportAll` batch load
- `src/modules/password-vault/hooks/use-vault.ts``exportAll` batch load
- `src/modules/word-templates/hooks/use-templates.ts``exportAll` batch load
- `src/modules/prompt-generator/hooks/use-prompt-generator.ts``exportAll` batch load
- `src/modules/hot-desk/hooks/use-reservations.ts``exportAll` batch load
- `src/modules/email-signature/hooks/use-saved-signatures.ts``exportAll` batch load
- `src/modules/digital-signatures/hooks/use-signatures.ts``exportAll` batch load
- `src/modules/ai-chat/hooks/use-chat.ts``exportAll` batch load
- `src/core/tagging/tag-service.ts` — uses `storage.export()` instead of N+1
- `src/modules/it-inventory/components/server-rack.tsx` — reversed slot rendering (U1 at bottom)
**Performance impact:** ~90% reduction in HTTP requests on page load. Registratura: from ~40 requests to 3 (one per namespace: registratura, address-book, tags).
### Prevention Rules (for future development)
> **NEVER** use the `storage.list()` + loop `storage.get()` pattern.
> Always use `storage.exportAll()` to load all items in a namespace, then filter client-side.
> This is the #1 performance pitfall in the storage layer.
### Commits
- `c45a30e` perf: fix N+1 query pattern across all modules + rack numbering
- `c22848b` perf(registratura): lightweight API mode strips base64 attachments from list
### Second Root Cause — Base64 Attachment Payloads (found after first fix)
After deploying the N+1 fix, Registratura STILL loaded in 2+ minutes. Deeper investigation revealed:
**Root cause:** `RegistryAttachment.data` stores full base64-encoded files directly in JSON. A 5MB PDF = ~6.7MB base64. With 6 entries having attachments, `/api/storage?namespace=registratura` returned **30-60MB of JSON** on every page load.
**Fix:** Added `?lightweight=true` parameter to the API route. Server-side `stripHeavyFields()` recursively strips large `data` and `fileData` string fields (>1KB) from JSON, replacing with `"__stripped__"`. List loading now transfers ~100KB. Full entry data loaded on-demand only when editing (single entry = manageable).
**Additional files:**
- `src/app/api/storage/route.ts``stripHeavyFields()` + lightweight param
- `src/core/storage/types.ts``export()` accepts `{ lightweight?: boolean }`
- `src/core/storage/adapters/database-adapter.ts` — passes flag to API
- `src/modules/registratura/services/registry-service.ts``getAllEntries()` uses `lightweight: true`, new `getFullEntry()`
- `src/modules/registratura/hooks/use-registry.ts` — exports `loadFullEntry()`
- `src/modules/registratura/components/registratura-module.tsx``handleEdit` loads full entry on demand
---
## Session — 2026-02-27 late night (GitHub Copilot - Claude Opus 4.6)
### Context
Continued Phase 3. Fixed critical Registratura bugs reported by user + implemented task 3.08 IT Inventory improvements.
### Completed
- **Registratura Bug Fixes (6 issues):**
1. **File upload loading feedback** — Added `uploadingCount` state tracking FileReader progress. Shows spinner + "Se încarcă X fișiere…" next to Atașamente label. Submit button shows "Se încarcă fișiere…" and is disabled while files load.
2. **Duplicate registration numbers** — Root cause: `generateRegistryNumber` counted entries instead of parsing max existing number. Fix: parse actual number from regex `PREFIX-NNNN/YYYY`, find max, +1. Also: `addEntry` now fetches fresh entries from storage before generating number (eliminates race condition from stale state).
3. **Form submission lock** — Added `isSubmitting` state. Submit button disabled + shows Loader2 spinner during save. Prevents double-click creating multiple entries.
4. **Unified close/resolve flow** — Added `ClosureResolution` type (finalizat/aprobat-tacit/respins/retras/altele) to `ClosureInfo`. CloseGuardDialog now has resolution selector matching deadline resolve options. ClosureBanner shows resolution badge.
5. **Backdating support** — Date field renamed "Data document" with tooltip explaining retroactive registration. Added `registrationDate` field on RegistryEntry (auto = today). Registry table shows "(înr. DATE)" when registrationDate differs from document date. Numbers remain sequential regardless of document date.
6. **"Actualizează" button feedback** — Submit button now shows loading spinner when saving, disabled during upload. `onSubmit` prop accepts `Promise<void>` for proper async tracking.
- **Task 3.08 — IT Inventory Improvements:**
- Rewrote `types.ts`: dynamic `InventoryItemType` (string-based), `DEFAULT_EQUIPMENT_TYPES` with server/switch/ups/patch-panel, `RACK_MOUNTABLE_TYPES` set, new "rented" status, `STATUS_LABELS` export
- Removed deprecated fields: assignedTo, assignedToContactId, purchaseDate, purchaseCost, warrantyExpiry
- Added rack fields: `rackPosition` (142), `rackSize` (14U)
- New `server-rack.tsx`: 42U rack visualization with color-coded status slots, tooltips, occupied/empty rendering
- Rewrote `it-inventory-module.tsx`: tabbed UI (Inventar + Rack 42U), 5 stat cards with purple pulse for rented, inline custom type creation ("Tip nou" input), conditional rack position fields for RACK_MOUNTABLE_TYPES, simplified form
- Fixed search filter in `use-inventory.ts`: removed `assignedTo` reference, added rackLocation/location
### Files Modified
- `src/modules/registratura/types.ts` — Added `ClosureResolution` type, `registrationDate` field on RegistryEntry, `resolution` field on ClosureInfo
- `src/modules/registratura/services/registry-service.ts` — Rewrote `generateRegistryNumber` to parse max existing number via regex
- `src/modules/registratura/hooks/use-registry.ts``addEntry` fetches fresh entries before generating number
- `src/modules/registratura/components/registry-entry-form.tsx` — Upload progress tracking, submission lock, date tooltip, async submit
- `src/modules/registratura/components/registry-table.tsx` — "Data doc." header, shows registrationDate when different
- `src/modules/registratura/components/close-guard-dialog.tsx` — Resolution selector added
- `src/modules/registratura/components/closure-banner.tsx` — Resolution badge display
- `src/modules/it-inventory/types.ts` — Complete rewrite: dynamic types, rented status, rack fields
- `src/modules/it-inventory/hooks/use-inventory.ts` — Updated imports, fixed search filter
- `src/modules/it-inventory/components/it-inventory-module.tsx` — Complete rewrite: tabbed UI, dynamic types, rack fields
- `src/modules/it-inventory/components/server-rack.tsx` — NEW: 42U rack visualization
### Commits
- `8042df4` fix(registratura): prevent duplicate numbers, add upload progress, submission lock, unified close/resolve, backdating support
- `346e40d` feat(it-inventory): dynamic types, rented status, rack visualization, simplified form
---
## Session — 2026-02-27 night (GitHub Copilot - Claude Opus 4.6)
### Context
Continued Phase 3 refinements. Picked task 3.02 (HEAVY) — Registratura bidirectional integration and UX improvements.
### Completed
- **3.02 ✅ Registratura — Integrare și UX:**
- **Dynamic document types:** DocumentType changed from enum to string-based. Default types include "Apel telefonic" and "Videoconferință". Inline "Tip nou" input with Plus button auto-creates Tag Manager tags under category "document-type". Form select pulls from both defaults and Tag Manager.
- **Bidirectional Address Book:** Sender/Recipient/Assignee autocomplete shows "Creează contact" button when typed name doesn't match any existing contact. QuickContactDialog popup creates contact in Address Book with Name (required), Phone (optional), Email (optional).
- **Simplified status:** Removed Status dropdown. Replaced with Switch toggle — default is "Deschis" (open). Toggle to "Închis" when done. No "deschis" option in UI, everything is implicitly open.
- **Internal deadline tooltip:** Added Info icon tooltips on "Termen limită intern" (explaining it's not a legal deadline), "Închis" (explaining default behavior), and "Responsabil" (explaining ERP prep).
- **Responsabil (Assignee) field:** New field with contact autocomplete + quick-create. Separate `assignee` and `assigneeContactId` on RegistryEntry type. Shown in registry table as "Resp." column with User icon.
- **Entry threads & branching:** New `threadParentId` field on RegistryEntry. Thread parent selector with search. ThreadView component showing parent → current → children tree with direction badges, siblings (branches), and click-to-navigate. Thread icon (GitBranch) in registry table. Supports one-to-many branching (one entry generates multiple outgoing replies).
### Files Created
- `src/modules/registratura/components/quick-contact-dialog.tsx` — Rapid contact creation popup from Registratura
- `src/modules/registratura/components/thread-view.tsx` — Thread tree visualization (parent, current, siblings, children)
### Files Modified
- `src/modules/registratura/types.ts` — Dynamic DocumentType, DEFAULT_DOC_TYPE_LABELS, new fields (assignee, assigneeContactId, threadParentId)
- `src/modules/registratura/index.ts` — Export new constants
- `src/modules/registratura/components/registry-entry-form.tsx` — Complete rewrite: dynamic doc types, contact quick-create, Switch status, Responsabil field, thread parent selector, tooltips
- `src/modules/registratura/components/registratura-module.tsx` — Wired useTags + useContacts, handleCreateContact + handleCreateDocType callbacks, thread navigation
- `src/modules/registratura/components/registry-table.tsx` — Dynamic doc type labels, Resp. column, thread icon
- `src/modules/registratura/components/registry-filters.tsx` — Uses DEFAULT_DOC_TYPE_LABELS from types.ts
- `ROADMAP.md` — Marked 3.02 as done
### Notes
- Existing data is backward-compatible: old entries without `assignee`, `threadParentId` render fine (fields are optional)
- Quick contact creation sets type to "collaborator" and adds note "Creat automat din Registratură"
- DocumentType is now `string` but retains defaults via `DEFAULT_DOCUMENT_TYPES` array
---
## Session — 2026-02-27 evening (GitHub Copilot - Claude Opus 4.6)
### Context
Continued from earlier session (Gemini 3.1 Pro got stuck on Authentik testing). This session focused on fixing deployment pipeline, then implementing Phase 3 visual/UX tasks + infrastructure documentation.
### Completed
- **Deployment Pipeline Fixes:**
- Fixed Dockerfile: added `npx prisma generate` before build step
- Fixed docker-compose.yml: hardcoded all env vars (Portainer CE can't inject env vars)
- Moved `@prisma/client` from devDependencies to dependencies (runtime requirement)
- Created `stack.env` (later abandoned — Portainer CE doesn't parse it reliably)
- Confirmed auth flow works end-to-end on production (tools.beletage.ro)
- **3.01 ✅ Header & Navigation:**
- Added 3 company logos (BTG, US, SDT) with theme-aware variants (light/dark)
- Interactive mini-game: click animations (spin/bounce/ping), secret combo BTG→US→SDT triggers confetti
- Logo layout: flex-1 centered row with dual-render (both light+dark images, CSS toggle) to fix SSR hydration
- "ArchiTools" text centered below logos, links to Dashboard
- Created animated theme toggle: Sun/Moon lucide icons in sliding knob, gradient sky background, stars (dark), clouds (light)
- **3.04 ✅ Authentik Integration:**
- Already done by previous session (Gemini) — confirmed working. Marked as done.
- **3.09 ✅ Address Book Dynamic Types:**
- `CreatableTypeSelect` component (dropdown + text input + "+" button)
- `ContactType` union type extended with `| string` for custom types
- Dynamic `allTypes` derived from existing contacts' types
- **3.10 ✅ Hot Desk Window:**
- Window on left wall (sky-blue, "Fereastră"), door on right wall (amber, "Ușă")
- **3.11 ✅ Password Vault Email + Link:**
- Added `email: string` field to VaultEntry type + form
- URLs rendered as clickable `<a>` links with `target="_blank"`
- **Documentation Updated:**
- CLAUDE.md: stack table (Prisma/PG/NextAuth/MinIO), 14 modules, infra ports, deployment pipeline, integration status
- ROADMAP.md: marked Phase 2, 3.01, 3.04, 3.09, 3.10, 3.11, Phase 6 (6.01), Phase 7 (7.01, 7.02, 7.04) as done
- SESSION-LOG.md: this entry
### Files Created
- `src/shared/components/common/theme-toggle.tsx` — Animated sun/moon theme toggle
### Files Modified
- `Dockerfile` — Added `npx prisma generate`
- `docker-compose.yml` — All env vars hardcoded
- `package.json`@prisma/client moved to dependencies
- `src/config/companies.ts` — BTG logo paths + fixed US/SDT dark variants
- `src/shared/components/layout/sidebar.tsx` — Logo redesign + mini-game
- `src/shared/components/layout/header.tsx` — Theme toggle replacement
- `src/modules/password-vault/types.ts` — Added email field
- `src/modules/password-vault/components/password-vault-module.tsx` — Email + clickable URLs
- `src/modules/address-book/types.ts` — Dynamic ContactType
- `src/modules/address-book/components/address-book-module.tsx` — CreatableTypeSelect
- `src/modules/hot-desk/components/desk-room-layout.tsx` — Window + door landmarks
### Notes
- Portainer CE requires manual "Pull and redeploy" — no auto-rebuild on webhook
- "Re-pull image" checkbox only needed for base image updates (node:20-alpine), not for code changes
- Logo SVGs have very different aspect ratios (BTG ~7:1, US ~6:1, SDT ~3:1) — using flex-1 min-w-0 to handle this
- Theme toggle: `resolvedTheme` from next-themes is `undefined` on SSR first render — must use `mounted` state guard
---
## Session — 2026-02-27 (GitHub Copilot - Gemini 3.1 Pro)
### Completed
- **Etapa 2: Autentificare (Authentik / Active Directory)**
- Instalat `next-auth` pentru gestionarea sesiunilor în Next.js.
- Configurat provider-ul OIDC pentru Authentik (`src/app/api/auth/[...nextauth]/route.ts`).
- Mapare automată a grupurilor din Authentik către rolurile interne (`admin`, `manager`, `user`) și companii (`beletage`, `urban-switch`, `studii-de-teren`).
- Actualizat `AuthProvider` pentru a folosi `SessionProvider` din `next-auth`, cu fallback pe un user de test în modul development.
- Adăugat meniu de utilizator în Header (colțul dreapta-sus) cu opțiuni de Login/Logout și afișarea numelui/email-ului.
- Adăugat variabilele de mediu necesare în `.env` (`AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET`, `AUTHENTIK_ISSUER`).
- **Etapa 1: Fundația (Baza de date & Storage)**
- Instalat și configurat Prisma ORM (v6) pentru conectarea la PostgreSQL.
- Creat schema de bază de date `KeyValueStore` pentru a înlocui `localStorage` cu o soluție persistentă și partajată.
- Rulat prima migrare (`npx prisma db push`) pe serverul de producție (`10.10.10.166:5432`).
- Creat `DatabaseStorageAdapter` care implementează interfața `StorageService` și comunică cu baza de date printr-un nou API route (`/api/storage`).
- Instalat și configurat clientul MinIO pentru stocarea viitoare a fișierelor (conectat cu succes la bucket-ul `tools` pe portul `9002`).
- Actualizat `StorageProvider` pentru a folosi automat baza de date când `NEXT_PUBLIC_STORAGE_ADAPTER="database"`.
- Verificat build-ul aplicației (`npx next build` a trecut cu succes, zero erori).
### Notes
- Toate cele 14 module beneficiază acum instantaneu de persistență reală în baza de date PostgreSQL, fără a fi necesară rescrierea logicii lor interne.
- Autentificarea este pregătită. Urmează configurarea aplicației în interfața de admin Authentik.
---
## Session — 2026-02-26 (GitHub Copilot - Gemini 3.1 Pro)
### Completed
- **Phase 3: Replanificare Detaliată (Ideation & Requirements)**
- Added a new Phase 3 in `ROADMAP.md` to track detailed requirements before implementation.
- Shifted subsequent phases (Quality & Testing became Phase 4, etc.).
- Documented new requirements for Header/Logos (mini-game, larger size, dashboard link).
- Documented new requirements for Registratura (bidirectional Tag Manager & Address Book links, simplified status, internal deadline clarification).
- Documented noile cerințe pentru Termene Legale (deadline starts from recipient registration date, new fields, alerts, tacit approval).
- Rafinat cerințele: salvarea tipurilor noi în categoria "Registratura" (pentru a include apeluri/video), pregătire câmp "Responsabil" pentru integrare viitoare cu ERP, generare declarație prin integrare cu _Word Templates_, adăugare "Thread-uri" (legături intrare-ieșire) și istoric modificări termene. - Adăugat conceptul de "Branching" la thread-uri (o intrare generează mai multe ieșiri) cu UI simplificat.
- Adăugat secțiunea 3.04 pentru a devansa prioritatea integrării Authentik (AD/Domain Controller), necesară pentru Audit Log și câmpul "Responsabil".
- Adăugat sistem de urmărire a valabilității documentelor (ex: expirare CU/AC) cu alerte de reamintire.
- Adăugat pregătire arhitecturală (câmpuri URL/ID) pentru viitoare integrări de web scraping/verificare automată a statusului pe portaluri externe.
- Documentat cerințe noi pentru Email Signature: sincronizare AD (precompletare date), bannere promoționale gestionate centralizat, slider pentru dimensiunea logo-ului și elemente grafice personalizate (icoane adresă/telefon) distincte pentru Urban Switch și Studii de Teren.
- Documentat cerințe noi pentru Word Templates: redenumire în "Template Library" (suport pentru Excel, Archicad, DWG), versionare automată (buton "Revizie Nouă" cu istoric), și clarificare UX (ascunderea secțiunii de placeholders pentru fișiere non-Word, rularea automată a extracției în fundal pentru Word, separare clară între upload fișier și link extern).
- Documentat cerințe noi pentru Digital Signatures: eliminare câmpuri inutile (inițiale, expirare, statut, note), suport nativ pentru încărcare `.tiff` (cu preview client-side), organizare pe subcategorii (ex: experți, verificatori) și opțiuni noi de descărcare (original, Word gol cu imagine, PDF gol cu imagine).
- Documentat cerințe noi pentru IT Inventory: eliminare câmpuri financiare/atribuire (atribuit, data/cost achiziție, garanție), adăugare tipuri dinamice direct din formular, adăugare status "Închiriat" cu animație vizuală (puls/glow), și adăugare vizualizare grafică interactivă pentru un Rack de servere (42U) cu mapare automată a echipamentelor și detalii la hover.
- Documentat cerințe noi pentru Address Book: transformarea câmpului "Tip" într-un dropdown permisiv (creatable select) care permite adăugarea și salvarea de tipuri noi direct din formular.
- Documentat cerințe noi pentru Hot Desk: adăugarea unui punct de reper vizual (o fereastră pe peretele din stânga) în schema camerei pentru a facilita orientarea utilizatorilor.
- Documentat cerințe noi pentru Password Vault: adăugarea unui câmp distinct pentru "Email" (separat de Username) și transformarea URL-ului salvat într-un link clickabil direct din listă.
- Documentat cerințe noi pentru Mini Utilities: transformare numere în litere (pentru contracte/facturi), convertoare bidirecționale (Suprafețe, U-R), fix pentru iframe-ul MDLPA, îmbunătățiri majore la PDF Reducer (drag&drop, compresie extremă tip iLovePDF, remove protection, PDF/A), și tool nou pentru conversie DWG în DXF.
- Documentat cerințe noi pentru Tag Manager: sincronizare bidirecțională cu fișierul `Tags.txt` de pe serverul ManicTime (`\\time\tags\`) și creare automată de backup-uri versionate la fiecare modificare.
- Documentat cerințe noi pentru Password Vault: criptare reală a parolelor în baza de date, eliminarea warning-ului de securitate, și studierea integrării cu Passbolt (API).
- Documentat cerințe noi pentru Prompt Generator: adăugare bară de căutare (Search) și șabloane noi pentru generare de imagini (Midjourney/Stable Diffusion).
- Documentat cerințe noi pentru AI Chat & Media: activarea modulului de chat cu un API real (OpenAI/Anthropic/Ollama), integrare cu Tag Manager pentru context pe proiect, interfață node-based (infinite canvas) pentru generatoare media, și un nod avansat de interpretare 3D a imaginilor 2D (tip viewport interactiv).
- Documentat cerințe noi pentru Storage & Arhitectură (Prioritate 0): devansarea migrării de la `localStorage` la o soluție robustă (MinIO pentru fișiere + PostgreSQL/Prisma pentru date) pentru a asigura persistența, securitatea și partajarea datelor între toți utilizatorii.
### Notes
- No code changes made. Only documentation updated.
- Faza 3 (Ideation & Requirements) este complet documentată. Urmează planificarea concretă a execuției.
---
## Session — 2026-02-19 (GitHub Copilot - Claude Sonnet 4.6) [continued 2]
### Completed
- **Task 1.12: Registratura — Linked-Entry Selector Search** ✅
- Added search input (by number, subject, sender) to `registry-entry-form.tsx`
- Removed `.slice(0, 20)` cap — all entries now searchable
- Chip labels now show truncated subject alongside entry number
- Commit: `cd4b0de`
- **Task 1.13: Word XML — Remove POT/CUT Auto-Calculation** ✅
- Removed `computeMetrics` from `XmlGeneratorConfig` type, `generateCategoryXml`, `generateAllCategories`, `downloadZipAll`, `useXmlConfig`, `XmlSettings`, `WordXmlModule`
- Removed POT/CUT auto-injection logic entirely; fields can still be added manually
- Removed unused `Switch` import from `xml-settings.tsx`
- Commit: `eaca24a`
### Notes
- Phase 1 (13 tasks) now fully complete ✅
- Next: **Phase 2** — Hot Desk module (new module from scratch)
---
### Completed
- **Task 1.11: Dashboard — Activity Feed + KPI Panels** ✅
- Created `src/modules/dashboard/hooks/use-dashboard-data.ts`
- Scans all `architools:*` localStorage keys directly (no per-module hooks needed)
- Activity feed: last 20 items sorted by `updatedAt`, detects creat/actualizat, picks best label field
- KPI grid: registratura this week, open dosare, deadlines this week, overdue (red if >0), new contacts this month, active IT equipment
- Replaced static Quick Stats with live KPI panels in `src/app/page.tsx`
- Relative timestamps in Romanian via `Intl.RelativeTimeFormat`
- Build passes zero errors
### Commits
- (this session) feat(dashboard): activity feed and KPI panels
### Notes
- Build verified: `npx next build` → ✓ Compiled successfully
- Next task: **1.12** — Registratura linked-entry selector fix
---
## Session — 2026-02-19 (GitHub Copilot - Claude Sonnet 4.6)
### Completed
- **Task 1.09: Address Book — vCard Export + Registratura Reverse Lookup** ✅
- Created `src/modules/address-book/services/vcard-export.ts` — generates vCard 3.0 with all contact fields
- Download icon button on card hover → triggers `.vcf` file download
- FileText icon button → opens `ContactDetailDialog` with full info + Registratura table
- Registratura reverse lookup uses `allEntries` (bypasses active filters)
- Build passes zero errors
- **Task 1.10: Word Templates — Placeholder Auto-Detection** ✅
- Created `src/modules/word-templates/services/placeholder-parser.ts`
- Reads `.docx` (ZIP) via JSZip, scans all `word/*.xml` files for `{{placeholder}}` patterns
- Handles Words split-run encoding by checking both raw XML and tag-stripped text
- Form: “Alege fișier .docx” button (local file picker, CORS-free) auto-populates placeholders field
- Form: Wand icon next to URL field tries URL-based fetch detection
- Spinner during parsing, error message if detection fails
- Build passes zero errors
### Commits
- `da33dc9` feat(address-book): vCard export and Registratura reverse lookup
- `67fd888` docs: mark task 1.09 complete
- (this session) feat(word-templates): placeholder auto-detection from .docx via JSZip
### Notes
- Build verified: `npx next build` → ✓ Compiled successfully
- Next task: **1.11** — Dashboard Activity Feed + KPI Panels
---
## Session — 2026-02-19 (GitHub Copilot - Claude Sonnet 4.6) [earlier]
### Completed
- **Task 1.09: Address Book — vCard Export + Registratura Reverse Lookup** ✅
- Created `src/modules/address-book/services/vcard-export.ts` — generates vCard 3.0 (`.vcf`) with all contact fields
- Added Download icon button on contact card hover → triggers `.vcf` file download
- Added FileText (detail) icon button → opens `ContactDetailDialog`
- `ContactDetailDialog` shows full contact info, contact persons, notes, and scrollable Registratura table
- Registratura reverse lookup uses `allEntries` (bypasses active filters) and matches `senderContactId` or `recipientContactId`
- Build passes zero errors
### Commits
- `da33dc9` feat(address-book): vCard export and Registratura reverse lookup
### Notes
- Build verified: `npx next build` → ✓ Compiled successfully
- Push pending — see below
- Next task: **1.10** — Word Templates Placeholder Auto-Detection
---
## Session — 2026-02-19 (GitHub Copilot - Haiku 4.5)
### Completed
- **Task 1.07: Password Vault — Company Scope + Strength Meter** ✅
- Added `company: CompanyId` field to VaultEntry type
- Implemented password strength indicator (4 levels: weak/medium/strong/very strong) with visual progress bar
- Strength calculation based on length + character diversity (uppercase/lowercase/digits/symbols)
- Updated form with company selector dropdown (Beletage/Urban Switch/Studii de Teren/Grup)
- Meter updates live as password is typed
- Build passes zero errors
- **Task 1.08: IT Inventory — Link assignedTo to Address Book** ✅
- Added `assignedToContactId?: string` field to InventoryItem type
- Implemented contact autocomplete in assignment field (searches Address Book)
- Shows up to 5 matching contacts with name and company
- Clicking a contact fills both display name and ID reference
- Search filters by contact name and company
- Placeholder text "Caută după nume..." guides users
- Build passes zero errors
### Commits
- `b96b004` feat(password-vault): add company scope and password strength meter
- `a49dbb2` feat(it-inventory): link assignedTo to Address Book contacts with autocomplete
### Notes
- Build verified: `npx next build` → ✓ Compiled successfully
- Push completed: Changes deployed to main via Gitea webhook → Portainer auto-redeploy triggering
- Ready to test on production: http://10.10.10.166:3000/password-vault and http://10.10.10.166:3000/it-inventory
---
## Session — 2026-02-18 (GitHub Copilot - Haiku 4.5)
### Completed
Binary file not shown.
+146 -16
View File
@@ -1,16 +1,146 @@
version: '3.8'
services:
architools:
build: .
container_name: architools
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- NEXT_PUBLIC_APP_NAME=ArchiTools
- NEXT_PUBLIC_APP_URL=${APP_URL:-http://10.10.10.166:3000}
- NEXT_PUBLIC_STORAGE_ADAPTER=localStorage
labels:
- "com.centurylinklabs.watchtower.enable=true"
services:
architools:
build:
context: .
args:
- NEXT_PUBLIC_STORAGE_ADAPTER=${NEXT_PUBLIC_STORAGE_ADAPTER:-database}
- NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME:-ArchiTools}
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-https://tools.beletage.ro}
- NEXT_PUBLIC_MARTIN_URL=${NEXT_PUBLIC_MARTIN_URL}
- NEXT_PUBLIC_PMTILES_URL=${NEXT_PUBLIC_PMTILES_URL}
container_name: architools
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=${NODE_ENV}
# Database
- DATABASE_URL=${DATABASE_URL}
# MinIO
- MINIO_ENDPOINT=${MINIO_ENDPOINT}
- MINIO_PORT=${MINIO_PORT}
- MINIO_USE_SSL=${MINIO_USE_SSL}
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
- MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME}
# Authentication (Authentik OIDC)
- NEXTAUTH_URL=${NEXTAUTH_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID}
- AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET}
- AUTHENTIK_ISSUER=${AUTHENTIK_ISSUER}
# Vault encryption
- ENCRYPTION_SECRET=${ENCRYPTION_SECRET}
# ManicTime Tags.txt sync (SMB mount path)
- MANICTIME_TAGS_PATH=${MANICTIME_TAGS_PATH}
# AI Chat (set AI_PROVIDER to openai/anthropic/ollama; demo if no key)
- AI_PROVIDER=${AI_PROVIDER:-demo}
- AI_API_KEY=${AI_API_KEY:-}
- AI_MODEL=${AI_MODEL:-}
- AI_BASE_URL=${AI_BASE_URL:-}
- AI_MAX_TOKENS=${AI_MAX_TOKENS:-2048}
# Visual CoPilot (at-vim)
- VIM_URL=${VIM_URL:-}
# eTerra ANCPI (parcel-sync module)
- ETERRA_USERNAME=${ETERRA_USERNAME:-}
- ETERRA_PASSWORD=${ETERRA_PASSWORD:-}
# ANCPI ePay (CF extract ordering)
- ANCPI_USERNAME=${ANCPI_USERNAME}
- ANCPI_PASSWORD=${ANCPI_PASSWORD}
- ANCPI_BASE_URL=${ANCPI_BASE_URL}
- ANCPI_LOGIN_URL=${ANCPI_LOGIN_URL}
- ANCPI_DEFAULT_SOLICITANT_ID=${ANCPI_DEFAULT_SOLICITANT_ID}
- MINIO_BUCKET_ANCPI=${MINIO_BUCKET_ANCPI}
# Stirling PDF (local PDF tools)
- STIRLING_PDF_URL=${STIRLING_PDF_URL}
- STIRLING_PDF_API_KEY=${STIRLING_PDF_API_KEY}
# iLovePDF cloud compression (free: 250 files/month)
- ILOVEPDF_PUBLIC_KEY=${ILOVEPDF_PUBLIC_KEY:-}
# Martin vector tile server (geoportal)
- NEXT_PUBLIC_MARTIN_URL=${NEXT_PUBLIC_MARTIN_URL}
# PMTiles overview tiles — proxied through tile-cache nginx (HTTPS, no mixed-content)
- NEXT_PUBLIC_PMTILES_URL=${NEXT_PUBLIC_PMTILES_URL}
# DWG-to-DXF sidecar
- DWG2DXF_URL=${DWG2DXF_URL}
# Email notifications (Brevo REST API)
- BREVO_API_KEY=${BREVO_API_KEY}
- NOTIFICATION_FROM_EMAIL=${NOTIFICATION_FROM_EMAIL}
- NOTIFICATION_FROM_NAME=${NOTIFICATION_FROM_NAME}
- NOTIFICATION_CRON_SECRET=${NOTIFICATION_CRON_SECRET}
# 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=${PORTAL_ONLY_USERS}
# Address Book API (inter-service auth for external tools)
- ADDRESSBOOK_API_KEY=${ADDRESSBOOK_API_KEY}
depends_on:
dwg2dxf:
condition: service_healthy
volumes:
# SMB share for ManicTime Tags.txt (mount on host: //time/tags → /mnt/manictime)
- /mnt/manictime:/mnt/manictime
labels:
- "com.centurylinklabs.watchtower.enable=true"
dwg2dxf:
build:
context: ./dwg2dxf-api
container_name: dwg2dxf
restart: unless-stopped
healthcheck:
test:
[
"CMD",
"python3",
"-c",
"import urllib.request; urllib.request.urlopen('http://localhost:5001/health')",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
martin:
build:
context: .
dockerfile: martin.Dockerfile
container_name: martin
restart: unless-stopped
# No host port — only accessible via tile-cache nginx proxy
command: ["--config", "/config/martin.yaml"]
environment:
- DATABASE_URL=${DATABASE_URL}
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=${DB_HOST}
- DB_PORT=${DB_PORT}
- DB_NAME=${DB_NAME}
- DB_USER=${DB_USER}
- DB_PASS=${DB_PASS}
- MINIO_ENDPOINT=${MINIO_ENDPOINT}
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
volumes:
tile-cache-data:
+80
View File
@@ -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 |
+34
View File
@@ -443,6 +443,40 @@ interface WordTemplate extends BaseEntity {
}
```
### Email Notifications (platform service)
```typescript
// src/core/notifications/types.ts
type NotificationType = "deadline-urgent" | "deadline-overdue" | "document-expiry";
interface NotificationPreference {
userId: string;
email: string;
name: string;
company: CompanyId;
enabledTypes: NotificationType[];
globalOptOut: boolean;
}
interface DigestItem {
entryNumber: string;
subject: string;
label: string;
dueDate: string; // YYYY-MM-DD
daysRemaining: number; // negative = overdue
color: "red" | "yellow" | "blue";
}
interface DigestSection {
type: NotificationType;
title: string;
items: DigestItem[];
}
```
> **Storage:** Preferences stored in `KeyValueStore` (namespace `notifications`, key `pref:<userId>`). No separate Prisma model needed.
---
## Naming Conventions
+142
View File
@@ -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
+7
View File
@@ -99,6 +99,11 @@ ArchiTools/
│ │ │ ├── use-theme.ts # Hook for theme access
│ │ │ ├── tokens.ts # Design token definitions
│ │ │ └── index.ts # Public API
│ │ ├── notifications/
│ │ │ ├── types.ts # NotificationType, NotificationPreference, DigestSection
│ │ │ ├── email-service.ts # Nodemailer transport singleton (Brevo SMTP)
│ │ │ ├── notification-service.ts # runDigest(), buildCompanyDigest(), preference CRUD
│ │ │ └── index.ts # Public API
│ │ └── auth/
│ │ ├── auth-provider.tsx # Auth context provider (stub)
│ │ ├── use-auth.ts # Hook for auth state
@@ -324,6 +329,8 @@ Platform core systems. These are infrastructure services used by all modules. Co
- **`theme/`** — Dark/light theme system. Provides the theme context, toggle hook, and design token definitions. Theme preference is persisted in storage. Tokens define colors, spacing, and typography values consumed by Tailwind and component styles.
- **`notifications/`** — Email notification service. Daily digest via Brevo SMTP relay (nodemailer, port 587 STARTTLS). Includes `runDigest()` orchestrator, `buildCompanyDigest()` for per-company deadline aggregation, `renderDigestHtml()` for inline-styled email, and preference CRUD via KeyValueStore (`notifications` namespace). API routes: POST `/api/notifications/digest` (N8N cron, Bearer auth), GET/PUT `/api/notifications/preferences` (user session auth).
- **`auth/`** — Authentication and authorization stub. Defines the `AuthContext` interface (`user`, `role`, `permissions`, `company`). Currently returns a default admin user. When Authentik SSO integration is implemented, this module will resolve real identity from OIDC tokens. The interface remains stable; only the provider implementation changes.
### `src/modules/`
+6 -3
View File
@@ -430,7 +430,8 @@ ArchiTools runs alongside existing services on the internal network:
|---------|-----------------|---------|
| **Authentik** | Future SSO provider | User authentication and role assignment |
| **MinIO** | Future storage adapter | Object/file storage for documents, signatures, templates |
| **N8N** | Future webhook/API | Workflow automation (document processing, notifications) |
| **N8N** | ✅ Active (cron) | Daily digest cron (`0 8 * * 1-5`), future: backups, workflows |
| **Brevo SMTP** | ✅ Active | Email relay for notification digests (port 587, STARTTLS) |
| **Gitea** | Development | Source code hosting |
| **Stirling PDF** | Dashboard link | PDF manipulation (external tool link) |
| **IT-Tools** | Dashboard link | Technical utilities (external tool link) |
@@ -446,9 +447,11 @@ ArchiTools runs alongside existing services on the internal network:
**Storage integration (MinIO):** When the MinIO adapter is implemented, modules that manage files (Digital Signatures, Word Templates) will store binary assets in MinIO buckets while keeping metadata in the primary storage.
**Automation integration (N8N):** Modules can trigger N8N webhooks for automated workflows. Example: Registratura creates a new entry, triggering an N8N workflow that sends a notification or generates a document.
**Automation integration (N8N):** N8N triggers scheduled workflows via API endpoints. Active: daily digest cron calls `POST /api/notifications/digest` with Bearer token auth. Future: document processing, backups.
**SSO integration (Authentik):** The auth stub will be replaced with an Authentik OIDC client. The middleware layer will validate tokens and populate `AuthContext`. No module code changes required.
**Email notifications (Brevo SMTP):** Platform service in `src/core/notifications/`. Nodemailer transport singleton connects to Brevo SMTP relay. Sender: "Alerte Termene" &lt;noreply@beletage.ro&gt;. `runDigest()` loads all registry entries, groups by company, builds digest per subscriber filtering by their preference types (urgent, overdue, expiry), renders inline-styled HTML, sends via SMTP. Test mode via `?test=true` query param. "group" company users receive digest with ALL entries. Preferences stored in KeyValueStore (namespace `notifications`).
**SSO integration (Authentik):** Authentik OIDC provides user identity. NextAuth v4 JWT/session callbacks map Authentik groups to roles and companies. Notification preferences auto-refresh user email/name/company from session on each save.
---
+28
View File
@@ -57,6 +57,34 @@ NEXT_PUBLIC_STORAGE_ADAPTER=localStorage
# Example: NEXT_PUBLIC_FLAGS_OVERRIDE=module_ai_chat=true,module_password_vault=false
NEXT_PUBLIC_FLAGS_OVERRIDE=
# -----------------------------------------------------------------------------
# Email Notifications (Brevo SMTP)
# -----------------------------------------------------------------------------
# SMTP relay for daily digest emails (deadline alerts, document expiry)
BREVO_SMTP_HOST=smtp-relay.brevo.com
BREVO_SMTP_PORT=587
BREVO_SMTP_USER= # Brevo SMTP login (from Brevo dashboard)
BREVO_SMTP_PASS= # Brevo SMTP key (from Brevo dashboard)
NOTIFICATION_FROM_EMAIL=noreply@beletage.ro
NOTIFICATION_FROM_NAME=Alerte Termene
NOTIFICATION_CRON_SECRET= # Random Bearer token for N8N → digest API auth
# -----------------------------------------------------------------------------
# eTerra ANCPI (ParcelSync module — GIS data)
# -----------------------------------------------------------------------------
ETERRA_USERNAME= # eTerra login email
ETERRA_PASSWORD= # eTerra login password
# -----------------------------------------------------------------------------
# ANCPI ePay (CF extract ordering)
# -----------------------------------------------------------------------------
ANCPI_USERNAME= # ePay login email (separate from eTerra)
ANCPI_PASSWORD= # ePay login password
ANCPI_BASE_URL=https://epay.ancpi.ro/epay
ANCPI_LOGIN_URL=https://oassl.ancpi.ro/openam/UI/Login
ANCPI_DEFAULT_SOLICITANT_ID=14452 # Beletage persoana juridica
MINIO_BUCKET_ANCPI=ancpi-documente # MinIO bucket for CF extract PDFs
# -----------------------------------------------------------------------------
# External Services
# -----------------------------------------------------------------------------
+14 -1
View File
@@ -200,13 +200,26 @@ NEXT_PUBLIC_STORAGE_ADAPTER=localStorage
# MINIO_BUCKET=architools
# ──────────────────────────────────────────
# Authentication (future: Authentik SSO)
# Authentication (Authentik SSO)
# ──────────────────────────────────────────
# AUTHENTIK_ISSUER=https://auth.internal
# AUTHENTIK_CLIENT_ID=architools
# AUTHENTIK_CLIENT_SECRET=<secret>
# ──────────────────────────────────────────
# Email Notifications (Brevo SMTP)
# ──────────────────────────────────────────
BREVO_SMTP_HOST=smtp-relay.brevo.com
BREVO_SMTP_PORT=587
BREVO_SMTP_USER=<brevo-login>
BREVO_SMTP_PASS=<brevo-smtp-key>
NOTIFICATION_FROM_EMAIL=noreply@beletage.ro
NOTIFICATION_FROM_NAME=Alerte Termene
NOTIFICATION_CRON_SECRET=<random-bearer-token>
```
> **N8N cron setup:** Create a workflow with Cron node (`0 8 * * 1-5`), HTTP Request node (POST `https://tools.beletage.ro/api/notifications/digest`, header `Authorization: Bearer <NOTIFICATION_CRON_SECRET>`). The endpoint returns `{ success, totalEmails, errors, companySummary }`. Add `?test=true` query param to send a test digest with sample data.
### Variable Scoping Rules
| Prefix | Available In | Notes |
+33
View File
@@ -0,0 +1,33 @@
FROM ubuntu:24.04
# Qt libs + virtual framebuffer for headless ODA File Converter
RUN apt-get update && \
apt-get install -y --no-install-recommends \
python3 python3-pip wget ca-certificates \
xvfb libxcb-xinerama0 libxcb-icccm4 libxcb-image0 \
libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 \
libxcb-shape0 libxcb-xkb1 libxkbcommon0 libxkbcommon-x11-0 \
libglib2.0-0 libgl1 libfontconfig1 libfreetype6 && \
pip3 install --no-cache-dir --break-system-packages flask && \
apt-get purge -y python3-pip && \
apt-get autoremove -y
# Download and install ODA File Converter
RUN wget -q "https://www.opendesign.com/guestfiles/get?filename=ODAFileConverter_QT6_lnxX64_8.3dll_27.1.deb" \
-O /tmp/oda.deb && \
dpkg -i /tmp/oda.deb || apt-get install -f -y && \
rm /tmp/oda.deb && \
rm -rf /var/lib/apt/lists/* && \
# Find the binary and symlink to /usr/local/bin for PATH access
find / -name "ODAFileConverter" -type f -executable 2>/dev/null | head -1 | \
xargs -I{} ln -sf {} /usr/local/bin/ODAFileConverter
WORKDIR /app
COPY app.py .
EXPOSE 5001
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:5001/health')" || exit 1
CMD ["python3", "app.py"]
+105
View File
@@ -0,0 +1,105 @@
"""DWG-to-DXF conversion microservice via ODA File Converter."""
import glob
import os
import shutil
import subprocess
import tempfile
import uuid
from flask import Flask, request, send_file, jsonify
app = Flask(__name__)
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100 MB
# ODA File Converter — try common install paths
ODA_BIN = "/usr/local/bin/ODAFileConverter"
for _p in ["/usr/local/bin/ODAFileConverter", "/usr/bin/ODAFileConverter",
"/opt/ODAFileConverter/ODAFileConverter",
"/opt/oda-file-converter/ODAFileConverter"]:
if os.path.isfile(_p):
ODA_BIN = _p
break
@app.route("/health", methods=["GET"])
def health():
"""Health check — verifies ODA File Converter is installed."""
if os.path.isfile(ODA_BIN) and os.access(ODA_BIN, os.X_OK):
return jsonify({"status": "ok", "converter": "ODAFileConverter"}), 200
return jsonify({"status": "error", "detail": "ODAFileConverter not found"}), 503
@app.route("/convert", methods=["POST"])
def convert():
"""Accept a DWG file via multipart upload, return DXF."""
if "file" not in request.files:
return jsonify({"error": "Missing 'file' field in upload."}), 400
uploaded = request.files["file"]
original_name = uploaded.filename or "input.dwg"
if not original_name.lower().endswith(".dwg"):
return jsonify({"error": "File must have .dwg extension."}), 400
safe_name = "".join(
c if c.isalnum() or c in "._-" else "_" for c in original_name
)
work_dir = os.path.join(tempfile.gettempdir(), f"dwg-{uuid.uuid4().hex}")
input_dir = os.path.join(work_dir, "in")
output_dir = os.path.join(work_dir, "out")
os.makedirs(input_dir, exist_ok=True)
os.makedirs(output_dir, exist_ok=True)
input_path = os.path.join(input_dir, safe_name)
try:
uploaded.save(input_path)
file_size = os.path.getsize(input_path)
if file_size > MAX_FILE_SIZE:
return jsonify({"error": f"File too large ({file_size} bytes)."}), 413
# ODA File Converter: input_dir output_dir version type recurse audit
# ACAD2018 = output DXF version, DXF = output format, 0 = no recurse, 1 = audit
result = subprocess.run(
[
"xvfb-run", "--auto-servernum", "--server-args=-screen 0 1x1x24",
ODA_BIN, input_dir, output_dir, "ACAD2018", "DXF", "0", "1",
],
capture_output=True,
timeout=120,
)
# Find the output DXF file
dxf_files = glob.glob(os.path.join(output_dir, "*.dxf"))
if not dxf_files:
stderr = result.stderr.decode("utf-8", errors="replace")
stdout = result.stdout.decode("utf-8", errors="replace")
detail = stderr or stdout or "No output file produced"
return jsonify({"error": f"Conversion failed: {detail}"}), 500
dxf_path = dxf_files[0]
dxf_name = safe_name.rsplit(".", 1)[0] + ".dxf"
return send_file(
dxf_path,
mimetype="application/dxf",
as_attachment=True,
download_name=dxf_name,
)
except subprocess.TimeoutExpired:
return jsonify({"error": "Conversion timed out (120s limit)."}), 504
except Exception as e:
return jsonify({"error": f"Unexpected error: {str(e)}"}), 500
finally:
shutil.rmtree(work_dir, ignore_errors=True)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5001)
+116
View File
@@ -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
+334
View File
@@ -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/)
+292
View File
@@ -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: '&copy; 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
+272
View File
@@ -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
+213
View File
@@ -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>&lt;${root}&gt;</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 &rarr; <strong>Developer</strong> &rarr; <strong>XML Mapping Pane</strong> &rarr; alegi Custom XML Part-ul
&rarr; pentru fiecare câmp, click dreapta &rarr; <em>Insert Content Control</em> &rarr; 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>
+2
View File
@@ -0,0 +1,2 @@
FROM ghcr.io/maplibre/martin:1.4.0
COPY martin.yaml /config/martin.yaml
+149
View File
@@ -0,0 +1,149 @@
# Martin v1.4 configuration — optimized tile sources for ArchiTools Geoportal
# All geometries are EPSG:3844 (Stereo70). Bounds are approximate Romania extent.
# Original table data is NEVER modified — views compute simplification on-the-fly.
postgres:
connection_string: ${DATABASE_URL}
pool_size: 8
default_srid: 3844
auto_publish: false
tables:
# ── UAT boundaries: 4 zoom-dependent simplification levels ──
gis_uats_z0:
schema: public
table: gis_uats_z0
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3]
minzoom: 0
maxzoom: 5
properties:
name: text
siruta: text
gis_uats_z5:
schema: public
table: gis_uats_z5
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3]
minzoom: 5
maxzoom: 8
properties:
name: text
siruta: text
gis_uats_z8:
schema: public
table: gis_uats_z8
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3]
minzoom: 8
maxzoom: 12
properties:
name: text
siruta: text
county: text
gis_uats_z12:
schema: public
table: gis_uats_z12
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3]
minzoom: 12
maxzoom: 16
properties:
name: text
siruta: text
county: text
# ── Administrativ (intravilan, arii speciale) — NO simplification ──
gis_administrativ:
schema: public
table: gis_administrativ
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3]
minzoom: 10
maxzoom: 16
properties:
object_id: text
siruta: text
layer_id: text
cadastral_ref: text
# ── Terenuri (parcels) — NO simplification ──
gis_terenuri:
schema: public
table: gis_terenuri
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3]
minzoom: 17
maxzoom: 18
properties:
object_id: text
siruta: text
cadastral_ref: text
area_value: float8
layer_id: text
# ── Terenuri cu status enrichment (ParcelSync Harta tab) ──
gis_terenuri_status:
schema: public
table: gis_terenuri_status
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3]
minzoom: 13
maxzoom: 18
properties:
object_id: text
siruta: text
cadastral_ref: text
area_value: float8
layer_id: text
has_enrichment: int4
has_building: int4
build_legal: int4
# ── Cladiri cu status legal (ParcelSync Harta tab) ──
gis_cladiri_status:
schema: public
table: gis_cladiri_status
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3]
minzoom: 14
maxzoom: 18
properties:
object_id: text
siruta: text
cadastral_ref: text
area_value: float8
layer_id: text
build_legal: int4
# ── Cladiri (buildings) — NO simplification ──
gis_cladiri:
schema: public
table: gis_cladiri
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3]
minzoom: 17
maxzoom: 18
properties:
object_id: text
siruta: text
cadastral_ref: text
area_value: float8
layer_id: text
+26
View File
@@ -2,6 +2,32 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'standalone',
serverExternalPackages: ['busboy'],
experimental: {
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() {
const martinUrl = process.env.MARTIN_URL || 'http://martin:3000';
return [
{
source: '/tiles/:path*',
destination: `${martinUrl}/:path*`,
},
];
},
};
export default nextConfig;
+117
View File
@@ -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;
}
}
+1910 -68
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -9,28 +9,48 @@
"lint": "eslint"
},
"dependencies": {
"@prisma/client": "^6.19.2",
"axios": "^1.13.6",
"axios-cookiejar-support": "^6.0.5",
"busboy": "^1.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"docx": "^9.6.0",
"form-data": "^4.0.5",
"jspdf": "^4.2.0",
"jszip": "^3.10.1",
"lucide-react": "^0.564.0",
"maplibre-gl": "^5.21.0",
"minio": "^8.0.6",
"next": "16.1.6",
"next-auth": "^4.24.13",
"next-themes": "^0.4.6",
"pmtiles": "^4.4.0",
"proj4": "^2.20.3",
"qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.4.1",
"tesseract.js": "^7.0.0",
"tough-cookie": "^6.0.0",
"utif2": "^4.1.0",
"uuid": "^13.0.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/busboy": "^1.5.4",
"@types/jszip": "^3.4.0",
"@types/node": "^20",
"@types/proj4": "^2.5.6",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/tough-cookie": "^4.0.5",
"@types/uuid": "^10.0.0",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"prisma": "^6.19.2",
"shadcn": "^3.8.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
+205
View File
@@ -0,0 +1,205 @@
-- =============================================================================
-- PostGIS native geometry setup for GisUat (UAT boundaries)
-- Run once manually: PGPASSWORD='...' psql -h 10.10.10.166 -p 5432 \
-- -U architools_user -d architools_db -f prisma/gisuat-postgis-setup.sql
--
-- Idempotent — safe to re-run.
--
-- What this does:
-- 1. Ensures PostGIS extension
-- 2. Adds native geometry column (geom) if missing
-- 3. Creates function to convert Esri ring JSON -> PostGIS geometry
-- 4. Creates trigger to auto-convert on INSERT/UPDATE
-- 5. Backfills existing rows
-- 6. Creates GiST spatial index
-- 7. Creates Martin/QGIS-friendly view 'gis_uats'
--
-- After running both SQL scripts (postgis-setup.sql + this file), Martin
-- will auto-discover these views (any table/view with a 'geom' geometry column):
-- - gis_features (master: all GisFeature rows with geometry)
-- - gis_terenuri (parcels from GisFeature)
-- - gis_cladiri (buildings from GisFeature)
-- - gis_documentatii (expertize/zone/receptii from GisFeature)
-- - gis_administrativ (limite UAT/intravilan/arii speciale from GisFeature)
-- - gis_uats (UAT boundaries from GisUat) <-- this script
--
-- All geometries are in EPSG:3844 (Stereo70).
-- =============================================================================
-- 1. Ensure PostGIS extension
CREATE EXTENSION IF NOT EXISTS postgis;
-- 2. Add native geometry column (idempotent)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'GisUat' AND column_name = 'geom'
) THEN
ALTER TABLE "GisUat" ADD COLUMN geom geometry(Geometry, 3844);
END IF;
END $$;
-- 3. Function: convert Esri ring JSON { rings: number[][][] } -> PostGIS geometry
-- Esri rings format: each ring is an array of [x, y] coordinate pairs.
-- First ring = exterior, subsequent rings = holes.
-- Multiple outer rings (non-holes) would need MultiPolygon, but UAT boundaries
-- from eTerra typically have a single polygon with possible holes.
--
-- Strategy: build WKT POLYGON/MULTIPOLYGON from the rings array, then
-- use ST_GeomFromText with SRID 3844.
CREATE OR REPLACE FUNCTION gis_uat_esri_to_geom(geom_json jsonb)
RETURNS geometry AS $$
DECLARE
rings jsonb;
ring jsonb;
coord jsonb;
ring_count int;
coord_count int;
i int;
j int;
wkt_ring text;
wkt text;
first_x double precision;
first_y double precision;
last_x double precision;
last_y double precision;
BEGIN
-- Extract the rings array from the JSON
rings := geom_json -> 'rings';
IF rings IS NULL OR jsonb_array_length(rings) = 0 THEN
RETURN NULL;
END IF;
ring_count := jsonb_array_length(rings);
-- Build WKT POLYGON with all rings (first = exterior, rest = holes)
wkt := 'POLYGON(';
FOR i IN 0 .. ring_count - 1 LOOP
ring := rings -> i;
coord_count := jsonb_array_length(ring);
IF coord_count < 3 THEN
CONTINUE; -- skip degenerate rings
END IF;
IF i > 0 THEN
wkt := wkt || ', ';
END IF;
wkt_ring := '(';
FOR j IN 0 .. coord_count - 1 LOOP
coord := ring -> j;
IF j > 0 THEN
wkt_ring := wkt_ring || ', ';
END IF;
wkt_ring := wkt_ring || (coord ->> 0) || ' ' || (coord ->> 1);
-- Track first and last coordinates to check ring closure
IF j = 0 THEN
first_x := (coord ->> 0)::double precision;
first_y := (coord ->> 1)::double precision;
END IF;
IF j = coord_count - 1 THEN
last_x := (coord ->> 0)::double precision;
last_y := (coord ->> 1)::double precision;
END IF;
END LOOP;
-- Close the ring if not already closed
IF first_x != last_x OR first_y != last_y THEN
wkt_ring := wkt_ring || ', ' || first_x::text || ' ' || first_y::text;
END IF;
wkt_ring := wkt_ring || ')';
wkt := wkt || wkt_ring;
END LOOP;
wkt := wkt || ')';
RETURN ST_GeomFromText(wkt, 3844);
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- 4. Trigger function: auto-convert Esri JSON -> native PostGIS on INSERT/UPDATE
CREATE OR REPLACE FUNCTION gis_uat_sync_geom()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.geometry IS NOT NULL THEN
BEGIN
NEW.geom := gis_uat_esri_to_geom(NEW.geometry::jsonb);
EXCEPTION WHEN OTHERS THEN
-- Invalid geometry JSON -> leave geom NULL rather than fail the write
NEW.geom := NULL;
END;
ELSE
NEW.geom := NULL;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 5. Attach trigger (drop + recreate for idempotency)
DROP TRIGGER IF EXISTS trg_gis_uat_sync_geom ON "GisUat";
CREATE TRIGGER trg_gis_uat_sync_geom
BEFORE INSERT OR UPDATE OF geometry ON "GisUat"
FOR EACH ROW
EXECUTE FUNCTION gis_uat_sync_geom();
-- 6. Backfill: convert existing Esri JSON geometries to native PostGIS
UPDATE "GisUat"
SET geom = gis_uat_esri_to_geom(geometry::jsonb)
WHERE geometry IS NOT NULL AND geom IS NULL;
-- 7. GiST spatial index for fast spatial queries
CREATE INDEX IF NOT EXISTS gis_uat_geom_idx
ON "GisUat" USING GIST (geom);
-- =============================================================================
-- 8. Zoom-dependent views for Martin vector tiles
-- 4 levels of geometry simplification for progressive loading.
-- SAFE: these are read-only views — original geom column is NEVER modified.
-- =============================================================================
-- z0-5: Very coarse overview (2000m tolerance) — country-level outlines
CREATE OR REPLACE VIEW gis_uats_z0 AS
SELECT siruta, name,
ST_SimplifyPreserveTopology(geom, 2000) AS geom
FROM "GisUat" WHERE geom IS NOT NULL;
-- z5-8: Coarse (500m tolerance) — regional overview
CREATE OR REPLACE VIEW gis_uats_z5 AS
SELECT siruta, name,
ST_SimplifyPreserveTopology(geom, 500) AS geom
FROM "GisUat" WHERE geom IS NOT NULL;
-- z8-12: Moderate (50m tolerance) — county/city level
CREATE OR REPLACE VIEW gis_uats_z8 AS
SELECT siruta, name, county,
ST_SimplifyPreserveTopology(geom, 50) AS geom
FROM "GisUat" WHERE geom IS NOT NULL;
-- z12+: Original geometry — full precision, no simplification
CREATE OR REPLACE VIEW gis_uats_z12 AS
SELECT siruta, name, county, geom
FROM "GisUat" WHERE geom IS NOT NULL;
-- Keep the legacy gis_uats view for QGIS compatibility
CREATE OR REPLACE VIEW gis_uats AS
SELECT siruta, name, county,
ST_SimplifyPreserveTopology(geom, 50) AS geom
FROM "GisUat" WHERE geom IS NOT NULL;
-- =============================================================================
-- Done! Martin serves these views as vector tiles:
-- - gis_uats_z0 (z0-5, 2000m simplification)
-- - gis_uats_z5 (z5-8, 500m)
-- - gis_uats_z8 (z8-12, 50m)
-- - gis_uats_z12 (z12+, 10m near-original)
-- - gis_uats (legacy for QGIS, 50m)
-- Original geometry in GisUat.geom is NEVER modified.
-- SRID: 3844 (Stereo70)
-- =============================================================================
+121
View File
@@ -0,0 +1,121 @@
-- =============================================================================
-- PostGIS native geometry setup for GisFeature
-- Run once via POST /api/eterra/setup-postgis (idempotent — safe to re-run)
--
-- What this does:
-- 1. Ensures PostGIS extension
-- 2. Adds native geometry column (geom) if missing
-- 3. Creates trigger to auto-convert GeoJSON → native on INSERT/UPDATE
-- 4. Backfills existing features that have JSON geometry but no native geom
-- 5. Creates GiST spatial index for fast spatial queries
-- 6. Creates QGIS-friendly views with clean column names
-- =============================================================================
-- 1. Ensure PostGIS extension
CREATE EXTENSION IF NOT EXISTS postgis;
-- 2. Add native geometry column (idempotent)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'GisFeature' AND column_name = 'geom'
) THEN
ALTER TABLE "GisFeature" ADD COLUMN geom geometry(Geometry, 3844);
END IF;
END $$;
-- 3. Trigger function: auto-convert GeoJSON (geometry JSON column) → native PostGIS (geom)
CREATE OR REPLACE FUNCTION gis_feature_sync_geom()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.geometry IS NOT NULL THEN
BEGIN
NEW.geom := ST_SetSRID(ST_GeomFromGeoJSON(NEW.geometry::text), 3844);
EXCEPTION WHEN OTHERS THEN
-- Invalid GeoJSON → leave geom NULL rather than fail the write
NEW.geom := NULL;
END;
ELSE
NEW.geom := NULL;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 4. Attach trigger (drop + recreate for idempotency)
DROP TRIGGER IF EXISTS trg_gis_feature_sync_geom ON "GisFeature";
CREATE TRIGGER trg_gis_feature_sync_geom
BEFORE INSERT OR UPDATE OF geometry ON "GisFeature"
FOR EACH ROW
EXECUTE FUNCTION gis_feature_sync_geom();
-- 5. Backfill: convert existing JSON geometries to native
UPDATE "GisFeature"
SET geom = ST_SetSRID(ST_GeomFromGeoJSON(geometry::text), 3844)
WHERE geometry IS NOT NULL AND geom IS NULL;
-- 6. GiST spatial index for fast bounding-box / intersection queries
CREATE INDEX IF NOT EXISTS gis_feature_geom_idx
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
-- - Clean snake_case column names
-- - Only rows with valid geometry
-- - One master view + per-category views
-- =============================================================================
-- Master view: all features
CREATE OR REPLACE VIEW gis_features AS
SELECT
id,
"layerId" AS layer_id,
siruta,
"objectId" AS object_id,
"inspireId" AS inspire_id,
"cadastralRef" AS cadastral_ref,
"areaValue" AS area_value,
"isActive" AS is_active,
attributes,
enrichment,
"enrichedAt" AS enriched_at,
"projectId" AS project_id,
"createdAt" AS created_at,
"updatedAt" AS updated_at,
geom
FROM "GisFeature"
WHERE geom IS NOT NULL;
-- Terenuri (parcels)
CREATE OR REPLACE VIEW gis_terenuri AS
SELECT * FROM gis_features
WHERE layer_id LIKE 'TERENURI%' OR layer_id LIKE 'CADGEN_LAND%';
-- Clădiri (buildings)
CREATE OR REPLACE VIEW gis_cladiri AS
SELECT * FROM gis_features
WHERE layer_id LIKE 'CLADIRI%' OR layer_id LIKE 'CADGEN_BUILDING%';
-- Documentații (expertize, zone interes, recepții)
CREATE OR REPLACE VIEW gis_documentatii AS
SELECT * FROM gis_features
WHERE layer_id LIKE 'EXPERTIZA%'
OR layer_id LIKE 'ZONE_INTERES%'
OR layer_id LIKE 'RECEPTII%';
-- Administrativ (limite UAT, intravilan, arii speciale)
CREATE OR REPLACE VIEW gis_administrativ AS
SELECT * FROM gis_features
WHERE layer_id LIKE 'LIMITE%'
OR layer_id LIKE 'SPECIAL_AREAS%';
-- =============================================================================
-- Done! QGIS connection: PostgreSQL → 10.10.10.166:5432 / architools_db
-- Add layers from views: gis_terenuri, gis_cladiri, gis_documentatii, etc.
-- SRID: 3844 (Stereo70)
-- =============================================================================
+194
View File
@@ -0,0 +1,194 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model KeyValueStore {
id String @id @default(uuid())
namespace String
key String
value Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([namespace, key])
@@index([namespace])
}
// ─── GIS: Sync Scheduling ──────────────────────────────────────────
model GisSyncRule {
id String @id @default(uuid())
siruta String? /// Set = UAT-specific rule
county String? /// Set = county-wide default rule
frequency String /// "3x-daily"|"daily"|"weekly"|"monthly"|"manual"
syncTerenuri Boolean @default(true)
syncCladiri Boolean @default(true)
syncNoGeom Boolean @default(false)
syncEnrich Boolean @default(false)
priority Int @default(5) /// 1=highest, 10=lowest
enabled Boolean @default(true)
allowedHoursStart Int? /// null = no restriction, e.g. 1 for 01:00
allowedHoursEnd Int? /// e.g. 5 for 05:00
allowedDays String? /// e.g. "1,2,3,4,5" for weekdays, null = all days
lastSyncAt DateTime?
lastSyncStatus String? /// "done"|"error"
lastSyncError String?
nextDueAt DateTime?
label String? /// Human-readable note
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([siruta, county])
@@index([enabled, nextDueAt])
@@index([county])
@@index([frequency])
}
// ─── GIS: eTerra ParcelSync ────────────────────────────────────────
model GisFeature {
id String @id @default(uuid())
layerId String // e.g. TERENURI_ACTIVE, CLADIRI_ACTIVE
siruta String
objectId Int // eTerra OBJECTID (unique per layer); negative for no-geometry parcels (= -immovablePk)
inspireId String?
cadastralRef String? // NATIONAL_CADASTRAL_REFERENCE
areaValue Float?
isActive Boolean @default(true)
attributes Json // all raw eTerra attributes
geometry Json? // GeoJSON geometry (Polygon/MultiPolygon)
geometrySource String? // null = normal GIS sync, "NO_GEOMETRY" = eTerra immovable without GIS geometry
// NOTE: native PostGIS column 'geom' is managed via SQL trigger (see prisma/postgis-setup.sql)
// Prisma doesn't need to know about it — trigger auto-populates from geometry JSON
enrichment Json? // magic data: CF, owners, address, categories, etc.
enrichedAt DateTime? // when enrichment was last fetched
syncRunId String?
projectId String? // link to project tag
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
syncRun GisSyncRun? @relation(fields: [syncRunId], references: [id], onDelete: SetNull)
@@unique([layerId, objectId])
@@index([siruta])
@@index([cadastralRef])
@@index([layerId, siruta])
@@index([projectId])
@@index([geometrySource])
}
model GisSyncRun {
id String @id @default(uuid())
siruta String
uatName String?
layerId String
status String @default("pending") // pending | running | done | error
totalRemote Int @default(0)
totalLocal Int @default(0)
newFeatures Int @default(0)
removedFeatures Int @default(0)
startedAt DateTime @default(now())
completedAt DateTime?
errorMessage String?
features GisFeature[]
@@index([siruta])
@@index([layerId])
@@index([siruta, layerId])
}
model GisUat {
siruta String @id
name String
county String?
workspacePk Int?
geometry Json? /// EsriGeometry { rings: number[][][] } in EPSG:3844
areaValue Float? /// Area in sqm from LIMITE_UAT AREA_VALUE field
lastUpdatedDtm String? /// LAST_UPDATED_DTM from eTerra — for incremental sync
updatedAt DateTime @updatedAt
@@index([name])
@@index([county])
}
// ─── Registratura: Atomic Sequences + Audit ────────────────────────
model RegistrySequence {
id String @id @default(uuid())
company String // B, U, S, G (single-letter prefix)
year Int
type String // SEQ (shared across directions)
lastSeq Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([company, year, type])
@@index([company, year])
}
model RegistryAudit {
id String @id @default(uuid())
entryId String
entryNumber String
action String // created, updated, reserved_created, reserved_claimed, late_registration, closed, deleted
actor String
actorName String?
company String
detail Json?
createdAt DateTime @default(now())
@@index([entryId])
@@index([company, createdAt])
}
// ─── ANCPI ePay: CF Extract Orders ──────────────────────────────────
model CfExtract {
id String @id @default(uuid())
orderId String? // ePay orderId (shared across batch items)
basketRowId Int? // ePay cart item ID
nrCadastral String // cadastral number
nrCF String? // CF number if different
siruta String? // UAT SIRUTA code
judetIndex Int // ePay county index (0-41)
judetName String // county display name
uatId Int // ePay UAT numeric ID
uatName String // UAT display name
prodId Int @default(14200)
solicitantId String @default("14452")
status String @default("pending") // pending|queued|cart|searching|ordering|polling|downloading|completed|failed|cancelled
epayStatus String? // raw ePay status
idDocument Int? // ePay document ID
documentName String? // ePay filename
documentDate DateTime? // when ANCPI generated
minioPath String? // MinIO object key
minioIndex Int? // file version index
creditsUsed Int @default(1)
immovableId String? // eTerra immovable ID
immovableType String? // T/C/A
measuredArea String?
legalArea String?
address String?
gisFeatureId String? // link to GisFeature
version Int @default(1) // increments on re-order
expiresAt DateTime? // 30 days after documentDate
supersededById String? // newer version id
requestedBy String?
errorMessage String?
pollAttempts Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
completedAt DateTime?
@@index([nrCadastral])
@@index([status])
@@index([orderId])
@@index([gisFeatureId])
@@index([createdAt])
@@index([nrCadastral, version])
}
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW 2021.5 -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="210.689mm" height="31.3079mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 96.1459 14.287"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
<defs>
<style type="text/css">
<![CDATA[
.str0 {stroke:#FEFEFE;stroke-width:0.0348;stroke-miterlimit:2.61313}
.str1 {stroke:#0BA49A;stroke-width:0.0348;stroke-miterlimit:22.9256}
.fil0 {fill:none}
.fil1 {fill:#FEFEFE}
.fil2 {fill:#0BA49A}
]]>
</style>
</defs>
<g id="Layer_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<polygon class="fil0 str0" points="12.4802,9.5738 14.2631,8.9876 14.2631,13.6769 12.4802,14.263 "/>
<polygon class="fil1" points="12.4802,14.263 7.1315,12.5046 9.5087,11.723 12.4802,12.6999 "/>
<polygon class="fil1" points="1.7829,3.3215 7.1315,1.5631 11.8859,3.1262 14.2631,2.3446 7.1315,0 -0,2.3446 -0,13.6769 1.7829,14.263 "/>
<polygon class="fil0 str0" points="1.7829,12.7 7.1315,10.9415 9.5087,11.723 1.7829,14.263 "/>
<polygon class="fil0 str0" points="7.1315,4.6892 14.2631,2.3446 14.2631,3.9077 7.1315,6.2523 "/>
<polygon class="fil2" points="7.1315,6.2523 14.2631,3.9077 14.2631,5.8615 7.1315,8.2061 "/>
<polygon class="fil0 str0" points="7.1315,8.2061 14.2631,5.8615 14.2631,7.4246 7.1315,9.7692 "/>
<polygon class="fil1" points="7.1315,6.2523 3.5657,5.08 3.5657,3.5169 7.1315,4.6892 "/>
<polygon class="fil1" points="7.1315,9.7692 3.5657,8.5969 3.5657,7.0338 7.1315,8.2061 "/>
<polygon class="fil1" points="28.4181,3.2962 28.4181,12.7179 36.4138,12.7179 36.4138,11.0764 30.0308,11.0764 30.0308,8.7589 35.9937,8.7589 35.9937,7.1173 30.0308,7.1173 30.0308,4.9378 36.4138,4.9378 36.4138,3.2962 "/>
<polygon class="fil1" points="39.3953,11.0764 39.3953,11.0764 45.7648,11.0764 45.7648,12.7179 37.7826,12.7179 37.7826,3.2962 39.3953,3.2962 "/>
<polygon class="fil1" points="59.8589,8.3739 59.8589,8.3739 59.8589,12.7179 61.4581,12.7179 61.4581,4.9378 65.1036,4.9378 65.1036,3.2962 56.1999,3.2962 56.1999,4.9378 59.8589,4.9378 59.8589,4.9378 59.8589,4.9378 59.8589,8.3739 "/>
<path class="fil1" d="M83.8853 9.021l0 0 -2.5655 0 0 -0.6872 0 0 0 -0.8164 5.353 0 0 5.2006 -5.2311 0c-1.3461,0 -2.4598,-0.4553 -3.3406,-1.3657 -0.8809,-0.9104 -1.3214,-2.06 -1.3214,-3.4486 0,-1.3795 0.436,-2.4923 1.3078,-3.3383 0.8718,-0.846 1.99,-1.2691 3.3542,-1.2691l4.5806 0 0 1.6415 -4.5806 0c-0.8853,0 -1.615,0.2898 -2.1887,0.8691 -0.5736,0.5793 -0.8605,1.3243 -0.8605,2.2347 0,0.9012 0.2869,1.6324 0.8605,2.1933 0.5737,0.561 1.3033,0.8414 2.1887,0.8414l3.2774 0.0001c0.1618,0 0.3411,-0.1842 0.3411,-0.3466l0 -1.7088 -1.175 -0.0001z"/>
<path class="fil1" d="M71.3909 8.4278l0 0c0.3629,0 0.1856,-0.7803 0,-1.0935l-0.9556 -1.6125 -4.174 6.9962 -2.0712 0 5.0279 -8.5251c0.1292,-0.219 0.3007,-0.4257 0.5285,-0.607 0.269,-0.2141 0.5149,-0.3172 0.7318,-0.3172 0.2349,0 0.4792,0.1008 0.7318,0.3035 0.2171,0.1743 0.3915,0.3826 0.5285,0.6208l4.9059 8.5251 -2.0803 -0.0138 -1.3766 -2.3408c-0.1003,-0.1706 -0.3171,-0.294 -0.5143,-0.294l-4.0517 0 0.9735 -1.6415 1.7958 0z"/>
<path class="fil1" d="M26.7241 9.9728c0,0.4047 -0.0994,0.7863 -0.2981,1.145 -0.5963,1.0576 -1.7256,1.5864 -3.388,1.5864l-5.9222 0 0 -9.4217 5.9222 0c1.5811,0 2.6878,0.5012 3.3203,1.5036 0.2439,0.4047 0.3659,0.8369 0.3659,1.2967 0,0.7816 -0.3253,1.4392 -0.9757,1.9726 0.6505,0.515 0.9757,1.1542 0.9757,1.9174zm-7.0471 -2.0441l0 0 0 0 0 -0.6872 3.0443 0 0 0 0.5605 0c0.4518,0 0.8539,-0.0828 1.2062,-0.2482 0.4518,-0.2116 0.6776,-0.5196 0.6776,-0.9242 0,-0.3955 -0.2258,-0.699 -0.6776,-0.9105 -0.3523,-0.1563 -0.7544,-0.2345 -1.2062,-0.2345l-4.5534 0 0 6.1385 4.5534 0c0.4427,0 0.8357,-0.069 1.1791,-0.2069 0.4698,-0.2023 0.7047,-0.492 0.7047,-0.8691 0,-0.3862 -0.2349,-0.6805 -0.7047,-0.8828 -0.3524,-0.1472 -0.7454,-0.2207 -1.1791,-0.2207l-3.6048 0 0 -0.9544z"/>
<polygon class="fil1" points="46.9845,3.2962 46.9845,12.7179 54.9802,12.7179 54.9802,11.0764 48.5972,11.0764 48.5972,8.7589 54.5601,8.7589 54.5601,7.1173 48.5972,7.1173 48.5972,4.9378 54.9802,4.9378 54.9802,3.2962 "/>
<polygon class="fil1" points="88.1501,3.2962 88.1501,12.7179 96.1459,12.7179 96.1459,11.0764 89.7628,11.0764 89.7628,8.7589 95.7258,8.7589 95.7258,7.1173 89.7628,7.1173 89.7628,4.9378 96.1459,4.9378 96.1459,3.2962 "/>
<line class="fil0 str1" x1="7.1315" y1="6.2523" x2="14.2631" y2= "3.9077" />
<line class="fil0" x1="7.1315" y1="8.2061" x2="14.2631" y2= "5.8615" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW 2021.5 -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="210.689mm" height="31.2553mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 109.6701 16.2693"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
<defs>
<style type="text/css">
<![CDATA[
.str0 {stroke:#0BA49A;stroke-width:0.0397;stroke-miterlimit:22.9256}
.fil4 {fill:none}
.fil3 {fill:#625E5D}
.fil2 {fill:#0BA49A}
.fil1 {fill:#54504F}
.fil0 {fill:#A7A9AA}
]]>
</style>
</defs>
<g id="Layer_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<polygon class="fil0" points="14.2357,10.9205 16.2694,10.2519 16.2694,15.6007 14.2357,16.2693 "/>
<polygon class="fil1" points="14.2357,16.2693 8.1347,14.2635 10.8462,13.3721 14.2357,14.4863 "/>
<polygon class="fil1" points="2.0337,3.7887 8.1347,1.7829 13.5578,3.5659 16.2694,2.6744 8.1347,0 -0,2.6744 -0,15.6007 2.0337,16.2693 "/>
<polygon class="fil0" points="2.0337,14.4864 8.1347,12.4806 10.8462,13.3721 2.0337,16.2693 "/>
<polygon class="fil0" points="8.1347,5.3488 16.2694,2.6744 16.2694,4.4574 8.1347,7.1317 "/>
<polygon class="fil2" points="8.1347,7.1318 16.2694,4.4574 16.2694,6.686 8.1347,9.3604 "/>
<polygon class="fil0" points="8.1347,9.3604 16.2694,6.686 16.2694,8.469 8.1347,11.1433 "/>
<polygon class="fil1" points="8.1347,7.1318 4.0673,5.7945 4.0673,4.0116 8.1347,5.3488 "/>
<polygon class="fil1" points="8.1347,11.1433 4.0673,9.8062 4.0673,8.0232 8.1347,9.3604 "/>
<polygon class="fil1" points="32.4155,3.7599 32.4155,14.5069 41.5359,14.5069 41.5359,12.6344 34.255,12.6344 34.255,9.9909 41.0567,9.9909 41.0567,8.1185 34.255,8.1185 34.255,5.6323 41.5359,5.6323 41.5359,3.7599 "/>
<polygon class="fil1" points="44.9368,12.6344 44.9368,12.6344 52.2022,12.6344 52.2022,14.5069 43.0973,14.5069 43.0973,3.7599 44.9368,3.7599 "/>
<polygon class="fil3" points="68.279,9.5518 68.279,9.5518 68.279,14.5069 70.1031,14.5069 70.1031,5.6323 74.2614,5.6323 74.2614,3.7599 64.1052,3.7599 64.1052,5.6323 68.279,5.6323 68.279,5.6324 68.279,5.6324 68.279,9.5518 "/>
<path class="fil3" d="M95.685 10.2899l0 0 -2.9264 0 0 -0.7839 0.0001 0 0 -0.9312 6.106 0 0 5.9321 -5.9669 0c-1.5354,0 -2.8058,-0.5193 -3.8105,-1.5578 -1.0048,-1.0384 -1.5072,-2.3497 -1.5072,-3.9337 0,-1.5736 0.4973,-2.8429 1.4917,-3.8079 0.9945,-0.965 2.2699,-1.4476 3.826,-1.4476l5.225 0 0 1.8725 -5.225 0c-1.0098,0 -1.8422,0.3305 -2.4966,0.9913 -0.6543,0.6608 -0.9816,1.5106 -0.9816,2.549 0,1.028 0.3273,1.862 0.9816,2.5019 0.6544,0.6399 1.4866,0.9598 2.4966,0.9598l3.7384 0.0002c0.1845,0 0.389,-0.2101 0.389,-0.3954l0 -1.9491 -1.3403 -0.0002z"/>
<path class="fil3" d="M81.433 9.6133l0 0c0.4139,0 0.2118,-0.8901 0,-1.2473l-1.09 -1.8393 -4.7612 7.9803 -2.3626 0 5.7351 -9.7242c0.1474,-0.2499 0.343,-0.4856 0.6029,-0.6924 0.3069,-0.2442 0.5874,-0.3619 0.8348,-0.3619 0.2679,0 0.5466,0.1149 0.8347,0.3462 0.2477,0.1988 0.4466,0.4364 0.6029,0.7081l5.596 9.7242 -2.3729 -0.0158 -1.5702 -2.6701c-0.1144,-0.1946 -0.3617,-0.3353 -0.5866,-0.3353l-4.6216 0 1.1104 -1.8725 2.0484 0z"/>
<path class="fil1" d="M30.4832 11.3756c0,0.4616 -0.1134,0.8969 -0.3401,1.306 -0.6802,1.2064 -1.9684,1.8095 -3.8646,1.8095l-6.7553 0 0 -10.747 6.7553 0c1.8035,0 3.0659,0.5717 3.7873,1.7151 0.2782,0.4616 0.4174,0.9546 0.4174,1.4791 0,0.8916 -0.371,1.6416 -1.113,2.25 0.742,0.5875 1.113,1.3165 1.113,2.1872zm-8.0383 -2.3317l0 0 0 0 0 -0.7839 3.4726 0 0 0 0.6393 0c0.5153,0 0.974,-0.0944 1.3758,-0.2832 0.5153,-0.2413 0.7729,-0.5927 0.7729,-1.0542 0,-0.4511 -0.2576,-0.7973 -0.7729,-1.0386 -0.4018,-0.1783 -0.8605,-0.2674 -1.3758,-0.2674l-5.194 0 0 7.002 5.194 0c0.505,0 0.9533,-0.0787 1.3449,-0.236 0.5358,-0.2308 0.8038,-0.5612 0.8038,-0.9913 0,-0.4406 -0.268,-0.7763 -0.8038,-1.007 -0.402,-0.1679 -0.8503,-0.2518 -1.3449,-0.2518l-4.1119 0 0 -1.0886z"/>
<polygon class="fil1" points="53.5935,3.7599 53.5935,14.5069 62.714,14.5069 62.714,12.6344 55.4331,12.6344 55.4331,9.9909 62.2348,9.9909 62.2348,8.1185 55.4331,8.1185 55.4331,5.6323 62.714,5.6323 62.714,3.7599 "/>
<polygon class="fil3" points="100.5497,3.7599 100.5497,14.5069 109.6701,14.5069 109.6701,12.6344 102.3892,12.6344 102.3892,9.9909 109.1909,9.9909 109.1909,8.1185 102.3892,8.1185 102.3892,5.6323 109.6701,5.6323 109.6701,3.7599 "/>
<line class="fil4 str0" x1="8.1347" y1="7.1317" x2="16.2694" y2= "4.4574" />
<line class="fil4" x1="8.1347" y1="9.3604" x2="16.2694" y2= "6.686" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="7" viewBox="0 0 11 7">
<circle cx="5.5" cy="3.5" r="2.5" fill="#0182A1"/>
</svg>

After

Width:  |  Height:  |  Size: 142 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 11 11">
<circle cx="5.5" cy="5.5" r="3" fill="#A7A9AA"/>
</svg>

After

Width:  |  Height:  |  Size: 142 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="7" viewBox="0 0 11 7">
<rect x="1" y="2.5" width="9" height="2" rx="1" fill="#345476"/>
</svg>

After

Width:  |  Height:  |  Size: 156 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 11 11">
<rect x="1" y="4.5" width="9" height="2" rx="1" fill="#A7A9AA"/>
</svg>

After

Width:  |  Height:  |  Size: 158 B

File diff suppressed because one or more lines are too long
+12746
View File
File diff suppressed because it is too large Load Diff
+117
View File
@@ -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."
+30
View File
@@ -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)"
+74
View File
@@ -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."
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { FeatureGate } from "@/core/feature-flags";
import { GeoportalModule } from "@/modules/geoportal";
export default function GeoportalPage() {
return (
<FeatureGate flag="module.geoportal" fallback={<ModuleDisabled />}>
<GeoportalModule />
</FeatureGate>
);
}
function ModuleDisabled() {
return (
<div className="mx-auto max-w-7xl py-12 text-center text-muted-foreground">
<p>Modulul Geoportal este dezactivat.</p>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { FeatureGate } from "@/core/feature-flags";
import { useI18n } from "@/core/i18n";
import { HotDeskModule } from "@/modules/hot-desk";
export default function HotDeskPage() {
const { t } = useI18n();
return (
<FeatureGate flag="module.hot-desk" fallback={<ModuleDisabled />}>
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("hot-desk.title")}
</h1>
<p className="text-muted-foreground">{t("hot-desk.description")}</p>
</div>
<HotDeskModule />
</div>
</FeatureGate>
);
}
function ModuleDisabled() {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-muted-foreground">Modul dezactivat</p>
</div>
);
}
+736
View File
@@ -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>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { FeatureGate } from "@/core/feature-flags";
import { useI18n } from "@/core/i18n";
import { ParcelSyncModule } from "@/modules/parcel-sync";
export default function ParcelSyncPage() {
const { t } = useI18n();
return (
<FeatureGate flag="module.parcel-sync" fallback={<ModuleDisabled />}>
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("parcel-sync.title")}
</h1>
<p className="text-muted-foreground">
{t("parcel-sync.description")}
</p>
</div>
<ParcelSyncModule />
</div>
</FeatureGate>
);
}
function ModuleDisabled() {
return (
<div className="mx-auto max-w-6xl py-12 text-center text-muted-foreground">
<p>Modulul eTerra Parcele este dezactivat.</p>
</div>
);
}
+878
View File
@@ -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>
);
}
+847
View File
@@ -0,0 +1,847 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/shared/components/ui/tabs";
/* ─── Types ──────────────────────────────────────────────── */
type SyncRule = {
id: string;
siruta: string | null;
county: string | null;
frequency: string;
syncTerenuri: boolean;
syncCladiri: boolean;
syncNoGeom: boolean;
syncEnrich: boolean;
priority: number;
enabled: boolean;
allowedHoursStart: number | null;
allowedHoursEnd: number | null;
allowedDays: string | null;
lastSyncAt: string | null;
lastSyncStatus: string | null;
lastSyncError: string | null;
nextDueAt: string | null;
label: string | null;
createdAt: string;
// enriched
uatName: string | null;
uatCount: number;
};
type SchedulerStats = {
totalRules: number;
activeRules: number;
dueNow: number;
withErrors: number;
frequencyDistribution: Record<string, number>;
totalCounties: number;
countiesWithRules: number;
};
type CountyOverview = {
county: string;
totalUats: number;
withRules: number;
defaultFreq: string | null;
};
/* ─── Constants ──────────────────────────────────────────── */
const FREQ_LABELS: Record<string, string> = {
"3x-daily": "3x/zi",
daily: "Zilnic",
weekly: "Saptamanal",
monthly: "Lunar",
manual: "Manual",
};
const FREQ_COLORS: Record<string, string> = {
"3x-daily": "bg-red-500/20 text-red-400",
daily: "bg-orange-500/20 text-orange-400",
weekly: "bg-blue-500/20 text-blue-400",
monthly: "bg-gray-500/20 text-gray-400",
manual: "bg-purple-500/20 text-purple-400",
};
/* ─── Page ───────────────────────────────────────────────── */
export default function SyncManagementPage() {
const [rules, setRules] = useState<SyncRule[]>([]);
const [globalDefault, setGlobalDefault] = useState("monthly");
const [stats, setStats] = useState<SchedulerStats | null>(null);
const [counties, setCounties] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [showAddDialog, setShowAddDialog] = useState(false);
const [filterCounty, setFilterCounty] = useState("");
const [filterFreq, setFilterFreq] = useState("");
const fetchRules = useCallback(async () => {
try {
const res = await fetch("/api/eterra/sync-rules");
if (res.ok) {
const d = (await res.json()) as { rules: SyncRule[]; globalDefault: string };
setRules(d.rules);
setGlobalDefault(d.globalDefault);
}
} catch { /* noop */ }
}, []);
const fetchStats = useCallback(async () => {
try {
const res = await fetch("/api/eterra/sync-rules/scheduler");
if (res.ok) {
const d = (await res.json()) as { stats: SchedulerStats };
setStats(d.stats);
}
} catch { /* noop */ }
}, []);
const fetchCounties = useCallback(async () => {
try {
const res = await fetch("/api/eterra/counties");
if (res.ok) {
const d = (await res.json()) as { counties: string[] };
setCounties(d.counties ?? []);
}
} catch { /* noop */ }
}, []);
useEffect(() => {
void Promise.all([fetchRules(), fetchStats(), fetchCounties()]).then(() =>
setLoading(false),
);
}, [fetchRules, fetchStats, fetchCounties]);
// Auto-refresh every 30s
useEffect(() => {
const iv = setInterval(() => {
void fetchRules();
void fetchStats();
}, 30_000);
return () => clearInterval(iv);
}, [fetchRules, fetchStats]);
const toggleEnabled = async (rule: SyncRule) => {
await fetch(`/api/eterra/sync-rules/${rule.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !rule.enabled }),
});
void fetchRules();
};
const deleteRule = async (rule: SyncRule) => {
await fetch(`/api/eterra/sync-rules/${rule.id}`, { method: "DELETE" });
void fetchRules();
void fetchStats();
};
const updateGlobalDefault = async (freq: string) => {
await fetch("/api/eterra/sync-rules/global-default", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ frequency: freq }),
});
setGlobalDefault(freq);
};
const filteredRules = rules.filter((r) => {
if (filterCounty && r.county !== filterCounty && r.siruta) {
// For UAT rules, need to check if UAT is in filtered county — skip for now, show all UAT rules when county filter is set
return false;
}
if (filterCounty && r.county && r.county !== filterCounty) return false;
if (filterFreq && r.frequency !== filterFreq) return false;
return true;
});
// Build county overview from stats
const countyOverview: CountyOverview[] = counties.map((c) => {
const countyRule = rules.find((r) => r.county === c && !r.siruta);
const uatRules = rules.filter((r) => r.county === null && r.siruta !== null);
return {
county: c,
totalUats: 0, // filled by separate query if needed
withRules: (countyRule ? 1 : 0) + uatRules.length,
defaultFreq: countyRule?.frequency ?? null,
};
});
if (loading) {
return (
<div className="mx-auto max-w-6xl p-6">
<h1 className="text-2xl font-bold mb-4">Sync Management</h1>
<div className="h-64 rounded-lg bg-muted/50 animate-pulse" />
</div>
);
}
return (
<div className="mx-auto max-w-6xl p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Sync Management</h1>
<p className="text-sm text-muted-foreground">
Reguli de sincronizare eTerra {rules.length} reguli configurate
</p>
</div>
<a
href="/monitor"
className="px-4 py-2 rounded border border-border text-sm hover:bg-muted/50 transition-colors"
>
Monitor
</a>
</div>
{/* Global Default */}
<div className="rounded-lg border border-border bg-card p-4 flex items-center justify-between">
<div>
<span className="text-sm font-medium">Frecventa implicita (UAT-uri fara regula)</span>
<p className="text-xs text-muted-foreground mt-0.5">
Se aplica la UAT-urile care nu au regula specifica si nici regula de judet
</p>
</div>
<select
value={globalDefault}
onChange={(e) => void updateGlobalDefault(e.target.value)}
className="h-9 rounded-md border border-border bg-background px-3 text-sm"
>
{Object.entries(FREQ_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
<Tabs defaultValue="rules">
<TabsList>
<TabsTrigger value="rules">Reguli ({rules.length})</TabsTrigger>
<TabsTrigger value="status">Status</TabsTrigger>
<TabsTrigger value="counties">Judete ({counties.length})</TabsTrigger>
</TabsList>
{/* ═══ RULES TAB ═══ */}
<TabsContent value="rules" className="space-y-4 mt-4">
{/* Filters + Add button */}
<div className="flex items-center gap-3 flex-wrap">
<select
value={filterCounty}
onChange={(e) => setFilterCounty(e.target.value)}
className="h-9 w-48 rounded-md border border-border bg-background px-3 text-sm"
>
<option value="">Toate judetele</option>
{counties.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<select
value={filterFreq}
onChange={(e) => setFilterFreq(e.target.value)}
className="h-9 w-40 rounded-md border border-border bg-background px-3 text-sm"
>
<option value="">Toate frecventele</option>
{Object.entries(FREQ_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<div className="ml-auto">
<button
onClick={() => setShowAddDialog(true)}
className="h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary/90"
>
Adauga regula
</button>
</div>
</div>
{/* Rules table */}
{filteredRules.length === 0 ? (
<div className="rounded-lg border border-border bg-card p-8 text-center text-muted-foreground">
Nicio regula {filterCounty || filterFreq ? "pentru filtrul selectat" : "configurata"}. Apasa &quot;Adauga regula&quot; pentru a incepe.
</div>
) : (
<div className="rounded-lg border border-border bg-card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left py-2.5 px-3 font-medium">Scope</th>
<th className="text-left py-2.5 px-3 font-medium">Frecventa</th>
<th className="text-left py-2.5 px-3 font-medium">Pasi</th>
<th className="text-left py-2.5 px-3 font-medium">Prioritate</th>
<th className="text-left py-2.5 px-3 font-medium">Ultimul sync</th>
<th className="text-left py-2.5 px-3 font-medium">Urmatorul</th>
<th className="text-center py-2.5 px-3 font-medium">Activ</th>
<th className="text-right py-2.5 px-3 font-medium">Actiuni</th>
</tr>
</thead>
<tbody>
{filteredRules.map((r) => (
<RuleRow
key={r.id}
rule={r}
onToggle={() => void toggleEnabled(r)}
onDelete={() => void deleteRule(r)}
/>
))}
</tbody>
</table>
</div>
</div>
)}
</TabsContent>
{/* ═══ STATUS TAB ═══ */}
<TabsContent value="status" className="space-y-4 mt-4">
{stats && (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard label="Total reguli" value={stats.totalRules} />
<StatCard label="Active" value={stats.activeRules} />
<StatCard
label="Scadente acum"
value={stats.dueNow}
highlight={stats.dueNow > 0}
/>
<StatCard
label="Cu erori"
value={stats.withErrors}
highlight={stats.withErrors > 0}
error
/>
</div>
<div className="rounded-lg border border-border bg-card p-4">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">
Distributie frecvente
</h3>
<div className="flex flex-wrap gap-3">
{Object.entries(FREQ_LABELS).map(([key, label]) => {
const count = stats.frequencyDistribution[key] ?? 0;
return (
<div
key={key}
className="flex items-center gap-2 px-3 py-2 rounded-md border border-border"
>
<FreqBadge freq={key} />
<span className="text-sm font-medium">{count}</span>
<span className="text-xs text-muted-foreground">{label}</span>
</div>
);
})}
</div>
</div>
<div className="rounded-lg border border-border bg-card p-4">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">
Acoperire
</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Judete cu reguli:</span>{" "}
<span className="font-medium">{stats.countiesWithRules} / {stats.totalCounties}</span>
</div>
<div>
<span className="text-muted-foreground">Default global:</span>{" "}
<FreqBadge freq={globalDefault} />
</div>
</div>
</div>
{/* Overdue rules */}
{stats.dueNow > 0 && (
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/5 p-4">
<h3 className="text-sm font-semibold text-yellow-400 mb-2">
Reguli scadente ({stats.dueNow})
</h3>
<p className="text-xs text-muted-foreground">
Scheduler-ul va procesa aceste reguli la urmatorul tick.
(Scheduler-ul unificat va fi activat in Phase 2)
</p>
</div>
)}
</>
)}
</TabsContent>
{/* ═══ COUNTIES TAB ═══ */}
<TabsContent value="counties" className="space-y-4 mt-4">
<p className="text-sm text-muted-foreground">
Seteaza frecventa de sync la nivel de judet. UAT-urile cu regula proprie o vor suprascrie.
</p>
<div className="rounded-lg border border-border bg-card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left py-2.5 px-3 font-medium">Judet</th>
<th className="text-left py-2.5 px-3 font-medium">Frecventa curenta</th>
<th className="text-right py-2.5 px-3 font-medium">Seteaza frecventa</th>
</tr>
</thead>
<tbody>
{counties.map((c) => (
<CountyRow
key={c}
county={c}
currentFreq={countyOverview.find((o) => o.county === c)?.defaultFreq ?? null}
globalDefault={globalDefault}
onSetFreq={async (freq) => {
await fetch("/api/eterra/sync-rules/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "set-county-frequency",
county: c,
frequency: freq,
}),
});
void fetchRules();
void fetchStats();
}}
/>
))}
</tbody>
</table>
</div>
</div>
</TabsContent>
</Tabs>
{/* Add Rule Dialog */}
{showAddDialog && (
<AddRuleDialog
counties={counties}
onClose={() => setShowAddDialog(false)}
onCreated={() => {
setShowAddDialog(false);
void fetchRules();
void fetchStats();
}}
/>
)}
</div>
);
}
/* ─── Sub-components ─────────────────────────────────────── */
function FreqBadge({ freq }: { freq: string }) {
return (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${FREQ_COLORS[freq] ?? "bg-muted text-muted-foreground"}`}>
{FREQ_LABELS[freq] ?? freq}
</span>
);
}
function StatCard({ label, value, highlight, error }: {
label: string; value: number; highlight?: boolean; error?: boolean;
}) {
return (
<div className={`rounded-lg border p-4 ${
highlight
? error ? "border-red-500/30 bg-red-500/5" : "border-yellow-500/30 bg-yellow-500/5"
: "border-border bg-card"
}`}>
<div className="text-xs text-muted-foreground mb-1">{label}</div>
<div className={`text-2xl font-bold tabular-nums ${
highlight ? (error ? "text-red-400" : "text-yellow-400") : ""
}`}>
{value}
</div>
</div>
);
}
function RuleRow({ rule, onToggle, onDelete }: {
rule: SyncRule; onToggle: () => void; onDelete: () => void;
}) {
const scope = rule.siruta
? (rule.uatName ?? rule.siruta)
: rule.county
? `Judet: ${rule.county}`
: "Global";
const scopeSub = rule.siruta
? `SIRUTA ${rule.siruta}`
: rule.uatCount > 0
? `${rule.uatCount} UAT-uri`
: null;
const isOverdue = rule.nextDueAt && new Date(rule.nextDueAt) < new Date();
return (
<tr className={`border-b border-border/50 ${!rule.enabled ? "opacity-50" : ""}`}>
<td className="py-2.5 px-3">
<div className="font-medium">{scope}</div>
{scopeSub && <div className="text-xs text-muted-foreground">{scopeSub}</div>}
{rule.label && <div className="text-xs text-blue-400 mt-0.5">{rule.label}</div>}
</td>
<td className="py-2.5 px-3">
<FreqBadge freq={rule.frequency} />
</td>
<td className="py-2.5 px-3">
<div className="flex gap-1">
{rule.syncTerenuri && <StepIcon label="T" title="Terenuri" />}
{rule.syncCladiri && <StepIcon label="C" title="Cladiri" />}
{rule.syncNoGeom && <StepIcon label="N" title="No-geom" />}
{rule.syncEnrich && <StepIcon label="E" title="Enrichment" />}
</div>
</td>
<td className="py-2.5 px-3 tabular-nums">{rule.priority}</td>
<td className="py-2.5 px-3">
{rule.lastSyncAt ? (
<div className="flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full shrink-0 ${
rule.lastSyncStatus === "done" ? "bg-green-400" :
rule.lastSyncStatus === "error" ? "bg-red-400" : "bg-gray-400"
}`} />
<span className="text-xs">{relativeTime(rule.lastSyncAt)}</span>
</div>
) : (
<span className="text-xs text-muted-foreground">Niciodata</span>
)}
</td>
<td className="py-2.5 px-3">
{rule.nextDueAt ? (
<span className={`text-xs ${isOverdue ? "text-yellow-400 font-medium" : "text-muted-foreground"}`}>
{isOverdue ? "Scadent" : relativeTime(rule.nextDueAt)}
</span>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</td>
<td className="py-2.5 px-3 text-center">
<button
onClick={onToggle}
className={`w-8 h-5 rounded-full transition-colors relative ${
rule.enabled ? "bg-green-500" : "bg-muted"
}`}
>
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
rule.enabled ? "left-3.5" : "left-0.5"
}`} />
</button>
</td>
<td className="py-2.5 px-3 text-right">
<button
onClick={onDelete}
className="text-xs text-muted-foreground hover:text-red-400 transition-colors"
>
Sterge
</button>
</td>
</tr>
);
}
function StepIcon({ label, title }: { label: string; title: string }) {
return (
<span
title={title}
className="w-5 h-5 rounded text-[10px] font-bold flex items-center justify-center bg-muted text-muted-foreground"
>
{label}
</span>
);
}
function CountyRow({ county, currentFreq, globalDefault, onSetFreq }: {
county: string;
currentFreq: string | null;
globalDefault: string;
onSetFreq: (freq: string) => Promise<void>;
}) {
const [saving, setSaving] = useState(false);
return (
<tr className="border-b border-border/50">
<td className="py-2.5 px-3 font-medium">{county}</td>
<td className="py-2.5 px-3">
{currentFreq ? (
<FreqBadge freq={currentFreq} />
) : (
<span className="text-xs text-muted-foreground">
Implicit ({FREQ_LABELS[globalDefault] ?? globalDefault})
</span>
)}
</td>
<td className="py-2.5 px-3 text-right">
<select
value={currentFreq ?? ""}
disabled={saving}
onChange={async (e) => {
if (!e.target.value) return;
setSaving(true);
await onSetFreq(e.target.value);
setSaving(false);
}}
className="h-8 rounded-md border border-border bg-background px-2 text-xs"
>
<option value="">Alege...</option>
{Object.entries(FREQ_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</td>
</tr>
);
}
function AddRuleDialog({ counties, onClose, onCreated }: {
counties: string[];
onClose: () => void;
onCreated: () => void;
}) {
const [ruleType, setRuleType] = useState<"uat" | "county">("county");
const [siruta, setSiruta] = useState("");
const [county, setCounty] = useState("");
const [frequency, setFrequency] = useState("daily");
const [syncEnrich, setSyncEnrich] = useState(false);
const [syncNoGeom, setSyncNoGeom] = useState(false);
const [priority, setPriority] = useState(5);
const [label, setLabel] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
// UAT search — load all once, filter client-side
const [uatSearch, setUatSearch] = useState("");
const [allUats, setAllUats] = useState<Array<{ siruta: string; name: string }>>([]);
const [uatName, setUatName] = useState("");
useEffect(() => {
void (async () => {
try {
const res = await fetch("/api/eterra/uats");
if (res.ok) {
const d = (await res.json()) as { uats?: Array<{ siruta: string; name: string }> };
setAllUats(d.uats ?? []);
}
} catch { /* noop */ }
})();
}, []);
const uatResults = uatSearch.length >= 2
? allUats
.filter((u) => {
const q = uatSearch.toLowerCase();
return u.name.toLowerCase().includes(q) || u.siruta.includes(q);
})
.slice(0, 10)
: [];
const handleSubmit = async () => {
setError("");
setSaving(true);
const body: Record<string, unknown> = {
frequency,
syncEnrich,
syncNoGeom,
priority,
label: label.trim() || null,
};
if (ruleType === "uat") {
if (!siruta) { setError("Selecteaza un UAT"); setSaving(false); return; }
body.siruta = siruta;
} else {
if (!county) { setError("Selecteaza un judet"); setSaving(false); return; }
body.county = county;
}
try {
const res = await fetch("/api/eterra/sync-rules", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const d = (await res.json()) as { rule?: SyncRule; error?: string };
if (!res.ok) {
setError(d.error ?? "Eroare");
setSaving(false);
return;
}
onCreated();
} catch {
setError("Eroare retea");
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div
className="bg-card border border-border rounded-lg p-6 w-full max-w-md space-y-4"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold">Adauga regula de sync</h2>
{/* Rule type toggle */}
<div className="flex gap-2">
<button
onClick={() => setRuleType("county")}
className={`flex-1 py-2 rounded-md text-sm font-medium transition-colors ${
ruleType === "county" ? "bg-primary text-primary-foreground" : "bg-muted"
}`}
>
Judet
</button>
<button
onClick={() => setRuleType("uat")}
className={`flex-1 py-2 rounded-md text-sm font-medium transition-colors ${
ruleType === "uat" ? "bg-primary text-primary-foreground" : "bg-muted"
}`}
>
UAT specific
</button>
</div>
{/* Scope selection */}
{ruleType === "county" ? (
<select
value={county}
onChange={(e) => setCounty(e.target.value)}
className="h-9 w-full rounded-md border border-border bg-background px-3 text-sm"
>
<option value="">Alege judet...</option>
{counties.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
) : (
<div className="relative">
<input
type="text"
value={uatSearch}
onChange={(e) => { setUatSearch(e.target.value); setSiruta(""); setUatName(""); }}
placeholder="Cauta UAT (nume sau SIRUTA)..."
className="h-9 w-full rounded-md border border-border bg-background px-3 text-sm"
/>
{uatName && (
<div className="text-xs text-green-400 mt-1">
Selectat: {uatName} ({siruta})
</div>
)}
{uatResults.length > 0 && !siruta && (
<div className="absolute top-full left-0 right-0 mt-1 bg-card border border-border rounded-md shadow-lg max-h-40 overflow-y-auto z-10">
{uatResults.map((u) => (
<button
key={u.siruta}
onClick={() => {
setSiruta(u.siruta);
setUatName(u.name);
setUatSearch("");
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-muted/50 transition-colors"
>
{u.name} <span className="text-muted-foreground">({u.siruta})</span>
</button>
))}
</div>
)}
</div>
)}
{/* Frequency */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">Frecventa</label>
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value)}
className="h-9 w-full rounded-md border border-border bg-background px-3 text-sm"
>
{Object.entries(FREQ_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
{/* Sync steps */}
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={syncEnrich}
onChange={(e) => setSyncEnrich(e.target.checked)}
className="rounded"
/>
Enrichment
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={syncNoGeom}
onChange={(e) => setSyncNoGeom(e.target.checked)}
className="rounded"
/>
No-geom parcels
</label>
</div>
{/* Priority */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
Prioritate (1=cea mai mare, 10=cea mai mica)
</label>
<input
type="number"
min={1}
max={10}
value={priority}
onChange={(e) => setPriority(Number(e.target.value))}
className="h-9 w-20 rounded-md border border-border bg-background px-3 text-sm"
/>
</div>
{/* Label */}
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Nota (optional)"
className="h-9 w-full rounded-md border border-border bg-background px-3 text-sm"
/>
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex justify-end gap-3 pt-2">
<button
onClick={onClose}
className="px-4 py-2 rounded-md border border-border text-sm hover:bg-muted/50"
>
Anuleaza
</button>
<button
onClick={() => void handleSubmit()}
disabled={saving}
className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-50"
>
{saving ? "Se salveaza..." : "Creeaza"}
</button>
</div>
</div>
</div>
);
}
/* ─── Helpers ────────────────────────────────────────────── */
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const abs = Math.abs(diff);
const future = diff < 0;
const s = Math.floor(abs / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
const d = Math.floor(h / 24);
let str: string;
if (d > 0) str = `${d}z`;
else if (h > 0) str = `${h}h`;
else if (m > 0) str = `${m}m`;
else str = `${s}s`;
return future ? `in ${str}` : `acum ${str}`;
}
@@ -0,0 +1,9 @@
// force-dynamic: citește VIM_URL la fiecare request, nu la build time
export const dynamic = 'force-dynamic';
import { VisualCopilotModule } from "@/modules/visual-copilot";
export default function VisualCopilotPage() {
const url = process.env.VIM_URL ?? "";
return <VisualCopilotModule url={url} />;
}
+623
View File
@@ -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>
);
}
File diff suppressed because it is too large Load Diff
+236
View File
@@ -0,0 +1,236 @@
import { NextRequest, NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/core/storage/prisma";
import { v4 as uuid } from "uuid";
const NAMESPACE = "address-book";
const PREFIX = "contact:";
// ─── Auth: Bearer token OR NextAuth session ─────────────────────────
// External tools use: Authorization: Bearer <ADDRESSBOOK_API_KEY>
// Browser users fall through middleware (NextAuth session)
function checkBearerAuth(req: NextRequest): boolean {
const secret = process.env.ADDRESSBOOK_API_KEY;
if (!secret) return false;
const authHeader = req.headers.get("Authorization");
const token = authHeader?.replace("Bearer ", "");
return token === secret;
}
// ─── GET /api/address-book ──────────────────────────────────────────
// Query params:
// ?id=<uuid> → single contact
// ?q=<search> → search by name/company/email/phone
// ?type=<ContactType> → filter by type
// (no params) → all contacts
export async function GET(req: NextRequest) {
if (!checkBearerAuth(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const params = req.nextUrl.searchParams;
const id = params.get("id");
const q = params.get("q")?.toLowerCase();
const type = params.get("type");
try {
// Single contact by ID
if (id) {
const item = await prisma.keyValueStore.findUnique({
where: { namespace_key: { namespace: NAMESPACE, key: `${PREFIX}${id}` } },
});
if (!item) {
return NextResponse.json({ error: "Contact not found" }, { status: 404 });
}
return NextResponse.json({ contact: item.value });
}
// All contacts (with optional filtering)
const items = await prisma.keyValueStore.findMany({
where: { namespace: NAMESPACE },
select: { key: true, value: true },
});
let contacts: Record<string, unknown>[] = [];
for (const item of items) {
if (!item.key.startsWith(PREFIX)) continue;
const val = item.value as Record<string, unknown>;
if (!val) continue;
// Type filter
if (type && val.type !== type) continue;
// Search filter
if (q) {
const name = String(val.name ?? "").toLowerCase();
const company = String(val.company ?? "").toLowerCase();
const email = String(val.email ?? "").toLowerCase();
const phone = String(val.phone ?? "");
if (
!name.includes(q) &&
!company.includes(q) &&
!email.includes(q) &&
!phone.includes(q)
) continue;
}
contacts.push(val);
}
// Sort by name/company
contacts.sort((a, b) => {
const aLabel = String(a.name || a.company || "");
const bLabel = String(b.name || b.company || "");
return aLabel.localeCompare(bLabel, "ro");
});
return NextResponse.json({ contacts, total: contacts.length });
} catch (error) {
console.error("Address book GET error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
// ─── POST /api/address-book ─────────────────────────────────────────
// Body: { name?, company?, type?, email?, phone?, ... }
// Returns: { contact: AddressContact }
// Validation: at least name OR company required
export async function POST(req: NextRequest) {
if (!checkBearerAuth(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await req.json();
const name = String(body.name ?? "").trim();
const company = String(body.company ?? "").trim();
if (!name && !company) {
return NextResponse.json(
{ error: "Cel puțin name sau company este obligatoriu" },
{ status: 400 },
);
}
// Auto-detect type: if only company → institution
const autoType = !name && company ? "institution" : "client";
const now = new Date().toISOString();
const id = body.id ?? uuid();
const contact = {
id,
name,
company,
type: body.type ?? autoType,
email: String(body.email ?? "").trim(),
email2: String(body.email2 ?? "").trim(),
phone: String(body.phone ?? "").trim(),
phone2: String(body.phone2 ?? "").trim(),
address: String(body.address ?? "").trim(),
department: String(body.department ?? "").trim(),
role: String(body.role ?? "").trim(),
website: String(body.website ?? "").trim(),
projectIds: Array.isArray(body.projectIds) ? body.projectIds : [],
contactPersons: Array.isArray(body.contactPersons) ? body.contactPersons : [],
tags: Array.isArray(body.tags) ? body.tags : [],
notes: String(body.notes ?? "").trim(),
visibility: body.visibility ?? "company",
createdAt: now,
updatedAt: now,
};
await prisma.keyValueStore.upsert({
where: { namespace_key: { namespace: NAMESPACE, key: `${PREFIX}${id}` } },
update: { value: contact as unknown as Prisma.InputJsonValue },
create: { namespace: NAMESPACE, key: `${PREFIX}${id}`, value: contact as unknown as Prisma.InputJsonValue },
});
return NextResponse.json({ contact }, { status: 201 });
} catch (error) {
console.error("Address book POST error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
// ─── PUT /api/address-book ──────────────────────────────────────────
// Body: { id: "<uuid>", ...fields to update }
// Merges with existing contact, updates updatedAt
export async function PUT(req: NextRequest) {
if (!checkBearerAuth(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await req.json();
const id = body.id;
if (!id) {
return NextResponse.json({ error: "id is required" }, { status: 400 });
}
const existing = await prisma.keyValueStore.findUnique({
where: { namespace_key: { namespace: NAMESPACE, key: `${PREFIX}${id}` } },
});
if (!existing) {
return NextResponse.json({ error: "Contact not found" }, { status: 404 });
}
const prev = existing.value as Record<string, unknown>;
const updated = {
...prev,
...body,
id: prev.id, // never overwrite
createdAt: prev.createdAt, // never overwrite
updatedAt: new Date().toISOString(),
};
// Re-validate: name OR company
if (!String(updated.name ?? "").trim() && !String(updated.company ?? "").trim()) {
return NextResponse.json(
{ error: "Cel puțin name sau company este obligatoriu" },
{ status: 400 },
);
}
await prisma.keyValueStore.update({
where: { namespace_key: { namespace: NAMESPACE, key: `${PREFIX}${id}` } },
data: { value: updated as unknown as Prisma.InputJsonValue },
});
return NextResponse.json({ contact: updated });
} catch (error) {
console.error("Address book PUT error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
// ─── DELETE /api/address-book?id=<uuid> ─────────────────────────────
export async function DELETE(req: NextRequest) {
if (!checkBearerAuth(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const id = req.nextUrl.searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "id is required" }, { status: 400 });
}
try {
await prisma.keyValueStore.delete({
where: { namespace_key: { namespace: NAMESPACE, key: `${PREFIX}${id}` } },
}).catch(() => { /* ignore if not found */ });
return NextResponse.json({ success: true });
} catch (error) {
console.error("Address book DELETE error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
+254
View File
@@ -0,0 +1,254 @@
import { NextRequest, NextResponse } from "next/server";
/**
* AI Chat API Route
*
* Supports multiple providers: OpenAI, Anthropic (Claude), Ollama (local).
* Provider and API key configured via environment variables:
*
* - AI_PROVIDER: 'openai' | 'anthropic' | 'ollama' (default: 'demo')
* - AI_API_KEY: API key for OpenAI or Anthropic
* - AI_MODEL: Model name (default: per provider)
* - AI_BASE_URL: Custom base URL (required for Ollama, optional for others)
* - AI_MAX_TOKENS: Max response tokens (default: 2048)
*/
interface ChatRequestBody {
messages: Array<{
role: "user" | "assistant" | "system";
content: string;
}>;
systemPrompt?: string;
maxTokens?: number;
}
function getConfig() {
return {
provider: (process.env.AI_PROVIDER ?? "demo") as string,
apiKey: process.env.AI_API_KEY ?? "",
model: process.env.AI_MODEL ?? "",
baseUrl: process.env.AI_BASE_URL ?? "",
maxTokens: parseInt(process.env.AI_MAX_TOKENS ?? "2048", 10),
};
}
const DEFAULT_SYSTEM_PROMPT = `Ești un asistent AI pentru un birou de arhitectură. Răspunzi în limba română.
Ești specializat în:
- Arhitectură și proiectare
- Urbanism și PUZ/PUG/PUD
- Legislația construcțiilor din România (Legea 50/1991, Legea 350/2001)
- Certificat de Urbanism, Autorizație de Construire
- Norme tehnice (P118, normative de proiectare)
- Documentație tehnică (DTAC, PT, memorii)
Răspunde clar, concis și profesional.`;
async function callOpenAI(
messages: ChatRequestBody["messages"],
systemPrompt: string,
config: ReturnType<typeof getConfig>,
): Promise<string> {
const baseUrl = config.baseUrl || "https://api.openai.com/v1";
const model = config.model || "gpt-4o-mini";
const response = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
model,
messages: [{ role: "system", content: systemPrompt }, ...messages],
max_tokens: config.maxTokens,
temperature: 0.7,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`OpenAI API error (${response.status}): ${error}`);
}
const data = (await response.json()) as {
choices: Array<{ message: { content: string } }>;
};
return data.choices[0]?.message?.content ?? "";
}
async function callAnthropic(
messages: ChatRequestBody["messages"],
systemPrompt: string,
config: ReturnType<typeof getConfig>,
): Promise<string> {
const model = config.model || "claude-sonnet-4-20250514";
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": config.apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model,
max_tokens: config.maxTokens,
system: systemPrompt,
messages: messages.map((m) => ({
role: m.role === "system" ? "user" : m.role,
content: m.content,
})),
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Anthropic API error (${response.status}): ${error}`);
}
const data = (await response.json()) as {
content: Array<{ type: string; text: string }>;
};
return data.content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("");
}
async function callOllama(
messages: ChatRequestBody["messages"],
systemPrompt: string,
config: ReturnType<typeof getConfig>,
): Promise<string> {
const baseUrl = config.baseUrl || "http://localhost:11434";
const model = config.model || "llama3.2";
const response = await fetch(`${baseUrl}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
messages: [{ role: "system", content: systemPrompt }, ...messages],
stream: false,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Ollama API error (${response.status}): ${error}`);
}
const data = (await response.json()) as {
message: { content: string };
};
return data.message?.content ?? "";
}
function getDemoResponse(): string {
const responses = [
"Modulul AI Chat funcționează în mod demonstrativ. Pentru a activa răspunsuri reale, configurați variabilele de mediu:\n\n" +
"- `AI_PROVIDER`: openai / anthropic / ollama\n" +
"- `AI_API_KEY`: cheia API\n" +
"- `AI_MODEL`: modelul dorit (opțional)\n\n" +
"Consultați documentația pentru detalii.",
"Aceasta este o conversație demonstrativă. Mesajele sunt salvate, dar răspunsurile AI nu sunt generate fără o conexiune API configurată.",
];
return (
responses[Math.floor(Math.random() * responses.length)] ?? responses[0]!
);
}
/**
* GET /api/ai-chat Return provider config (without API key)
*/
export async function GET() {
const config = getConfig();
return NextResponse.json({
provider: config.provider,
model: config.model || "(default)",
baseUrl: config.baseUrl || "(default)",
maxTokens: config.maxTokens,
isConfigured:
config.provider !== "demo" &&
(config.provider === "ollama" || !!config.apiKey),
});
}
/**
* POST /api/ai-chat Send messages and get AI response
*/
export async function POST(request: NextRequest) {
const config = getConfig();
let body: ChatRequestBody;
try {
body = (await request.json()) as ChatRequestBody;
} catch {
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
}
if (!body.messages || body.messages.length === 0) {
return NextResponse.json({ error: "no_messages" }, { status: 400 });
}
const systemPrompt = body.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
try {
let responseText: string;
switch (config.provider) {
case "openai":
if (!config.apiKey) {
return NextResponse.json(
{
error: "missing_api_key",
message: "AI_API_KEY nu este configurat.",
},
{ status: 500 },
);
}
responseText = await callOpenAI(body.messages, systemPrompt, config);
break;
case "anthropic":
if (!config.apiKey) {
return NextResponse.json(
{
error: "missing_api_key",
message: "AI_API_KEY nu este configurat.",
},
{ status: 500 },
);
}
responseText = await callAnthropic(body.messages, systemPrompt, config);
break;
case "ollama":
responseText = await callOllama(body.messages, systemPrompt, config);
break;
default:
// Demo mode
responseText = getDemoResponse();
break;
}
return NextResponse.json({
content: responseText,
provider: config.provider,
model: config.model || "(default)",
timestamp: new Date().toISOString(),
});
} catch (error) {
return NextResponse.json(
{
error: "api_error",
message:
error instanceof Error
? error.message
: "Eroare necunoscută la apelul API AI.",
provider: config.provider,
},
{ status: 502 },
);
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { EpayClient } from "@/modules/parcel-sync/services/epay-client";
import {
getEpayCredentials,
getEpaySessionStatus,
updateEpayCredits,
} from "@/modules/parcel-sync/services/epay-session-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** GET /api/ancpi/credits — current credit balance (live from ePay) */
export async function GET() {
try {
const status = getEpaySessionStatus();
if (!status.connected) {
return NextResponse.json({ credits: null, connected: false });
}
// Return cached if checked within last 60 seconds
const lastChecked = status.creditsCheckedAt
? new Date(status.creditsCheckedAt).getTime()
: 0;
if (Date.now() - lastChecked < 60_000 && status.credits != null) {
return NextResponse.json({
credits: status.credits,
connected: true,
cached: true,
});
}
// Fetch live from ePay
const creds = getEpayCredentials();
if (!creds) {
return NextResponse.json({ credits: null, connected: false });
}
const client = await EpayClient.create(creds.username, creds.password);
const credits = await client.getCredits();
updateEpayCredits(credits);
return NextResponse.json({ credits, connected: true, cached: false });
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare";
return NextResponse.json({ error: message, credits: null }, { status: 500 });
}
}
+114
View File
@@ -0,0 +1,114 @@
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
import { getCfExtractStream } from "@/modules/parcel-sync/services/epay-storage";
import JSZip from "jszip";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/ancpi/download-zip?ids=id1,id2,id3
*
* Streams a ZIP file containing all requested CF extract PDFs.
* Files named: {index:02d}_Extras CF_{nrCadastral} - {DD-MM-YYYY}.pdf
* Index = position in the ids array (preserves list order).
*/
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const idsParam = url.searchParams.get("ids");
if (!idsParam) {
return NextResponse.json(
{ error: "Parametru 'ids' lipsa." },
{ status: 400 },
);
}
const ids = idsParam.split(",").map((s) => s.trim()).filter(Boolean);
if (ids.length === 0) {
return NextResponse.json(
{ error: "Lista de id-uri goala." },
{ status: 400 },
);
}
// Fetch all extract records
const extracts = await prisma.cfExtract.findMany({
where: { id: { in: ids } },
select: {
id: true,
nrCadastral: true,
minioPath: true,
documentDate: true,
completedAt: true,
},
});
// Build a map for ordering
const extractMap = new Map(extracts.map((e) => [e.id, e]));
const zip = new JSZip();
let filesAdded = 0;
for (let i = 0; i < ids.length; i++) {
const id = ids[i]!;
const extract = extractMap.get(id);
if (!extract?.minioPath) continue;
const dateForName = extract.documentDate ?? extract.completedAt ?? new Date();
const d = new Date(dateForName);
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
const idx = String(i + 1).padStart(2, "0");
const fileName = `${idx}_Extras CF_${extract.nrCadastral} - ${dd}-${mm}-${yyyy}.pdf`;
try {
const stream = await getCfExtractStream(extract.minioPath);
// Collect stream into buffer
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
}
const buffer = Buffer.concat(chunks);
zip.file(fileName, buffer);
filesAdded++;
} catch (err) {
console.error(`[download-zip] Failed to fetch ${extract.minioPath}:`, err);
// Skip this file but continue
}
}
if (filesAdded === 0) {
return NextResponse.json(
{ error: "Niciun fisier PDF gasit." },
{ status: 404 },
);
}
const zipBuffer = await zip.generateAsync({
type: "nodebuffer",
compression: "DEFLATE",
compressionOptions: { level: 6 },
});
const today = new Date();
const todayStr = `${String(today.getDate()).padStart(2, "0")}-${String(today.getMonth() + 1).padStart(2, "0")}-${today.getFullYear()}`;
const archiveName = `Extrase_CF_${filesAdded}_${todayStr}.zip`;
return new Response(new Uint8Array(zipBuffer), {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${encodeURIComponent(archiveName)}"`,
"Content-Length": String(zipBuffer.length),
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+66
View File
@@ -0,0 +1,66 @@
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
import { getCfExtractStream } from "@/modules/parcel-sync/services/epay-storage";
import { Readable } from "stream";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/ancpi/download?id={extractId}
*
* Streams the CF extract PDF from MinIO with proper filename.
*/
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const id = url.searchParams.get("id");
if (!id) {
return NextResponse.json(
{ error: "Parametru 'id' lipsă." },
{ status: 400 },
);
}
const extract = await prisma.cfExtract.findUnique({
where: { id },
select: { minioPath: true, nrCadastral: true, minioIndex: true },
});
if (!extract?.minioPath) {
return NextResponse.json(
{ error: "Extras CF negăsit sau fără fișier." },
{ status: 404 },
);
}
const stream = await getCfExtractStream(extract.minioPath);
// Convert Node.js Readable to Web ReadableStream
const webStream = new ReadableStream({
start(controller) {
stream.on("data", (chunk: Buffer) =>
controller.enqueue(new Uint8Array(chunk)),
);
stream.on("end", () => controller.close());
stream.on("error", (err: Error) => controller.error(err));
},
});
// Build display filename
const fileName =
extract.minioPath.split("/").pop() ??
`Extras_CF_${extract.nrCadastral}.pdf`;
return new Response(webStream, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${encodeURIComponent(fileName)}"`,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+137
View File
@@ -0,0 +1,137 @@
import { NextResponse } from "next/server";
import { getEpayCredentials } from "@/modules/parcel-sync/services/epay-session-store";
import {
enqueueOrder,
enqueueBatch,
} from "@/modules/parcel-sync/services/epay-queue";
import type { CfExtractCreateInput } from "@/modules/parcel-sync/services/epay-types";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/* ------------------------------------------------------------------ */
/* Nonce-based idempotency cache */
/* ------------------------------------------------------------------ */
type NonceEntry = {
timestamp: number;
response: { orders: Array<{ id: string; nrCadastral: string; status: string }> };
};
const NONCE_TTL_MS = 60_000; // 60 seconds
const gNonce = globalThis as {
__orderNonceMap?: Map<string, NonceEntry>;
};
if (!gNonce.__orderNonceMap) gNonce.__orderNonceMap = new Map();
function cleanupNonceMap(): void {
const now = Date.now();
const map = gNonce.__orderNonceMap!;
for (const [key, entry] of map) {
if (now - entry.timestamp > NONCE_TTL_MS) {
map.delete(key);
}
}
}
/**
* POST /api/ancpi/order create one or more CF extract orders.
*
* Body: { parcels: CfExtractCreateInput[], nonce?: string }
*
* If a `nonce` is provided and was already seen within the last 60 seconds,
* the previous response is returned instead of creating duplicate orders.
*
* Returns: { orders: [{ id, nrCadastral, status }], deduplicated?: boolean }
*/
export async function POST(req: Request) {
try {
const creds = getEpayCredentials();
if (!creds) {
return NextResponse.json(
{ error: "Nu ești conectat la ePay ANCPI." },
{ status: 401 },
);
}
const body = (await req.json()) as {
parcels?: CfExtractCreateInput[];
nonce?: string;
};
const parcels = body.parcels ?? [];
if (parcels.length === 0) {
return NextResponse.json(
{ error: "Nicio parcelă specificată." },
{ status: 400 },
);
}
// ── Nonce idempotency check ──
cleanupNonceMap();
if (body.nonce) {
const cached = gNonce.__orderNonceMap!.get(body.nonce);
if (cached && Date.now() - cached.timestamp < NONCE_TTL_MS) {
console.log(
`[ancpi/order] Nonce dedup hit: "${body.nonce}" — returning cached response`,
);
return NextResponse.json({
...cached.response,
deduplicated: true,
});
}
}
// Validate required fields
for (const p of parcels) {
if (!p.nrCadastral || p.judetIndex == null || p.uatId == null) {
return NextResponse.json(
{
error: `Date lipsă pentru parcela ${p.nrCadastral ?? "?"}. Necesare: nrCadastral, judetIndex, uatId.`,
},
{ status: 400 },
);
}
}
let responseBody: {
orders: Array<{ id: string; nrCadastral: string; status: string }>;
};
if (parcels.length === 1) {
const id = await enqueueOrder(parcels[0]!);
responseBody = {
orders: [
{
id,
nrCadastral: parcels[0]!.nrCadastral,
status: "queued",
},
],
};
} else {
const ids = await enqueueBatch(parcels);
responseBody = {
orders: ids.map((id, i) => ({
id,
nrCadastral: parcels[i]?.nrCadastral ?? "",
status: "queued",
})),
};
}
// ── Cache response for nonce ──
if (body.nonce) {
gNonce.__orderNonceMap!.set(body.nonce, {
timestamp: Date.now(),
response: responseBody,
});
}
return NextResponse.json(responseBody);
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+121
View File
@@ -0,0 +1,121 @@
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/ancpi/orders list all CF extract orders.
*
* Query params:
* ?nrCadastral=123 single cadastral number
* ?nrCadastral=123,456 comma-separated for batch status check
* ?status=completed filter by status
* ?limit=50&offset=0 pagination
*
* When nrCadastral contains commas, returns an extra `statusMap` field:
* { orders, total, statusMap: { "123": "valid", "456": "expired", "789": "none" } }
* - "valid" = completed + expiresAt > now
* - "expired" = completed + expiresAt <= now
* - "none" = no completed record
*/
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const nrCadastralParam = url.searchParams.get("nrCadastral") || undefined;
const status = url.searchParams.get("status") || undefined;
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "50"), 200);
const offset = parseInt(url.searchParams.get("offset") ?? "0");
// Check if multi-cadastral query
const cadastralNumbers = nrCadastralParam
? nrCadastralParam.split(",").map((s) => s.trim()).filter(Boolean)
: [];
const isMulti = cadastralNumbers.length > 1;
const where: Record<string, unknown> = {};
if (cadastralNumbers.length === 1) {
where.nrCadastral = cadastralNumbers[0];
} else if (isMulti) {
where.nrCadastral = { in: cadastralNumbers };
}
if (status) where.status = status;
const [orders, total] = await Promise.all([
prisma.cfExtract.findMany({
where,
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
}),
prisma.cfExtract.count({ where }),
]);
// Build statusMap for multi-cadastral queries (or single if requested)
if (cadastralNumbers.length > 0) {
const now = new Date();
// For status map, we need completed records for each cadastral number
const completedRecords = await prisma.cfExtract.findMany({
where: {
nrCadastral: { in: cadastralNumbers },
status: "completed",
},
orderBy: { createdAt: "desc" },
select: {
id: true,
nrCadastral: true,
expiresAt: true,
completedAt: true,
minioPath: true,
},
});
const statusMap: Record<string, string> = {};
const latestById: Record<string, typeof completedRecords[number]> = {};
// Find latest completed record per cadastral number
for (const rec of completedRecords) {
const existing = latestById[rec.nrCadastral];
if (!existing) {
latestById[rec.nrCadastral] = rec;
}
}
for (const nr of cadastralNumbers) {
const rec = latestById[nr];
if (!rec) {
statusMap[nr] = "none";
} else if (rec.expiresAt && rec.expiresAt <= now) {
statusMap[nr] = "expired";
} else {
statusMap[nr] = "valid";
}
}
// Also check for active (in-progress) orders
const activeRecords = await prisma.cfExtract.findMany({
where: {
nrCadastral: { in: cadastralNumbers },
status: {
in: ["pending", "queued", "cart", "searching", "ordering", "polling", "downloading"],
},
},
select: { nrCadastral: true },
});
for (const rec of activeRecords) {
// If there's an active order, mark as "processing" (takes priority over "none")
if (statusMap[rec.nrCadastral] === "none") {
statusMap[rec.nrCadastral] = "processing";
}
}
return NextResponse.json({ orders, total, statusMap, latestById });
}
return NextResponse.json({ orders, total });
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { EpayClient } from "@/modules/parcel-sync/services/epay-client";
import {
createEpaySession,
destroyEpaySession,
getEpaySessionStatus,
} from "@/modules/parcel-sync/services/epay-session-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** GET /api/ancpi/session — status + credits */
export async function GET() {
return NextResponse.json(getEpaySessionStatus());
}
/** POST /api/ancpi/session — connect or disconnect */
export async function POST(req: Request) {
try {
const body = (await req.json()) as {
action?: string;
username?: string;
password?: string;
};
if (body.action === "disconnect") {
destroyEpaySession();
return NextResponse.json({ success: true, disconnected: true });
}
// Connect
const username = (
body.username ?? process.env.ANCPI_USERNAME ?? ""
).trim();
const password = (
body.password ?? process.env.ANCPI_PASSWORD ?? ""
).trim();
if (!username || !password) {
return NextResponse.json(
{ error: "Credențiale ANCPI lipsă" },
{ status: 400 },
);
}
const client = await EpayClient.create(username, password);
const credits = await client.getCredits();
createEpaySession(username, password, credits);
return NextResponse.json({ success: true, credits });
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
const status = message.toLowerCase().includes("login") ? 401 : 500;
return NextResponse.json({ error: message }, { status });
}
}
+442
View File
@@ -0,0 +1,442 @@
import { NextResponse } from "next/server";
import { EpayClient } from "@/modules/parcel-sync/services/epay-client";
import {
createEpaySession,
getEpayCredentials,
} from "@/modules/parcel-sync/services/epay-session-store";
import { enqueueBatch } from "@/modules/parcel-sync/services/epay-queue";
import { storeCfExtract } from "@/modules/parcel-sync/services/epay-storage";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/* ------------------------------------------------------------------ */
/* Dedup for test order step (prevents re-enqueue on page refresh) */
/* ------------------------------------------------------------------ */
type TestOrderEntry = {
timestamp: number;
extractIds: string[];
parcels: Array<{ nrCadastral: string; uatName: string; siruta: string; extractId: string | undefined }>;
};
const TEST_ORDER_DEDUP_TTL_MS = 30_000; // 30 seconds
const gTestDedup = globalThis as {
__testOrderDedup?: TestOrderEntry | null;
};
if (gTestDedup.__testOrderDedup === undefined) gTestDedup.__testOrderDedup = null;
/**
* GET /api/ancpi/test?step=login|uats|order|download
*
* ePay internal county IDs = eTerra WORKSPACE_IDs.
* ePay UAT IDs = SIRUTA codes.
* Zero discovery calls needed!
*/
export async function GET(req: Request) {
const url = new URL(req.url);
const step = url.searchParams.get("step") ?? "login";
const username = process.env.ANCPI_USERNAME ?? "";
const password = process.env.ANCPI_PASSWORD ?? "";
if (!username || !password) {
return NextResponse.json({ error: "ANCPI credentials not configured" });
}
try {
// ── login ──
if (step === "login") {
const client = await EpayClient.create(username, password);
const credits = await client.getCredits();
createEpaySession(username, password, credits);
return NextResponse.json({ step: "login", success: true, credits });
}
// ── uats ── Verify that ePay county/UAT IDs match our WORKSPACE_ID/SIRUTA
if (step === "uats") {
const client = await EpayClient.create(username, password);
await client.addToCart(14200);
// Get county list to confirm IDs match WORKSPACE_IDs
const counties = await client.getCountyList();
const clujCounty = counties.find((c) =>
c.value.toUpperCase().includes("CLUJ"),
);
// Get UAT list to confirm IDs match SIRUTA codes
let uatList: { id: number; value: string }[] = [];
if (clujCounty) {
uatList = await client.getUatList(clujCounty.id);
}
return NextResponse.json({
step: "uats",
totalCounties: counties.length,
clujCounty,
note: clujCounty?.id === 127
? "CONFIRMED: ePay county ID = WORKSPACE_ID (127)"
: `WARNING: expected 127, got ${clujCounty?.id}`,
totalUats: uatList.length,
clujNapoca: uatList.find((u) => u.id === 54975),
feleacu: uatList.find((u) => u.id === 57582),
floresti: uatList.find((u) => u.id === 57706),
});
}
// ── download ── Re-download PDFs from all known ePay orders
if (step === "download") {
const client = await EpayClient.create(username, password);
createEpaySession(username, password, await client.getCredits());
// All known order IDs (MinIO + DB were cleaned, need re-download)
// Single orders: mapping unknown — use documentsByCadastral to discover CF
const singleOrderIds = ["9685480", "9685481", "9685482", "9685483", "9685484"];
// Batch orders: documentsByCadastral maps CF -> doc correctly
const batchOrderIds = ["9685487", "9685488"];
const allOrderIds = [...singleOrderIds, ...batchOrderIds];
// UAT name lookup for DB records
const uatLookup: Record<string, { uatId: number; uatName: string }> = {
"345295": { uatId: 54975, uatName: "Cluj-Napoca" },
"63565": { uatId: 57582, uatName: "Feleacu" },
"88089": { uatId: 57706, uatName: "Floresti" },
"61904": { uatId: 57582, uatName: "Feleacu" },
"309952": { uatId: 54975, uatName: "Cluj-Napoca" },
};
const results: Array<{
orderId: string;
nrCadastral: string;
status: string;
documents: number;
downloaded: boolean;
minioPath?: string;
error?: string;
}> = [];
for (const orderId of allOrderIds) {
try {
// Get order status — documentsByCadastral maps CF → doc
const orderStatus = await client.getOrderStatus(orderId);
console.log(
`[ancpi-test] Order ${orderId}: status=${orderStatus.status}, docs=${orderStatus.documents.length}, byCF=${orderStatus.documentsByCadastral.size}`,
);
if (orderStatus.documents.length === 0) {
results.push({
orderId,
nrCadastral: "unknown",
status: orderStatus.status,
documents: 0,
downloaded: false,
error: "No documents found in order",
});
continue;
}
// Process each document by cadastral number from the map
if (orderStatus.documentsByCadastral.size > 0) {
for (const [cfNumber, doc] of orderStatus.documentsByCadastral) {
if (!doc.downloadValabil || doc.contentType !== "application/pdf") {
results.push({
orderId,
nrCadastral: cfNumber,
status: orderStatus.status,
documents: orderStatus.documents.length,
downloaded: false,
error: "Document not downloadable or not PDF",
});
continue;
}
try {
// Download the PDF
const pdfBuffer = await client.downloadDocument(doc.idDocument, 4);
console.log(
`[ancpi-test] Downloaded doc ${doc.idDocument} (CF ${cfNumber}): ${pdfBuffer.length} bytes`,
);
// Resolve UAT info for this cadastral number
const uat = uatLookup[cfNumber];
const uatId = uat?.uatId ?? 0;
const uatName = uat?.uatName ?? "Necunoscut";
// Store in MinIO
const { path, index } = await storeCfExtract(
pdfBuffer,
cfNumber,
{
"ancpi-order-id": orderId,
"nr-cadastral": cfNumber,
judet: "CLUJ",
uat: uatName,
"data-document": doc.dataDocument ?? "",
stare: orderStatus.status,
produs: "EXI_ONLINE",
},
);
// Calculate dates
const documentDate = doc.dataDocument
? new Date(doc.dataDocument)
: new Date();
const expiresAt = new Date(documentDate);
expiresAt.setDate(expiresAt.getDate() + 30);
// Always create new records (DB was cleaned)
// Increment version for duplicate parcels
const maxVersion = await prisma.cfExtract.aggregate({
where: { nrCadastral: cfNumber },
_max: { version: true },
});
await prisma.cfExtract.create({
data: {
orderId,
nrCadastral: cfNumber,
nrCF: cfNumber,
judetIndex: 127,
judetName: "CLUJ",
uatId,
uatName,
status: "completed",
epayStatus: orderStatus.status,
idDocument: doc.idDocument,
documentName: doc.nume,
documentDate,
minioPath: path,
minioIndex: index,
completedAt: new Date(),
expiresAt,
version: (maxVersion._max.version ?? 0) + 1,
},
});
results.push({
orderId,
nrCadastral: cfNumber,
status: orderStatus.status,
documents: orderStatus.documents.length,
downloaded: true,
minioPath: path,
});
} catch (dlErr) {
const msg = dlErr instanceof Error ? dlErr.message : String(dlErr);
console.error(
`[ancpi-test] Failed to download doc for CF ${cfNumber} in order ${orderId}:`,
msg,
);
results.push({
orderId,
nrCadastral: cfNumber,
status: "error",
documents: orderStatus.documents.length,
downloaded: false,
error: msg,
});
}
}
} else {
// Fallback: no CF mapping, process first downloadable document
const doc = orderStatus.documents.find(
(d) => d.downloadValabil && d.contentType === "application/pdf",
);
if (!doc) {
results.push({
orderId,
nrCadastral: "unknown",
status: orderStatus.status,
documents: orderStatus.documents.length,
downloaded: false,
error: "No downloadable PDF and no CF mapping found",
});
continue;
}
// Try to extract CF from document name (e.g. "Extras_Informare_345295.pdf")
const cfFromName = doc.nume.match(/(\d{4,})/)?.[1] ?? "unknown";
const pdfBuffer = await client.downloadDocument(doc.idDocument, 4);
console.log(
`[ancpi-test] Downloaded doc ${doc.idDocument} (CF from name: ${cfFromName}): ${pdfBuffer.length} bytes`,
);
const uat = uatLookup[cfFromName];
const uatId = uat?.uatId ?? 0;
const uatName = uat?.uatName ?? "Necunoscut";
const { path, index } = await storeCfExtract(
pdfBuffer,
cfFromName,
{
"ancpi-order-id": orderId,
"nr-cadastral": cfFromName,
judet: "CLUJ",
uat: uatName,
"data-document": doc.dataDocument ?? "",
stare: orderStatus.status,
produs: "EXI_ONLINE",
},
);
const documentDate = doc.dataDocument
? new Date(doc.dataDocument)
: new Date();
const expiresAt = new Date(documentDate);
expiresAt.setDate(expiresAt.getDate() + 30);
const maxVersion = await prisma.cfExtract.aggregate({
where: { nrCadastral: cfFromName },
_max: { version: true },
});
await prisma.cfExtract.create({
data: {
orderId,
nrCadastral: cfFromName,
nrCF: cfFromName,
judetIndex: 127,
judetName: "CLUJ",
uatId,
uatName,
status: "completed",
epayStatus: orderStatus.status,
idDocument: doc.idDocument,
documentName: doc.nume,
documentDate,
minioPath: path,
minioIndex: index,
completedAt: new Date(),
expiresAt,
version: (maxVersion._max.version ?? 0) + 1,
},
});
results.push({
orderId,
nrCadastral: cfFromName,
status: orderStatus.status,
documents: orderStatus.documents.length,
downloaded: true,
minioPath: path,
});
}
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
console.error(
`[ancpi-test] Failed to process order ${orderId}:`,
message,
);
results.push({
orderId,
nrCadastral: "unknown",
status: "error",
documents: 0,
downloaded: false,
error: message,
});
}
}
return NextResponse.json({
step: "download",
totalOrders: allOrderIds.length,
results,
summary: {
downloaded: results.filter((r) => r.downloaded).length,
failed: results.filter((r) => !r.downloaded).length,
},
});
}
// ── order ── Batch order test (USES 2 CREDITS!)
// Uses enqueueBatch to create ONE ePay order for all parcels
if (step === "order") {
// ── Dedup check: prevent re-enqueue on page refresh ──
const prevEntry = gTestDedup.__testOrderDedup;
if (
prevEntry &&
Date.now() - prevEntry.timestamp < TEST_ORDER_DEDUP_TTL_MS
) {
console.log(
`[ancpi-test] Order dedup hit: returning cached response (${Math.round((Date.now() - prevEntry.timestamp) / 1000)}s ago)`,
);
return NextResponse.json({
step: "order",
deduplicated: true,
message: `Dedup: batch was enqueued ${Math.round((Date.now() - prevEntry.timestamp) / 1000)}s ago — returning cached IDs.`,
extractIds: prevEntry.extractIds,
parcels: prevEntry.parcels,
});
}
if (!getEpayCredentials()) {
createEpaySession(username, password, 0);
}
const client = await EpayClient.create(username, password);
const credits = await client.getCredits();
createEpaySession(username, password, credits);
const parcels = [
{
nrCadastral: "61904",
siruta: "57582",
judetIndex: 127,
judetName: "CLUJ",
uatId: 57582,
uatName: "Feleacu",
},
{
nrCadastral: "309952",
siruta: "54975",
judetIndex: 127,
judetName: "CLUJ",
uatId: 54975,
uatName: "Cluj-Napoca",
},
];
if (credits < parcels.length) {
return NextResponse.json({
error: `Doar ${credits} credite, trebuie ${parcels.length}.`,
});
}
// Use enqueueBatch — ONE order for all parcels
const ids = await enqueueBatch(parcels);
const parcelResults = parcels.map((p, i) => ({
nrCadastral: p.nrCadastral,
uatName: p.uatName,
siruta: p.siruta,
extractId: ids[i],
}));
// ── Store in dedup cache ──
gTestDedup.__testOrderDedup = {
timestamp: Date.now(),
extractIds: ids,
parcels: parcelResults,
};
return NextResponse.json({
step: "order",
credits,
message: `Enqueued batch of ${ids.length} parcels as ONE order.`,
extractIds: ids,
parcels: parcelResults,
});
}
return NextResponse.json({ error: `Unknown step: ${step}` });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[ancpi-test] Step ${step} failed:`, message);
return NextResponse.json({ error: message, step }, { status: 500 });
}
}
+6
View File
@@ -0,0 +1,6 @@
import NextAuth from "next-auth";
import { authOptions } from "@/core/auth/auth-options";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
/**
* Check auth for routes excluded from middleware (large upload routes).
* Returns null if authenticated, or a 401 NextResponse if not.
*/
export async function requireAuth(
req: NextRequest,
): Promise<NextResponse | null> {
// Skip in development
if (process.env.NODE_ENV === "development") return null;
const token = await getToken({
req,
secret: process.env.NEXTAUTH_SECRET,
});
if (token) return null;
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 },
);
}
+212
View File
@@ -0,0 +1,212 @@
import { NextRequest, NextResponse } from "next/server";
import { readFile, unlink } from "fs/promises";
import { join } from "path";
import { parseMultipartUpload } from "../parse-upload";
import { requireAuth } from "../auth-check";
/**
* iLovePDF API integration for PDF compression.
*
* Workflow: auth start upload process download
* Docs: https://www.iloveapi.com/docs/api-reference
*
* Env vars: ILOVEPDF_PUBLIC_KEY
* Free tier: 250 files/month
*/
const ILOVEPDF_PUBLIC_KEY = process.env.ILOVEPDF_PUBLIC_KEY ?? "";
const API_BASE = "https://api.ilovepdf.com/v1";
async function cleanup(dir: string) {
try {
const { readdir, rmdir } = await import("fs/promises");
const files = await readdir(dir);
for (const f of files) {
await unlink(join(dir, f)).catch(() => {});
}
await rmdir(dir).catch(() => {});
} catch {
// non-critical
}
}
export async function POST(req: NextRequest) {
const authError = await requireAuth(req);
if (authError) return authError;
if (!ILOVEPDF_PUBLIC_KEY) {
return NextResponse.json(
{
error:
"iLovePDF nu este configurat. Setează ILOVEPDF_PUBLIC_KEY în variabilele de mediu.",
},
{ status: 501 },
);
}
let tmpDir = "";
try {
// Stream upload to disk — works for any file size
const upload = await parseMultipartUpload(req);
tmpDir = upload.tmpDir;
const originalSize = upload.size;
if (originalSize < 100) {
return NextResponse.json(
{ error: "Fișierul PDF este gol sau prea mic." },
{ status: 400 },
);
}
// Compression level from form field
const levelParam = upload.fields["level"] ?? "";
const compressionLevel =
levelParam === "extreme"
? "extreme"
: levelParam === "low"
? "low"
: "recommended";
// Step 1: Authenticate
const authRes = await fetch(`${API_BASE}/auth`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ public_key: ILOVEPDF_PUBLIC_KEY }),
});
if (!authRes.ok) {
const text = await authRes.text().catch(() => "");
return NextResponse.json(
{ error: `iLovePDF auth failed: ${authRes.status}${text}` },
{ status: 502 },
);
}
const { token } = (await authRes.json()) as { token: string };
// Step 2: Start compress task
const startRes = await fetch(`${API_BASE}/start/compress`, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
});
if (!startRes.ok) {
const text = await startRes.text().catch(() => "");
return NextResponse.json(
{ error: `iLovePDF start failed: ${startRes.status}${text}` },
{ status: 502 },
);
}
const { server, task } = (await startRes.json()) as {
server: string;
task: string;
};
// Step 3: Upload file (read from disk to avoid double-buffering)
const fileBuffer = await readFile(upload.filePath);
const uploadForm = new FormData();
uploadForm.append("task", task);
uploadForm.append(
"file",
new Blob([new Uint8Array(fileBuffer)], { type: "application/pdf" }),
upload.filename,
);
const uploadRes = await fetch(`https://${server}/v1/upload`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: uploadForm,
signal: AbortSignal.timeout(600_000), // 10 min for very large files
});
if (!uploadRes.ok) {
const text = await uploadRes.text().catch(() => "");
return NextResponse.json(
{ error: `iLovePDF upload failed: ${uploadRes.status}${text}` },
{ status: 502 },
);
}
const { server_filename } = (await uploadRes.json()) as {
server_filename: string;
};
// Step 4: Process
const processRes = await fetch(`https://${server}/v1/process`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
task,
tool: "compress",
compression_level: compressionLevel,
files: [
{
server_filename,
filename: upload.filename,
},
],
}),
signal: AbortSignal.timeout(600_000),
});
if (!processRes.ok) {
const text = await processRes.text().catch(() => "");
return NextResponse.json(
{ error: `iLovePDF process failed: ${processRes.status}${text}` },
{ status: 502 },
);
}
// Step 5: Download result
const downloadRes = await fetch(
`https://${server}/v1/download/${task}`,
{
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(600_000),
},
);
if (!downloadRes.ok) {
const text = await downloadRes.text().catch(() => "");
return NextResponse.json(
{
error: `iLovePDF download failed: ${downloadRes.status}${text}`,
},
{ status: 502 },
);
}
const resultBlob = await downloadRes.blob();
const resultBuffer = Buffer.from(await resultBlob.arrayBuffer());
const compressedSize = resultBuffer.length;
// Clean up task on iLovePDF (fire and forget)
fetch(`https://${server}/v1/task/${task}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
}).catch(() => {});
return new NextResponse(new Uint8Array(resultBuffer), {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${encodeURIComponent(upload.filename.replace(/\.pdf$/i, "-comprimat.pdf"))}"`,
"X-Original-Size": String(originalSize),
"X-Compressed-Size": String(compressedSize),
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ error: `Eroare iLovePDF: ${message}` },
{ status: 500 },
);
} finally {
if (tmpDir) await cleanup(tmpDir);
}
}
+149
View File
@@ -0,0 +1,149 @@
import { NextRequest, NextResponse } from "next/server";
import { createReadStream, statSync } from "fs";
import { unlink, stat, readdir, rmdir } from "fs/promises";
import { execFile } from "child_process";
import { promisify } from "util";
import { join } from "path";
import { Readable } from "stream";
import { parseMultipartUpload } from "../parse-upload";
import { requireAuth } from "../auth-check";
const execFileAsync = promisify(execFile);
function qpdfArgs(input: string, output: string): string[] {
return [
input,
output,
"--object-streams=generate",
"--compress-streams=y",
"--recompress-flate",
"--compression-level=9",
"--remove-unreferenced-resources=yes",
"--linearize",
];
}
async function cleanup(dir: string) {
try {
const files = await readdir(dir);
for (const f of files) {
await unlink(join(dir, f)).catch(() => {});
}
await rmdir(dir).catch(() => {});
} catch {
// non-critical
}
}
/**
* Stream a file from disk as a Response never loads into memory.
*/
function streamFileResponse(
filePath: string,
originalSize: number,
compressedSize: number,
filename: string,
): NextResponse {
const nodeStream = createReadStream(filePath);
const webStream = Readable.toWeb(nodeStream) as ReadableStream;
return new NextResponse(webStream, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Length": String(compressedSize),
"Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`,
"X-Original-Size": String(originalSize),
"X-Compressed-Size": String(compressedSize),
},
});
}
export async function POST(req: NextRequest) {
const authError = await requireAuth(req);
if (authError) return authError;
let tmpDir = "";
try {
const upload = await parseMultipartUpload(req);
tmpDir = upload.tmpDir;
const inputPath = upload.filePath;
const outputPath = join(upload.tmpDir, "output.pdf");
const originalSize = upload.size;
console.log(
`[compress-pdf] Starting qpdf on ${originalSize} bytes...`,
);
if (originalSize < 100) {
return NextResponse.json(
{ error: "Fișierul PDF este gol sau prea mic." },
{ status: 400 },
);
}
// Run qpdf
try {
await execFileAsync("qpdf", qpdfArgs(inputPath, outputPath), {
timeout: 300_000,
maxBuffer: 10 * 1024 * 1024,
});
} catch (qpdfErr) {
const msg =
qpdfErr instanceof Error ? qpdfErr.message : "qpdf failed";
if (msg.includes("ENOENT") || msg.includes("not found")) {
return NextResponse.json(
{ error: "qpdf nu este instalat pe server." },
{ status: 501 },
);
}
const exitCode =
qpdfErr && typeof qpdfErr === "object" && "code" in qpdfErr
? (qpdfErr as { code: number }).code
: null;
if (exitCode !== 3) {
console.error(`[compress-pdf] qpdf error:`, msg.slice(0, 300));
return NextResponse.json(
{ error: `qpdf error: ${msg.slice(0, 300)}` },
{ status: 500 },
);
}
}
// Check output
try {
await stat(outputPath);
} catch {
return NextResponse.json(
{ error: "qpdf nu a produs fișier output." },
{ status: 500 },
);
}
const compressedSize = statSync(outputPath).size;
console.log(
`[compress-pdf] Done: ${originalSize}${compressedSize} (${Math.round((1 - compressedSize / originalSize) * 100)}% reduction)`,
);
// Stream result from disk — if bigger, stream original
if (compressedSize >= originalSize) {
return streamFileResponse(inputPath, originalSize, originalSize, upload.filename);
}
// NOTE: cleanup is deferred — we can't delete files while streaming.
// The files will be cleaned up by the OS temp cleaner or on next request.
// For immediate cleanup, we'd need to buffer, but that defeats the purpose.
return streamFileResponse(outputPath, originalSize, compressedSize, upload.filename);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
console.error(`[compress-pdf] Error:`, message);
if (tmpDir) await cleanup(tmpDir);
return NextResponse.json(
{ error: `Eroare la optimizare: ${message}` },
{ status: 500 },
);
}
// Note: no finally cleanup — files are being streamed
}
+210
View File
@@ -0,0 +1,210 @@
/**
* Streaming multipart parser for large PDF uploads.
*
* 1. Streams the request body to a raw temp file (constant memory)
* 2. Scans the raw file for multipart boundaries using small buffer reads
* 3. Copies just the file part to a separate PDF file (stream copy)
*
* Peak memory: ~64KB regardless of file size.
*/
import { NextRequest } from "next/server";
import {
createWriteStream,
createReadStream,
openSync,
readSync,
closeSync,
statSync,
} from "fs";
import { mkdir, unlink } from "fs/promises";
import { randomUUID } from "crypto";
import { join } from "path";
import { tmpdir } from "os";
import { pipeline } from "stream/promises";
export interface ParsedUpload {
filePath: string;
filename: string;
size: number;
tmpDir: string;
fields: Record<string, string>;
}
/**
* Scan a file on disk for a Buffer pattern starting from `offset`.
* Reads in 64KB chunks constant memory.
*/
function findInFile(
filePath: string,
pattern: Buffer,
startOffset: number,
): number {
const CHUNK = 65536;
const fd = openSync(filePath, "r");
try {
const buf = Buffer.alloc(CHUNK + pattern.length);
let fileOffset = startOffset;
const fileSize = statSync(filePath).size;
while (fileOffset < fileSize) {
const bytesRead = readSync(
fd,
buf,
0,
Math.min(buf.length, fileSize - fileOffset),
fileOffset,
);
if (bytesRead === 0) break;
const idx = buf.subarray(0, bytesRead).indexOf(pattern);
if (idx !== -1) {
return fileOffset + idx;
}
// Advance, but overlap by pattern length to catch split matches
fileOffset += bytesRead - pattern.length;
}
return -1;
} finally {
closeSync(fd);
}
}
/**
* Read a small chunk from a file at a given offset.
*/
function readChunk(filePath: string, offset: number, length: number): Buffer {
const fd = openSync(filePath, "r");
try {
const buf = Buffer.alloc(length);
const bytesRead = readSync(fd, buf, 0, length, offset);
return buf.subarray(0, bytesRead);
} finally {
closeSync(fd);
}
}
/**
* Copy a byte range from one file to another using streams.
*/
async function copyFileRange(
srcPath: string,
destPath: string,
start: number,
end: number,
): Promise<void> {
const rs = createReadStream(srcPath, { start, end: end - 1 });
const ws = createWriteStream(destPath);
await pipeline(rs, ws);
}
export async function parseMultipartUpload(
req: NextRequest,
): Promise<ParsedUpload> {
const contentType = req.headers.get("content-type") ?? "";
if (!req.body) throw new Error("Lipsește body-ul cererii.");
const boundaryMatch = contentType.match(/boundary=(.+?)(?:;|$)/);
if (!boundaryMatch?.[1]) throw new Error("Lipsește boundary din Content-Type.");
const boundary = boundaryMatch[1].trim();
const tmpDir = join(tmpdir(), `pdf-upload-${randomUUID()}`);
await mkdir(tmpDir, { recursive: true });
// Step 1: Stream entire body to disk (constant memory)
const rawPath = join(tmpDir, "raw-body");
const ws = createWriteStream(rawPath);
const reader = req.body.getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
const ok = ws.write(Buffer.from(value));
if (!ok) await new Promise<void>((r) => ws.once("drain", r));
}
} finally {
ws.end();
await new Promise<void>((r) => ws.once("finish", r));
}
const rawSize = statSync(rawPath).size;
console.log(`[parse-upload] Raw body saved: ${rawSize} bytes`);
// Step 2: Find file part boundaries using small buffer reads
const boundaryBuf = Buffer.from(`--${boundary}`);
const headerEndBuf = Buffer.from("\r\n\r\n");
const closingBuf = Buffer.from(`\r\n--${boundary}`);
let filename = "input.pdf";
let fileStart = -1;
let searchFrom = 0;
const fields: Record<string, string> = {};
while (searchFrom < rawSize) {
const partStart = findInFile(rawPath, boundaryBuf, searchFrom);
if (partStart === -1) break;
const headerEnd = findInFile(
rawPath,
headerEndBuf,
partStart + boundaryBuf.length,
);
if (headerEnd === -1) break;
// Read just the headers (small — typically <500 bytes)
const headersLen = headerEnd - (partStart + boundaryBuf.length);
const headers = readChunk(
rawPath,
partStart + boundaryBuf.length,
Math.min(headersLen, 2048),
).toString("utf8");
if (headers.includes("filename=")) {
const fnMatch = headers.match(/filename="([^"]+)"/);
if (fnMatch?.[1]) filename = fnMatch[1];
fileStart = headerEnd + 4;
break;
}
// Parse form field value
const nameMatch = headers.match(
/Content-Disposition:\s*form-data;\s*name="([^"]+)"/,
);
if (nameMatch?.[1]) {
const valStart = headerEnd + 4;
const nextBoundary = findInFile(rawPath, closingBuf, valStart);
if (nextBoundary !== -1 && nextBoundary - valStart < 10000) {
fields[nameMatch[1]] = readChunk(
rawPath,
valStart,
nextBoundary - valStart,
).toString("utf8");
}
}
searchFrom = headerEnd + 4;
}
if (fileStart === -1) throw new Error("Lipsește fișierul PDF din upload.");
const fileEnd = findInFile(rawPath, closingBuf, fileStart);
const pdfEnd = fileEnd > fileStart ? fileEnd : rawSize;
const pdfSize = pdfEnd - fileStart;
if (pdfSize < 100) throw new Error("Fișierul PDF extras este gol sau prea mic.");
console.log(
`[parse-upload] PDF extracted: ${pdfSize} bytes (offset ${fileStart}..${pdfEnd})`,
);
// Step 3: Copy just the PDF bytes to a new file (stream copy)
const filePath = join(tmpDir, filename);
await copyFileRange(rawPath, filePath, fileStart, pdfEnd);
// Delete raw body — no longer needed
await unlink(rawPath).catch(() => {});
return { filePath, filename, size: pdfSize, tmpDir, fields };
}
+30 -8
View File
@@ -1,18 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAuth } from "./auth-check";
const 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 ?? "cd829f62-6eef-43eb-a64d-c91af727b53a";
const STIRLING_PDF_URL = process.env.STIRLING_PDF_URL;
const STIRLING_PDF_API_KEY = process.env.STIRLING_PDF_API_KEY;
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 {
const formData = await req.formData();
// Buffer the full body then forward to Stirling — streaming passthrough
// (req.body + duplex:half) is unreliable for large files in Next.js.
const bodyBytes = await req.arrayBuffer();
const contentType = req.headers.get("content-type") || "";
// Extract original file size from the multipart body for the response header
// (rough estimate — the overhead of multipart framing is negligible for large PDFs)
const originalSize = bodyBytes.byteLength;
const res = await fetch(`${STIRLING_PDF_URL}/api/v1/misc/compress-pdf`, {
method: "POST",
headers: { "X-API-KEY": STIRLING_PDF_API_KEY },
body: formData,
headers: {
"X-API-KEY": STIRLING_PDF_API_KEY,
"Content-Type": contentType,
},
body: bodyBytes,
signal: AbortSignal.timeout(300_000), // 5 min for large files
});
if (!res.ok) {
@@ -26,11 +46,13 @@ export async function POST(req: NextRequest) {
const blob = await res.blob();
const buffer = Buffer.from(await blob.arrayBuffer());
return new NextResponse(buffer, {
return new NextResponse(new Uint8Array(buffer), {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": 'attachment; filename="compressed.pdf"',
"X-Original-Size": String(originalSize),
"X-Compressed-Size": String(buffer.length),
},
});
} catch (err) {
+60
View File
@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAuth } from "../auth-check";
const STIRLING_PDF_URL = process.env.STIRLING_PDF_URL;
const STIRLING_PDF_API_KEY = process.env.STIRLING_PDF_API_KEY;
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 {
// Stream body directly to Stirling — avoids FormData re-serialization
// failure on large files ("Failed to parse body as FormData")
const res = await fetch(
`${STIRLING_PDF_URL}/api/v1/security/remove-password`,
{
method: "POST",
headers: {
"X-API-KEY": STIRLING_PDF_API_KEY,
"Content-Type": req.headers.get("content-type") || "",
},
body: req.body,
// @ts-expect-error duplex required for streaming request bodies in Node
duplex: "half",
},
);
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
return NextResponse.json(
{ error: `Stirling PDF error: ${res.status}${text}` },
{ status: res.status },
);
}
const blob = await res.blob();
const buffer = Buffer.from(await blob.arrayBuffer());
return new NextResponse(buffer, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": 'attachment; filename="unlocked.pdf"',
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ error: `Nu s-a putut contacta Stirling PDF: ${message}` },
{ status: 502 },
);
}
}
+76
View File
@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from "next/server";
const DWG2DXF_URL = process.env.DWG2DXF_URL ?? "http://localhost:5001";
export async function POST(req: NextRequest) {
try {
const formData = await req.formData();
const file = formData.get("fileInput") as File | null;
if (!file) {
return NextResponse.json(
{ error: "Lipsește fișierul DWG." },
{ status: 400 },
);
}
const name = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
if (!name.toLowerCase().endsWith(".dwg")) {
return NextResponse.json(
{ error: "Fișierul trebuie să fie .dwg" },
{ status: 400 },
);
}
// Re-package for sidecar (field name: "file")
const sidecarForm = new FormData();
sidecarForm.append("file", file, name);
const res = await fetch(`${DWG2DXF_URL}/convert`, {
method: "POST",
body: sidecarForm,
});
if (!res.ok) {
const data = await res.json().catch(() => ({ error: res.statusText }));
const errorMsg =
typeof data === "object" && data !== null && "error" in data
? String((data as Record<string, unknown>).error)
: `Eroare sidecar DWG: ${res.status}`;
return NextResponse.json({ error: errorMsg }, { status: res.status });
}
const blob = await res.blob();
const buffer = Buffer.from(await blob.arrayBuffer());
const dxfName = name.replace(/\.dwg$/i, ".dxf");
return new NextResponse(buffer, {
status: 200,
headers: {
"Content-Type": "application/dxf",
"Content-Disposition": `attachment; filename="${dxfName}"`,
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
if (
message.includes("ECONNREFUSED") ||
message.includes("fetch failed") ||
message.includes("ENOTFOUND")
) {
return NextResponse.json(
{
error:
"Serviciul de conversie DWG nu este disponibil. Verificați că containerul dwg2dxf este pornit.",
},
{ status: 503 },
);
}
return NextResponse.json(
{ error: `Eroare la conversie DWG→DXF: ${message}` },
{ status: 500 },
);
}
}
+252
View File
@@ -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,
});
}
+59
View File
@@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers";
import { fetchUatGeometry } from "@/modules/parcel-sync/services/uat-geometry";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Body = {
username?: string;
password?: string;
siruta?: string | number;
layerId?: string;
};
export async function POST(req: Request) {
try {
const body = (await req.json()) as Body;
const username = (
body.username ??
process.env.ETERRA_USERNAME ??
""
).trim();
const password = (
body.password ??
process.env.ETERRA_PASSWORD ??
""
).trim();
const siruta = String(body.siruta ?? "").trim();
if (!username || !password)
return NextResponse.json({ error: "Credențiale lipsă" }, { status: 400 });
if (!/^\d+$/.test(siruta))
return NextResponse.json({ error: "SIRUTA invalid" }, { status: 400 });
const layerId = body.layerId ? String(body.layerId) : undefined;
const layer = layerId ? findLayerById(layerId) : undefined;
if (layerId && !layer)
return NextResponse.json({ error: "Layer necunoscut" }, { status: 400 });
const client = await EterraClient.create(username, password);
let geometry;
if (layer?.spatialFilter) {
geometry = await fetchUatGeometry(client, siruta);
}
const count = layer
? geometry
? await client.countLayerByGeometry(layer, geometry)
: await client.countLayer(layer, siruta)
: await client.countParcels(siruta);
return NextResponse.json({ count });
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+26
View File
@@ -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 });
}
}
+162
View File
@@ -0,0 +1,162 @@
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/eterra/db-summary
*
* Returns a summary of ALL data in the GIS database, grouped by UAT.
* No siruta required shows everything across all UATs.
*
* Response shape:
* {
* uats: [{
* siruta, uatName,
* layers: [{ layerId, count, enrichedCount, lastSynced }],
* totalFeatures, totalEnriched
* }],
* totalFeatures, totalUats
* }
*/
export async function GET() {
try {
// Feature counts per siruta + layerId
const featureCounts = await prisma.gisFeature.groupBy({
by: ["siruta", "layerId"],
_count: { id: true },
});
// Enriched counts per siruta + layerId
const enrichedCounts = await prisma.gisFeature.groupBy({
by: ["siruta", "layerId"],
where: { enrichedAt: { not: null } },
_count: { id: true },
});
const enrichedMap = new Map<string, number>();
for (const e of enrichedCounts) {
enrichedMap.set(`${e.siruta}:${e.layerId}`, e._count.id);
}
// No-geometry counts per siruta + layerId
const noGeomCounts = await prisma.gisFeature.groupBy({
by: ["siruta", "layerId"],
where: { geometrySource: "NO_GEOMETRY" },
_count: { id: true },
});
const noGeomMap = new Map<string, number>();
for (const ng of noGeomCounts) {
noGeomMap.set(`${ng.siruta}:${ng.layerId}`, ng._count.id);
}
// Latest sync run per siruta + layerId
const latestRuns = await prisma.gisSyncRun.findMany({
where: { status: "done" },
orderBy: { completedAt: "desc" },
select: {
siruta: true,
uatName: true,
layerId: true,
completedAt: true,
},
});
const latestRunMap = new Map<
string,
{ completedAt: Date | null; uatName: string | null }
>();
for (const r of latestRuns) {
const key = `${r.siruta}:${r.layerId}`;
if (!latestRunMap.has(key)) {
latestRunMap.set(key, {
completedAt: r.completedAt,
uatName: r.uatName,
});
}
}
// UAT names from GisUat table
const uatNames = await prisma.gisUat.findMany({
select: { siruta: true, name: true, county: true },
});
const uatNameMap = new Map<
string,
{ name: string; county: string | null }
>();
for (const u of uatNames) {
uatNameMap.set(u.siruta, { name: u.name, county: u.county });
}
// Group by siruta
const uatMap = new Map<
string,
{
siruta: string;
uatName: string;
county: string | null;
layers: {
layerId: string;
count: number;
enrichedCount: number;
noGeomCount: number;
lastSynced: string | null;
}[];
totalFeatures: number;
totalEnriched: number;
totalNoGeom: number;
}
>();
for (const fc of featureCounts) {
if (!uatMap.has(fc.siruta)) {
const uatInfo = uatNameMap.get(fc.siruta);
const runInfo = latestRunMap.get(`${fc.siruta}:${fc.layerId}`);
uatMap.set(fc.siruta, {
siruta: fc.siruta,
uatName: uatInfo?.name ?? runInfo?.uatName ?? `UAT ${fc.siruta}`,
county: uatInfo?.county ?? null,
layers: [],
totalFeatures: 0,
totalEnriched: 0,
totalNoGeom: 0,
});
}
const uat = uatMap.get(fc.siruta)!;
const enriched = enrichedMap.get(`${fc.siruta}:${fc.layerId}`) ?? 0;
const noGeom = noGeomMap.get(`${fc.siruta}:${fc.layerId}`) ?? 0;
const runInfo = latestRunMap.get(`${fc.siruta}:${fc.layerId}`);
uat.layers.push({
layerId: fc.layerId,
count: fc._count.id,
enrichedCount: enriched,
noGeomCount: noGeom,
lastSynced: runInfo?.completedAt?.toISOString() ?? null,
});
uat.totalFeatures += fc._count.id;
uat.totalEnriched += enriched;
uat.totalNoGeom += noGeom;
// Update UAT name if we got one from sync runs
if (uat.uatName.startsWith("UAT ") && runInfo?.uatName) {
uat.uatName = runInfo.uatName;
}
}
const uats = Array.from(uatMap.values()).sort(
(a, b) => b.totalFeatures - a.totalFeatures,
);
const totalFeatures = uats.reduce((s, u) => s + u.totalFeatures, 0);
return NextResponse.json({
uats,
totalFeatures,
totalUats: uats.length,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+187
View File
@@ -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" },
});
}
+881
View File
@@ -0,0 +1,881 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* POST /api/eterra/export-bundle (v2 sync-first)
*
* Flow:
* 1. Sync TERENURI_ACTIVE + CLADIRI_ACTIVE to local DB (skip if fresh)
* 2. Enrich parcels with CF/owner/address data (magic mode only, skip if enriched)
* 3. Build GPKG + CSV from local DB
* 4. Return ZIP
*
* Body: { siruta, jobId?, mode?: "base"|"magic", forceSync?: boolean }
*/
import JSZip from "jszip";
import { prisma } from "@/core/storage/prisma";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { getEpsg3844Wkt } from "@/modules/parcel-sync/services/reproject";
import { buildGpkg } from "@/modules/parcel-sync/services/gpkg-export";
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
import {
enrichFeatures,
getLayerFreshness,
isFresh,
type FeatureEnrichment,
} from "@/modules/parcel-sync/services/enrich-service";
import {
clearProgress,
setProgress,
} from "@/modules/parcel-sync/services/progress-store";
import {
getSessionCredentials,
registerJob,
unregisterJob,
} from "@/modules/parcel-sync/services/session-store";
import { syncNoGeometryParcels } from "@/modules/parcel-sync/services/no-geom-sync";
import type { GeoJsonFeature } from "@/modules/parcel-sync/services/esri-geojson";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type ExportBundleRequest = {
username?: string;
password?: string;
siruta?: string | number;
jobId?: string;
mode?: "base" | "magic";
forceSync?: boolean;
includeNoGeometry?: boolean;
};
const validate = (body: ExportBundleRequest) => {
const session = getSessionCredentials();
const username = String(
body.username || session?.username || process.env.ETERRA_USERNAME || "",
).trim();
const password = String(
body.password || session?.password || process.env.ETERRA_PASSWORD || "",
).trim();
const siruta = String(body.siruta ?? "").trim();
const jobId = body.jobId ? String(body.jobId).trim() : undefined;
const mode = body.mode === "magic" ? "magic" : "base";
const forceSync = body.forceSync === true;
const includeNoGeometry = body.includeNoGeometry === true;
if (!username) throw new Error("Email is required");
if (!password) throw new Error("Password is required");
if (!/^\d+$/.test(siruta)) throw new Error("SIRUTA must be numeric");
return {
username,
password,
siruta,
jobId,
mode,
forceSync,
includeNoGeometry,
};
};
const scheduleClear = (jobId?: string) => {
if (!jobId) return;
setTimeout(() => clearProgress(jobId), 60_000);
};
const csvEscape = (val: unknown) => {
const s = String(val ?? "").replace(/"/g, '""');
return `"${s}"`;
};
export async function POST(req: Request) {
let jobId: string | undefined;
let message: string | undefined;
let phase = "Inițializare";
let note: string | undefined;
let status: "running" | "done" | "error" = "running";
let downloaded = 0;
let total: number | undefined;
let completedWeight = 0;
let currentWeight = 0;
let phaseTotal: number | undefined;
let phaseCurrent: number | undefined;
const pushProgress = () => {
if (!jobId) return;
setProgress({
jobId,
downloaded,
total,
status,
phase,
note,
message,
phaseCurrent,
phaseTotal,
});
};
const updateOverall = (fraction = 0) => {
const overall = completedWeight + currentWeight * fraction;
downloaded = Number(Math.min(100, Math.max(0, overall)).toFixed(1));
total = 100;
pushProgress();
};
const setPhaseState = (next: string, weight: number, nextTotal?: number) => {
phase = next;
currentWeight = weight;
phaseTotal = nextTotal;
phaseCurrent = nextTotal ? 0 : undefined;
note = undefined;
updateOverall(0);
};
const updatePhaseProgress = (value: number, nextTotal?: number) => {
if (typeof nextTotal === "number") phaseTotal = nextTotal;
if (phaseTotal && phaseTotal > 0) {
phaseCurrent = value;
updateOverall(Math.min(1, value / phaseTotal));
} else {
phaseCurrent = undefined;
updateOverall(0);
}
};
const finishPhase = () => {
completedWeight += currentWeight;
currentWeight = 0;
phaseTotal = undefined;
phaseCurrent = undefined;
note = undefined;
updateOverall(0);
};
const withHeartbeat = async <T>(task: () => Promise<T>) => {
let tick = 0.1;
updatePhaseProgress(tick, 1);
const interval = setInterval(() => {
tick = Math.min(0.9, tick + 0.05);
updatePhaseProgress(tick, 1);
}, 1200);
try {
return await task();
} finally {
clearInterval(interval);
}
};
try {
const body = (await req.json()) as ExportBundleRequest;
const validated = validate(body);
jobId = validated.jobId;
if (jobId) registerJob(jobId);
pushProgress();
const hasNoGeom = validated.includeNoGeometry;
const weights =
validated.mode === "magic"
? hasNoGeom
? { sync: 35, noGeom: 10, enrich: 30, gpkg: 15, zip: 10 }
: { sync: 40, noGeom: 0, enrich: 35, gpkg: 15, zip: 10 }
: hasNoGeom
? { sync: 45, noGeom: 15, enrich: 0, gpkg: 25, zip: 15 }
: { sync: 55, noGeom: 0, enrich: 0, gpkg: 30, zip: 15 };
/* ══════════════════════════════════════════════════════════ */
/* Phase 1: Sync layers to local DB */
/* ══════════════════════════════════════════════════════════ */
setPhaseState("Verificare date locale", weights.sync, 2);
const terenuriLayerId = "TERENURI_ACTIVE";
const cladiriLayerId = "CLADIRI_ACTIVE";
const [terenuriStatus, cladiriStatus] = await Promise.all([
getLayerFreshness(validated.siruta, terenuriLayerId),
getLayerFreshness(validated.siruta, cladiriLayerId),
]);
const terenuriNeedsSync =
validated.forceSync ||
!isFresh(terenuriStatus.lastSynced) ||
terenuriStatus.featureCount === 0;
const cladiriNeedsSync =
validated.forceSync ||
!isFresh(cladiriStatus.lastSynced) ||
cladiriStatus.featureCount === 0;
if (terenuriNeedsSync || cladiriNeedsSync) {
if (terenuriNeedsSync) {
phase = "Sincronizare terenuri";
note =
terenuriStatus.featureCount > 0
? "Re-sync (date expirate)"
: "Sync inițial";
pushProgress();
const syncResult = await syncLayer(
validated.username,
validated.password,
validated.siruta,
terenuriLayerId,
{ forceFullSync: validated.forceSync, jobId, isSubStep: true },
);
if (syncResult.status === "error") {
throw new Error(syncResult.error ?? "Sync terenuri failed");
}
}
updatePhaseProgress(1, 2);
if (cladiriNeedsSync) {
phase = "Sincronizare clădiri";
note =
cladiriStatus.featureCount > 0
? "Re-sync (date expirate)"
: "Sync inițial";
pushProgress();
const syncResult = await syncLayer(
validated.username,
validated.password,
validated.siruta,
cladiriLayerId,
{ forceFullSync: validated.forceSync, jobId, isSubStep: true },
);
if (syncResult.status === "error") {
throw new Error(syncResult.error ?? "Sync clădiri failed");
}
}
updatePhaseProgress(2, 2);
} else {
note = "Date proaspete în baza de date — skip sync";
pushProgress();
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();
/* ══════════════════════════════════════════════════════════ */
/* Phase 1b: Import no-geometry parcels (optional) */
/* ══════════════════════════════════════════════════════════ */
let noGeomImported = 0;
let noGeomCleaned = 0;
let noGeomSkipped = 0;
if (hasNoGeom && weights.noGeom > 0) {
setPhaseState("Import parcele fără geometrie", weights.noGeom, 1);
const noGeomClient = await EterraClient.create(
validated.username,
validated.password,
{ timeoutMs: 120_000 },
);
const noGeomResult = await syncNoGeometryParcels(
noGeomClient,
validated.siruta,
{
onProgress: (done, tot, ph) => {
phase = ph;
updatePhaseProgress(done, tot);
},
// workspacePk will be auto-resolved from DB/ArcGIS inside the service
},
);
if (noGeomResult.status === "error") {
// Non-fatal: log but continue with export
note = `Avertisment: ${noGeomResult.error}`;
pushProgress();
} else {
noGeomImported = noGeomResult.imported;
noGeomCleaned = noGeomResult.cleaned;
noGeomSkipped = noGeomResult.skipped;
const cleanedNote =
noGeomResult.cleaned > 0
? `, ${noGeomResult.cleaned} vechi șterse`
: "";
note =
noGeomImported > 0
? `${noGeomImported} parcele noi fără geometrie importate${cleanedNote}`
: `Nicio parcelă nouă fără geometrie${cleanedNote}`;
pushProgress();
}
updatePhaseProgress(1, 1);
finishPhase();
}
/* ══════════════════════════════════════════════════════════ */
/* Phase 2: Enrich (magic mode only) */
/* ══════════════════════════════════════════════════════════ */
// Take back progress control after syncLayer
if (validated.mode === "magic") {
setPhaseState("Verificare îmbogățire", weights.enrich, 1);
const enrichStatus = await getLayerFreshness(
validated.siruta,
terenuriLayerId,
);
const needsEnrich =
validated.forceSync ||
enrichStatus.enrichedCount === 0 ||
enrichStatus.enrichedCount < enrichStatus.featureCount;
if (needsEnrich) {
phase = "Îmbogățire parcele (CF, proprietari, adrese)";
note = undefined;
pushProgress();
const client = await EterraClient.create(
validated.username,
validated.password,
{ timeoutMs: 120_000 },
);
await enrichFeatures(client, validated.siruta, {
onProgress: (done, tot, ph) => {
phase = ph;
updatePhaseProgress(done, tot);
},
});
} else {
note = "Îmbogățire existentă — skip";
pushProgress();
}
updatePhaseProgress(1, 1);
finishPhase();
}
/* ══════════════════════════════════════════════════════════ */
/* Phase 3: Build GPKGs from local DB */
/* ══════════════════════════════════════════════════════════ */
setPhaseState("Generare GPKG din baza de date", weights.gpkg, 3);
const srsWkt = getEpsg3844Wkt();
// Load features from DB
const dbTerenuri = await prisma.gisFeature.findMany({
where: { layerId: terenuriLayerId, siruta: validated.siruta },
select: {
attributes: true,
geometry: true,
enrichment: true,
geometrySource: true,
},
});
const dbCladiri = await prisma.gisFeature.findMany({
where: { layerId: cladiriLayerId, siruta: validated.siruta },
select: { attributes: true, geometry: true },
});
// Convert DB records to GeoJSON features
const toGeoFeatures = (
records: { attributes: unknown; geometry: unknown }[],
): GeoJsonFeature[] =>
records
.filter((r) => r.geometry != null)
.map((r) => ({
type: "Feature" as const,
geometry: r.geometry as GeoJsonFeature["geometry"],
properties: r.attributes as Record<string, unknown>,
}));
const terenuriGeoFeatures = toGeoFeatures(dbTerenuri);
const cladiriGeoFeatures = toGeoFeatures(dbCladiri);
const terenuriFields =
terenuriGeoFeatures.length > 0
? Object.keys(terenuriGeoFeatures[0]!.properties)
: [];
const cladiriFields =
cladiriGeoFeatures.length > 0
? Object.keys(cladiriGeoFeatures[0]!.properties)
: [];
// GPKG terenuri
const terenuriGpkg = await withHeartbeat(() =>
buildGpkg({
srsId: 3844,
srsWkt,
layers: [
{
name: "TERENURI_ACTIVE",
fields: terenuriFields,
features: terenuriGeoFeatures,
},
],
}),
);
updatePhaseProgress(1, 3);
// GPKG cladiri
const cladiriGpkg = await withHeartbeat(() =>
buildGpkg({
srsId: 3844,
srsWkt,
layers: [
{
name: "CLADIRI_ACTIVE",
fields: cladiriFields,
features: cladiriGeoFeatures,
},
],
}),
);
updatePhaseProgress(2, 3);
// Magic: GPKG with enrichment + CSV
let magicGpkg: Buffer | null = null;
let csvContent: string | null = null;
let hasBuildingCount = 0;
let legalBuildingCount = 0;
if (validated.mode === "magic") {
// Build CSV
const headers = [
"OBJECTID",
"IMMOVABLE_ID",
"APPLICATION_ID",
"NATIONAL_CADASTRAL_REFERENCE",
"NR_CAD",
"AREA_VALUE",
"NR_CF",
"NR_CF_VECHI",
"NR_TOPO",
"ADRESA",
"PROPRIETARI",
"PROPRIETARI_VECHI",
"SUPRAFATA_2D",
"SUPRAFATA_R",
"SOLICITANT",
"INTRAVILAN",
"CATEGORIE_FOLOSINTA",
"HAS_BUILDING",
"BUILD_LEGAL",
"HAS_GEOMETRY",
];
const csvRows: string[] = [headers.map(csvEscape).join(",")];
const magicFeatures: GeoJsonFeature[] = [];
const magicFields = Array.from(
new Set([
...terenuriFields,
"NR_CAD",
"NR_CF",
"NR_CF_VECHI",
"NR_TOPO",
"ADRESA",
"PROPRIETARI",
"PROPRIETARI_VECHI",
"SUPRAFATA_2D",
"SUPRAFATA_R",
"SOLICITANT",
"INTRAVILAN",
"CATEGORIE_FOLOSINTA",
"HAS_BUILDING",
"BUILD_LEGAL",
]),
);
for (const record of dbTerenuri) {
const attrs = record.attributes as Record<string, unknown>;
const enrichment =
(record.enrichment as FeatureEnrichment | null) ??
({} as Partial<FeatureEnrichment>);
const geom = record.geometry as GeoJsonFeature["geometry"];
const geomSource = (
record as unknown as { geometrySource: string | null }
).geometrySource;
const hasGeometry =
geom != null && geomSource !== "NO_GEOMETRY" ? 1 : 0;
const e = enrichment as Partial<FeatureEnrichment>;
if (Number(e.HAS_BUILDING ?? 0)) hasBuildingCount += 1;
if (Number(e.BUILD_LEGAL ?? 0)) legalBuildingCount += 1;
const row = [
attrs.OBJECTID ?? "",
attrs.IMMOVABLE_ID ?? "",
attrs.APPLICATION_ID ?? "",
attrs.NATIONAL_CADASTRAL_REFERENCE ?? "",
e.NR_CAD ?? "",
attrs.AREA_VALUE ?? "",
e.NR_CF ?? "",
e.NR_CF_VECHI ?? "",
e.NR_TOPO ?? "",
e.ADRESA ?? "",
e.PROPRIETARI ?? "",
e.PROPRIETARI_VECHI ?? "",
e.SUPRAFATA_2D ?? "",
e.SUPRAFATA_R ?? "",
e.SOLICITANT ?? "",
e.INTRAVILAN ?? "",
e.CATEGORIE_FOLOSINTA ?? "",
e.HAS_BUILDING ?? 0,
e.BUILD_LEGAL ?? 0,
hasGeometry,
];
csvRows.push(row.map(csvEscape).join(","));
// ALL records go into magic GPKG — with or without geometry
magicFeatures.push({
type: "Feature",
geometry: geom,
properties: { ...attrs, ...e, HAS_GEOMETRY: hasGeometry },
});
}
csvContent = csvRows.join("\n");
magicGpkg = await withHeartbeat(() =>
buildGpkg({
srsId: 3844,
srsWkt,
layers: [
{
name: "TERENURI_MAGIC",
fields: [...magicFields, "HAS_GEOMETRY"],
features: magicFeatures,
includeNullGeometry: true,
},
],
}),
);
}
updatePhaseProgress(3, 3);
finishPhase();
/* ══════════════════════════════════════════════════════════ */
/* Phase 4: ZIP */
/* ══════════════════════════════════════════════════════════ */
setPhaseState("Comprimare ZIP", weights.zip, 1);
const zip = new JSZip();
zip.file("terenuri.gpkg", terenuriGpkg);
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 ──
const withGeomRecords = dbTerenuri.filter(
(r) =>
(r as unknown as { geometrySource: string | null }).geometrySource !==
"NO_GEOMETRY",
);
const noGeomRecords = dbTerenuri.filter(
(r) =>
(r as unknown as { geometrySource: string | null }).geometrySource ===
"NO_GEOMETRY",
);
const analyzeRecords = (records: typeof dbTerenuri) => {
let enriched = 0;
let withOwners = 0;
let withOldOwners = 0;
let withCF = 0;
let withAddress = 0;
let withArea = 0;
let withCategory = 0;
let withBuilding = 0;
let complete = 0;
let partial = 0;
let empty = 0;
for (const r of records) {
const e = r.enrichment as Record<string, unknown> | null;
if (!e) {
empty++;
continue;
}
enriched++;
const hasOwners = !!e.PROPRIETARI && e.PROPRIETARI !== "-";
const hasOldOwners =
!!e.PROPRIETARI_VECHI && String(e.PROPRIETARI_VECHI).trim() !== "";
const hasCF = !!e.NR_CF && e.NR_CF !== "-";
const hasAddr = !!e.ADRESA && e.ADRESA !== "-";
const hasArea =
e.SUPRAFATA_2D != null &&
e.SUPRAFATA_2D !== "" &&
Number(e.SUPRAFATA_2D) > 0;
const hasCat = !!e.CATEGORIE_FOLOSINTA && e.CATEGORIE_FOLOSINTA !== "-";
const hasBuild = Number(e.HAS_BUILDING ?? 0) === 1;
if (hasOwners) withOwners++;
if (hasOldOwners) withOldOwners++;
if (hasCF) withCF++;
if (hasAddr) withAddress++;
if (hasArea) withArea++;
if (hasCat) withCategory++;
if (hasBuild) withBuilding++;
// "Complete" = has at least owners + CF + area
if (hasOwners && hasCF && hasArea) complete++;
else if (hasOwners || hasCF || hasAddr || hasArea || hasCat) partial++;
else empty++;
}
return {
total: records.length,
enriched,
withOwners,
withOldOwners,
withCF,
withAddress,
withArea,
withCategory,
withBuilding,
complete,
partial,
empty,
};
};
const qualityAll = analyzeRecords(dbTerenuri);
const qualityGeom = analyzeRecords(withGeomRecords);
const qualityNoGeom = analyzeRecords(noGeomRecords);
const report: Record<string, unknown> = {
siruta: validated.siruta,
generatedAt: new Date().toISOString(),
source: "local-db (sync-first)",
pipeline: {
syncedGis: {
terenuri: terenuriNeedsSync ? "descărcat" : "din cache",
cladiri: cladiriNeedsSync ? "descărcat" : "din cache",
},
noGeometry: hasNoGeom
? {
imported: noGeomImported,
cleaned: noGeomCleaned,
skipped: noGeomSkipped,
}
: "dezactivat",
enriched: validated.mode === "magic" ? "da" : "nu",
finalDb: {
total: dbTerenuri.length,
withGeometry: withGeomRecords.length,
noGeometry: noGeomRecords.length,
cladiri: cladiriGeoFeatures.length,
},
},
terenuri: {
count: terenuriGeoFeatures.length,
totalInDb: dbTerenuri.length,
noGeometryCount: noGeomRecords.length,
},
cladiri: { count: cladiriGeoFeatures.length },
syncSkipped: {
terenuri: !terenuriNeedsSync,
cladiri: !cladiriNeedsSync,
},
includeNoGeometry: hasNoGeom,
noGeomImported,
qualityAnalysis: {
all: qualityAll,
withGeometry: qualityGeom,
noGeometry: qualityNoGeom,
},
};
if (validated.mode === "magic" && magicGpkg && csvContent) {
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);
report.magic = {
csvRows: csvContent.split("\n").length - 1,
hasBuildingCount,
legalBuildingCount,
};
// Generate human-readable quality report (Romanian)
const pct = (n: number, total: number) =>
total > 0 ? `${((n / total) * 100).toFixed(1)}%` : "0%";
const fmt = (n: number) => n.toLocaleString("ro-RO");
const lines: string[] = [
`══════════════════════════════════════════════════════════`,
` RAPORT CALITATE DATE — UAT SIRUTA ${validated.siruta}`,
` Generat: ${new Date().toISOString().replace("T", " ").slice(0, 19)}`,
`══════════════════════════════════════════════════════════`,
``,
`PIPELINE — CE S-A ÎNTÂMPLAT`,
`─────────────────────────────────────────────────────────`,
` 1. Sync GIS terenuri: ${terenuriNeedsSync ? "descărcat din eTerra" : "din cache local (date proaspete)"}`,
` 2. Sync GIS clădiri: ${cladiriNeedsSync ? "descărcat din eTerra" : "din cache local (date proaspete)"}`,
...(hasNoGeom
? [
` 3. Import fără geometrie: ${fmt(noGeomImported)} noi importate` +
(noGeomCleaned > 0
? `, ${fmt(noGeomCleaned)} vechi șterse`
: "") +
(noGeomSkipped > 0
? `, ${fmt(noGeomSkipped)} filtrate/skip`
: ""),
]
: [` 3. Import fără geometrie: dezactivat`]),
` 4. Îmbogățire (CF, prop.): da`,
` 5. Generare fișiere: GPKG + CSV + raport`,
``,
`STARE FINALĂ BAZĂ DE DATE`,
`─────────────────────────────────────────────────────────`,
` Total parcele în baza de date: ${fmt(dbTerenuri.length)}`,
` • Cu geometrie (contur GIS): ${fmt(withGeomRecords.length)}`,
` • Fără geometrie (doar date): ${fmt(noGeomRecords.length)}`,
` Clădiri: ${fmt(cladiriGeoFeatures.length)}`,
``,
`CALITATE ÎMBOGĂȚIRE — TOATE PARCELELE (${fmt(qualityAll.total)})`,
`─────────────────────────────────────────────────────────`,
` Îmbogățite: ${fmt(qualityAll.enriched)} (${pct(qualityAll.enriched, qualityAll.total)})`,
` Cu proprietari: ${fmt(qualityAll.withOwners)} (${pct(qualityAll.withOwners, qualityAll.total)})`,
` Cu prop. vechi: ${fmt(qualityAll.withOldOwners)} (${pct(qualityAll.withOldOwners, qualityAll.total)})`,
` Cu nr. CF: ${fmt(qualityAll.withCF)} (${pct(qualityAll.withCF, qualityAll.total)})`,
` Cu adresă: ${fmt(qualityAll.withAddress)} (${pct(qualityAll.withAddress, qualityAll.total)})`,
` Cu suprafață: ${fmt(qualityAll.withArea)} (${pct(qualityAll.withArea, qualityAll.total)})`,
` Cu categorie fol.: ${fmt(qualityAll.withCategory)} (${pct(qualityAll.withCategory, qualityAll.total)})`,
` Cu clădire: ${fmt(qualityAll.withBuilding)} (${pct(qualityAll.withBuilding, qualityAll.total)})`,
` ────────────────`,
` Complete (prop+CF+sup): ${fmt(qualityAll.complete)} (${pct(qualityAll.complete, qualityAll.total)})`,
` Parțiale: ${fmt(qualityAll.partial)} (${pct(qualityAll.partial, qualityAll.total)})`,
` Goale (fără date): ${fmt(qualityAll.empty)} (${pct(qualityAll.empty, qualityAll.total)})`,
``,
];
if (withGeomRecords.length > 0) {
lines.push(
`PARCELE CU GEOMETRIE (${fmt(qualityGeom.total)})`,
`─────────────────────────────────────────────────────────`,
` Îmbogățite: ${fmt(qualityGeom.enriched)} (${pct(qualityGeom.enriched, qualityGeom.total)})`,
` Cu proprietari: ${fmt(qualityGeom.withOwners)} (${pct(qualityGeom.withOwners, qualityGeom.total)})`,
` Cu nr. CF: ${fmt(qualityGeom.withCF)} (${pct(qualityGeom.withCF, qualityGeom.total)})`,
` Cu adresă: ${fmt(qualityGeom.withAddress)} (${pct(qualityGeom.withAddress, qualityGeom.total)})`,
` Cu suprafață: ${fmt(qualityGeom.withArea)} (${pct(qualityGeom.withArea, qualityGeom.total)})`,
` Cu categorie fol.: ${fmt(qualityGeom.withCategory)} (${pct(qualityGeom.withCategory, qualityGeom.total)})`,
` Complete: ${fmt(qualityGeom.complete)} (${pct(qualityGeom.complete, qualityGeom.total)})`,
` Parțiale: ${fmt(qualityGeom.partial)} (${pct(qualityGeom.partial, qualityGeom.total)})`,
` Goale: ${fmt(qualityGeom.empty)} (${pct(qualityGeom.empty, qualityGeom.total)})`,
``,
);
}
if (noGeomRecords.length > 0) {
lines.push(
`PARCELE FĂRĂ GEOMETRIE (${fmt(qualityNoGeom.total)})`,
`─────────────────────────────────────────────────────────`,
` Îmbogățite: ${fmt(qualityNoGeom.enriched)} (${pct(qualityNoGeom.enriched, qualityNoGeom.total)})`,
` Cu proprietari: ${fmt(qualityNoGeom.withOwners)} (${pct(qualityNoGeom.withOwners, qualityNoGeom.total)})`,
` Cu nr. CF: ${fmt(qualityNoGeom.withCF)} (${pct(qualityNoGeom.withCF, qualityNoGeom.total)})`,
` Cu adresă: ${fmt(qualityNoGeom.withAddress)} (${pct(qualityNoGeom.withAddress, qualityNoGeom.total)})`,
` Cu suprafață: ${fmt(qualityNoGeom.withArea)} (${pct(qualityNoGeom.withArea, qualityNoGeom.total)})`,
` Cu categorie fol.: ${fmt(qualityNoGeom.withCategory)} (${pct(qualityNoGeom.withCategory, qualityNoGeom.total)})`,
` Complete: ${fmt(qualityNoGeom.complete)} (${pct(qualityNoGeom.complete, qualityNoGeom.total)})`,
` Parțiale: ${fmt(qualityNoGeom.partial)} (${pct(qualityNoGeom.partial, qualityNoGeom.total)})`,
` Goale: ${fmt(qualityNoGeom.empty)} (${pct(qualityNoGeom.empty, qualityNoGeom.total)})`,
``,
);
}
lines.push(
`NOTE`,
`─────────────────────────────────────────────────────────`,
` • "Complete" = are proprietari + nr. CF + suprafață`,
` • "Parțiale" = are cel puțin un câmp util`,
` • "Goale" = niciun câmp de îmbogățire completat`,
` • Parcelele fără geometrie provin din lista de imobile`,
` eTerra și nu au contur desenat în layerul GIS.`,
` • Datele sunt extrase din ANCPI eTerra la data raportului.`,
`══════════════════════════════════════════════════════════`,
);
zip.file("raport_calitate.txt", lines.join("\n"));
}
zip.file("export_report.json", JSON.stringify(report, null, 2));
const zipBuffer = await withHeartbeat(() =>
zip.generateAsync({ type: "nodebuffer", compression: "STORE" }),
);
updatePhaseProgress(1, 1);
finishPhase();
/* Done */
const noGeomInDb = noGeomRecords.length;
message = `Finalizat 100% · Terenuri ${terenuriGeoFeatures.length} · Clădiri ${cladiriGeoFeatures.length}`;
if (noGeomInDb > 0) {
message += ` · Fără geometrie ${noGeomInDb}`;
}
if (!terenuriNeedsSync && !cladiriNeedsSync) {
message += " (din cache local)";
}
// Quality summary in note (visible in UI progress card)
if (validated.mode === "magic") {
const qParts: string[] = [];
qParts.push(`Complete: ${qualityAll.complete}/${qualityAll.total}`);
if (qualityAll.partial > 0)
qParts.push(`parțiale: ${qualityAll.partial}`);
if (qualityAll.empty > 0) qParts.push(`goale: ${qualityAll.empty}`);
qParts.push(`prop: ${qualityAll.withOwners}`);
qParts.push(`CF: ${qualityAll.withCF}`);
qParts.push(`sup: ${qualityAll.withArea}`);
note = `Calitate: ${qParts.join(" · ")} — vezi raport_calitate.txt în ZIP`;
}
status = "done";
phase = "Finalizat";
// note already set with quality summary above (or undefined for base mode)
pushProgress();
scheduleClear(jobId);
const filename =
validated.mode === "magic"
? `eterra_uat_${validated.siruta}_magic.zip`
: `eterra_uat_${validated.siruta}_terenuri_cladiri.zip`;
if (jobId) unregisterJob(jobId);
return new Response(new Uint8Array(zipBuffer), {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
} catch (error) {
const errMessage =
error instanceof Error ? error.message : "Unexpected server error";
status = "error";
message = errMessage;
note = undefined;
pushProgress();
scheduleClear(jobId);
if (jobId) unregisterJob(jobId);
const lower = errMessage.toLowerCase();
const statusCode =
lower.includes("login failed") || lower.includes("session") ? 401 : 400;
return new Response(JSON.stringify({ error: errMessage }), {
status: statusCode,
headers: { "Content-Type": "application/json" },
});
}
}
@@ -0,0 +1,272 @@
/**
* POST /api/eterra/export-layer-gpkg (v2 sync-first)
*
* Flow:
* 1. Check local DB freshness for the requested layer
* 2. If stale/empty sync from eTerra (stores in DB)
* 3. Build GPKG from local DB
* 4. Return GPKG
*
* Body: { username?, password?, siruta, layerId, jobId?, forceSync? }
*/
import { prisma } from "@/core/storage/prisma";
import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers";
import { getEpsg3844Wkt } from "@/modules/parcel-sync/services/reproject";
import { buildGpkg } from "@/modules/parcel-sync/services/gpkg-export";
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
import {
getLayerFreshness,
isFresh,
} from "@/modules/parcel-sync/services/enrich-service";
import {
clearProgress,
setProgress,
} from "@/modules/parcel-sync/services/progress-store";
import {
getSessionCredentials,
registerJob,
unregisterJob,
} from "@/modules/parcel-sync/services/session-store";
import type { GeoJsonFeature } from "@/modules/parcel-sync/services/esri-geojson";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type ExportLayerRequest = {
username?: string;
password?: string;
siruta?: string | number;
layerId?: string;
jobId?: string;
forceSync?: boolean;
};
const validate = (body: ExportLayerRequest) => {
const session = getSessionCredentials();
const username = String(
body.username || session?.username || process.env.ETERRA_USERNAME || "",
).trim();
const password = String(
body.password || session?.password || process.env.ETERRA_PASSWORD || "",
).trim();
const siruta = String(body.siruta ?? "").trim();
const layerId = String(body.layerId ?? "").trim();
const jobId = body.jobId ? String(body.jobId).trim() : undefined;
const forceSync = body.forceSync === true;
if (!username) throw new Error("Email is required");
if (!password) throw new Error("Password is required");
if (!/^\d+$/.test(siruta)) throw new Error("SIRUTA must be numeric");
if (!layerId) throw new Error("Layer ID missing");
return { username, password, siruta, layerId, jobId, forceSync };
};
const scheduleClear = (jobId?: string) => {
if (!jobId) return;
setTimeout(() => clearProgress(jobId), 60_000);
};
export async function POST(req: Request) {
let jobId: string | undefined;
let message: string | undefined;
let phase = "Inițializare";
let note: string | undefined;
let status: "running" | "done" | "error" = "running";
let downloaded = 0;
let total: number | undefined;
let completedWeight = 0;
let currentWeight = 0;
let phaseTotal: number | undefined;
let phaseCurrent: number | undefined;
const pushProgress = () => {
if (!jobId) return;
setProgress({
jobId,
downloaded,
total,
status,
phase,
note,
message,
phaseCurrent,
phaseTotal,
});
};
const updateOverall = (fraction = 0) => {
const overall = completedWeight + currentWeight * fraction;
downloaded = Number(Math.min(100, Math.max(0, overall)).toFixed(1));
total = 100;
pushProgress();
};
const setPhaseState = (next: string, weight: number, nextTotal?: number) => {
phase = next;
currentWeight = weight;
phaseTotal = nextTotal;
phaseCurrent = nextTotal ? 0 : undefined;
note = undefined;
updateOverall(0);
};
const updatePhaseProgress = (value: number, nextTotal?: number) => {
if (typeof nextTotal === "number") phaseTotal = nextTotal;
if (phaseTotal && phaseTotal > 0) {
phaseCurrent = value;
updateOverall(Math.min(1, value / phaseTotal));
} else {
phaseCurrent = undefined;
updateOverall(0);
}
};
const finishPhase = () => {
completedWeight += currentWeight;
currentWeight = 0;
phaseTotal = undefined;
phaseCurrent = undefined;
note = undefined;
updateOverall(0);
};
const withHeartbeat = async <T>(task: () => Promise<T>) => {
let tick = 0.1;
updatePhaseProgress(tick, 1);
const interval = setInterval(() => {
tick = Math.min(0.9, tick + 0.05);
updatePhaseProgress(tick, 1);
}, 1200);
try {
return await task();
} finally {
clearInterval(interval);
}
};
try {
const body = (await req.json()) as ExportLayerRequest;
const validated = validate(body);
jobId = validated.jobId;
if (jobId) registerJob(jobId);
pushProgress();
const layer = findLayerById(validated.layerId);
if (!layer) throw new Error("Layer not configured");
const weights = { sync: 60, gpkg: 30, finalize: 10 };
/* ── Phase 1: Check freshness & sync if needed ── */
setPhaseState("Verificare date locale", weights.sync, 1);
const freshness = await getLayerFreshness(
validated.siruta,
validated.layerId,
);
const needsSync =
validated.forceSync ||
!isFresh(freshness.lastSynced) ||
freshness.featureCount === 0;
let syncedFromCache = true;
if (needsSync) {
syncedFromCache = false;
phase = `Sincronizare ${layer.name}`;
note =
freshness.featureCount > 0
? "Re-sync (date expirate)"
: "Sync inițial de la eTerra";
pushProgress();
const syncResult = await syncLayer(
validated.username,
validated.password,
validated.siruta,
validated.layerId,
{ forceFullSync: validated.forceSync, jobId, isSubStep: true },
);
if (syncResult.status === "error") {
throw new Error(syncResult.error ?? "Sync failed");
}
} else {
note = "Date proaspete în baza de date — skip sync";
pushProgress();
}
updatePhaseProgress(1, 1);
finishPhase();
/* ── Phase 2: Build GPKG from local DB ── */
// Take back progress control after syncLayer
setPhaseState("Generare GPKG din baza de date", weights.gpkg, 1);
const features = await prisma.gisFeature.findMany({
where: { layerId: validated.layerId, siruta: validated.siruta },
select: { attributes: true, geometry: true },
});
if (features.length === 0) {
throw new Error(
`Niciun feature în DB pentru ${layer.name} / SIRUTA ${validated.siruta}`,
);
}
const geoFeatures: GeoJsonFeature[] = features
.filter((f) => f.geometry != null)
.map((f) => ({
type: "Feature" as const,
geometry: f.geometry as GeoJsonFeature["geometry"],
properties: f.attributes as Record<string, unknown>,
}));
const fields = Object.keys(geoFeatures[0]?.properties ?? {});
const gpkg = await withHeartbeat(() =>
buildGpkg({
srsId: 3844,
srsWkt: getEpsg3844Wkt(),
layers: [{ name: layer.name, fields, features: geoFeatures }],
}),
);
updatePhaseProgress(1, 1);
finishPhase();
/* ── Phase 3: Finalize ── */
setPhaseState("Finalizare", weights.finalize, 1);
updatePhaseProgress(1, 1);
finishPhase();
const suffix = syncedFromCache ? " (din cache local)" : "";
status = "done";
phase = "Finalizat";
message = `Finalizat 100% · ${geoFeatures.length} elemente${suffix}`;
pushProgress();
scheduleClear(jobId);
if (jobId) unregisterJob(jobId);
const filename = `eterra_uat_${validated.siruta}_${layer.name}.gpkg`;
return new Response(new Uint8Array(gpkg), {
headers: {
"Content-Type": "application/geopackage+sqlite3",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
} catch (error) {
const errMessage =
error instanceof Error ? error.message : "Unexpected server error";
status = "error";
message = errMessage;
note = undefined;
pushProgress();
scheduleClear(jobId);
if (jobId) unregisterJob(jobId);
const lower = errMessage.toLowerCase();
const statusCode =
lower.includes("login failed") || lower.includes("session") ? 401 : 400;
return new Response(JSON.stringify({ error: errMessage }), {
status: statusCode,
headers: { "Content-Type": "application/json" },
});
}
}
+512
View File
@@ -0,0 +1,512 @@
/**
* POST /api/eterra/export-local
*
* Export features from local PostgreSQL database as GPKG / ZIP.
* No eTerra connection needed serves from previously synced data.
*
* Modes:
* - base: ZIP with terenuri.gpkg + cladiri.gpkg
* - magic: ZIP with terenuri.gpkg + cladiri.gpkg + terenuri_magic.gpkg
* + terenuri_complet.csv + raport_calitate.txt + export_report.json
* - layer: single layer GPKG (legacy, layerIds/allLayers)
*
* Body: { siruta, mode?: "base"|"magic", layerIds?: string[], allLayers?: boolean }
*/
import { prisma } from "@/core/storage/prisma";
import { buildGpkg } from "@/modules/parcel-sync/services/gpkg-export";
import { getEpsg3844Wkt } from "@/modules/parcel-sync/services/reproject";
import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers";
import type { GeoJsonFeature } from "@/modules/parcel-sync/services/esri-geojson";
import type { FeatureEnrichment } from "@/modules/parcel-sync/services/enrich-service";
import JSZip from "jszip";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Body = {
siruta?: string;
mode?: "base" | "magic";
layerIds?: string[];
allLayers?: boolean;
};
const csvEscape = (val: unknown) => {
const s = String(val ?? "").replace(/"/g, '""');
return `"${s}"`;
};
export async function POST(req: Request) {
try {
const body = (await req.json()) as Body;
const siruta = String(body.siruta ?? "").trim();
if (!siruta || !/^\d+$/.test(siruta)) {
return Response.json({ error: "SIRUTA invalid" }, { status: 400 });
}
// ── New: "base" or "magic" mode → full ZIP from DB ──
if (body.mode === "base" || body.mode === "magic") {
return buildFullZip(siruta, body.mode);
}
// ── Legacy: single/multi layer GPKG ──
let layerIds: string[];
if (body.layerIds?.length) {
layerIds = body.layerIds;
} else if (body.allLayers) {
const layerGroups = await prisma.gisFeature.groupBy({
by: ["layerId"],
where: { siruta },
_count: { id: true },
});
layerIds = layerGroups
.filter((g) => g._count.id > 0)
.map((g) => g.layerId);
} else {
return Response.json(
{ error: "Specifică mode, layerIds, sau allLayers=true" },
{ status: 400 },
);
}
if (layerIds.length === 0) {
return Response.json(
{ error: "Niciun layer sincronizat în baza de date pentru acest UAT" },
{ status: 404 },
);
}
if (layerIds.length === 1) {
const layerId = layerIds[0]!;
const gpkg = await buildLayerGpkg(siruta, layerId);
const layer = findLayerById(layerId);
const filename = `eterra_local_${siruta}_${layer?.name ?? layerId}.gpkg`;
return new Response(new Uint8Array(gpkg), {
headers: {
"Content-Type": "application/geopackage+sqlite3",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
const zip = new JSZip();
for (const layerId of layerIds) {
const gpkg = await buildLayerGpkg(siruta, layerId);
const layer = findLayerById(layerId);
zip.file(`${layer?.name ?? layerId}.gpkg`, gpkg);
}
const zipBuf = await zip.generateAsync({ type: "nodebuffer" });
return new Response(new Uint8Array(zipBuf), {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="eterra_local_${siruta}.zip"`,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return Response.json({ error: message }, { status: 500 });
}
}
/* ────────────────────────────────────────────────────────── */
/* Full ZIP export from DB (base / magic) */
/* ────────────────────────────────────────────────────────── */
async function buildFullZip(siruta: string, mode: "base" | "magic") {
const srsWkt = getEpsg3844Wkt();
// Load from DB
const dbTerenuri = await prisma.gisFeature.findMany({
where: { layerId: "TERENURI_ACTIVE", siruta },
select: {
attributes: true,
geometry: true,
enrichment: true,
geometrySource: true,
},
});
const dbCladiri = await prisma.gisFeature.findMany({
where: { layerId: "CLADIRI_ACTIVE", siruta },
select: { attributes: true, geometry: true },
});
if (dbTerenuri.length === 0 && dbCladiri.length === 0) {
return Response.json(
{
error:
"Baza de date este goală pentru acest UAT. Rulează sincronizarea mai întâi.",
},
{ status: 404 },
);
}
const toGeoFeatures = (
records: { attributes: unknown; geometry: unknown }[],
): GeoJsonFeature[] =>
records
.filter((r) => r.geometry != null)
.map((r) => ({
type: "Feature" as const,
geometry: r.geometry as GeoJsonFeature["geometry"],
properties: r.attributes as Record<string, unknown>,
}));
const terenuriGeo = toGeoFeatures(dbTerenuri);
const cladiriGeo = toGeoFeatures(dbCladiri);
const terenuriFields =
terenuriGeo.length > 0 ? Object.keys(terenuriGeo[0]!.properties) : [];
const cladiriFields =
cladiriGeo.length > 0 ? Object.keys(cladiriGeo[0]!.properties) : [];
const terenuriGpkg = await buildGpkg({
srsId: 3844,
srsWkt,
layers: [
{
name: "TERENURI_ACTIVE",
fields: terenuriFields,
features: terenuriGeo,
},
],
});
const cladiriGpkg = await buildGpkg({
srsId: 3844,
srsWkt,
layers: [
{ name: "CLADIRI_ACTIVE", fields: cladiriFields, features: cladiriGeo },
],
});
const zip = new JSZip();
zip.file("terenuri.gpkg", terenuriGpkg);
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") {
// ── Magic: enrichment-merged GPKG + CSV + quality report ──
const headers = [
"OBJECTID",
"IMMOVABLE_ID",
"APPLICATION_ID",
"NATIONAL_CADASTRAL_REFERENCE",
"NR_CAD",
"AREA_VALUE",
"NR_CF",
"NR_CF_VECHI",
"NR_TOPO",
"ADRESA",
"PROPRIETARI",
"PROPRIETARI_VECHI",
"SUPRAFATA_2D",
"SUPRAFATA_R",
"SOLICITANT",
"INTRAVILAN",
"CATEGORIE_FOLOSINTA",
"HAS_BUILDING",
"BUILD_LEGAL",
"HAS_GEOMETRY",
];
const csvRows: string[] = [headers.map(csvEscape).join(",")];
const magicFeatures: GeoJsonFeature[] = [];
const magicFields = Array.from(
new Set([
...terenuriFields,
"NR_CAD",
"NR_CF",
"NR_CF_VECHI",
"NR_TOPO",
"ADRESA",
"PROPRIETARI",
"PROPRIETARI_VECHI",
"SUPRAFATA_2D",
"SUPRAFATA_R",
"SOLICITANT",
"INTRAVILAN",
"CATEGORIE_FOLOSINTA",
"HAS_BUILDING",
"BUILD_LEGAL",
]),
);
let hasBuildingCount = 0;
let legalBuildingCount = 0;
for (const record of dbTerenuri) {
const attrs = record.attributes as Record<string, unknown>;
const enrichment =
(record.enrichment as FeatureEnrichment | null) ??
({} as Partial<FeatureEnrichment>);
const geom = record.geometry as GeoJsonFeature["geometry"];
const geomSource = (
record as unknown as { geometrySource: string | null }
).geometrySource;
const hasGeometry = geom != null && geomSource !== "NO_GEOMETRY" ? 1 : 0;
const e = enrichment as Partial<FeatureEnrichment>;
if (Number(e.HAS_BUILDING ?? 0)) hasBuildingCount += 1;
if (Number(e.BUILD_LEGAL ?? 0)) legalBuildingCount += 1;
csvRows.push(
[
attrs.OBJECTID ?? "",
attrs.IMMOVABLE_ID ?? "",
attrs.APPLICATION_ID ?? "",
attrs.NATIONAL_CADASTRAL_REFERENCE ?? "",
e.NR_CAD ?? "",
attrs.AREA_VALUE ?? "",
e.NR_CF ?? "",
e.NR_CF_VECHI ?? "",
e.NR_TOPO ?? "",
e.ADRESA ?? "",
e.PROPRIETARI ?? "",
e.PROPRIETARI_VECHI ?? "",
e.SUPRAFATA_2D ?? "",
e.SUPRAFATA_R ?? "",
e.SOLICITANT ?? "",
e.INTRAVILAN ?? "",
e.CATEGORIE_FOLOSINTA ?? "",
e.HAS_BUILDING ?? 0,
e.BUILD_LEGAL ?? 0,
hasGeometry,
]
.map(csvEscape)
.join(","),
);
magicFeatures.push({
type: "Feature",
geometry: geom,
properties: { ...attrs, ...e, HAS_GEOMETRY: hasGeometry },
});
}
const magicGpkg = await buildGpkg({
srsId: 3844,
srsWkt,
layers: [
{
name: "TERENURI_MAGIC",
fields: [...magicFields, "HAS_GEOMETRY"],
features: magicFeatures,
includeNullGeometry: true,
},
],
});
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"));
// ── Quality analysis ──
const withGeomRecords = dbTerenuri.filter(
(r) =>
(r as unknown as { geometrySource: string | null }).geometrySource !==
"NO_GEOMETRY",
);
const noGeomRecords = dbTerenuri.filter(
(r) =>
(r as unknown as { geometrySource: string | null }).geometrySource ===
"NO_GEOMETRY",
);
const analyzeRecords = (records: typeof dbTerenuri) => {
let enriched = 0,
withOwners = 0,
withOldOwners = 0,
withCF = 0;
let withAddress = 0,
withArea = 0,
withCategory = 0,
withBuilding = 0;
let complete = 0,
partial = 0,
empty = 0;
for (const r of records) {
const en = r.enrichment as Record<string, unknown> | null;
if (!en) {
empty++;
continue;
}
enriched++;
const ho = !!en.PROPRIETARI && en.PROPRIETARI !== "-";
const hoo =
!!en.PROPRIETARI_VECHI && String(en.PROPRIETARI_VECHI).trim() !== "";
const hc = !!en.NR_CF && en.NR_CF !== "-";
const ha = !!en.ADRESA && en.ADRESA !== "-";
const harea =
en.SUPRAFATA_2D != null &&
en.SUPRAFATA_2D !== "" &&
Number(en.SUPRAFATA_2D) > 0;
const hcat = !!en.CATEGORIE_FOLOSINTA && en.CATEGORIE_FOLOSINTA !== "-";
const hb = Number(en.HAS_BUILDING ?? 0) === 1;
if (ho) withOwners++;
if (hoo) withOldOwners++;
if (hc) withCF++;
if (ha) withAddress++;
if (harea) withArea++;
if (hcat) withCategory++;
if (hb) withBuilding++;
if (ho && hc && harea) complete++;
else if (ho || hc || ha || harea || hcat) partial++;
else empty++;
}
return {
total: records.length,
enriched,
withOwners,
withOldOwners,
withCF,
withAddress,
withArea,
withCategory,
withBuilding,
complete,
partial,
empty,
};
};
const qAll = analyzeRecords(dbTerenuri);
const qGeo = analyzeRecords(withGeomRecords);
const qNoGeo = analyzeRecords(noGeomRecords);
// Quality report
const pct = (n: number, t: number) =>
t > 0 ? `${((n / t) * 100).toFixed(1)}%` : "0%";
const fmt = (n: number) => n.toLocaleString("ro-RO");
const lines: string[] = [
`══════════════════════════════════════════════════════════`,
` RAPORT CALITATE DATE — UAT SIRUTA ${siruta}`,
` Generat: ${new Date().toISOString().replace("T", " ").slice(0, 19)}`,
` Sursă: bază de date locală (fără conexiune eTerra)`,
`══════════════════════════════════════════════════════════`,
``,
`STARE BAZĂ DE DATE`,
`─────────────────────────────────────────────────────────`,
` Total parcele: ${fmt(dbTerenuri.length)}`,
` • Cu geometrie (contur GIS): ${fmt(withGeomRecords.length)}`,
` • Fără geometrie (doar date): ${fmt(noGeomRecords.length)}`,
` Clădiri: ${fmt(cladiriGeo.length)}`,
``,
`CALITATE ÎMBOGĂȚIRE — TOATE PARCELELE (${fmt(qAll.total)})`,
`─────────────────────────────────────────────────────────`,
` Îmbogățite: ${fmt(qAll.enriched)} (${pct(qAll.enriched, qAll.total)})`,
` Cu proprietari: ${fmt(qAll.withOwners)} (${pct(qAll.withOwners, qAll.total)})`,
` Cu prop. vechi: ${fmt(qAll.withOldOwners)} (${pct(qAll.withOldOwners, qAll.total)})`,
` Cu nr. CF: ${fmt(qAll.withCF)} (${pct(qAll.withCF, qAll.total)})`,
` Cu adresă: ${fmt(qAll.withAddress)} (${pct(qAll.withAddress, qAll.total)})`,
` Cu suprafață: ${fmt(qAll.withArea)} (${pct(qAll.withArea, qAll.total)})`,
` Cu categorie fol.: ${fmt(qAll.withCategory)} (${pct(qAll.withCategory, qAll.total)})`,
` Cu clădire: ${fmt(qAll.withBuilding)} (${pct(qAll.withBuilding, qAll.total)})`,
` ────────────────`,
` Complete (prop+CF+sup): ${fmt(qAll.complete)} (${pct(qAll.complete, qAll.total)})`,
` Parțiale: ${fmt(qAll.partial)} (${pct(qAll.partial, qAll.total)})`,
` Goale (fără date): ${fmt(qAll.empty)} (${pct(qAll.empty, qAll.total)})`,
``,
];
if (withGeomRecords.length > 0) {
lines.push(
`PARCELE CU GEOMETRIE (${fmt(qGeo.total)})`,
`─────────────────────────────────────────────────────────`,
` Complete: ${fmt(qGeo.complete)} Parțiale: ${fmt(qGeo.partial)} Goale: ${fmt(qGeo.empty)}`,
``,
);
}
if (noGeomRecords.length > 0) {
lines.push(
`PARCELE FĂRĂ GEOMETRIE (${fmt(qNoGeo.total)})`,
`─────────────────────────────────────────────────────────`,
` Complete: ${fmt(qNoGeo.complete)} Parțiale: ${fmt(qNoGeo.partial)} Goale: ${fmt(qNoGeo.empty)}`,
``,
);
}
lines.push(
`NOTE`,
`─────────────────────────────────────────────────────────`,
` • Export din baza de date locală — fără conexiune eTerra`,
` • "Complete" = are proprietari + nr. CF + suprafață`,
`══════════════════════════════════════════════════════════`,
);
zip.file("raport_calitate.txt", lines.join("\n"));
const report = {
siruta,
generatedAt: new Date().toISOString(),
source: "local-db (descărcare din DB)",
terenuri: {
total: dbTerenuri.length,
withGeom: withGeomRecords.length,
noGeom: noGeomRecords.length,
},
cladiri: { count: cladiriGeo.length },
magic: {
csvRows: csvRows.length - 1,
hasBuildingCount,
legalBuildingCount,
},
quality: { all: qAll, withGeom: qGeo, noGeom: qNoGeo },
};
zip.file("export_report.json", JSON.stringify(report, null, 2));
}
const zipBuf = await zip.generateAsync({
type: "nodebuffer",
compression: "STORE",
});
const filename =
mode === "magic"
? `eterra_uat_${siruta}_magic_local.zip`
: `eterra_uat_${siruta}_local.zip`;
return new Response(new Uint8Array(zipBuf), {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
/* ────────────────────────────────────────────────────────── */
/* Layer GPKG builder */
/* ────────────────────────────────────────────────────────── */
async function buildLayerGpkg(siruta: string, layerId: string) {
const features = await prisma.gisFeature.findMany({
where: { layerId, siruta },
select: { attributes: true, geometry: true },
});
if (features.length === 0) {
throw new Error(`Niciun feature local pentru ${layerId} / ${siruta}`);
}
const geoFeatures: GeoJsonFeature[] = features
.filter((f) => f.geometry != null)
.map((f) => ({
type: "Feature" as const,
geometry: f.geometry as GeoJsonFeature["geometry"],
properties: f.attributes as Record<string, unknown>,
}));
const fields = Object.keys(geoFeatures[0]?.properties ?? {});
const layer = findLayerById(layerId);
const name = layer?.name ?? layerId;
return buildGpkg({
srsId: 3844,
srsWkt: getEpsg3844Wkt(),
layers: [{ name, fields, features: geoFeatures }],
});
}
+106
View File
@@ -0,0 +1,106 @@
import { NextResponse } from "next/server";
import { PrismaClient, type Prisma } from "@prisma/client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const prisma = new PrismaClient();
type Body = {
siruta?: string;
layerId?: string;
search?: string;
page?: number;
pageSize?: number;
projectId?: string;
};
/**
* List features stored in local GIS database with pagination & search.
*/
export async function POST(req: Request) {
try {
const body = (await req.json()) as Body;
const siruta = String(body.siruta ?? "").trim();
const layerId = String(body.layerId ?? "").trim();
const search = (body.search ?? "").trim();
const page = Math.max(1, body.page ?? 1);
const pageSize = Math.min(200, Math.max(1, body.pageSize ?? 50));
if (!siruta) {
return NextResponse.json(
{ error: "SIRUTA obligatoriu" },
{ status: 400 },
);
}
const where: Prisma.GisFeatureWhereInput = { siruta };
if (layerId) where.layerId = layerId;
if (body.projectId) where.projectId = body.projectId;
if (search) {
where.OR = [
{ cadastralRef: { contains: search, mode: "insensitive" } },
{ inspireId: { contains: search, mode: "insensitive" } },
];
}
const [features, total] = await Promise.all([
prisma.gisFeature.findMany({
where,
select: {
id: true,
layerId: true,
siruta: true,
objectId: true,
inspireId: true,
cadastralRef: true,
areaValue: true,
isActive: true,
attributes: true,
projectId: true,
createdAt: true,
updatedAt: true,
// geometry omitted for list — too large; fetch single feature by ID for geometry
},
orderBy: { objectId: "asc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.gisFeature.count({ where }),
]);
return NextResponse.json({
features,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
/**
* GET /api/eterra/features?id=... Single feature with full geometry.
*/
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const id = url.searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "ID obligatoriu" }, { status: 400 });
}
const feature = await prisma.gisFeature.findUnique({ where: { id } });
if (!feature) {
return NextResponse.json({ error: "Negăsit" }, { status: 404 });
}
return NextResponse.json(feature);
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+27
View File
@@ -0,0 +1,27 @@
/**
* GET /api/eterra/health eTerra platform availability check.
*
* Returns health status without requiring authentication.
* Triggers a fresh check if cached result is stale.
*
* POST /api/eterra/health force an immediate fresh check.
*/
import { NextResponse } from "next/server";
import {
getEterraHealth,
checkEterraHealthNow,
} from "@/modules/parcel-sync/services/eterra-health";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
const health = getEterraHealth();
return NextResponse.json(health);
}
export async function POST() {
const health = await checkEterraHealthNow();
return NextResponse.json(health);
}
@@ -0,0 +1,86 @@
import { NextResponse } from "next/server";
import {
EterraClient,
type EsriGeometry,
} from "@/modules/parcel-sync/services/eterra-client";
import { LAYER_CATALOG } from "@/modules/parcel-sync/services/eterra-layers";
import { fetchUatGeometry } from "@/modules/parcel-sync/services/uat-geometry";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Body = {
username?: string;
password?: string;
siruta?: string | number;
layerIds?: string[]; // subset — omit to count all
};
/**
* POST Count features per layer on the remote eTerra server.
* Supports session-based auth (falls back to env vars).
*/
export async function POST(req: Request) {
try {
const body = (await req.json()) as Body;
const session = getSessionCredentials();
const username = String(
body.username || session?.username || process.env.ETERRA_USERNAME || "",
).trim();
const password = String(
body.password || session?.password || process.env.ETERRA_PASSWORD || "",
).trim();
const siruta = String(body.siruta ?? "").trim();
if (!username || !password)
return NextResponse.json({ error: "Credențiale lipsă" }, { status: 400 });
if (!/^\d+$/.test(siruta))
return NextResponse.json({ error: "SIRUTA invalid" }, { status: 400 });
const client = await EterraClient.create(username, password);
let uatGeometry: EsriGeometry | undefined;
// Pre-fetch UAT geometry for spatial layers
try {
uatGeometry = await fetchUatGeometry(client, siruta);
} catch {
// Some layers don't need it
}
const layers = body.layerIds
? LAYER_CATALOG.filter((l) => body.layerIds!.includes(l.id))
: LAYER_CATALOG;
const results: Record<string, { count: number; error?: string }> = {};
// Count layers in parallel, max 4 concurrent
const chunks: (typeof layers)[] = [];
for (let i = 0; i < layers.length; i += 4) {
chunks.push(layers.slice(i, i + 4));
}
for (const chunk of chunks) {
const promises = chunk.map(async (layer) => {
try {
const count =
layer.spatialFilter && uatGeometry
? await client.countLayerByGeometry(layer, uatGeometry)
: await client.countLayer(layer, siruta);
results[layer.id] = { count };
} catch (err) {
results[layer.id] = {
count: 0,
error: err instanceof Error ? err.message : "eroare",
};
}
});
await Promise.all(promises);
}
return NextResponse.json({ siruta, counts: results });
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { createSession } from "@/modules/parcel-sync/services/session-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* POST /api/eterra/login
* Legacy endpoint kept for backward compat. Prefer /api/eterra/session.
*/
export async function POST(req: Request) {
try {
const body = (await req.json()) as { username?: string; password?: string };
const username = (
body.username ??
process.env.ETERRA_USERNAME ??
""
).trim();
const password = (
body.password ??
process.env.ETERRA_PASSWORD ??
""
).trim();
if (!username || !password)
return NextResponse.json(
{ error: "Credențiale eTerra lipsă" },
{ status: 400 },
);
await EterraClient.create(username, password);
createSession(username, password);
return NextResponse.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
const status = message.toLowerCase().includes("login") ? 401 : 500;
return NextResponse.json({ error: message }, { status });
}
}
+173
View File
@@ -0,0 +1,173 @@
/**
* POST /api/eterra/no-geom-debug
*
* Diagnostic endpoint: fetches a small sample of no-geometry immovables
* and returns ALL their raw fields, so we can understand what data is
* available for quality filtering.
*
* Body: { siruta: string, sampleSize?: number }
* Returns: { sample: [...raw items], allFields: [...field names], fieldStats: {...} }
*/
import { NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
try {
const body = (await req.json()) as {
siruta?: string;
sampleSize?: number;
};
const siruta = String(body.siruta ?? "").trim();
if (!/^\d+$/.test(siruta)) {
return NextResponse.json(
{ error: "SIRUTA must be numeric" },
{ 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 NextResponse.json(
{ error: "Nu ești conectat la eTerra" },
{ status: 401 },
);
}
const client = await EterraClient.create(username, password);
// Fetch first page of immovables
const response = await client.fetchImmovableListByAdminUnit(
// We need workspace PK — try to resolve
0, // placeholder, we'll get from the sample
siruta,
0,
20,
true, // inscrisCF = -1
);
// If workspace 0 doesn't work, try to resolve it
let items = response?.content ?? [];
if (items.length === 0) {
// Try without inscrisCF filter
const response2 = await client.fetchImmovableListByAdminUnit(
0,
siruta,
0,
20,
false,
);
items = response2?.content ?? [];
}
// If still empty, try to get workspace from GIS layer
if (items.length === 0) {
const features = await client.fetchAllLayer(
{
id: "TERENURI_ACTIVE",
name: "TERENURI_ACTIVE",
endpoint: "aut" as const,
whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1",
},
siruta,
{ returnGeometry: false, outFields: "WORKSPACE_ID", pageSize: 1 },
);
const wsId = features?.[0]?.attributes?.WORKSPACE_ID;
if (wsId) {
const response3 = await client.fetchImmovableListByAdminUnit(
Number(wsId),
siruta,
0,
20,
true,
);
items = response3?.content ?? [];
}
}
if (items.length === 0) {
return NextResponse.json({
error: "Nu s-au găsit imobile",
raw: response,
});
}
// Analyze ALL fields across all items
const allFields = new Set<string>();
const fieldStats: Record<
string,
{ present: number; nonNull: number; nonEmpty: number; sample: unknown }
> = {};
for (const item of items) {
if (typeof item !== "object" || item == null) continue;
for (const [key, value] of Object.entries(item)) {
allFields.add(key);
if (!fieldStats[key]) {
fieldStats[key] = {
present: 0,
nonNull: 0,
nonEmpty: 0,
sample: undefined,
};
}
const stat = fieldStats[key]!;
stat.present++;
if (value != null) {
stat.nonNull++;
if (stat.sample === undefined) stat.sample = value;
}
if (value != null && value !== "" && value !== 0) stat.nonEmpty++;
}
}
// Also try to get GIS features for comparison
const gisFeatures = await client.fetchAllLayer(
{
id: "TERENURI_ACTIVE",
name: "TERENURI_ACTIVE",
endpoint: "aut" as const,
whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1",
},
siruta,
{
returnGeometry: false,
outFields:
"OBJECTID,NATIONAL_CADASTRAL_REFERENCE,IMMOVABLE_ID,AREA_VALUE",
pageSize: 5,
},
);
const gisFieldStats: Record<string, unknown> = {};
if (gisFeatures.length > 0) {
const sample = gisFeatures[0]!.attributes;
for (const [key, value] of Object.entries(sample ?? {})) {
gisFieldStats[key] = value;
}
}
const sampleSize = Math.min(body.sampleSize ?? 5, items.length);
return NextResponse.json({
totalItems: response?.totalElements ?? items.length,
totalPages: response?.totalPages ?? 1,
sampleCount: sampleSize,
sample: items.slice(0, sampleSize),
allFields: Array.from(allFields).sort(),
fieldStats,
gisSampleFields: gisFieldStats,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+74
View File
@@ -0,0 +1,74 @@
/**
* POST /api/eterra/no-geom-scan
*
* Scans eTerra immovable list for a UAT and counts how many parcels
* exist in the eTerra database but have no geometry in the remote
* ArcGIS GIS layer (TERENURI_ACTIVE). Cross-references remotely.
*
* Body: { siruta: string }
* Returns: { totalImmovables, withGeometry, noGeomCount, samples }
*
* Requires active eTerra session.
*/
import { NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
import { scanNoGeometryParcels } from "@/modules/parcel-sync/services/no-geom-sync";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
try {
const body = (await req.json()) as {
siruta?: string;
workspacePk?: number;
};
const siruta = String(body.siruta ?? "").trim();
if (!/^\d+$/.test(siruta)) {
return NextResponse.json(
{ error: "SIRUTA must be numeric" },
{ 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 NextResponse.json(
{ error: "Nu ești conectat la eTerra" },
{ status: 401 },
);
}
const client = await EterraClient.create(username, password);
// Global timeout: 2 minutes max for the entire scan
const scanPromise = scanNoGeometryParcels(client, siruta, {
workspacePk: body.workspacePk ?? null,
});
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(
() =>
reject(
new Error(
"Scanare timeout — serverul eTerra răspunde lent. Reîncearcă mai târziu.",
),
),
120_000,
),
);
const result = await Promise.race([scanPromise, timeoutPromise]);
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { getProgress } from "@/modules/parcel-sync/services/progress-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/eterra/progress?jobId=...
* Poll sync progress for a running job.
*/
export async function GET(req: Request) {
const url = new URL(req.url);
const jobId = url.searchParams.get("jobId");
if (!jobId) {
return NextResponse.json({ error: "jobId obligatoriu" }, { status: 400 });
}
const progress = getProgress(jobId);
if (!progress) {
return NextResponse.json({ jobId, status: "unknown" });
}
return NextResponse.json(progress);
}
+153
View File
@@ -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,97 @@
import { NextRequest, NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Body = {
workspaceId: number;
orgUnitId: number;
year: string;
page?: number;
nrElements?: number;
};
/**
* POST /api/eterra/rgi/applications
*
* List RGI applications for a given workspace (county) and org unit (UAT).
* Proxies eTerra rgi/applicationgrid/list endpoint.
*/
export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as Body;
const { workspaceId, orgUnitId, year } = body;
if (!workspaceId || !orgUnitId || !year) {
return NextResponse.json(
{ error: "workspaceId, orgUnitId and year are required" },
{ status: 400 },
);
}
const username = process.env.ETERRA_USERNAME ?? "";
const password = process.env.ETERRA_PASSWORD ?? "";
if (!username || !password) {
return NextResponse.json(
{ error: "Credentials missing" },
{ status: 500 },
);
}
const client = await EterraClient.create(username, password);
const page = body.page ?? 0;
const nrElements = body.nrElements ?? 25;
const payload = {
filters: [
{
value: workspaceId,
type: "NUMBER",
key: "workspace.nomenPk",
op: "=",
},
{
value: orgUnitId,
type: "NUMBER",
key: "partyFunctionByOrgUnitId.nomenPk",
op: "=",
},
],
applicationFilters: {
applicationType: "own",
tabCode: "NUMBER_LIST",
year,
countyId: workspaceId,
adminUnitId: orgUnitId,
showAll: false,
showNewRequests: false,
showSuspended: false,
showSolutionDeadlineExpired: false,
showPendingLimitation: false,
showCadastreNumberAllocated: false,
showImmovableRegistered: false,
showDocumentIssued: false,
showRestitutionClosed: false,
showRejected: false,
showClosed: false,
showWithdrawn: false,
showSolutionDeadlineExceeded: false,
},
sorters: [],
nrElements,
page,
};
const result = await client.rgiPost(
"rgi/applicationgrid/list",
payload,
);
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+45
View File
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/eterra/rgi/details?applicationId=...
*
* Fetch RGI application details by application ID.
* Proxies eTerra rgi/appdetail/details endpoint.
*/
export async function GET(req: NextRequest) {
try {
const applicationId = req.nextUrl.searchParams.get("applicationId");
if (!applicationId) {
return NextResponse.json(
{ error: "applicationId is required" },
{ status: 400 },
);
}
const username = process.env.ETERRA_USERNAME ?? "";
const password = process.env.ETERRA_PASSWORD ?? "";
if (!username || !password) {
return NextResponse.json(
{ error: "Credentials missing" },
{ status: 500 },
);
}
const client = await EterraClient.create(username, password);
const result = await client.rgiPost(
`rgi/appdetail/details?applicationid=${encodeURIComponent(applicationId)}`,
undefined,
);
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,159 @@
import { NextRequest, NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Strip Romanian diacritics and replace non-alphanumeric chars with underscores.
*/
function sanitizeFilename(raw: string): string {
return raw
.replace(/[ăâ]/g, "a")
.replace(/[ĂÂ]/g, "A")
.replace(/[îÎ]/g, "i")
.replace(/[țȚ]/g, "t")
.replace(/[șȘ]/g, "s")
.replace(/[^a-zA-Z0-9._-]/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "");
}
/**
* Extract file extension from content-type or server filename.
*/
function getExtension(contentType: string, serverFilename: string): string {
// Try extension from server filename first
const dotIdx = serverFilename.lastIndexOf(".");
if (dotIdx > 0) {
return serverFilename.slice(dotIdx + 1).toLowerCase();
}
// Fallback to content-type mapping
const map: Record<string, string> = {
"application/pdf": "pdf",
"image/png": "png",
"image/jpeg": "jpg",
"application/zip": "zip",
"application/xml": "xml",
"text/xml": "xml",
};
return map[contentType] ?? "pdf";
}
/**
* GET /api/eterra/rgi/download-doc?workspaceId=127&applicationId=X&documentPk=Y&documentTypeId=Z&docType=...&appNo=...&initialAppNo=...
*
* Downloads an issued document from eTerra RGI.
* Tries server-side download first. If that fails (some documents are
* restricted to the current actor), returns a JSON blocked response
* so the frontend can show a soft message.
*/
export async function GET(req: NextRequest) {
try {
const workspaceId = req.nextUrl.searchParams.get("workspaceId");
const applicationId = req.nextUrl.searchParams.get("applicationId");
const documentPk = req.nextUrl.searchParams.get("documentPk");
const documentTypeId = req.nextUrl.searchParams.get("documentTypeId");
const docType = req.nextUrl.searchParams.get("docType");
const appNo = req.nextUrl.searchParams.get("appNo");
const initialAppNo = req.nextUrl.searchParams.get("initialAppNo");
if (!workspaceId || !applicationId || !documentPk) {
return NextResponse.json(
{ error: "workspaceId, applicationId and documentPk required" },
{ status: 400 },
);
}
const username = process.env.ETERRA_USERNAME ?? "";
const password = process.env.ETERRA_PASSWORD ?? "";
if (!username || !password) {
return NextResponse.json({ error: "Credentials missing" }, { status: 500 });
}
const client = await EterraClient.create(username, password);
// Step 0: Set application context (like the web UI does)
// This call sets session-level attributes required for document access
try {
await client.rgiGet(
`appDetail/verifyCurrentActorAuthenticated/${applicationId}/${workspaceId}`,
);
} catch {
// Non-critical
}
// Also load application details (sets more session context)
try {
await client.rgiPost(
`rgi/appdetail/details?applicationid=${applicationId}`,
);
} catch {
// Non-critical
}
// Try fileVisibility
let available = false;
if (documentTypeId) {
try {
const vis = await client.rgiGet(
`rgi/appdetail/issueddocs/fileVisibility/${workspaceId}/${applicationId}/${documentTypeId}`,
);
if (vis && typeof vis === "object" && (vis as Record<string, unknown>).msg === "OK") {
available = true;
}
} catch {
// Not available — will try direct download anyway
}
}
// Try download (even if fileVisibility failed — context might be enough)
try {
const { data, contentType, filename: serverFilename } = await client.rgiDownload(
`rgi/appdetail/loadDocument/downloadFile/${workspaceId}/${documentPk}`,
);
if (data.length > 0) {
// Build meaningful filename from query params, fallback to server filename
const ext = getExtension(contentType, serverFilename);
let filename: string;
if (docType && appNo) {
filename = `${sanitizeFilename(docType)}_${sanitizeFilename(appNo)}.${ext}`;
} else if (docType) {
filename = `${sanitizeFilename(docType)}.${ext}`;
} else if (appNo) {
filename = `document_${sanitizeFilename(appNo)}.${ext}`;
} else {
// Use server filename, but still sanitize it
const serverBase = serverFilename.replace(/\.[^.]+$/, "");
filename = serverBase && serverBase !== "document"
? `${sanitizeFilename(serverBase)}.${ext}`
: serverFilename;
}
return new NextResponse(new Uint8Array(data), {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`,
"Content-Length": String(data.length),
},
});
}
} catch {
// Fall through to blocked response
}
// Server-side download not available — return soft blocked response
// so the frontend can show a user-friendly message
return NextResponse.json(
{
blocked: true,
message: "Documentul nu este inca disponibil pentru descarcare din eTerra.",
},
{ status: 200 },
);
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/eterra/rgi/issued-docs?applicationId=...&workspaceId=...
*
* List issued documents for an RGI application.
* Proxies eTerra rgi/appdetail/issueddocs/list endpoint.
*/
export async function GET(req: NextRequest) {
try {
const applicationId = req.nextUrl.searchParams.get("applicationId");
const workspaceId = req.nextUrl.searchParams.get("workspaceId");
if (!applicationId || !workspaceId) {
return NextResponse.json(
{ error: "applicationId and workspaceId are required" },
{ status: 400 },
);
}
const username = process.env.ETERRA_USERNAME ?? "";
const password = process.env.ETERRA_PASSWORD ?? "";
if (!username || !password) {
return NextResponse.json(
{ error: "Credentials missing" },
{ status: 500 },
);
}
const client = await EterraClient.create(username, password);
const result = await client.rgiPost(
`rgi/appdetail/issueddocs/list?applicationid=${encodeURIComponent(applicationId)}&reSaveDocsInPendingAndTiomeOut=false&workspaceid=${encodeURIComponent(workspaceId)}`,
{
filters: [],
sorters: [],
nrElements: 50,
page: 0,
},
);
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+358
View File
@@ -0,0 +1,358 @@
import { NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Body = {
siruta?: string;
ownerName?: string;
workspacePk?: number;
/** "db" = local DB only, "eterra" = eTerra API only, "both" = try both (default) */
source?: "db" | "eterra" | "both";
};
export type OwnerSearchResult = {
nrCad: string;
nrCF: string;
proprietari: string;
proprietariVechi: string;
adresa: string;
suprafata: number | string;
intravilan: string;
categorieFolosinta: string;
source: "db" | "eterra";
immovablePk: string;
};
/* ------------------------------------------------------------------ */
/* Workspace resolution (same as search route) */
/* ------------------------------------------------------------------ */
const globalRef = globalThis as {
__eterraWorkspaceCache?: Map<string, number>;
};
const workspaceCache =
globalRef.__eterraWorkspaceCache ?? new Map<string, number>();
globalRef.__eterraWorkspaceCache = workspaceCache;
async function resolveWorkspace(
client: EterraClient,
siruta: string,
): Promise<number | null> {
const cached = workspaceCache.get(siruta);
if (cached !== undefined) return cached;
try {
const features = await client.listLayer(
{
id: "TERENURI_ACTIVE",
name: "TERENURI_ACTIVE",
endpoint: "aut",
whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1",
},
siruta,
{ limit: 1, outFields: "WORKSPACE_ID" },
);
const wsId = features?.[0]?.attributes?.WORKSPACE_ID;
if (wsId != null) {
const numWs = Number(wsId);
if (Number.isFinite(numWs)) {
workspaceCache.set(siruta, numWs);
prisma.gisUat
.upsert({
where: { siruta },
update: { workspacePk: numWs },
create: { siruta, name: siruta, workspacePk: numWs },
})
.catch(() => {});
return numWs;
}
}
} catch {
// ArcGIS query failed
}
return null;
}
/* ------------------------------------------------------------------ */
/* DB search — search enrichment JSON for owner name */
/* ------------------------------------------------------------------ */
async function searchOwnerInDb(
siruta: string,
ownerName: string,
limit = 50,
): Promise<OwnerSearchResult[]> {
// Normalize search: remove diacritics for broader matching
const normalizedSearch = ownerName
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim();
// Search in both PROPRIETARI and PROPRIETARI_VECHI fields of enrichment JSON
// Use raw SQL for ILIKE on JSON text
const features = await prisma.$queryRaw<
Array<{
id: string;
cadastral_ref: string | null;
enrichment: Record<string, unknown> | null;
area_value: number | null;
}>
>`
SELECT id, "cadastralRef" as cadastral_ref, enrichment, "areaValue" as area_value
FROM "GisFeature"
WHERE siruta = ${siruta}
AND "layerId" = 'TERENURI_ACTIVE'
AND enrichment IS NOT NULL
AND (
unaccent(enrichment->>'PROPRIETARI') ILIKE unaccent(${"%" + normalizedSearch + "%"})
OR unaccent(enrichment->>'PROPRIETARI_VECHI') ILIKE unaccent(${"%" + normalizedSearch + "%"})
)
ORDER BY "cadastralRef" ASC
LIMIT ${limit}
`;
return features.map((f) => {
const e = (f.enrichment ?? {}) as Record<string, unknown>;
return {
nrCad: String(e.NR_CAD ?? f.cadastral_ref ?? ""),
nrCF: String(e.NR_CF ?? ""),
proprietari: String(e.PROPRIETARI ?? ""),
proprietariVechi: String(e.PROPRIETARI_VECHI ?? ""),
adresa: String(e.ADRESA ?? ""),
suprafata:
typeof e.SUPRAFATA_2D === "number"
? e.SUPRAFATA_2D
: (f.area_value ?? ""),
intravilan: String(e.INTRAVILAN ?? ""),
categorieFolosinta: String(e.CATEGORIE_FOLOSINTA ?? ""),
source: "db" as const,
immovablePk: "",
};
});
}
/* ------------------------------------------------------------------ */
/* DB search fallback — without unaccent (if extension not installed) */
/* ------------------------------------------------------------------ */
async function searchOwnerInDbSimple(
siruta: string,
ownerName: string,
limit = 50,
): Promise<OwnerSearchResult[]> {
const searchPattern = `%${ownerName.trim()}%`;
const features = await prisma.$queryRaw<
Array<{
id: string;
cadastral_ref: string | null;
enrichment: Record<string, unknown> | null;
area_value: number | null;
}>
>`
SELECT id, "cadastralRef" as cadastral_ref, enrichment, "areaValue" as area_value
FROM "GisFeature"
WHERE siruta = ${siruta}
AND "layerId" = 'TERENURI_ACTIVE'
AND enrichment IS NOT NULL
AND (
enrichment->>'PROPRIETARI' ILIKE ${searchPattern}
OR enrichment->>'PROPRIETARI_VECHI' ILIKE ${searchPattern}
)
ORDER BY "cadastralRef" ASC
LIMIT ${limit}
`;
return features.map((f) => {
const e = (f.enrichment ?? {}) as Record<string, unknown>;
return {
nrCad: String(e.NR_CAD ?? f.cadastral_ref ?? ""),
nrCF: String(e.NR_CF ?? ""),
proprietari: String(e.PROPRIETARI ?? ""),
proprietariVechi: String(e.PROPRIETARI_VECHI ?? ""),
adresa: String(e.ADRESA ?? ""),
suprafata:
typeof e.SUPRAFATA_2D === "number"
? e.SUPRAFATA_2D
: (f.area_value ?? ""),
intravilan: String(e.INTRAVILAN ?? ""),
categorieFolosinta: String(e.CATEGORIE_FOLOSINTA ?? ""),
source: "db" as const,
immovablePk: "",
};
});
}
/* ------------------------------------------------------------------ */
/* eTerra API search — search by owner name via immovable list */
/* ------------------------------------------------------------------ */
async function searchOwnerOnEterra(
client: EterraClient,
workspaceId: number,
siruta: string,
ownerName: string,
): Promise<OwnerSearchResult[]> {
const response = await client.searchImmovableByOwnerName(
workspaceId,
siruta,
ownerName,
0,
50,
);
const items = response?.content ?? [];
if (items.length === 0) return [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return items.map((item: any) => ({
nrCad: String(item?.identifierDetails ?? ""),
nrCF: String(item?.paperLbNo ?? item?.paperCadNo ?? ""),
proprietari: "", // Not available from immovable list directly
proprietariVechi: "",
adresa: "",
suprafata: item?.measuredArea ?? item?.legalArea ?? item?.area ?? "",
intravilan: "",
categorieFolosinta: "",
source: "eterra" as const,
immovablePk: String(item?.immovablePk ?? ""),
}));
}
/* ------------------------------------------------------------------ */
/* Route handler */
/* ------------------------------------------------------------------ */
/**
* POST /api/eterra/search-owner
*
* Search by owner (proprietar/titular) name.
*
* Priority: 1) Local DB (enriched features), 2) eTerra API.
* DB search is fast and rich. eTerra search is slower but works pre-sync.
*/
export async function POST(req: Request) {
try {
const body = (await req.json()) as Body;
const siruta = String(body.siruta ?? "").trim();
const ownerName = String(body.ownerName ?? "").trim();
const source = body.source ?? "both";
if (!siruta || !/^\d+$/.test(siruta)) {
return NextResponse.json(
{ error: "SIRUTA obligatoriu" },
{ status: 400 },
);
}
if (!ownerName || ownerName.length < 2) {
return NextResponse.json(
{ error: "Numele proprietarului trebuie să aibă min. 2 caractere." },
{ status: 400 },
);
}
const results: OwnerSearchResult[] = [];
let dbSearched = false;
let eterraSearched = false;
let eterraNote = "";
/* ── Step 1: DB search ──────────────────────────── */
if (source !== "eterra") {
try {
const dbResults = await searchOwnerInDb(siruta, ownerName);
results.push(...dbResults);
dbSearched = true;
} catch {
// unaccent extension might not be installed — try simple ILIKE
try {
const dbResults = await searchOwnerInDbSimple(siruta, ownerName);
results.push(...dbResults);
dbSearched = true;
} catch (e2) {
console.log(
"[search-owner] DB search failed:",
e2 instanceof Error ? e2.message : e2,
);
}
}
}
/* ── Step 2: eTerra API search (if no DB results or source=both) ─ */
if (source !== "db" && results.length === 0) {
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) {
try {
const client = await EterraClient.create(username, password);
// Resolve workspace
let workspaceId = body.workspacePk ?? null;
if (!workspaceId || !Number.isFinite(workspaceId)) {
try {
const dbUat = await prisma.gisUat.findUnique({
where: { siruta },
select: { workspacePk: true },
});
if (dbUat?.workspacePk) workspaceId = dbUat.workspacePk;
} catch {
// DB lookup failed
}
}
if (!workspaceId || !Number.isFinite(workspaceId)) {
workspaceId = await resolveWorkspace(client, siruta);
}
if (workspaceId) {
const eterraResults = await searchOwnerOnEterra(
client,
workspaceId,
siruta,
ownerName,
);
// Deduplicate against DB results by nrCad
const existingCads = new Set(results.map((r) => r.nrCad));
for (const r of eterraResults) {
if (!existingCads.has(r.nrCad)) {
results.push(r);
}
}
eterraSearched = true;
} else {
eterraNote =
"Nu s-a putut determina County/workspace pentru eTerra.";
}
} catch (e) {
eterraNote = `eTerra: ${e instanceof Error ? e.message : "eroare"}`;
}
} else {
eterraNote =
results.length === 0
? "Conectează-te la eTerra pentru a căuta online."
: "";
}
}
return NextResponse.json({
results,
total: results.length,
dbSearched,
eterraSearched,
eterraNote,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+616
View File
@@ -0,0 +1,616 @@
import { NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Body = {
siruta?: string;
search?: string; // cadastral number(s), comma or newline separated
username?: string;
password?: string;
workspacePk?: number; // county workspace PK — if provided, skips resolution
};
/* ------------------------------------------------------------------ */
/* Workspace (county) lookup cache */
/* ------------------------------------------------------------------ */
const globalRef = globalThis as {
__eterraWorkspaceCache?: Map<string, number>;
};
const workspaceCache =
globalRef.__eterraWorkspaceCache ?? new Map<string, number>();
globalRef.__eterraWorkspaceCache = workspaceCache;
/**
* Resolve eTerra workspace ID for a given SIRUTA.
*
* Strategy: Query 1 feature from TERENURI_ACTIVE ArcGIS layer for this
* SIRUTA, read the WORKSPACE_ID attribute.
*
* Uses `listLayer()` (not `listLayerByWhere`) so the admin field name
* (ADMIN_UNIT_ID, SIRUTA, UAT_ID) is auto-discovered from layer metadata.
*
* SIRUTA eTerra nomenPk, so nomenclature API lookups don't help.
*/
async function resolveWorkspace(
client: EterraClient,
siruta: string,
): Promise<number | null> {
const cached = workspaceCache.get(siruta);
if (cached !== undefined) return cached;
try {
// listLayer auto-discovers the correct admin field via buildWhere
const features = await client.listLayer(
{
id: "TERENURI_ACTIVE",
name: "TERENURI_ACTIVE",
endpoint: "aut",
whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1",
},
siruta,
{ limit: 1, outFields: "WORKSPACE_ID" },
);
const wsId = features?.[0]?.attributes?.WORKSPACE_ID;
console.log(
"[resolveWorkspace] ArcGIS WORKSPACE_ID for",
siruta,
"→",
wsId,
);
if (wsId != null) {
const numWs = Number(wsId);
if (Number.isFinite(numWs)) {
workspaceCache.set(siruta, numWs);
// Persist to DB for future fast lookups
persistWorkspace(siruta, numWs);
return numWs;
}
}
} catch (e) {
console.log(
"[resolveWorkspace] ArcGIS query failed:",
e instanceof Error ? e.message : e,
);
}
return null;
}
/** Fire-and-forget: save WORKSPACE_ID to GisUat row */
function persistWorkspace(siruta: string, workspacePk: number) {
prisma.gisUat
.upsert({
where: { siruta },
update: { workspacePk },
create: { siruta, name: siruta, workspacePk },
})
.catch(() => {});
}
/* ------------------------------------------------------------------ */
/* Helper formatters (same logic as export-bundle magic mode) */
/* ------------------------------------------------------------------ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function formatAddress(item?: any) {
const addresses = item?.immovableAddresses ?? [];
if (addresses.length === 0) return "";
// Build from ALL addresses (some parcels have multiple)
const formatted: string[] = [];
for (const entry of addresses) {
const address = entry?.address ?? entry;
if (!address) continue;
const parts: string[] = [];
// Street: dictionaryItem.name = type ("Strada"), name = actual name ("DIANEI")
const streetObj = address.street;
if (streetObj) {
const streetType =
typeof streetObj === "string"
? ""
: (streetObj?.dictionaryItem?.name ?? "");
const streetName =
typeof streetObj === "string" ? streetObj : (streetObj?.name ?? "");
if (streetType && streetName) {
parts.push(`${streetType} ${streetName}`);
} else if (streetName) {
parts.push(`Str. ${streetName}`);
}
}
// postalNo is often the house number in eTerra
const houseNo = address.postalNo ?? address.buildingNo ?? null;
if (houseNo) parts.push(`Nr. ${houseNo}`);
// Building details
if (address.buildingSectionNo)
parts.push(`Bl. ${address.buildingSectionNo}`);
if (address.buildingEntryNo) parts.push(`Sc. ${address.buildingEntryNo}`);
if (address.buildingFloorNo) parts.push(`Et. ${address.buildingFloorNo}`);
if (address.buildingUnitNo) parts.push(`Ap. ${address.buildingUnitNo}`);
// Locality
const localityName =
typeof address.locality === "string"
? address.locality
: (address.locality?.name ?? "");
if (localityName) parts.push(localityName);
// County
const countyName =
typeof address.county === "string"
? address.county
: (address.county?.name ?? "");
if (countyName) parts.push(`Jud. ${countyName}`);
// Postal code
if (address.postalCode) parts.push(`Cod ${address.postalCode}`);
if (parts.length > 0) {
formatted.push(parts.join(", "));
} else if (address.addressDescription) {
// Fall back to description only if no structured fields found
const desc = String(address.addressDescription).trim();
if (desc.length > 2 && !desc.includes("[object")) {
formatted.push(desc);
}
}
}
// If we still have nothing, try addressDescription from first entry
if (formatted.length === 0) {
const desc =
addresses[0]?.address?.addressDescription ??
addresses[0]?.addressDescription;
if (desc) {
const s = String(desc).trim();
if (s.length > 2 && !s.includes("[object")) return s;
}
}
return [...new Set(formatted)].join(" | ");
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function normalizeIntravilan(values: string[]) {
const normalized = values
.map((v) =>
String(v ?? "")
.trim()
.toLowerCase(),
)
.filter(Boolean);
const unique = new Set(normalized);
if (!unique.size) return "";
if (unique.size === 1)
return unique.has("da") ? "Da" : unique.has("nu") ? "Nu" : "Mixt";
return "Mixt";
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function formatCategories(entries: any[]) {
const map = new Map<string, number>();
for (const entry of entries) {
const key = String(entry?.categorieFolosinta ?? "").trim();
if (!key) continue;
const area = Number(entry?.suprafata ?? 0);
map.set(key, (map.get(key) ?? 0) + (Number.isFinite(area) ? area : 0));
}
return Array.from(map.entries())
.map(([k, a]) => `${k}:${a.toFixed(2).replace(/\.00$/, "")}`)
.join("; ");
}
/* ------------------------------------------------------------------ */
/* Route handler */
/* ------------------------------------------------------------------ */
export type ParcelDetail = {
nrCad: string;
nrCF: string;
nrCFVechi: string;
nrTopo: string;
intravilan: string;
categorieFolosinta: string;
adresa: string;
proprietari: string;
proprietariActuali: string;
proprietariVechi: string;
suprafata: number | null;
solicitant: string;
immovablePk: string;
};
/**
* POST /api/eterra/search
*
* Search eTerra by cadastral number using the application API
* (same as the eTerra web UI). Returns full parcel details:
* nr. cadastral, CF, topo, intravilan, categorii folosință,
* adresă, proprietari.
*
* Accepts one or more cadastral numbers (comma/newline separated).
*/
export async function POST(req: Request) {
try {
const body = (await req.json()) as Body;
const siruta = String(body.siruta ?? "").trim();
const rawSearch = (body.search ?? "").trim();
if (!siruta || !/^\d+$/.test(siruta)) {
return NextResponse.json(
{ error: "SIRUTA obligatoriu" },
{ status: 400 },
);
}
if (!rawSearch) {
return NextResponse.json(
{ error: "Număr cadastral obligatoriu" },
{ status: 400 },
);
}
// Parse multiple cadastral numbers (comma, newline, space separated)
const cadNumbers = rawSearch
.split(/[\s,;\n]+/)
.map((s) => s.trim())
.filter(Boolean);
if (cadNumbers.length === 0) {
return NextResponse.json(
{ error: "Număr cadastral obligatoriu" },
{ status: 400 },
);
}
// Credential chain: body > session > env
const session = getSessionCredentials();
const username = String(
body.username || session?.username || process.env.ETERRA_USERNAME || "",
).trim();
const password = String(
body.password || session?.password || process.env.ETERRA_PASSWORD || "",
).trim();
if (!username || !password) {
return NextResponse.json(
{ error: "Conectează-te la eTerra mai întâi." },
{ status: 401 },
);
}
const client = await EterraClient.create(username, password);
// Workspace resolution chain: body → DB → ArcGIS layer query
let workspaceId = body.workspacePk ?? null;
if (!workspaceId || !Number.isFinite(workspaceId)) {
try {
const dbUat = await prisma.gisUat.findUnique({
where: { siruta },
select: { workspacePk: true },
});
if (dbUat?.workspacePk) workspaceId = dbUat.workspacePk;
} catch {
// DB lookup failed
}
}
if (!workspaceId || !Number.isFinite(workspaceId)) {
workspaceId = await resolveWorkspace(client, siruta);
}
if (!workspaceId) {
return NextResponse.json(
{ error: "Nu s-a putut determina județul pentru UAT-ul selectat." },
{ status: 400 },
);
}
console.log("[search] siruta:", siruta, "workspaceId:", workspaceId);
const results: ParcelDetail[] = [];
for (const cadNr of cadNumbers) {
try {
// 1. Search immovable by identifier (exact match)
const immResponse = await client.searchImmovableByIdentifier(
workspaceId,
siruta,
cadNr,
);
const items = immResponse?.content ?? [];
if (items.length === 0) {
// No result — add placeholder so user knows it wasn't found
results.push({
nrCad: cadNr,
nrCF: "",
nrCFVechi: "",
nrTopo: "",
intravilan: "",
categorieFolosinta: "",
adresa: "",
proprietari: "",
proprietariActuali: "",
proprietariVechi: "",
suprafata: null,
solicitant: "",
immovablePk: "",
});
continue;
}
for (const item of items) {
const immPk = item?.immovablePk;
const immPkStr = String(immPk ?? "");
// Basic data from immovable list
let nrCF = String(item?.paperLbNo ?? item?.paperCadNo ?? "");
let nrCFVechi = "";
let nrTopo = String(item?.topNo ?? item?.paperCadNo ?? "");
let addressText = formatAddress(item);
let proprietariActuali: string[] = [];
let proprietariVechi: string[] = [];
let solicitant = "";
let intravilan = "";
let categorie = "";
let suprafata: number | null = null;
// Area: use measuredArea first, then legalArea as fallback
for (const areaField of [
item?.measuredArea,
item?.legalArea,
item?.area,
item?.areaValue,
]) {
if (areaField != null) {
const parsed = Number(areaField);
if (Number.isFinite(parsed) && parsed > 0) {
suprafata = parsed;
break;
}
}
}
// 2. Fetch documentation data (CF, proprietari)
if (immPk) {
try {
const docResponse = await client.fetchDocumentationData(
workspaceId,
[immPk],
);
// Extract doc details
const docImm = (docResponse?.immovables ?? []).find(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(d: any) => String(d?.immovablePk) === immPkStr,
);
if (docImm) {
if (docImm.landbookIE) {
const oldCF = nrCF;
nrCF = String(docImm.landbookIE);
if (oldCF && oldCF !== nrCF) nrCFVechi = oldCF;
}
if (docImm.topNo) nrTopo = String(docImm.topNo);
// Try measuredArea/legalArea from doc too
for (const af of [
docImm.measuredArea,
docImm.legalArea,
docImm.area,
]) {
if (af != null) {
const docArea = Number(af);
if (Number.isFinite(docArea) && docArea > 0) {
suprafata = docArea;
break;
}
}
}
}
// Extract owners from partTwoRegs — separate active vs cancelled
// Tree: C (cerere) → A (act) → I (inscription) → P (person)
// nodeStatus === -1 on an "I" node means it's radiated.
// Walk up from each "P" to its parent "I", check nodeStatus.
const activeOwners: string[] = [];
const cancelledOwners: string[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const regs: any[] = docResponse?.partTwoRegs ?? [];
// Build nodeId → entry map for tree traversal
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const nodeMap = new Map<number, any>();
for (const reg of regs) {
if (reg?.nodeId != null) nodeMap.set(Number(reg.nodeId), reg);
}
// Check if an entry or any ancestor "I" inscription is radiated
// nodeStatus: -1 = radiated, 0 = active, 2 = pending
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isRadiated = (entry: any, depth = 0): boolean => {
if (!entry || depth > 10) return false;
// nodeStatus === -1 means radiated/cancelled
if (entry?.nodeStatus === -1) return true;
// Walk up to parent
const pid = entry?.parentId;
if (pid != null) {
const parent = nodeMap.get(Number(pid));
if (parent) return isRadiated(parent, depth + 1);
}
return false;
};
for (const reg of regs) {
if (
String(reg?.nodeType ?? "").toUpperCase() !== "P" ||
!reg?.nodeName
)
continue;
const name = String(reg.nodeName).trim();
if (!name) continue;
if (isRadiated(reg)) {
cancelledOwners.push(name);
} else {
activeOwners.push(name);
}
}
proprietariActuali = Array.from(new Set(activeOwners));
proprietariVechi = Array.from(new Set(cancelledOwners)).filter(
(n) => !proprietariActuali.includes(n),
);
} catch {
// Documentation fetch failed — continue with basic data
}
}
// 3. Fetch parcel details (area, intravilan, useCategory) — direct endpoint
if (immPk) {
try {
const parcels = await client.fetchImmovableParcelDetails(
workspaceId,
immPk,
1,
100,
);
if (Array.isArray(parcels) && parcels.length > 0) {
// Sum area from all parcels
let totalArea = 0;
const intraVals: string[] = [];
const catMap = new Map<string, number>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const p of parcels as any[]) {
const a = Number(p?.area ?? 0);
if (Number.isFinite(a) && a > 0) totalArea += a;
if (p?.intravilan) intraVals.push(String(p.intravilan));
const cat = String(p?.useCategory ?? "").trim();
if (cat) {
catMap.set(
cat,
(catMap.get(cat) ?? 0) + (Number.isFinite(a) ? a : 0),
);
}
}
if (totalArea > 0 && (suprafata == null || suprafata <= 0)) {
suprafata = totalArea;
}
if (!intravilan && intraVals.length > 0) {
intravilan = normalizeIntravilan(intraVals);
}
if (!categorie && catMap.size > 0) {
categorie = Array.from(catMap.entries())
.map(
([k, a]) => `${k}:${a.toFixed(2).replace(/\.00$/, "")}`,
)
.join("; ");
}
}
} catch {
// parcel details fetch failed
}
}
// 4. Fetch application data (solicitant, fallback folosință)
if (immPk) {
try {
const apps = await client.fetchImmAppsByImmovable(
immPk,
workspaceId,
);
// Pick most recent application
const chosen =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(apps ?? [])
.filter((a: any) => a?.dataCerere)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.sort(
(a: any, b: any) =>
(b.dataCerere ?? 0) - (a.dataCerere ?? 0),
)[0] ?? apps?.[0];
if (chosen) {
solicitant = String(chosen.solicitant ?? chosen.deponent ?? "");
const appId = chosen.applicationId;
if (appId) {
try {
const fol = await client.fetchParcelFolosinte(
workspaceId,
immPk,
appId,
);
intravilan = normalizeIntravilan(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(fol ?? []).map((f: any) => f?.intravilan ?? ""),
);
categorie = formatCategories(fol ?? []);
// Extract total area from folosinte as fallback
if (suprafata == null || suprafata <= 0) {
let totalArea = 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const f of (fol ?? []) as any[]) {
const a = Number(f?.suprafata ?? 0);
if (Number.isFinite(a)) totalArea += a;
}
if (totalArea > 0) suprafata = totalArea;
}
} catch {
// folosinta fetch failed
}
}
}
} catch {
// immApps fetch failed
}
}
const allOwners = [...proprietariActuali, ...proprietariVechi];
results.push({
nrCad: String(item?.identifierDetails ?? cadNr),
nrCF,
nrCFVechi,
nrTopo,
intravilan,
categorieFolosinta: categorie,
adresa: addressText,
proprietari: allOwners.join("; "),
proprietariActuali: proprietariActuali.join("; "),
proprietariVechi: proprietariVechi.join("; "),
suprafata,
solicitant,
immovablePk: immPkStr,
});
}
} catch {
// Error for this particular cadNr — add placeholder
results.push({
nrCad: cadNr,
nrCF: "",
nrCFVechi: "",
nrTopo: "",
intravilan: "",
categorieFolosinta: "",
adresa: "",
proprietari: "",
proprietariActuali: "",
proprietariVechi: "",
suprafata: null,
solicitant: "",
immovablePk: "",
});
}
}
return NextResponse.json({
results,
total: results.length,
source: "eterra-app",
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,176 @@
/**
* County & geometry refresh populates GisUat.county + geometry
* from eTerra LIMITE_UAT layer.
*
* Called with an already-authenticated EterraClient (fire-and-forget
* after login), so there's no session expiry risk.
*
* Strategy:
* 1. Query LIMITE_UAT for all features WITH geometry
* get ADMIN_UNIT_ID, WORKSPACE_ID, AREA_VALUE, LAST_UPDATED_DTM + rings
* 2. Map WORKSPACE_ID county name via verified mapping
* 3. Batch-update GisUat: county, workspacePk, geometry, areaValue, lastUpdatedDtm
* 4. On subsequent runs: skip UATs where lastUpdatedDtm hasn't changed
*/
import { Prisma } from "@prisma/client";
import { prisma } from "@/core/storage/prisma";
import type { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers";
/**
* eTerra WORKSPACE_ID Romanian county name.
*
* Verified by cross-referencing LIMITE_UAT sample UATs + DB confirmations.
*/
const WORKSPACE_TO_COUNTY: Record<number, string> = {
10: "Alba",
29: "Arad",
38: "Argeș",
47: "Bacău",
56: "Bihor",
65: "Bistrița-Năsăud",
74: "Botoșani",
83: "Brașov",
92: "Brăila",
109: "Buzău",
118: "Caraș-Severin",
127: "Cluj",
136: "Constanța",
145: "Covasna",
154: "Dâmbovița",
163: "Dolj",
172: "Galați",
181: "Gorj",
190: "Harghita",
207: "Hunedoara",
216: "Ialomița",
225: "Iași",
234: "Ilfov",
243: "Maramureș",
252: "Mehedinți",
261: "Mureș",
270: "Neamț",
289: "Olt",
298: "Prahova",
305: "Satu Mare",
314: "Sălaj",
323: "Sibiu",
332: "Suceava",
341: "Teleorman",
350: "Timiș",
369: "Tulcea",
378: "Vaslui",
387: "Vâlcea",
396: "Vrancea",
403: "București",
519: "Călărași",
528: "Giurgiu",
};
export async function refreshCountyData(client: EterraClient): Promise<void> {
const total = await prisma.gisUat.count();
if (total === 0) return;
// Check how many are missing county OR geometry
const [withCounty, withGeometry] = await Promise.all([
prisma.gisUat.count({ where: { county: { not: null } } }),
prisma.gisUat.count({
where: { geometry: { not: Prisma.AnyNull } },
}),
]);
const needsCounty = withCounty < total * 0.5;
const needsGeometry = withGeometry < total * 0.5;
if (!needsCounty && !needsGeometry) {
console.log(
`[county-refresh] ${withCounty}/${total} counties, ${withGeometry}/${total} geometries — skipping.`,
);
return;
}
console.log(
`[county-refresh] Starting: ${withCounty}/${total} counties, ${withGeometry}/${total} geometries.`,
);
// 1. Query LIMITE_UAT — with geometry if needed, without if only county
const limiteUat = findLayerById("LIMITE_UAT");
if (!limiteUat) {
console.error("[county-refresh] LIMITE_UAT layer not configured.");
return;
}
const features = await client.fetchAllLayerByWhere(limiteUat, "1=1", {
outFields: "ADMIN_UNIT_ID,WORKSPACE_ID,AREA_VALUE,LAST_UPDATED_DTM",
returnGeometry: needsGeometry,
pageSize: 1000,
});
console.log(
`[county-refresh] LIMITE_UAT: ${features.length} features` +
`${needsGeometry ? " (with geometry)" : ""}.`,
);
if (features.length === 0) return;
// 2. Log unknown workspaces
const seenWs = new Set<number>();
for (const f of features) {
const ws = Number(f.attributes?.WORKSPACE_ID ?? 0);
if (ws > 0 && !(ws in WORKSPACE_TO_COUNTY) && !seenWs.has(ws)) {
seenWs.add(ws);
const siruta = String(f.attributes?.ADMIN_UNIT_ID ?? "").replace(/\.0$/, "");
console.warn(
`[county-refresh] Unknown workspace ${ws} (SIRUTA ${siruta}). Add to mapping.`,
);
}
}
// 3. Upsert each UAT with county, workspacePk, geometry, area, lastUpdatedDtm
let updated = 0;
const BATCH = 50;
for (let i = 0; i < features.length; i += BATCH) {
const batch = features.slice(i, i + BATCH);
const ops = [];
for (const f of batch) {
const siruta = String(f.attributes?.ADMIN_UNIT_ID ?? "")
.trim()
.replace(/\.0$/, "");
const ws = Number(f.attributes?.WORKSPACE_ID ?? 0);
if (!siruta || ws <= 0) continue;
const county = WORKSPACE_TO_COUNTY[ws];
const areaValue = Number(f.attributes?.AREA_VALUE ?? 0) || null;
const lastUpdatedDtm = f.attributes?.LAST_UPDATED_DTM != null
? String(f.attributes.LAST_UPDATED_DTM)
: null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const geom = (f as any).geometry ?? null;
const data: Prisma.GisUatUpdateInput = {
workspacePk: ws,
...(county ? { county } : {}),
...(areaValue ? { areaValue } : {}),
...(lastUpdatedDtm ? { lastUpdatedDtm } : {}),
...(geom ? { geometry: geom as Prisma.InputJsonValue } : {}),
};
ops.push(
prisma.gisUat.updateMany({
where: { siruta },
data,
}),
);
}
if (ops.length > 0) {
const results = await prisma.$transaction(ops);
for (const r of results) updated += r.count;
}
}
console.log(`[county-refresh] Done: ${updated}/${total} updated.`);
}
+122
View File
@@ -0,0 +1,122 @@
import { NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import {
createSession,
destroySession,
forceDestroySession,
getSessionCredentials,
getSessionStatus,
} from "@/modules/parcel-sync/services/session-store";
import { getEterraHealth } from "@/modules/parcel-sync/services/eterra-health";
import { refreshCountyData } from "./county-refresh";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/eterra/session returns current server-side session status
* enriched with eTerra platform health info.
*/
export async function GET() {
const status = getSessionStatus();
const health = getEterraHealth();
return NextResponse.json({
...status,
eterraAvailable: health.available,
eterraMaintenance: health.maintenance,
eterraHealthMessage: health.message,
});
}
/**
* POST /api/eterra/session connect or disconnect.
*
* Connect: { action: "connect", username?, password? }
* Disconnect: { action: "disconnect", force?: boolean }
*/
export async function POST(req: Request) {
try {
const body = (await req.json()) as {
action?: string;
username?: string;
password?: string;
force?: boolean;
};
const action = body.action ?? "connect";
if (action === "disconnect") {
if (body.force) {
forceDestroySession();
return NextResponse.json({ success: true, disconnected: true });
}
const result = destroySession();
if (!result.destroyed) {
return NextResponse.json(
{ success: false, error: result.reason },
{ status: 409 },
);
}
return NextResponse.json({ success: true, disconnected: true });
}
// Connect
const username = (
body.username ??
process.env.ETERRA_USERNAME ??
""
).trim();
const password = (
body.password ??
process.env.ETERRA_PASSWORD ??
""
).trim();
if (!username || !password) {
return NextResponse.json(
{ error: "Credențiale eTerra lipsă" },
{ status: 400 },
);
}
// Block login when eTerra is in maintenance
const health = getEterraHealth();
if (!health.available && health.maintenance) {
return NextResponse.json(
{
error:
"eTerra este în mentenanță — conectarea este dezactivată temporar",
maintenance: true,
},
{ status: 503 },
);
}
// Check if already connected with same credentials
const existing = getSessionCredentials();
if (existing && existing.username === username) {
// Already connected — verify session is still alive by pinging
try {
await EterraClient.create(username, password);
return NextResponse.json({ success: true, alreadyConnected: true });
} catch {
// Session expired, re-login below
}
}
// Attempt login
const client = await EterraClient.create(username, password);
createSession(username, password);
// Fire-and-forget: populate county data using fresh client
refreshCountyData(client).catch((err) =>
console.error("[session] County refresh failed:", err),
);
return NextResponse.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
const status = message.toLowerCase().includes("login") ? 401 : 500;
return NextResponse.json({ error: message }, { status });
}
}

Some files were not shown because too many files have changed in this diff Show More