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>
This commit is contained in:
@@ -1,463 +1,197 @@
|
|||||||
# ArchiTools — Project Context for AI Assistants
|
# ArchiTools — Project Context for AI Assistants
|
||||||
|
|
||||||
> This file provides all context needed for Claude Code, Sonnet, or any AI model to work on this project from scratch.
|
## Quick Start
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
npm install
|
||||||
## Quick Start
|
npm run dev # http://localhost:3000
|
||||||
|
npx next build # verify zero errors before pushing
|
||||||
```bash
|
git push origin main # manual redeploy via Portainer UI
|
||||||
npm install
|
```
|
||||||
npm run dev # http://localhost:3000
|
|
||||||
npx next build # verify zero errors before pushing
|
---
|
||||||
git push origin main # auto-deploys via Portainer webhook
|
|
||||||
```
|
## Project Overview
|
||||||
|
|
||||||
---
|
**ArchiTools** is a modular internal web dashboard for 3 architecture/engineering companies:
|
||||||
|
**Beletage** (architecture), **Urban Switch** (urbanism), **Studii de Teren** (geotechnics).
|
||||||
## Project Overview
|
Production: `tools.beletage.ro` — Docker on-premise, Portainer CE, Traefik v3 proxy.
|
||||||
|
|
||||||
**ArchiTools** is a modular internal web dashboard for an architecture/engineering office group of 3 companies:
|
### Stack
|
||||||
|
|
||||||
- **Beletage** (architecture)
|
| Layer | Technology |
|
||||||
- **Urban Switch** (urbanism)
|
| ---------- | ------------------------------------------------------- |
|
||||||
- **Studii de Teren** (geotechnics)
|
| Framework | Next.js 16.x, App Router, TypeScript (strict) |
|
||||||
|
| Styling | Tailwind CSS v4, shadcn/ui |
|
||||||
It runs on two on-premise servers, containerized with Docker, managed via Portainer CE.
|
| Database | PostgreSQL + PostGIS via Prisma v6 ORM |
|
||||||
|
| Storage | `DatabaseStorageAdapter` (Prisma), localStorage fallback |
|
||||||
### Stack
|
| Files | MinIO (S3-compatible object storage) |
|
||||||
|
| Auth | NextAuth v4 + Authentik OIDC (auth.beletage.ro) |
|
||||||
| Layer | Technology |
|
| Deploy | Docker multi-stage → Portainer CE → Traefik v3 + SSL |
|
||||||
| ------------ | ---------------------------------------------------------------------------- |
|
| Repo | Gitea at `git.beletage.ro/gitadmin/ArchiTools` |
|
||||||
| Framework | Next.js 16.x, App Router, TypeScript (strict) |
|
| Language | Code: **English**, UI: **Romanian** |
|
||||||
| Styling | Tailwind CSS v4, shadcn/ui |
|
|
||||||
| Database | PostgreSQL (10.10.10.166:5432) via Prisma v6 ORM |
|
### Architecture Principles
|
||||||
| Storage | `DatabaseStorageAdapter` (PostgreSQL) — localStorage fallback available |
|
|
||||||
| File Storage | MinIO (10.10.10.166:9002 API / 9003 UI) — client configured, adapter pending |
|
- **Module platform** — each module isolated: own types/services/hooks/components
|
||||||
| Auth | NextAuth v4 + Authentik OIDC (auth.beletage.ro) |
|
- **Feature flags** gate loading (disabled = zero bundle cost)
|
||||||
| Proxy | Traefik v3 on `10.10.10.199` (proxy server), SSL via Let's Encrypt |
|
- **Storage abstraction** via `StorageService` interface + adapters
|
||||||
| Deploy | Docker multi-stage, Portainer CE on `10.10.10.166` (satra) |
|
- **Auth via Authentik SSO** — group → role/company mapping
|
||||||
| Repo | Gitea at `https://git.beletage.ro/gitadmin/ArchiTools` |
|
- **All entities** include `visibility` / `createdBy` from day one
|
||||||
| Language | Code in **English**, UI in **Romanian** |
|
|
||||||
|
---
|
||||||
### Architecture Principles
|
|
||||||
|
## Repository Structure
|
||||||
- **Module platform, not monolith** — each module isolated with own types/services/hooks/components
|
|
||||||
- **Feature flags** gate module loading (disabled = zero bundle cost)
|
```
|
||||||
- **Storage abstraction**: `StorageService` interface with adapters (database default via Prisma, localStorage fallback)
|
src/
|
||||||
- **Cross-module tagging system** as shared service
|
├── app/(modules)/ # Route pages (thin wrappers)
|
||||||
- **Auth via Authentik SSO** — NextAuth v4 + OIDC, group→role/company mapping
|
├── core/ # Platform: auth, storage, flags, tagging, i18n, theme
|
||||||
- **All entities** include `visibility` / `createdBy` fields from day one
|
├── modules/<name>/ # Module business logic (see MODULE-MAP.md)
|
||||||
- **Company logos** — theme-aware (light/dark variants), dual-rendered for SSR safety
|
│ ├── components/ # UI components
|
||||||
|
│ ├── hooks/ # Module hooks
|
||||||
---
|
│ ├── services/ # Business logic
|
||||||
|
│ ├── types.ts # Interfaces
|
||||||
## Repository Structure
|
│ ├── config.ts # Module metadata
|
||||||
|
│ └── index.ts # Public exports
|
||||||
```
|
├── shared/components/ # ui/ (shadcn), layout/ (sidebar/header), common/
|
||||||
src/
|
├── config/ # modules.ts, flags.ts, navigation.ts, companies.ts
|
||||||
├── app/ # Routing only (thin wrappers)
|
docs/ # Architecture, guides, module deep-dives
|
||||||
│ ├── (modules)/ # Module route pages
|
```
|
||||||
│ └── layout.tsx # App shell
|
|
||||||
├── core/ # Platform services
|
---
|
||||||
│ ├── module-registry/ # Module registration + types
|
|
||||||
│ ├── feature-flags/ # Flag evaluation + env override
|
## Modules (17 total)
|
||||||
│ ├── storage/ # StorageService + adapters
|
|
||||||
│ │ └── adapters/ # localStorage adapter (+ future DB/MinIO)
|
| Module | Route | Key Features |
|
||||||
│ ├── tagging/ # Cross-module tag service
|
| ------------------ | ------------------- | --------------------------------------------------- |
|
||||||
│ ├── i18n/ # Romanian translations
|
| Dashboard | `/` | KPI cards, activity feed, module grid |
|
||||||
│ ├── theme/ # Light/dark theme
|
| Email Signature | `/email-signature` | Multi-company, live preview, copy/download |
|
||||||
│ └── auth/ # Auth types + stub (future Authentik)
|
| Word XML | `/word-xml` | Category-based XML, simple/advanced, ZIP export |
|
||||||
├── modules/ # Module business logic
|
| Registratura | `/registratura` | Registry CRUD, legal deadlines, notifications, NAS |
|
||||||
│ ├── <module-name>/
|
| Tag Manager | `/tag-manager` | Tags CRUD, ManicTime sync |
|
||||||
│ │ ├── components/ # Module UI components
|
| IT Inventory | `/it-inventory` | Equipment, rack visualization, filters |
|
||||||
│ │ ├── hooks/ # Module-specific hooks
|
| Address Book | `/address-book` | Contacts, vCard, Registratura integration |
|
||||||
│ │ ├── services/ # Module business logic
|
| Password Vault | `/password-vault` | AES-256-GCM encrypted, WiFi QR, multi-user |
|
||||||
│ │ ├── types.ts # Module types
|
| Mini Utilities | `/mini-utilities` | 12+ tools: PDF compress, OCR, converters, calc |
|
||||||
│ │ ├── config.ts # Module metadata
|
| Prompt Generator | `/prompt-generator` | 18 templates, text + image targets |
|
||||||
│ │ └── index.ts # Public exports
|
| Digital Signatures | `/digital-signatures` | Assets CRUD, file upload, tags |
|
||||||
│ └── ...
|
| Word Templates | `/word-templates` | Template library, .docx placeholder detection |
|
||||||
├── shared/ # Shared UI
|
| AI Chat | `/ai-chat` | Multi-provider (OpenAI/Claude/Ollama) |
|
||||||
│ ├── components/
|
| Hot Desk | `/hot-desk` | 4 desks, week calendar, room layout |
|
||||||
│ │ ├── ui/ # shadcn/ui primitives
|
| ParcelSync | `/parcel-sync` | eTerra ANCPI, PostGIS, enrichment, ePay ordering |
|
||||||
│ │ ├── layout/ # Sidebar, Header
|
| Geoportal | `/geoportal` | MapLibre viewer, parcel search, UAT layers |
|
||||||
│ │ └── common/ # Reusable app components
|
| Visual CoPilot | `/visual-copilot` | Placeholder — separate repo |
|
||||||
│ ├── hooks/ # Shared hooks
|
|
||||||
│ └── lib/ # Utils (cn, etc.)
|
See `docs/MODULE-MAP.md` for entry points, API routes, and cross-module deps.
|
||||||
├── config/ # Global config
|
|
||||||
│ ├── modules.ts # Module registry entries
|
---
|
||||||
│ ├── flags.ts # Default feature flags
|
|
||||||
│ ├── navigation.ts # Sidebar nav structure
|
## Development Rules
|
||||||
│ └── companies.ts # Company definitions
|
|
||||||
docs/ # 16 internal technical docs
|
### TypeScript Strict Mode Gotchas
|
||||||
legacy/ # Original HTML tools for reference
|
|
||||||
```
|
- `arr[0]` is `T | undefined` even after length check — assign to const first
|
||||||
|
- `Record<string, T>[key]` returns `T | undefined` — always null-check
|
||||||
---
|
- Spread of possibly-undefined: `{ ...obj[key] }` — check existence first
|
||||||
|
- lucide-react Icons: cast through `unknown` → `React.ComponentType<{ className?: string }>`
|
||||||
## Implemented Modules (16 total — 14 original + 2 new)
|
- Prisma `$queryRaw` returns `unknown[]` — cast with `as Array<{ field: type }>`
|
||||||
|
- `?? ""` on `{}` field produces `{}` not `string` — use `typeof` check
|
||||||
| # | Module | Route | Version | Key Features |
|
|
||||||
| --- | ---------------------- | --------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
### Conventions
|
||||||
| 1 | **Dashboard** | `/` | 0.1.0 | KPI cards (6), activity feed (last 20), module grid, external tools |
|
|
||||||
| 2 | **Email Signature** | `/email-signature` | 0.1.0 | Multi-company branding, address toggle (BTG/US/SDT), live preview, zoom/copy/download |
|
- **Code**: English | **UI text**: Romanian | **IDs**: uuid v4
|
||||||
| 3 | **Word XML Generator** | `/word-xml` | 0.1.0 | Category-based XML gen, simple/advanced mode, ZIP export |
|
- **Dates**: ISO strings (`YYYY-MM-DD` display, full ISO timestamps)
|
||||||
| 4 | **Registratura** | `/registratura` | 0.5.0 | CRUD registry, dynamic doc types, bidirectional Address Book, threads, backdating, **legal deadline tracking** (6 categories, 18 types), recipient registration, document expiry, **NAS network path attachments** (A/O/P/T drives, copy-to-clipboard), **detail sheet side panel**, **configurable column visibility**, **QuickLook attachment preview** (images: zoom/pan, PDFs: native viewer, multi-file navigation), **email notifications** (Brevo SMTP daily digest, per-user preferences), **compact registry numbers** (single-letter company badge + direction arrow + plain number) |
|
- **Components**: functional, `'use client'` where needed
|
||||||
| 5 | **Tag Manager** | `/tag-manager` | 0.2.0 | CRUD tags, category/scope/color, US/SDT seeds, mandatory categories, **ManicTime bidirectional sync** |
|
- **No emojis** in code or UI
|
||||||
| 6 | **IT Inventory** | `/it-inventory` | 0.2.0 | Dynamic equipment types, rented status (purple pulse), **42U rack visualization**, type/status/company filters |
|
|
||||||
| 7 | **Address Book** | `/address-book` | 0.2.0 | CRUD contacts (person OR institution), card grid, vCard export, Registratura reverse lookup, **dynamic types (creatable)**, **name OR company required** (flexible validation), **ContactPerson with department field**, **quick contact from Registratura** (persons + institutions) |
|
### Storage Performance (CRITICAL)
|
||||||
| 8 | **Password Vault** | `/password-vault` | 0.4.0 | CRUD credentials, 9 categorii cu iconițe, **WiFi QR code real**, context-aware form, strength meter, company scope, **AES-256-GCM encryption**, **utilizatori multipli per intrare** (VaultUser[]: username/password/email/notes, colapsibil în form, badge în list) |
|
|
||||||
| 9 | **Mini Utilities** | `/mini-utilities` | 0.4.0 | Text case, char counter, percentage, **TVA calculator (cotă configurabilă: 5/9/19/21% + custom)**, area converter, U→R, num→text, artifact cleaner, MDLPA, **PDF compression** (qpdf local lossless + iLovePDF API cloud lossy, streaming upload for large files), PDF unlock, **DWG→DXF**, OCR, color palette, **paste on all drop zones**, **thermal drag-drop reorder**, **Calculator scară desen** (real cm↔desen mm, 7 preseturi 1:20..1:5000 + custom) |
|
- **NEVER** `storage.list()` + `storage.get()` in loop — N+1 bug
|
||||||
| 10 | **Prompt Generator** | `/prompt-generator` | 0.2.0 | Template-driven prompt builder, **18 templates** (14 text + 4 image), search bar, target type filter |
|
- **ALWAYS** use `storage.exportAll()` or `storage.export(namespace)` for batch-load
|
||||||
| 11 | **Digital Signatures** | `/digital-signatures` | 0.1.0 | CRUD assets, drag-and-drop file upload, tag chips |
|
- **NEVER** store base64 files in entity JSON — use `lightweight: true` for listing
|
||||||
| 12 | **Word Templates** | `/word-templates` | 0.1.0 | Template library, 8 categories, version tracking, .docx placeholder auto-detection |
|
- After mutations: optimistic update OR single `refresh()` — never both
|
||||||
| 13 | **AI Chat** | `/ai-chat` | 0.2.0 | Multi-provider (OpenAI/Claude/Ollama/demo), **project linking via Tag Manager**, provider status badge |
|
|
||||||
| 14 | **Hot Desk** | `/hot-desk` | 0.1.1 | 4 desks, week-ahead calendar, room layout (window+door proportioned), reserve/cancel |
|
### Middleware & Large Uploads
|
||||||
| 15 | **ParcelSync** | `/parcel-sync` | 0.6.0 | eTerra ANCPI integration, **PostGIS database**, background sync, 23-layer catalog, enrichment pipeline, owner search, **per-UAT analytics dashboard**, **health check + maintenance detection**, **ANCPI ePay CF extract ordering** (batch orders, MinIO PDF storage, dedup protection, credit tracking), **static WORKSPACE_TO_COUNTY mapping**, **GisUat geometry select optimization**, **feature count cache (5-min TTL)** |
|
|
||||||
| 16 | **Visual Copilot** | `/visual-copilot` | 0.1.0 | AI-powered image analysis — **developed in separate repo** (`https://git.beletage.ro/gitadmin/vim`), placeholder in ArchiTools, will be merged as module later |
|
- Middleware buffers entire body — exclude large-upload routes from matcher
|
||||||
|
- Excluded routes: `api/auth|api/notifications/digest|api/compress-pdf|api/address-book|api/projects`
|
||||||
### Registratura — Legal Deadline Tracking (Termene Legale)
|
- Excluded routes use `requireAuth()` from `auth-check.ts` instead
|
||||||
|
- To add new upload route: (1) exclude from middleware, (2) add `requireAuth()`
|
||||||
The Registratura module includes a full legal deadline tracking engine for Romanian construction permitting:
|
|
||||||
|
### eTerra / ANCPI Rules
|
||||||
- **18 deadline types** across 6 categories (Certificat, Avize, Completari, Urbanism, Autorizare, Litigii)
|
|
||||||
- **Working days vs calendar days** with Romanian public holiday support (including Orthodox Easter via Meeus algorithm)
|
- ArcGIS: paginate with `resultOffset`/`resultRecordCount` (max 1000)
|
||||||
- **Chain deadlines** (resolving one prompts adding the next — e.g., CU analiza → emitere, PUZ/PUD analiza → post-CTATU → emitere)
|
- Sessions expire ~10min — cache TTL 9min, auto-relogin on 401
|
||||||
- **Tacit approval** (auto-detected when overdue + applicable type)
|
- Health check detects maintenance — block login when down
|
||||||
- **Tabbed UI**: "Registru" tab (existing registry) + "Termene legale" tab (deadline dashboard)
|
- `WORKSPACE_TO_COUNTY` (42 entries in `county-refresh.ts`) is authoritative
|
||||||
- **Email notifications**: daily digest via Brevo SMTP, per-user opt-in/opt-out preferences, N8N cron trigger
|
- `GisUat.geometry` is huge — always `select` to exclude in list queries
|
||||||
|
- Feature counts cached 5-min TTL
|
||||||
Key files:
|
- ePay: form-urlencoded body, OpenAM auth, MinIO metadata must be ASCII
|
||||||
|
|
||||||
- `services/working-days.ts` — Romanian holidays, `addWorkingDays()`, `isWorkingDay()`
|
### Before Pushing
|
||||||
- `services/deadline-catalog.ts` — 18 `DeadlineTypeDef` entries across 6 categories
|
|
||||||
- `services/deadline-service.ts` — `createTrackedDeadline()`, `resolveDeadline()`, `aggregateDeadlines()`
|
1. `npx next build` — zero errors
|
||||||
- `components/attachment-preview.tsx` — QuickLook-style fullscreen preview (images: zoom/pan, PDFs: blob URL iframe, multi-file nav)
|
2. Test on `localhost:3000`
|
||||||
- `components/deadline-dashboard.tsx` — Stats + filters + table
|
3. Commit with descriptive message
|
||||||
- `components/deadline-add-dialog.tsx` — 3-step wizard (category → type → date preview)
|
4. `git push origin main` → manual Portainer redeploy
|
||||||
- `components/notification-preferences.tsx` — Bell button + dialog with per-type toggles
|
|
||||||
- `components/registry-table.tsx` — `CompactNumber` component: single-letter company badge (B/U/S/G), direction arrow (↓ intrat / ↑ iesit), plain number
|
---
|
||||||
|
|
||||||
### Address Book — Flexible Contact Model
|
## Common Pitfalls (Top 10)
|
||||||
|
|
||||||
The Address Book supports both persons and institutions:
|
1. **Middleware body buffering** — upload routes >10MB must be excluded from matcher
|
||||||
|
2. **N+1 storage queries** — use `exportAll()`, never `list()` + `get()` loop
|
||||||
- **Flexible validation**: either `name` OR `company` required (not both mandatory)
|
3. **GisUat geometry in queries** — exclude with `select`, or 50ms → 5+ seconds
|
||||||
- **Auto-type detection**: when only company is set via quick-create, type defaults to "institution"
|
4. **Enrichment data loss on re-sync** — upsert must preserve enrichment field
|
||||||
- **ContactPerson sub-entities**: each has `name`, `department`, `role`, `email`, `phone`
|
5. **Ghostscript corrupts fonts** — use qpdf for PDF compression, never GS
|
||||||
- **Quick contact creation from Registratura**: inline dialog with name + company + phone + email
|
6. **eTerra timeout too low** — geometry pages need 60-90s; default 120s
|
||||||
- **Display logic**: if no name, company shows as primary; if both, shows "Name (Company)"
|
7. **Traefik 60s readTimeout** — must set 600s in static config for uploads
|
||||||
- **Creatable types**: dropdown with defaults (client/supplier/institution/collaborator/internal) + user-created custom types
|
8. **Portainer CE can't inject env vars** — all env in docker-compose.yml
|
||||||
|
9. **`@prisma/client` in dependencies** (not devDeps) — runtime requirement
|
||||||
Key files:
|
10. **`output: 'standalone'`** in next.config.ts — required for Docker
|
||||||
|
|
||||||
- `modules/address-book/types.ts` — `AddressContact`, `ContactPerson` interfaces
|
---
|
||||||
- `modules/address-book/components/address-book-module.tsx` — Full UI (cards, detail dialog, form)
|
|
||||||
- `modules/address-book/hooks/use-contacts.ts` — Storage hook with search/filter
|
## Infrastructure Quick Reference
|
||||||
- `modules/address-book/services/vcard-export.ts` — vCard 3.0 export
|
|
||||||
- `modules/registratura/components/quick-contact-dialog.tsx` — Quick create from registry
|
| Service | Address | Purpose |
|
||||||
|
| ----------- | ------------------------ | -------------------------- |
|
||||||
### PDF Compression — Dual Mode (Local + Cloud)
|
| App | 10.10.10.166:3000 | ArchiTools (tools.beletage.ro) |
|
||||||
|
| PostgreSQL | 10.10.10.166:5432 | Database (Prisma) |
|
||||||
Two compression routes, both with streaming upload support for large files (tested up to 287MB):
|
| MinIO | 10.10.10.166:9002/9003 | Object storage |
|
||||||
|
| Authentik | 10.10.10.166:9100 | SSO (auth.beletage.ro) |
|
||||||
- **Local (qpdf)**: lossless structural optimization — stream compression, object dedup, linearization. Safe, no font corruption. Typical reduction: 3-15%.
|
| Portainer | 10.10.10.166:9000 | Docker management |
|
||||||
- **Cloud (iLovePDF API)**: lossy image re-compression via iLovePDF REST API. Levels: extreme/recommended/low. Typical reduction: 50-91%. Requires `ILOVEPDF_PUBLIC_KEY` env var.
|
| Gitea | 10.10.10.166:3002 | Git (git.beletage.ro) |
|
||||||
|
| Traefik | 10.10.10.199 | Reverse proxy + SSL |
|
||||||
**Architecture** (zero-memory for any file size):
|
| N8N | 10.10.10.166:5678 | Workflow automation |
|
||||||
1. `parseMultipartUpload()` streams request body to disk (constant 64KB memory)
|
| Stirling PDF | 10.10.10.166:8087 | PDF tools (needs env vars!) |
|
||||||
2. Scans raw file for multipart boundaries using `findInFile()` with 64KB sliding window
|
|
||||||
3. Stream-copies PDF bytes to separate file
|
## Company IDs
|
||||||
4. Route handler processes (qpdf exec or iLovePDF API) and streams response back
|
|
||||||
|
| ID | Name | Prefix |
|
||||||
**Critical gotchas**:
|
| ----------------- | --------------- | ------ |
|
||||||
- Middleware body buffering: `api/compress-pdf` routes are **excluded from middleware matcher** (middleware buffers entire body at 10MB default)
|
| `beletage` | Beletage | B |
|
||||||
- Auth: route-level `requireAuth()` instead of middleware (in `auth-check.ts`)
|
| `urban-switch` | Urban Switch | US |
|
||||||
- Unicode filenames: `Content-Disposition` header uses `encodeURIComponent()` to avoid ByteString errors with Romanian chars (Ș, Ț, etc.)
|
| `studii-de-teren` | Studii de Teren | SDT |
|
||||||
- Ghostscript `-sDEVICE=pdfwrite` destroys font encodings — **never use GS for compression**, only qpdf
|
| `group` | Grup | G |
|
||||||
|
|
||||||
Key files:
|
---
|
||||||
|
|
||||||
- `app/api/compress-pdf/parse-upload.ts` — Streaming multipart parser (zero memory)
|
## Documentation
|
||||||
- `app/api/compress-pdf/extreme/route.ts` — qpdf local compression
|
|
||||||
- `app/api/compress-pdf/cloud/route.ts` — iLovePDF API integration
|
| Doc | Path |
|
||||||
- `app/api/compress-pdf/auth-check.ts` — Shared auth for routes excluded from middleware
|
| ------------------- | ------------------------------------------ |
|
||||||
|
| Module Map | `docs/MODULE-MAP.md` |
|
||||||
### Email Notifications (Brevo SMTP)
|
| Architecture Quick | `docs/ARCHITECTURE-QUICK.md` |
|
||||||
|
| System Architecture | `docs/architecture/SYSTEM-ARCHITECTURE.md` |
|
||||||
Platform-level notification service for daily email digests:
|
| Module System | `docs/architecture/MODULE-SYSTEM.md` |
|
||||||
|
| Feature Flags | `docs/architecture/FEATURE-FLAGS.md` |
|
||||||
- **Brevo SMTP relay** via nodemailer (port 587, STARTTLS)
|
| Storage Layer | `docs/architecture/STORAGE-LAYER.md` |
|
||||||
- **N8N cron**: weekdays 8:00 → POST `/api/notifications/digest` with Bearer token
|
| Security & Roles | `docs/architecture/SECURITY-AND-ROLES.md` |
|
||||||
- **Per-user preferences**: stored in KeyValueStore (`notifications` namespace), toggle global opt-out + 3 notification types
|
| Module Dev Guide | `docs/guides/MODULE-DEVELOPMENT.md` |
|
||||||
- **Digest content**: urgent deadlines (<=5 days), overdue deadlines, expiring documents (CU/AC)
|
| Docker Deployment | `docs/guides/DOCKER-DEPLOYMENT.md` |
|
||||||
- **HTML email**: inline-styled table layout, color-coded rows (red/yellow/blue), per-company grouping
|
| Coding Standards | `docs/guides/CODING-STANDARDS.md` |
|
||||||
- **Sender**: "Alerte Termene" <noreply@beletage.ro>, test mode via `?test=true` query param
|
| Data Model | `docs/DATA-MODEL.md` |
|
||||||
|
|
||||||
Key files:
|
For module-specific deep dives, see `docs/modules/`.
|
||||||
|
|
||||||
- `src/core/notifications/types.ts` — `NotificationType`, `NotificationPreference`, `DigestSection`, `DigestItem`
|
|
||||||
- `src/core/notifications/email-service.ts` — Nodemailer transport singleton (Brevo SMTP)
|
|
||||||
- `src/core/notifications/notification-service.ts` — `runDigest()`, `buildCompanyDigest()`, `renderDigestHtml()`, preference CRUD
|
|
||||||
- `src/app/api/notifications/digest/route.ts` — POST endpoint (N8N cron, Bearer auth)
|
|
||||||
- `src/app/api/notifications/preferences/route.ts` — GET/PUT (user session auth)
|
|
||||||
|
|
||||||
### ParcelSync — eTerra ANCPI GIS Integration
|
|
||||||
|
|
||||||
The ParcelSync module connects to Romania's national eTerra/ANCPI cadastral system:
|
|
||||||
|
|
||||||
- **eTerra API client** (`eterra-client.ts`): form-post auth, JSESSIONID cookie jar, session caching (9min TTL), auto-relogin, paginated fetching with `maxRecordCount=1000` + fallback page sizes (500, 200)
|
|
||||||
- **23-layer catalog** (`eterra-layers.ts`): TERENURI_ACTIVE, CLADIRI_ACTIVE, LIMITE_UAT, etc. organized in 6 categories
|
|
||||||
- **PostGIS storage**: `GisFeature` model with geometry column, SIRUTA-based partitioning, `enrichment` JSONB field
|
|
||||||
- **Background sync**: long-running jobs via server singleton, progress polling (2s), phase tracking (fetch → save → enrich)
|
|
||||||
- **Enrichment pipeline** (`enrich-service.ts`): hits eTerra `/api/immovable/list` per parcel to extract NR_CAD, NR_CF, PROPRIETARI, SUPRAFATA, INTRAVILAN, CATEGORIE_FOLOSINTA, HAS_BUILDING, etc.
|
|
||||||
- **Owner search**: DB-first (ILIKE on enrichment JSON) with eTerra API fallback
|
|
||||||
- **Per-UAT dashboard**: SQL aggregates (area stats, intravilan/extravilan, land use, top owners), CSS-only visualizations (donut ring, bar charts)
|
|
||||||
- **Health check** (`eterra-health.ts`): pings `eterra.ancpi.ro` every 3min, detects maintenance by keywords in HTML response, blocks login when down, UI shows amber "Mentenanță" state
|
|
||||||
- **ANCPI ePay CF extract ordering**: batch orders via `epay-client.ts`, PDF storage to MinIO, dedup protection (queue + API level), credit tracking
|
|
||||||
- **Static county mapping**: `WORKSPACE_TO_COUNTY` in `county-refresh.ts` — 42 verified entries, preferred over unreliable nomenclature API
|
|
||||||
- **Performance**: GisUat queries use `select` to exclude geometry column; feature counts cached 5-min TTL
|
|
||||||
- **Test UAT**: Feleacu (SIRUTA 57582, ~30k immovables, ~8k GIS features)
|
|
||||||
|
|
||||||
Key files:
|
|
||||||
|
|
||||||
- `services/eterra-client.ts` — API client (~1000 lines), session cache, pagination, retry
|
|
||||||
- `services/eterra-layers.ts` — 23-layer catalog with categories
|
|
||||||
- `services/sync-service.ts` — Layer sync engine with progress tracking
|
|
||||||
- `services/enrich-service.ts` — Enrichment pipeline (FeatureEnrichment type)
|
|
||||||
- `services/eterra-health.ts` — Health check singleton, maintenance detection
|
|
||||||
- `services/session-store.ts` — Server-side session management
|
|
||||||
- `services/epay-client.ts` — ePay HTTP client (login, cart, metadata, submit, poll, download)
|
|
||||||
- `services/epay-queue.ts` — Batch queue with dedup protection
|
|
||||||
- `services/epay-storage.ts` — MinIO storage helpers for CF extract PDFs
|
|
||||||
- `services/epay-counties.ts` — County index mapping (eTerra county name → ePay alphabetical index 0-41)
|
|
||||||
- `app/api/eterra/session/county-refresh.ts` — Static `WORKSPACE_TO_COUNTY` mapping, LIMITE_UAT geometry refresh
|
|
||||||
- `components/parcel-sync-module.tsx` — Main UI (~4100 lines), 5 tabs (Export/Layers/Search/DB/Extrase CF)
|
|
||||||
- `components/uat-dashboard.tsx` — Per-UAT analytics dashboard (CSS-only charts)
|
|
||||||
- `components/epay-tab.tsx` — CF extract ordering tab
|
|
||||||
- `components/epay-connect.tsx` — ePay connection widget
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Infrastructure
|
|
||||||
|
|
||||||
### Server: `satra` — 10.10.10.166 (Ubuntu, app server)
|
|
||||||
|
|
||||||
| Service | Port | Purpose |
|
|
||||||
| ----------------------- | ---------------------- | ----------------------------------- |
|
|
||||||
| **ArchiTools** | 3000 | This app (tools.beletage.ro) |
|
|
||||||
| **Gitea** | 3002 | Git hosting (git.beletage.ro) |
|
|
||||||
| **PostgreSQL** | 5432 | App database (Prisma ORM) |
|
|
||||||
| **Portainer CE** | 9000 | Docker management + deploy |
|
|
||||||
| **Uptime Kuma** | 3001 | Service monitoring |
|
|
||||||
| **MinIO** | 9002 (API) / 9003 (UI) | Object storage |
|
|
||||||
| **Authentik** | 9100 | SSO (auth.beletage.ro) — **active** |
|
|
||||||
| **N8N** | 5678 | Workflow automation (daily digest) |
|
|
||||||
| **Stirling PDF** | 8087 | PDF tools |
|
|
||||||
| **IT-Tools** | 8085 | Developer utilities |
|
|
||||||
| **FileBrowser** | 8086 | File management |
|
|
||||||
| **Netdata** | 19999 | System monitoring |
|
|
||||||
| **Dozzle** | 9999 | Docker log viewer |
|
|
||||||
| **CrowdSec** | 8088 | Security |
|
|
||||||
|
|
||||||
### Server: `proxy` — 10.10.10.199 (Traefik reverse proxy)
|
|
||||||
|
|
||||||
| Config | Path / Value |
|
|
||||||
| ----------------------- | ---------------------------------------- |
|
|
||||||
| **Static config** | `/opt/traefik/traefik.yml` |
|
|
||||||
| **Dynamic configs** | `/opt/traefik/dynamic/` (file provider, `watch: true`) |
|
|
||||||
| **ArchiTools route** | `/opt/traefik/dynamic/tools.yml` |
|
|
||||||
| **SSL** | Let's Encrypt ACME, HTTP challenge |
|
|
||||||
| **Timeouts** | `readTimeout: 600s`, `writeTimeout: 600s`, `idleTimeout: 600s` on `websecure` entrypoint |
|
|
||||||
| **Response forwarding** | `flushInterval: 100ms` (streaming support) |
|
|
||||||
|
|
||||||
**IMPORTANT**: Default Traefik v2.11+ has 60s `readTimeout` — breaks large file uploads. Must set explicitly in static config.
|
|
||||||
|
|
||||||
### Deployment Pipeline
|
|
||||||
|
|
||||||
```
|
|
||||||
git push origin main
|
|
||||||
→ Gitea webhook fires
|
|
||||||
→ Portainer CE: Stacks → architools → "Pull and redeploy"
|
|
||||||
→ Toggle "Re-pull image and redeploy" ON → click "Update"
|
|
||||||
→ Portainer re-clones git repo + Docker multi-stage build (~2 min)
|
|
||||||
→ Container starts on :3000
|
|
||||||
→ Traefik routes tools.beletage.ro → http://10.10.10.166:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
**Portainer CE deploy**: NOT automatic. Must manually click "Pull and redeploy" in Portainer UI after each push. The stack is configured from git repo `http://10.10.10.166:3002/gitadmin/ArchiTools`.
|
|
||||||
|
|
||||||
### Docker
|
|
||||||
|
|
||||||
- `Dockerfile`: 3-stage build (deps → builder → runner), `node:22-alpine`, non-root user
|
|
||||||
- Runner stage installs: `gdal gdal-tools ghostscript qpdf` (for PDF compression, GIS)
|
|
||||||
- `Dockerfile` includes `npx prisma generate` before build step
|
|
||||||
- `docker-compose.yml`: single service, port 3000, **all env vars hardcoded** (Portainer CE can't inject env vars)
|
|
||||||
- `output: 'standalone'` in `next.config.ts` is **required**
|
|
||||||
- `@prisma/client` must be in `dependencies` (not devDependencies) for runtime
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development Rules
|
|
||||||
|
|
||||||
### TypeScript Strict Mode Gotchas
|
|
||||||
|
|
||||||
- `array.split()[0]` returns `string | undefined` — use `.slice(0, 10)` instead
|
|
||||||
- `Record<string, T>[key]` returns `T | undefined` — always guard with null check
|
|
||||||
- Spread of possibly-undefined objects: `{ ...obj[key], field }` — check existence first
|
|
||||||
- lucide-react Icons: cast through `unknown` → `React.ComponentType<{ className?: string }>`
|
|
||||||
- `arr[0]` is `T | undefined` even after `arr.length > 0` check — assign to const first: `const first = arr[0]; if (first) { ... }`
|
|
||||||
- Prisma `$queryRaw` returns `unknown[]` — always cast with `as Array<{ field: type }>` and guard access
|
|
||||||
- `?? ""` on an object field typed `{}` produces `{}` not `string` — use explicit `typeof x === 'string'` or `'number'` check
|
|
||||||
|
|
||||||
### Conventions
|
|
||||||
|
|
||||||
- **Code**: English
|
|
||||||
- **UI text**: Romanian
|
|
||||||
- **Components**: functional, `'use client'` directive where needed
|
|
||||||
- **State**: localStorage via `useStorage('module-name')` hook
|
|
||||||
- **IDs**: `uuid v4`
|
|
||||||
- **Dates**: ISO strings (`YYYY-MM-DD` for display, full ISO for timestamps)
|
|
||||||
- **No emojis** in code or UI unless explicitly requested
|
|
||||||
|
|
||||||
### Storage Performance Rules
|
|
||||||
|
|
||||||
- **NEVER** use `storage.list()` followed by `storage.get()` in a loop — this is an N+1 query bug
|
|
||||||
- `list()` fetches ALL items (keys+values) from DB but discards values, then each `get()` re-fetches individually
|
|
||||||
- **ALWAYS** use `storage.exportAll()` (namespaced) or `storage.export(namespace)` (service-level) to batch-load
|
|
||||||
- Filter items client-side after a single fetch: `for (const [key, value] of Object.entries(all)) { ... }`
|
|
||||||
- After mutations (add/update), either do optimistic local state update or a single `refresh()` — never both
|
|
||||||
- **NEVER store large binary data (base64 files) inside entity JSON** — this makes list loading transfer tens of MB
|
|
||||||
- For modules with attachments: use `exportAll({ lightweight: true })` for listing, `storage.get()` for single-entry full load
|
|
||||||
- The API `?lightweight=true` parameter strips `data`/`fileData` strings >1KB from JSON values server-side
|
|
||||||
- Future: move file data to MinIO; only store metadata (name, size, type, url) in the entity JSON
|
|
||||||
|
|
||||||
### Module Development Pattern
|
|
||||||
|
|
||||||
Every module follows:
|
|
||||||
|
|
||||||
```
|
|
||||||
src/modules/<name>/
|
|
||||||
├── components/ # React components
|
|
||||||
├── hooks/ # Custom hooks (use-<name>.ts)
|
|
||||||
├── services/ # Business logic (pure functions)
|
|
||||||
├── types.ts # TypeScript interfaces
|
|
||||||
├── config.ts # ModuleConfig metadata
|
|
||||||
└── index.ts # Public exports
|
|
||||||
```
|
|
||||||
|
|
||||||
### Middleware & Large Upload Routes
|
|
||||||
|
|
||||||
- Next.js middleware buffers the **entire request body** even if it only reads cookies/headers
|
|
||||||
- Default middleware body limit is 10MB — any upload route handling large files MUST be excluded
|
|
||||||
- Excluded routes pattern in `src/middleware.ts` matcher: `api/auth|api/notifications/digest|api/compress-pdf`
|
|
||||||
- Excluded routes handle auth via `requireAuth()` helper (`src/app/api/compress-pdf/auth-check.ts`)
|
|
||||||
- To add a new large-upload route: (1) add to middleware matcher exclusion, (2) add `requireAuth()` call in route handler
|
|
||||||
- `next.config.ts` has `experimental: { middlewareClientMaxBodySize: '500mb' }` but this is unreliable with `output: 'standalone'`
|
|
||||||
|
|
||||||
### eTerra / External API Rules
|
|
||||||
|
|
||||||
- **ArcGIS REST API** has `maxRecordCount=1000` — always paginate with `resultOffset`/`resultRecordCount`
|
|
||||||
- **eTerra sessions expire after ~10min** — session cache TTL is 9min, auto-relogin on 401/redirect
|
|
||||||
- **eTerra goes into maintenance regularly** — health check must detect and block login attempts
|
|
||||||
- **Never hardcode timeouts too low** — eTerra 1000-feature geometry pages can take 60-90s; default is 120s
|
|
||||||
- **CookieJar + axios-cookiejar-support** required for eTerra auth (JSESSIONID tracking)
|
|
||||||
- **Page size fallbacks**: if 1000 fails, retry with 500, then 200
|
|
||||||
- **WORKSPACE_TO_COUNTY is the authoritative county mapping** — static 42-entry map in `county-refresh.ts`, preferred over `fetchCounties()` which 404s intermittently
|
|
||||||
- **GisUat.geometry is huge** — always use Prisma `select` to exclude it in list queries; forgetting this turns 50ms into 5+ seconds
|
|
||||||
- **Feature counts are expensive** — cached in global with 5-min TTL in UATs route; returns stale data while refreshing
|
|
||||||
|
|
||||||
### ANCPI ePay Rules
|
|
||||||
|
|
||||||
- **ePay county IDs = eTerra WORKSPACE_IDs** (CLUJ=127, ALBA=10) — zero discovery calls needed
|
|
||||||
- **ePay UAT IDs = SIRUTA codes** — use `GisUat.workspacePk` + `siruta` directly
|
|
||||||
- **EpayJsonInterceptor uses form-urlencoded** (NOT JSON body) — `reqType=nomenclatorUAT&countyId=127`
|
|
||||||
- **saveProductMetadataForBasketItem uses multipart/form-data** (form-data npm package)
|
|
||||||
- **Document IDs are HTML-encoded** in ShowOrderDetails — `"idDocument":47301767` must be decoded before JSON parse
|
|
||||||
- **ePay auth is OpenAM** — gets `AMAuthCookie`, then navigate to `http://` (not https) for JSESSIONID
|
|
||||||
- **MinIO metadata must be ASCII** — strip diacritics from values before storing
|
|
||||||
- Env vars: `ANCPI_USERNAME`, `ANCPI_PASSWORD`, `ANCPI_BASE_URL`, `ANCPI_LOGIN_URL`, `ANCPI_DEFAULT_SOLICITANT_ID`, `MINIO_BUCKET_ANCPI`
|
|
||||||
|
|
||||||
### Before Pushing
|
|
||||||
|
|
||||||
1. `npx next build` — must pass with zero errors
|
|
||||||
2. Test the feature manually on `localhost:3000`
|
|
||||||
3. Commit with descriptive message
|
|
||||||
4. `git push origin main` — Portainer auto-deploys
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Company IDs
|
|
||||||
|
|
||||||
| ID | Name | Prefix |
|
|
||||||
| ----------------- | --------------- | ------ |
|
|
||||||
| `beletage` | Beletage | B |
|
|
||||||
| `urban-switch` | Urban Switch | US |
|
|
||||||
| `studii-de-teren` | Studii de Teren | SDT |
|
|
||||||
| `group` | Grup | G |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Current Integrations
|
|
||||||
|
|
||||||
| Feature | Status | Notes |
|
|
||||||
| -------------------- | ---------------------- | ------------------------------------------------------------------------------------------ |
|
|
||||||
| **Authentik SSO** | ✅ Active | NextAuth v4 + OIDC, group→role/company mapping |
|
|
||||||
| **PostgreSQL** | ✅ Active | Prisma ORM, `KeyValueStore` model, `/api/storage` route |
|
|
||||||
| **MinIO** | Client configured | 10.10.10.166:9002, bucket `tools`, adapter pending |
|
|
||||||
| **AI Chat API** | ✅ Multi-provider | `/api/ai-chat` — OpenAI/Claude/Ollama/demo; needs API key env |
|
|
||||||
| **Vault Encryption** | ✅ Active | AES-256-GCM server-side, `/api/vault`, ENCRYPTION_SECRET env |
|
|
||||||
| **ManicTime Sync** | ✅ Implemented | `/api/manictime` — bidirectional Tags.txt sync, needs SMB mount |
|
|
||||||
| **NAS Paths** | ✅ Active | `\\newamun` (10.10.10.10), drives A/O/P/T, hostname+IP fallback, `src/config/nas-paths.ts` |
|
|
||||||
| **eTerra ANCPI** | ✅ Active | ParcelSync module, `eterra-client.ts`, health check + maintenance detection |
|
|
||||||
| **ANCPI ePay** | ✅ Active | CF extract ordering, `epay-client.ts`, MinIO PDF storage, batch queue + dedup, `/api/ancpi/*` routes |
|
|
||||||
| **PostGIS** | ✅ Active | `GisFeature` model, geometry storage, spatial queries, used by ParcelSync |
|
|
||||||
| **Email Notifications** | ✅ Implemented | Brevo SMTP daily digest, `/api/notifications/digest` + `/preferences`, N8N cron trigger |
|
|
||||||
| **N8N automations** | ✅ Active (digest cron) | Daily digest cron `0 8 * * 1-5`, Bearer token auth, future: backups, workflows |
|
|
||||||
| **iLovePDF API** | ✅ Active | Cloud PDF compression, `ILOVEPDF_PUBLIC_KEY` env, free tier 250 files/month |
|
|
||||||
| **qpdf** | ✅ Active | Local lossless PDF optimization, installed in Docker image (`apk add qpdf`) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Model Recommendations
|
|
||||||
|
|
||||||
| Task Type | Claude | OpenAI | Google | Notes |
|
|
||||||
| ----------------------------- | -------------- | ------------- | ---------------- | ----------------------------------------------- |
|
|
||||||
| **Bug fixes, config** | Haiku 4.5 | GPT-4o-mini | Gemini 2.5 Flash | Fast, cheap |
|
|
||||||
| **Features, tests, UI** | **Sonnet 4.6** | GPT-5.2 | Gemini 3 Flash | Best value — Opus-class quality at Sonnet price |
|
|
||||||
| **New modules, architecture** | Opus 4.6 | GPT-5.3-Codex | Gemini 3 Pro | Complex multi-file, business logic |
|
|
||||||
|
|
||||||
**Default: Sonnet 4.6** for most work. See `ROADMAP.md` for per-task recommendations.
|
|
||||||
|
|
||||||
### Session Handoff Tips
|
|
||||||
|
|
||||||
- Read this `CLAUDE.md` first — it has all context
|
|
||||||
- Read `ROADMAP.md` for the complete task list with dependencies
|
|
||||||
- Check `docs/` for deep dives on specific systems
|
|
||||||
- Check `src/modules/<name>/types.ts` before modifying any module
|
|
||||||
- Always run `npx next build` before committing
|
|
||||||
- Push to `main` → Portainer auto-deploys via Gitea webhook
|
|
||||||
- The 16 docs in `docs/` total ~10,600 lines — search them for architecture questions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Documentation Index
|
|
||||||
|
|
||||||
| Doc | Path | Content |
|
|
||||||
| ------------------- | ------------------------------------------ | -------------------------------------------- |
|
|
||||||
| System Architecture | `docs/architecture/SYSTEM-ARCHITECTURE.md` | Overall architecture, module platform design |
|
|
||||||
| Module System | `docs/architecture/MODULE-SYSTEM.md` | Module registry, lifecycle, config format |
|
|
||||||
| Feature Flags | `docs/architecture/FEATURE-FLAGS.md` | Flag system, env overrides |
|
|
||||||
| Storage Layer | `docs/architecture/STORAGE-LAYER.md` | StorageService interface, adapters |
|
|
||||||
| Tagging System | `docs/architecture/TAGGING-SYSTEM.md` | Cross-module tags |
|
|
||||||
| Security & Roles | `docs/architecture/SECURITY-AND-ROLES.md` | Visibility, auth, roles |
|
|
||||||
| Module Dev Guide | `docs/guides/MODULE-DEVELOPMENT.md` | How to create a new module |
|
|
||||||
| HTML Integration | `docs/guides/HTML-TOOL-INTEGRATION.md` | Legacy tool migration |
|
|
||||||
| UI Design System | `docs/guides/UI-DESIGN-SYSTEM.md` | Design tokens, component patterns |
|
|
||||||
| Docker Deployment | `docs/guides/DOCKER-DEPLOYMENT.md` | Full Docker/Portainer/Nginx guide |
|
|
||||||
| Coding Standards | `docs/guides/CODING-STANDARDS.md` | TS strict, naming, patterns |
|
|
||||||
| Testing Strategy | `docs/guides/TESTING-STRATEGY.md` | Testing approach |
|
|
||||||
| Configuration | `docs/guides/CONFIGURATION.md` | Env vars, flags, companies |
|
|
||||||
| Data Model | `docs/DATA-MODEL.md` | All entity schemas |
|
|
||||||
| Repo Structure | `docs/REPO-STRUCTURE.md` | Directory layout |
|
|
||||||
| Prompt Generator | `docs/modules/PROMPT-GENERATOR.md` | Prompt module deep dive |
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# ArchiTools — Architecture Quick Reference
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser → Traefik (tools.beletage.ro) → Next.js :3000
|
||||||
|
├── App Router (pages)
|
||||||
|
├── API Routes (/api/*)
|
||||||
|
│ ├── Prisma → PostgreSQL + PostGIS
|
||||||
|
│ ├── MinIO (file storage)
|
||||||
|
│ ├── eTerra ANCPI (external GIS API)
|
||||||
|
│ └── Brevo SMTP (email notifications)
|
||||||
|
└── Auth: NextAuth → Authentik OIDC
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
registratura ←→ address-book (bidirectional: contacts + reverse lookup)
|
||||||
|
parcel-sync → geoportal (map components reuse)
|
||||||
|
geoportal → PostGIS (spatial queries, vector tiles)
|
||||||
|
parcel-sync → eTerra API (external: ANCPI cadastral data)
|
||||||
|
parcel-sync → ePay API (external: ANCPI CF extract ordering)
|
||||||
|
parcel-sync → MinIO (CF extract PDF storage)
|
||||||
|
notifications → registratura (deadline digest data)
|
||||||
|
all modules → core/storage (KeyValueStore via Prisma)
|
||||||
|
all modules → core/auth (Authentik SSO session)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Critical API Routes (Write Operations)
|
||||||
|
|
||||||
|
| Route | Method | What it does | Auth |
|
||||||
|
| ---------------------------------- | ------ | ----------------------------------- | --------- |
|
||||||
|
| `/api/storage` | PUT/DELETE | KeyValueStore CRUD | Middleware |
|
||||||
|
| `/api/registratura` | POST/PUT/DELETE | Registry entries + audit | Middleware + Bearer |
|
||||||
|
| `/api/registratura/reserved` | POST | Reserve future registry slots | Middleware |
|
||||||
|
| `/api/registratura/debug-sequences`| POST/PATCH | Reset sequence counters | Admin only |
|
||||||
|
| `/api/vault` | PUT/DELETE | Encrypted vault entries | Middleware |
|
||||||
|
| `/api/address-book` | PUT/DELETE | Contact CRUD | Middleware + Bearer |
|
||||||
|
| `/api/eterra/sync-background` | POST | Start GIS sync job | Middleware |
|
||||||
|
| `/api/eterra/uats` | POST/PATCH | UAT management + county refresh | Middleware |
|
||||||
|
| `/api/ancpi/order` | POST | ePay CF extract order | Middleware |
|
||||||
|
| `/api/notifications/digest` | POST | Trigger email digest | Bearer |
|
||||||
|
| `/api/notifications/preferences` | PUT | User notification prefs | Middleware |
|
||||||
|
| `/api/compress-pdf/*` | POST | PDF compression/unlock | requireAuth |
|
||||||
|
|
||||||
|
## Storage Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
KeyValueStore (Prisma) GisFeature (PostGIS) MinIO
|
||||||
|
├── namespace: module-id ├── layerId + objectId ├── bucket: tools
|
||||||
|
├── key: entity UUID ├── geometry (GeoJSON) ├── bucket: ancpi-cf
|
||||||
|
└── value: JSON blob ├── enrichment (JSONB) └── PDF files
|
||||||
|
└── geom (native PostGIS)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User → /auth/signin → Authentik OIDC → callback → NextAuth session
|
||||||
|
├── Middleware: checks JWT token, redirects if unauthenticated
|
||||||
|
├── Portal-only users: env PORTAL_ONLY_USERS → redirected to /portal
|
||||||
|
└── API routes excluded from middleware: use requireAuth() or Bearer token
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables (Critical)
|
||||||
|
|
||||||
|
| Var | Required | Used by |
|
||||||
|
| ---------------------- | -------- | -------------------------- |
|
||||||
|
| `DATABASE_URL` | Yes | Prisma |
|
||||||
|
| `NEXTAUTH_SECRET` | Yes | NextAuth JWT |
|
||||||
|
| `NEXTAUTH_URL` | Yes | Auth redirects |
|
||||||
|
| `ENCRYPTION_SECRET` | Yes | Password Vault AES-256 |
|
||||||
|
| `STIRLING_PDF_URL` | Yes | PDF compression/unlock |
|
||||||
|
| `STIRLING_PDF_API_KEY` | Yes | Stirling PDF auth |
|
||||||
|
| `NOTIFICATION_CRON_SECRET` | Yes | Digest endpoint Bearer |
|
||||||
|
| `MINIO_*` | Yes | MinIO connection |
|
||||||
|
| `ANCPI_*` | For ePay | ePay CF ordering |
|
||||||
|
| `ILOVEPDF_PUBLIC_KEY` | Optional | Cloud PDF compression |
|
||||||
|
| `PORTAL_ONLY_USERS` | Optional | Comma-separated usernames |
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# 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), `components/basemap-switcher.tsx`, `components/selection-toolbar.tsx`, `components/feature-info-panel.tsx`
|
||||||
|
- **API routes**: `/api/geoportal/*` (search, boundary-check, uat-bounds, setup-views)
|
||||||
|
- **Cross-deps**: **parcel-sync** (declared dependency — uses PostGIS data)
|
||||||
|
|
||||||
|
### Visual CoPilot
|
||||||
|
- **Route**: `/visual-copilot`
|
||||||
|
- **Status**: Placeholder (iframe to separate repo `git.beletage.ro/gitadmin/vim`)
|
||||||
|
- **API routes**: none
|
||||||
|
- **Cross-deps**: none
|
||||||
@@ -1,456 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ro">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Configurator semnatura e-mail</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body { font-family: 'Inter', sans-serif; }
|
|
||||||
.no-select { -webkit-user-select: none; -ms-user-select: none; user-select: none; }
|
|
||||||
|
|
||||||
input[type=range] {
|
|
||||||
-webkit-appearance: none; appearance: none; width: 100%; height: 4px;
|
|
||||||
background: #e5e7eb; border-radius: 5px; outline: none; transition: background 0.2s ease;
|
|
||||||
}
|
|
||||||
input[type=range]:hover { background: #d1d5db; }
|
|
||||||
input[type=range]::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none; appearance: none; width: 12px; height: 20px;
|
|
||||||
background: #22B5AB; cursor: pointer; border-radius: 4px;
|
|
||||||
margin-top: -8px; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
|
||||||
transition: transform 0.1s ease-in-out, box-shadow 0.1s ease-in-out;
|
|
||||||
}
|
|
||||||
input[type=range]::-webkit-slider-thumb:active { transform: scale(1.15); box-shadow: 0 2px 6px rgba(0,0,0,0.3); }
|
|
||||||
input[type=range]::-moz-range-thumb {
|
|
||||||
width: 12px; height: 20px; background: #22B5AB; cursor: pointer;
|
|
||||||
border-radius: 4px; border: none; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
#preview-wrapper { transition: transform 0.2s ease-in-out; transform-origin: top left; }
|
|
||||||
.color-swatch {
|
|
||||||
width: 24px; height: 24px; border-radius: 9999px; cursor: pointer;
|
|
||||||
border: 2px solid transparent; transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
.color-swatch.active { border-color: #22B5AB; transform: scale(1.1); box-shadow: 0 0 0 2px white, 0 0 0 4px #22B5AB; }
|
|
||||||
.collapsible-content { max-height: 0; overflow: hidden; transition: max-height 0.3s ease-out; }
|
|
||||||
.collapsible-content.open { max-height: 1000px; /* Valoare mare pentru a permite extinderea */ }
|
|
||||||
.collapsible-trigger svg { transition: transform 0.3s ease; }
|
|
||||||
.collapsible-trigger.open svg { transform: rotate(90deg); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-50 text-gray-800 no-select">
|
|
||||||
|
|
||||||
<div class="container mx-auto p-4 md:p-8">
|
|
||||||
<header class="text-center mb-10">
|
|
||||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-900">Configurator semnatura e-mail</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="flex flex-col lg:flex-row gap-8">
|
|
||||||
|
|
||||||
<!-- Panoul de control -->
|
|
||||||
<aside class="lg:w-2/5 bg-white p-6 rounded-2xl shadow-lg border border-gray-200">
|
|
||||||
<div id="controls">
|
|
||||||
<!-- Secțiunea Date Personale -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-800 border-b pb-2 mb-4">Date Personale</h3>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label for="input-prefix" class="block text-sm font-medium text-gray-700 mb-1">Titulatură (prefix)</label>
|
|
||||||
<input type="text" id="input-prefix" value="arh." class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-teal-500 focus:border-teal-500">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="input-name" class="block text-sm font-medium text-gray-700 mb-1">Nume și Prenume</label>
|
|
||||||
<input type="text" id="input-name" value="Marius TĂRĂU" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-teal-500 focus:border-teal-500">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="input-title" class="block text-sm font-medium text-gray-700 mb-1">Funcția</label>
|
|
||||||
<input type="text" id="input-title" value="Arhitect • Beletage SRL" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-teal-500 focus:border-teal-500">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="input-phone" class="block text-sm font-medium text-gray-700 mb-1">Telefon (format 07xxxxxxxx)</label>
|
|
||||||
<input type="tel" id="input-phone" value="0785123433" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-teal-500 focus:border-teal-500">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Culori Text (Collapsible) -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="collapsible-trigger flex justify-between items-center cursor-pointer border-b pb-2 mb-2">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-800">Culori Text</h3>
|
|
||||||
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
|
||||||
</div>
|
|
||||||
<div class="collapsible-content">
|
|
||||||
<div id="color-controls" class="space-y-2 pt-2"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Secțiunea Stil & Aranjare (Collapsible) -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="collapsible-trigger flex justify-between items-center cursor-pointer border-b pb-2 mb-2">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-800">Stil & Aranjare</h3>
|
|
||||||
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
|
||||||
</div>
|
|
||||||
<div class="collapsible-content">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-3 pt-2">
|
|
||||||
<div>
|
|
||||||
<label for="green-line-width" class="block text-sm font-medium text-gray-700 mb-2">Lungime linie verde (<span id="green-line-value">97</span>px)</label>
|
|
||||||
<input id="green-line-width" type="range" min="50" max="300" value="97">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="section-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiere vert. secțiuni (<span id="section-spacing-value">10</span>px)</label>
|
|
||||||
<input id="section-spacing" type="range" min="0" max="30" value="10">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="logo-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiere vert. Logo (<span id="logo-spacing-value">10</span>px)</label>
|
|
||||||
<input id="logo-spacing" type="range" min="0" max="30" value="10">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="title-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiere vert. funcție (<span id="title-spacing-value">2</span>px)</label>
|
|
||||||
<input id="title-spacing" type="range" min="0" max="20" value="2">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="b-gutter-width" class="block text-sm font-medium text-gray-700 mb-2">Aliniere contact (<span id="b-gutter-value">13</span>px)</label>
|
|
||||||
<input id="b-gutter-width" type="range" min="0" max="150" value="13">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="icon-text-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiu Icon-Text (<span id="icon-text-spacing-value">5</span>px)</label>
|
|
||||||
<input id="icon-text-spacing" type="range" min="-10" max="30" value="5">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="icon-vertical-pos" class="block text-sm font-medium text-gray-700 mb-2">Aliniere vert. iconițe (<span id="icon-vertical-value">1</span>px)</label>
|
|
||||||
<input id="icon-vertical-pos" type="range" min="-10" max="10" value="1">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="motto-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiere vert. motto (<span id="motto-spacing-value">3</span>px)</label>
|
|
||||||
<input id="motto-spacing" type="range" min="0" max="20" value="3">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Opțiuni -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-800 border-b pb-2 mb-4">Opțiuni</h3>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<label class="flex items-center space-x-3 cursor-pointer">
|
|
||||||
<input type="checkbox" id="reply-variant-checkbox" class="h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500">
|
|
||||||
<span class="text-sm font-medium text-gray-700">Variantă simplă (fără logo/adresă)</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center space-x-3 cursor-pointer">
|
|
||||||
<input type="checkbox" id="super-reply-variant-checkbox" class="h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500">
|
|
||||||
<span class="text-sm font-medium text-gray-700">Super-simplă (doar nume/telefon)</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center space-x-3 cursor-pointer">
|
|
||||||
<input type="checkbox" id="use-svg-checkbox" class="h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500">
|
|
||||||
<span class="text-sm font-medium text-gray-700">Folosește imagini SVG (calitate maximă)</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Buton de Export -->
|
|
||||||
<div class="mt-8 pt-6 border-t">
|
|
||||||
<button id="export-btn" class="w-full bg-teal-600 text-white font-bold py-3 px-4 rounded-lg hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-teal-500 transition-all duration-300 ease-in-out transform hover:scale-105">
|
|
||||||
Descarcă HTML
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Previzualizare Live -->
|
|
||||||
<main class="lg:w-3/5 bg-white p-6 rounded-2xl shadow-lg border border-gray-200 overflow-hidden">
|
|
||||||
<div class="flex justify-between items-center border-b pb-3 mb-4">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900">Previzualizare Live</h2>
|
|
||||||
<button id="zoom-btn" class="text-sm bg-gray-200 text-gray-700 px-3 py-1 rounded-md hover:bg-gray-300">Zoom 100%</button>
|
|
||||||
</div>
|
|
||||||
<div id="preview-wrapper" class="overflow-auto">
|
|
||||||
<div id="preview-container">
|
|
||||||
<!-- Aici este inserat codul HTML al semnăturii -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const controls = {
|
|
||||||
prefix: document.getElementById('input-prefix'),
|
|
||||||
name: document.getElementById('input-name'),
|
|
||||||
title: document.getElementById('input-title'),
|
|
||||||
phone: document.getElementById('input-phone'),
|
|
||||||
greenLine: document.getElementById('green-line-width'),
|
|
||||||
gutter: document.getElementById('b-gutter-width'),
|
|
||||||
iconTextSpacing: document.getElementById('icon-text-spacing'),
|
|
||||||
iconVertical: document.getElementById('icon-vertical-pos'),
|
|
||||||
mottoSpacing: document.getElementById('motto-spacing'),
|
|
||||||
sectionSpacing: document.getElementById('section-spacing'),
|
|
||||||
titleSpacing: document.getElementById('title-spacing'),
|
|
||||||
logoSpacing: document.getElementById('logo-spacing'),
|
|
||||||
replyCheckbox: document.getElementById('reply-variant-checkbox'),
|
|
||||||
superReplyCheckbox: document.getElementById('super-reply-variant-checkbox'),
|
|
||||||
useSvgCheckbox: document.getElementById('use-svg-checkbox'),
|
|
||||||
exportBtn: document.getElementById('export-btn'),
|
|
||||||
zoomBtn: document.getElementById('zoom-btn'),
|
|
||||||
colorControls: document.getElementById('color-controls')
|
|
||||||
};
|
|
||||||
|
|
||||||
const values = {
|
|
||||||
greenLine: document.getElementById('green-line-value'),
|
|
||||||
gutter: document.getElementById('b-gutter-value'),
|
|
||||||
iconTextSpacing: document.getElementById('icon-text-spacing-value'),
|
|
||||||
iconVertical: document.getElementById('icon-vertical-value'),
|
|
||||||
mottoSpacing: document.getElementById('motto-spacing-value'),
|
|
||||||
sectionSpacing: document.getElementById('section-spacing-value'),
|
|
||||||
titleSpacing: document.getElementById('title-spacing-value'),
|
|
||||||
logoSpacing: document.getElementById('logo-spacing-value')
|
|
||||||
};
|
|
||||||
|
|
||||||
const previewContainer = document.getElementById('preview-container');
|
|
||||||
const previewWrapper = document.getElementById('preview-wrapper');
|
|
||||||
|
|
||||||
const imageSets = {
|
|
||||||
png: {
|
|
||||||
logo: 'https://beletage.ro/img/Semnatura-Logo.png',
|
|
||||||
greySlash: 'https://beletage.ro/img/Grey-slash.png',
|
|
||||||
greenSlash: 'https://beletage.ro/img/Green-slash.png'
|
|
||||||
},
|
|
||||||
svg: {
|
|
||||||
logo: 'https://beletage.ro/img/Logo-Beletage.svg',
|
|
||||||
greySlash: 'https://beletage.ro/img/Grey-slash.svg',
|
|
||||||
greenSlash: 'https://beletage.ro/img/Green-slash.svg'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const beletageColors = {
|
|
||||||
verde: '#22B5AB',
|
|
||||||
griInchis: '#54504F',
|
|
||||||
griDeschis: '#A7A9AA',
|
|
||||||
negru: '#323232'
|
|
||||||
};
|
|
||||||
|
|
||||||
const colorConfig = {
|
|
||||||
prefix: { label: 'Titulatură', default: beletageColors.griInchis },
|
|
||||||
name: { label: 'Nume', default: beletageColors.griInchis },
|
|
||||||
title: { label: 'Funcție', default: beletageColors.griDeschis },
|
|
||||||
address: { label: 'Adresă', default: beletageColors.griDeschis },
|
|
||||||
phone: { label: 'Telefon', default: beletageColors.griInchis },
|
|
||||||
website: { label: 'Website', default: beletageColors.griInchis },
|
|
||||||
motto: { label: 'Motto', default: beletageColors.verde }
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentColors = {};
|
|
||||||
|
|
||||||
function createColorPickers() {
|
|
||||||
for (const [key, config] of Object.entries(colorConfig)) {
|
|
||||||
currentColors[key] = config.default;
|
|
||||||
const controlRow = document.createElement('div');
|
|
||||||
controlRow.className = 'flex items-center justify-between';
|
|
||||||
const label = document.createElement('span');
|
|
||||||
label.className = 'text-sm font-medium text-gray-700';
|
|
||||||
label.textContent = config.label;
|
|
||||||
controlRow.appendChild(label);
|
|
||||||
const swatchesContainer = document.createElement('div');
|
|
||||||
swatchesContainer.className = 'flex items-center space-x-2';
|
|
||||||
swatchesContainer.dataset.controlKey = key;
|
|
||||||
for (const color of Object.values(beletageColors)) {
|
|
||||||
const swatch = document.createElement('div');
|
|
||||||
swatch.className = 'color-swatch';
|
|
||||||
swatch.style.backgroundColor = color;
|
|
||||||
swatch.dataset.color = color;
|
|
||||||
if (color === config.default) swatch.classList.add('active');
|
|
||||||
swatchesContainer.appendChild(swatch);
|
|
||||||
}
|
|
||||||
controlRow.appendChild(swatchesContainer);
|
|
||||||
controls.colorControls.appendChild(controlRow);
|
|
||||||
}
|
|
||||||
|
|
||||||
controls.colorControls.addEventListener('click', (e) => {
|
|
||||||
if (e.target.classList.contains('color-swatch')) {
|
|
||||||
const key = e.target.parentElement.dataset.controlKey;
|
|
||||||
currentColors[key] = e.target.dataset.color;
|
|
||||||
e.target.parentElement.querySelectorAll('.color-swatch').forEach(s => s.classList.remove('active'));
|
|
||||||
e.target.classList.add('active');
|
|
||||||
updatePreview();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateSignatureHTML(data) {
|
|
||||||
const {
|
|
||||||
prefix, name, title, phone, phoneLink, greenLineWidth, gutterWidth,
|
|
||||||
iconTextSpacing, iconVerticalOffset, mottoSpacing, sectionSpacing, titleSpacing, logoSpacing,
|
|
||||||
isReply, isSuperReply, colors, images
|
|
||||||
} = data;
|
|
||||||
|
|
||||||
const hideTitle = isReply || isSuperReply ? 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;' : '';
|
|
||||||
const hideLogoAddress = isReply || isSuperReply ? 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;' : '';
|
|
||||||
const hideBottom = isSuperReply ? 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;' : '';
|
|
||||||
const hidePhoneIcon = isSuperReply ? 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;' : '';
|
|
||||||
|
|
||||||
const spacerWidth = Math.max(0, iconTextSpacing);
|
|
||||||
const textPaddingLeft = Math.max(0, -iconTextSpacing);
|
|
||||||
|
|
||||||
const prefixHTML = prefix ? `<span style="font-size:13px; color:${colors.prefix};">${prefix} </span>` : '';
|
|
||||||
const logoWidth = controls.useSvgCheckbox.checked ? 162 : 162;
|
|
||||||
const logoHeight = controls.useSvgCheckbox.checked ? 24 : 24;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540" style="font-family: Arial, Helvetica, sans-serif; color:#333333; font-size:14px; line-height:18px;">
|
|
||||||
<tbody>
|
|
||||||
<tr><td style="padding:0 0 ${titleSpacing}px 0;">${prefixHTML}<span style="font-size:15px; color:${colors.name}; font-weight:700;">${name}</span></td></tr>
|
|
||||||
<tr style="${hideTitle}"><td style="padding:0 0 8px 0;"><span style="font-size:12px; color:${colors.title};">${title}</span></td></tr>
|
|
||||||
<tr style="${hideBottom}">
|
|
||||||
<td style="padding:0; font-size:0; line-height:0;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540">
|
|
||||||
<tr>
|
|
||||||
<td width="${greenLineWidth}" height="2" bgcolor="${beletageColors.verde}" style="font-size:0; line-height:0; height:2px;"></td>
|
|
||||||
<td width="${540 - greenLineWidth}" height="2" style="font-size:0; line-height:0; height:2px;"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="${hideLogoAddress}"><td style="padding:${logoSpacing}px 0 ${parseInt(logoSpacing, 10) + 2}px 0;">
|
|
||||||
<a href="https://www.beletage.ro" style="text-decoration:none; border:0;">
|
|
||||||
<img src="${images.logo}" alt="Beletage" style="display:block; border:0; height:${logoHeight}px; width:${logoWidth}px;" height="${logoHeight}" width="${logoWidth}">
|
|
||||||
</a>
|
|
||||||
</td></tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding-top:${hideLogoAddress ? '0' : sectionSpacing}px;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540" style="font-size:13px; line-height:18px;">
|
|
||||||
<tbody>
|
|
||||||
<tr style="${hideLogoAddress}">
|
|
||||||
<td width="${gutterWidth}" style="width:${gutterWidth}px; font-size:0; line-height:0;"></td>
|
|
||||||
<td width="11" style="width:11px; vertical-align:top; padding-top:${4 + iconVerticalOffset}px;">
|
|
||||||
<img src="${images.greySlash}" alt="" width="11" height="11" style="display: block; border:0;">
|
|
||||||
</td>
|
|
||||||
<td width="${spacerWidth}" style="width:${spacerWidth}px; font-size:0; line-height:0;"></td>
|
|
||||||
<td style="vertical-align:top; padding:0 0 0 ${textPaddingLeft}px;">
|
|
||||||
<a href="https://maps.google.com/?q=str.%20Unirii%203%2C%20ap.%2026%2C%20Cluj-Napoca%20400417%2C%20Rom%C3%A2nia" style="color:${colors.address}; text-decoration:none;"><span style="color:${colors.address}; text-decoration:none;">str. Unirii, nr. 3, ap. 26<br>Cluj-Napoca, Cluj 400417<br>România</span></a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td width="${gutterWidth}" style="width:${gutterWidth}px; font-size:0; line-height:0;"></td>
|
|
||||||
<td width="11" style="width:11px; vertical-align:top; padding-top:${12 + iconVerticalOffset}px; ${hidePhoneIcon}">
|
|
||||||
<img src="${images.greenSlash}" alt="" width="11" height="7" style="display: block; border:0;">
|
|
||||||
</td>
|
|
||||||
<td width="${isSuperReply ? 0 : spacerWidth}" style="width:${isSuperReply ? 0 : spacerWidth}px; font-size:0; line-height:0;"></td>
|
|
||||||
<td style="vertical-align:top; padding:8px 0 0 ${isSuperReply ? 0 : textPaddingLeft}px;">
|
|
||||||
<a href="${phoneLink}" style="color:${colors.phone}; text-decoration:none;"><span style="color:${colors.phone}; text-decoration:none;">${phone}</span></a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="${hideBottom}"><td style="padding:${sectionSpacing}px 0 ${mottoSpacing}px 0;"><a href="https://www.beletage.ro" style="color:${colors.website}; text-decoration:none;"><span style="color:${colors.website}; text-decoration:none;">www.beletage.ro</span></a></td></tr>
|
|
||||||
<tr style="${hideBottom}">
|
|
||||||
<td style="padding:0; font-size:0; line-height:0;">
|
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540">
|
|
||||||
<tr>
|
|
||||||
<td width="${greenLineWidth}" height="1" bgcolor="${beletageColors.verde}" style="font-size:0; line-height:0; height:1px;"></td>
|
|
||||||
<td width="${540 - greenLineWidth}" height="1" style="font-size:0; line-height:0; height:1px;"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="${hideBottom}"><td style="padding:${mottoSpacing}px 0 0 0;"><span style="font-size:12px; color:${colors.motto}; font-style:italic;">we make complex simple</span></td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePreview() {
|
|
||||||
const phoneRaw = controls.phone.value.replace(/\s/g, '');
|
|
||||||
let formattedPhone = controls.phone.value;
|
|
||||||
let phoneLink = `tel:${phoneRaw}`;
|
|
||||||
|
|
||||||
if (phoneRaw.length === 10 && phoneRaw.startsWith('07')) {
|
|
||||||
formattedPhone = `+40 ${phoneRaw.substring(1, 4)} ${phoneRaw.substring(4, 7)} ${phoneRaw.substring(7, 10)}`;
|
|
||||||
phoneLink = `tel:+40${phoneRaw.substring(1)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (controls.superReplyCheckbox.checked) {
|
|
||||||
controls.replyCheckbox.checked = true;
|
|
||||||
controls.replyCheckbox.disabled = true;
|
|
||||||
} else {
|
|
||||||
controls.replyCheckbox.disabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
prefix: controls.prefix.value,
|
|
||||||
name: controls.name.value,
|
|
||||||
title: controls.title.value,
|
|
||||||
phone: formattedPhone,
|
|
||||||
phoneLink: phoneLink,
|
|
||||||
greenLineWidth: controls.greenLine.value,
|
|
||||||
gutterWidth: controls.gutter.value,
|
|
||||||
iconTextSpacing: controls.iconTextSpacing.value,
|
|
||||||
iconVerticalOffset: parseInt(controls.iconVertical.value, 10),
|
|
||||||
mottoSpacing: controls.mottoSpacing.value,
|
|
||||||
sectionSpacing: controls.sectionSpacing.value,
|
|
||||||
titleSpacing: controls.titleSpacing.value,
|
|
||||||
logoSpacing: controls.logoSpacing.value,
|
|
||||||
isReply: controls.replyCheckbox.checked,
|
|
||||||
isSuperReply: controls.superReplyCheckbox.checked,
|
|
||||||
colors: { ...currentColors },
|
|
||||||
images: controls.useSvgCheckbox.checked ? imageSets.svg : imageSets.png
|
|
||||||
};
|
|
||||||
|
|
||||||
values.greenLine.textContent = data.greenLineWidth;
|
|
||||||
values.gutter.textContent = data.gutterWidth;
|
|
||||||
values.iconTextSpacing.textContent = data.iconTextSpacing;
|
|
||||||
values.iconVertical.textContent = data.iconVerticalOffset;
|
|
||||||
values.mottoSpacing.textContent = data.mottoSpacing;
|
|
||||||
values.sectionSpacing.textContent = data.sectionSpacing;
|
|
||||||
values.titleSpacing.textContent = data.titleSpacing;
|
|
||||||
values.logoSpacing.textContent = data.logoSpacing;
|
|
||||||
|
|
||||||
previewContainer.innerHTML = generateSignatureHTML(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Inițializare ---
|
|
||||||
createColorPickers();
|
|
||||||
|
|
||||||
Object.values(controls).forEach(control => {
|
|
||||||
if (control.id !== 'export-btn' && control.id !== 'zoom-btn') {
|
|
||||||
control.addEventListener('input', updatePreview);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('.collapsible-trigger').forEach(trigger => {
|
|
||||||
trigger.addEventListener('click', () => {
|
|
||||||
const content = trigger.nextElementSibling;
|
|
||||||
trigger.classList.toggle('open');
|
|
||||||
content.classList.toggle('open');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
controls.zoomBtn.addEventListener('click', () => {
|
|
||||||
const isZoomed = previewWrapper.style.transform === 'scale(2)';
|
|
||||||
if (isZoomed) {
|
|
||||||
previewWrapper.style.transform = 'scale(1)';
|
|
||||||
controls.zoomBtn.textContent = 'Zoom 200%';
|
|
||||||
} else {
|
|
||||||
previewWrapper.style.transform = 'scale(2)';
|
|
||||||
controls.zoomBtn.textContent = 'Zoom 100%';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
controls.exportBtn.addEventListener('click', () => {
|
|
||||||
const finalHTML = previewContainer.innerHTML;
|
|
||||||
const blob = new Blob([finalHTML], { type: 'text/html' });
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = URL.createObjectURL(blob);
|
|
||||||
a.download = 'semnatura-beletage.html';
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
});
|
|
||||||
|
|
||||||
updatePreview();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
Pauza de masa
|
|
||||||
Timp personal
|
|
||||||
|
|
||||||
Concediu
|
|
||||||
Compensare overtime
|
|
||||||
|
|
||||||
Beletage
|
|
||||||
Ofertare
|
|
||||||
Configurari
|
|
||||||
Organizare initiala
|
|
||||||
Pregatire Portofoliu
|
|
||||||
Website
|
|
||||||
Documentare
|
|
||||||
Design grafic
|
|
||||||
Design interior
|
|
||||||
Design exterior
|
|
||||||
|
|
||||||
Releveu
|
|
||||||
Reclama
|
|
||||||
|
|
||||||
000 Farmacie
|
|
||||||
002 Cladire birouri Stratec
|
|
||||||
003 PUZ Bellavista
|
|
||||||
007 Design Apartament Teodora
|
|
||||||
010 Casa Doinei
|
|
||||||
016 Duplex Eremia
|
|
||||||
024 Bloc Petofi
|
|
||||||
028 PUZ Borhanci-Sopor
|
|
||||||
033 Mansardare Branului
|
|
||||||
039 Cabinete Stoma Scala
|
|
||||||
041 Imobil mixt Progresului
|
|
||||||
045 Casa Andrei Muresanu
|
|
||||||
052 PUZ Carpenului
|
|
||||||
059 PUZ Nordului
|
|
||||||
064 Casa Salicea
|
|
||||||
066 Terasa Gherase
|
|
||||||
070 Bloc Fanatelor
|
|
||||||
073 Case Frumoasa
|
|
||||||
074 PUG Cosbuc
|
|
||||||
076 Casa Copernicus
|
|
||||||
077 PUZ Schimbare destinatie Brancusi
|
|
||||||
078 Service auto Linistei
|
|
||||||
079 Amenajare drum Servitute Eremia
|
|
||||||
080 Bloc Tribunul
|
|
||||||
081 Extindere casa Gherase
|
|
||||||
083 Modificari casa Zsigmund 18
|
|
||||||
084 Mansardare Petofi 21
|
|
||||||
085 Container CT Spital Tabacarilor
|
|
||||||
086 Imprejmuire casa sat Gheorgheni
|
|
||||||
087 Duplex Oasului fn
|
|
||||||
089 PUZ A-Liu Sopor
|
|
||||||
090 VR MedEvents
|
|
||||||
091 Reclama Caparol
|
|
||||||
092 Imobil birouri 13 Septembrie
|
|
||||||
093 Casa Salistea Noua
|
|
||||||
094 PUD Casa Rediu
|
|
||||||
095 Duplex Vanatorului
|
|
||||||
096 Design apartament Sopor
|
|
||||||
097 Cabana Gilau
|
|
||||||
101 PUZ Gilau
|
|
||||||
102 PUZ Ghimbav
|
|
||||||
103 Piscine Lunca Noua
|
|
||||||
104 PUZ REGHIN
|
|
||||||
105 CUT&Crust
|
|
||||||
106 PUZ Mihai Romanu Nord
|
|
||||||
108 Reabilitare Bloc Beiusului
|
|
||||||
109 Case Samboleni
|
|
||||||
110 Penny Crasna
|
|
||||||
111 Anexa Piscina Borhanci
|
|
||||||
112 PUZ Blocuri Bistrita
|
|
||||||
113 PUZ VARATEC-FIRIZA
|
|
||||||
114 PUG Husi
|
|
||||||
115 PUG Josenii Bargaului
|
|
||||||
116 PUG Monor
|
|
||||||
117 Schimbare Destinatie Mihai Viteazu 2
|
|
||||||
120 Anexa Brasov
|
|
||||||
121 Imprejurare imobil Mesterul Manole 9
|
|
||||||
122 Fastfood Bashar
|
|
||||||
123 PUD Rediu 2
|
|
||||||
127 Casa Socaciu Ciurila
|
|
||||||
128 Schimbare de destinatie Danubius
|
|
||||||
129 (re) Casa Sarca-Sorescu
|
|
||||||
130 Casa Suta-Wonderland
|
|
||||||
131 PUD Oasului Hufi
|
|
||||||
132 Reabilitare Camin Cultural Baciu
|
|
||||||
133 PUG Feldru
|
|
||||||
134 DALI Blocuri Murfatlar
|
|
||||||
135 Case de vacanta Dianei
|
|
||||||
136 PUG BROSTENI
|
|
||||||
139 Casa Turda
|
|
||||||
140 Releveu Bistrita (Morariu)
|
|
||||||
141 PUZ Janovic Jeno
|
|
||||||
142 Penny Borhanci
|
|
||||||
143 Pavilion Politie Radauti
|
|
||||||
149 Duplex Sorescu 31-33
|
|
||||||
150 DALI SF Scoala Baciu
|
|
||||||
151 Casa Alexandru Bohatiel 17
|
|
||||||
152 PUZ Penny Tautii Magheraus
|
|
||||||
153 PUG Banita
|
|
||||||
155 PT Scoala Floresti
|
|
||||||
156 Case Sorescu
|
|
||||||
157 Gradi-Cresa Baciu
|
|
||||||
158 Duplex Sorescu 21-23
|
|
||||||
159 Amenajare Spatiu Grenke PBC
|
|
||||||
160 Etajare Primaria Baciu
|
|
||||||
161 Extindere Ap Baciu
|
|
||||||
164 SD salon Aurel Vlaicu
|
|
||||||
165 Reclama Marasti
|
|
||||||
166 Catei Apahida
|
|
||||||
167 Apartament Mircea Zaciu 13-15
|
|
||||||
169 Casa PETRILA 37
|
|
||||||
170 Cabana Campeni AB
|
|
||||||
171 Camin Apahida
|
|
||||||
L089 PUZ TUSA-BOJAN
|
|
||||||
172 Design casa Iugoslaviei 18
|
|
||||||
173 Reabilitare spitale Sighetu
|
|
||||||
174 StudX UMFST
|
|
||||||
176 - 2025 - ReAC Ansamblu rezi Bibescu
|
|
||||||
|
|
||||||
|
|
||||||
CU
|
|
||||||
Schita
|
|
||||||
Avize
|
|
||||||
PUD
|
|
||||||
AO
|
|
||||||
PUZ
|
|
||||||
PUG
|
|
||||||
DTAD
|
|
||||||
DTAC
|
|
||||||
PT
|
|
||||||
Detalii de Executie
|
|
||||||
Studii de fundamentare
|
|
||||||
|
|
||||||
Regulament
|
|
||||||
Parte desenata
|
|
||||||
Parte scrisa
|
|
||||||
Consultanta client
|
|
||||||
Macheta
|
|
||||||
Consultanta receptie
|
|
||||||
|
|
||||||
Redactare
|
|
||||||
Depunere
|
|
||||||
Ridicare
|
|
||||||
Verificare proiect
|
|
||||||
|
|
||||||
Vizita santier
|
|
||||||
|
|
||||||
Master MATDR
|
|
||||||
@@ -1,694 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ro">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Beletage – Word XML Data Engine</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<!-- JSZip pentru arhivă ZIP -->
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"
|
|
||||||
integrity="sha512-FGv7V3GpCr3C6wz6Q4z8F1v8y4mZohwPqhwKiPfz0btvAvOE0tfLOgvBcFQncn1C3KW0y5fN9c7v1sQW8vGfMQ=="
|
|
||||||
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
margin: 0;
|
|
||||||
padding: 1.5rem;
|
|
||||||
background: #020617;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-size: 1.7rem;
|
|
||||||
margin-bottom: .25rem;
|
|
||||||
}
|
|
||||||
.subtitle {
|
|
||||||
font-size: .9rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
background: #020617;
|
|
||||||
border-radius: 1rem;
|
|
||||||
padding: 1.1rem 1.3rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
border: 1px solid #1e293b;
|
|
||||||
box-shadow: 0 15px 35px rgba(0,0,0,.45);
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
font-size: .8rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
display: block;
|
|
||||||
margin-bottom: .2rem;
|
|
||||||
}
|
|
||||||
input, textarea, select {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: .5rem .6rem;
|
|
||||||
border-radius: .5rem;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
background: #020617;
|
|
||||||
color: #e5e7eb;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: .9rem;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
input:focus, textarea:focus, select:focus {
|
|
||||||
border-color: #38bdf8;
|
|
||||||
box-shadow: 0 0 0 1px #38bdf8;
|
|
||||||
}
|
|
||||||
textarea { min-height: 140px; resize: vertical; }
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
.col-3 { flex: 1 1 220px; }
|
|
||||||
.col-6 { flex: 1 1 320px; }
|
|
||||||
.col-9 { flex: 3 1 420px; }
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: .55rem 1.1rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: none;
|
|
||||||
background: linear-gradient(135deg, #38bdf8, #6366f1);
|
|
||||||
color: #fff;
|
|
||||||
font-size: .9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 12px 25px rgba(37,99,235,.4);
|
|
||||||
}
|
|
||||||
button:hover { filter: brightness(1.05); transform: translateY(-1px); }
|
|
||||||
button:active { transform: translateY(0); box-shadow: 0 8px 18px rgba(37,99,235,.6); }
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid #4b5563;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: #020617;
|
|
||||||
box-shadow: 0 8px 20px rgba(0,0,0,.6);
|
|
||||||
}
|
|
||||||
.btn-small {
|
|
||||||
font-size: .8rem;
|
|
||||||
padding: .35rem .8rem;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: .4rem;
|
|
||||||
font-size: .8rem;
|
|
||||||
color: #cbd5f5;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.toggle input { width: auto; }
|
|
||||||
|
|
||||||
.pill-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: .4rem;
|
|
||||||
margin-bottom: .4rem;
|
|
||||||
}
|
|
||||||
.pill {
|
|
||||||
padding: .25rem .7rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
font-size: .8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
background: #020617;
|
|
||||||
color: #e5e7eb;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: .35rem;
|
|
||||||
}
|
|
||||||
.pill.active {
|
|
||||||
background: linear-gradient(135deg, #38bdf8, #6366f1);
|
|
||||||
border-color: transparent;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
.pill span.remove {
|
|
||||||
font-size: .8rem;
|
|
||||||
opacity: .7;
|
|
||||||
}
|
|
||||||
.pill span.remove:hover { opacity: 1; }
|
|
||||||
|
|
||||||
.small {
|
|
||||||
font-size: .8rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
margin-top: .25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
background: #020617;
|
|
||||||
border-radius: .75rem;
|
|
||||||
padding: .7rem .8rem;
|
|
||||||
border: 1px solid #1f2937;
|
|
||||||
overflow: auto;
|
|
||||||
font-size: .8rem;
|
|
||||||
max-height: 340px;
|
|
||||||
}
|
|
||||||
.badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: .15rem .45rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: .7rem;
|
|
||||||
background: rgba(148,163,184,.18);
|
|
||||||
margin-right: .4rem;
|
|
||||||
margin-bottom: .25rem;
|
|
||||||
}
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
body { padding: 1rem; }
|
|
||||||
.card { padding: 1rem; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>Beletage – Word XML Data Engine</h1>
|
|
||||||
<p class="subtitle">
|
|
||||||
Generator de <strong>Custom XML Parts</strong> pentru Word, pe categorii (Beneficiar, Proiect, Suprafete, Meta etc.),
|
|
||||||
cu mod <em>Simple</em> / <em>Advanced</em> și câmpuri derivate (Short, Upper, Initials) + POT/CUT pregătite.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- SETĂRI GLOBALE -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-6">
|
|
||||||
<label for="baseNs">Bază Namespace (se completează automat cu /Categorie)</label>
|
|
||||||
<input id="baseNs" type="text" value="http://schemas.beletage.ro/contract">
|
|
||||||
<div class="small">Ex: <code>http://schemas.beletage.ro/contract</code> → pentru categoria „Proiect” devine
|
|
||||||
<code>http://schemas.beletage.ro/contract/Proiect</code>.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-3">
|
|
||||||
<label>Mod generare câmpuri</label>
|
|
||||||
<div class="pill-row">
|
|
||||||
<div class="pill active" id="modeSimplePill" onclick="setMode('simple')">Simple</div>
|
|
||||||
<div class="pill" id="modeAdvancedPill" onclick="setMode('advanced')">Advanced</div>
|
|
||||||
</div>
|
|
||||||
<div class="small">
|
|
||||||
<strong>Simple</strong>: doar câmpurile tale.<br>
|
|
||||||
<strong>Advanced</strong>: + Short / Upper / Lower / Initials / First pentru fiecare câmp.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-3">
|
|
||||||
<label>Opțiuni extra</label>
|
|
||||||
<div class="small" style="margin-top:.25rem;">
|
|
||||||
<label class="toggle">
|
|
||||||
<input type="checkbox" id="computeMetrics" checked>
|
|
||||||
<span>Adaugă câmpuri POT / CUT în categoria Suprafete</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CATEGORII -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-3">
|
|
||||||
<label>Categorii de date</label>
|
|
||||||
<div id="categoryPills" class="pill-row"></div>
|
|
||||||
<button class="btn-secondary btn-small" onclick="addCategoryPrompt()">+ Adaugă categorie</button>
|
|
||||||
<div class="small">
|
|
||||||
Exemple de organizare: <code>Beneficiar</code>, <code>Proiect</code>, <code>Suprafete</code>, <code>Meta</code>.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-9">
|
|
||||||
<label>Câmpuri pentru categoria selectată</label>
|
|
||||||
<textarea id="fieldsArea"></textarea>
|
|
||||||
<div class="small">
|
|
||||||
Un câmp pe linie. Poți edita lista. Butonul „Reset categorie la preset” reîncarcă valorile default pentru
|
|
||||||
categoria curentă (dacă există).
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:.5rem; display:flex; gap:.5rem; flex-wrap:wrap;">
|
|
||||||
<button class="btn-secondary btn-small" onclick="resetCategoryToPreset()">Reset categorie la preset</button>
|
|
||||||
<button class="btn-secondary btn-small" onclick="clearCategoryFields()">Curăță câmpurile</button>
|
|
||||||
</div>
|
|
||||||
<div class="small" id="nsRootInfo" style="margin-top:.6rem;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- GENERARE & DOWNLOAD -->
|
|
||||||
<div class="card">
|
|
||||||
<div style="display:flex; flex-wrap:wrap; gap:.5rem; align-items:center; margin-bottom:.5rem;">
|
|
||||||
<button onclick="generateAll()">Generează XML pentru toate categoriile</button>
|
|
||||||
<button class="btn-secondary" onclick="downloadCurrentXml()">Descarcă XML categorie curentă</button>
|
|
||||||
<button class="btn-secondary" onclick="downloadZipAll()">Descarcă ZIP cu toate XML-urile</button>
|
|
||||||
</div>
|
|
||||||
<div class="small">
|
|
||||||
<span class="badge">Tip</span>
|
|
||||||
În Word, fiecare fișier generat devine un Custom XML Part separat (ex: <code>BeneficiarData.xml</code>,
|
|
||||||
<code>ProiectData.xml</code> etc.), perfect pentru organizarea mapping-urilor.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- PREVIEW -->
|
|
||||||
<div class="card">
|
|
||||||
<h3 style="margin-top:0;">Preview XML & XPaths</h3>
|
|
||||||
<div class="small" style="margin-bottom:.4rem;">
|
|
||||||
Selectează o categorie pentru a vedea XML-ul și XPaths-urile aferente.
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-6">
|
|
||||||
<div class="badge">XML categorie curentă</div>
|
|
||||||
<pre id="xmlPreview"></pre>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<div class="badge">XPaths categorie curentă</div>
|
|
||||||
<pre id="xpathPreview"></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// --- PRESETURI CATEGORII ---
|
|
||||||
const defaultPresets = {
|
|
||||||
"Beneficiar": [
|
|
||||||
"NumeClient",
|
|
||||||
"Adresa",
|
|
||||||
"CUI",
|
|
||||||
"CNP",
|
|
||||||
"Reprezentant",
|
|
||||||
"Email",
|
|
||||||
"Telefon"
|
|
||||||
],
|
|
||||||
"Proiect": [
|
|
||||||
"TitluProiect",
|
|
||||||
"AdresaImobil",
|
|
||||||
"NrCadastral",
|
|
||||||
"NrCF",
|
|
||||||
"Localitate",
|
|
||||||
"Judet"
|
|
||||||
],
|
|
||||||
"Suprafete": [
|
|
||||||
"SuprafataTeren",
|
|
||||||
"SuprafataConstruitaLaSol",
|
|
||||||
"SuprafataDesfasurata",
|
|
||||||
"SuprafataUtila"
|
|
||||||
],
|
|
||||||
"Meta": [
|
|
||||||
"NrContract",
|
|
||||||
"DataContract",
|
|
||||||
"Responsabil",
|
|
||||||
"VersiuneDocument",
|
|
||||||
"DataGenerarii"
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- STATE ---
|
|
||||||
let categories = {}; // { Categorie: { fieldsText: "..." } }
|
|
||||||
let currentCategory = "Beneficiar";
|
|
||||||
let mode = "advanced"; // "simple" | "advanced"
|
|
||||||
const xmlParts = {}; // { Categorie: xmlString }
|
|
||||||
const xpathParts = {}; // { Categorie: xpathString }
|
|
||||||
|
|
||||||
// --- UTILITARE ---
|
|
||||||
function sanitizeName(name) {
|
|
||||||
if (!name) return null;
|
|
||||||
let n = name.trim();
|
|
||||||
if (!n) return null;
|
|
||||||
n = n.replace(/\s+/g, "_").replace(/[^A-Za-z0-9_.-]/g, "");
|
|
||||||
if (!/^[A-Za-z_]/.test(n)) n = "_" + n;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initialsFromLabel(label) {
|
|
||||||
if (!label) return "";
|
|
||||||
return label.trim().split(/\s+/).map(s => s.charAt(0).toUpperCase() + ".").join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstToken(label) {
|
|
||||||
if (!label) return "";
|
|
||||||
return label.trim().split(/\s+/)[0] || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBaseNamespace() {
|
|
||||||
const val = document.getElementById("baseNs").value.trim();
|
|
||||||
return val || "http://schemas.beletage.ro/contract";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCategoryNamespace(cat) {
|
|
||||||
const base = getBaseNamespace();
|
|
||||||
const safeCat = sanitizeName(cat) || cat;
|
|
||||||
return base.replace(/\/+$/,"") + "/" + safeCat;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCategoryRoot(cat) {
|
|
||||||
const safeCat = sanitizeName(cat) || cat;
|
|
||||||
return safeCat + "Data";
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- MOD SIMPLE/ADVANCED ---
|
|
||||||
function setMode(m) {
|
|
||||||
mode = m === "advanced" ? "advanced" : "simple";
|
|
||||||
document.getElementById("modeSimplePill").classList.toggle("active", mode === "simple");
|
|
||||||
document.getElementById("modeAdvancedPill").classList.toggle("active", mode === "advanced");
|
|
||||||
// regenerăm previw dacă avem ceva
|
|
||||||
generateAll(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- CATEGORII: INIT, UI, STORAGE ---
|
|
||||||
function initCategories() {
|
|
||||||
// încarcă din localStorage, altfel default
|
|
||||||
const saved = window.localStorage.getItem("beletage_xml_categories");
|
|
||||||
if (saved) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(saved);
|
|
||||||
categories = parsed.categories || {};
|
|
||||||
currentCategory = parsed.currentCategory || "Beneficiar";
|
|
||||||
} catch(e) {
|
|
||||||
Object.keys(defaultPresets).forEach(cat => {
|
|
||||||
categories[cat] = { fieldsText: defaultPresets[cat].join("\n") };
|
|
||||||
});
|
|
||||||
currentCategory = "Beneficiar";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Object.keys(defaultPresets).forEach(cat => {
|
|
||||||
categories[cat] = { fieldsText: defaultPresets[cat].join("\n") };
|
|
||||||
});
|
|
||||||
currentCategory = "Beneficiar";
|
|
||||||
}
|
|
||||||
|
|
||||||
renderCategoryPills();
|
|
||||||
loadCategoryToUI(currentCategory);
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistCategories() {
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem("beletage_xml_categories", JSON.stringify({
|
|
||||||
categories,
|
|
||||||
currentCategory
|
|
||||||
}));
|
|
||||||
} catch(e){}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCategoryPills() {
|
|
||||||
const container = document.getElementById("categoryPills");
|
|
||||||
container.innerHTML = "";
|
|
||||||
Object.keys(categories).forEach(cat => {
|
|
||||||
const pill = document.createElement("div");
|
|
||||||
pill.className = "pill" + (cat === currentCategory ? " active" : "");
|
|
||||||
pill.onclick = () => switchCategory(cat);
|
|
||||||
pill.textContent = cat;
|
|
||||||
|
|
||||||
// nu permitem ștergerea preset-urilor de bază direct (doar la custom)
|
|
||||||
if (!defaultPresets[cat]) {
|
|
||||||
const remove = document.createElement("span");
|
|
||||||
remove.className = "remove";
|
|
||||||
remove.textContent = "×";
|
|
||||||
remove.onclick = (ev) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
deleteCategory(cat);
|
|
||||||
};
|
|
||||||
pill.appendChild(remove);
|
|
||||||
}
|
|
||||||
container.appendChild(pill);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchCategory(cat) {
|
|
||||||
saveCurrentCategoryFields();
|
|
||||||
currentCategory = cat;
|
|
||||||
renderCategoryPills();
|
|
||||||
loadCategoryToUI(cat);
|
|
||||||
updateNsRootInfo();
|
|
||||||
showPreview(cat);
|
|
||||||
persistCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadCategoryToUI(cat) {
|
|
||||||
const area = document.getElementById("fieldsArea");
|
|
||||||
area.value = categories[cat]?.fieldsText || "";
|
|
||||||
updateNsRootInfo();
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveCurrentCategoryFields() {
|
|
||||||
const area = document.getElementById("fieldsArea");
|
|
||||||
if (!categories[currentCategory]) {
|
|
||||||
categories[currentCategory] = { fieldsText: "" };
|
|
||||||
}
|
|
||||||
categories[currentCategory].fieldsText = area.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteCategory(cat) {
|
|
||||||
if (!confirm(`Sigur ștergi categoria "${cat}"?`)) return;
|
|
||||||
delete categories[cat];
|
|
||||||
const keys = Object.keys(categories);
|
|
||||||
currentCategory = keys[0] || "Beneficiar";
|
|
||||||
renderCategoryPills();
|
|
||||||
loadCategoryToUI(currentCategory);
|
|
||||||
updateNsRootInfo();
|
|
||||||
persistCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
function addCategoryPrompt() {
|
|
||||||
const name = prompt("Nume categorie nouă (ex: Urbanism, Fiscal, Altele):");
|
|
||||||
if (!name) return;
|
|
||||||
const trimmed = name.trim();
|
|
||||||
if (!trimmed) return;
|
|
||||||
if (categories[trimmed]) {
|
|
||||||
alert("Categoria există deja.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
categories[trimmed] = { fieldsText: "" };
|
|
||||||
currentCategory = trimmed;
|
|
||||||
renderCategoryPills();
|
|
||||||
loadCategoryToUI(currentCategory);
|
|
||||||
updateNsRootInfo();
|
|
||||||
persistCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetCategoryToPreset() {
|
|
||||||
if (!defaultPresets[currentCategory]) {
|
|
||||||
alert("Categoria curentă nu are preset definit.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!confirm("Resetezi lista de câmpuri la presetul standard pentru această categorie?")) return;
|
|
||||||
categories[currentCategory].fieldsText = defaultPresets[currentCategory].join("\n");
|
|
||||||
loadCategoryToUI(currentCategory);
|
|
||||||
persistCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearCategoryFields() {
|
|
||||||
categories[currentCategory].fieldsText = "";
|
|
||||||
loadCategoryToUI(currentCategory);
|
|
||||||
persistCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateNsRootInfo() {
|
|
||||||
const ns = getCategoryNamespace(currentCategory);
|
|
||||||
const root = getCategoryRoot(currentCategory);
|
|
||||||
document.getElementById("nsRootInfo").innerHTML =
|
|
||||||
`<strong>Namespace:</strong> <code>${ns}</code><br>` +
|
|
||||||
`<strong>Root element:</strong> <code><${root}></code>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- GENERARE XML PENTRU O CATEGORIE ---
|
|
||||||
function generateCategory(cat) {
|
|
||||||
const entry = categories[cat];
|
|
||||||
if (!entry) return { xml: "", xpaths: "" };
|
|
||||||
|
|
||||||
const raw = (entry.fieldsText || "").split(/\r?\n/)
|
|
||||||
.map(l => l.trim())
|
|
||||||
.filter(l => l.length > 0);
|
|
||||||
|
|
||||||
if (raw.length === 0) {
|
|
||||||
return { xml: "", xpaths: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const ns = getCategoryNamespace(cat);
|
|
||||||
const root = getCategoryRoot(cat);
|
|
||||||
const computeMetrics = document.getElementById("computeMetrics").checked;
|
|
||||||
|
|
||||||
const usedNames = new Set();
|
|
||||||
const fields = []; // { label, baseName, variants: [] }
|
|
||||||
|
|
||||||
for (const label of raw) {
|
|
||||||
const base = sanitizeName(label);
|
|
||||||
if (!base) continue;
|
|
||||||
|
|
||||||
let baseName = base;
|
|
||||||
let idx = 2;
|
|
||||||
while (usedNames.has(baseName)) {
|
|
||||||
baseName = base + "_" + idx;
|
|
||||||
idx++;
|
|
||||||
}
|
|
||||||
usedNames.add(baseName);
|
|
||||||
|
|
||||||
const variants = [baseName];
|
|
||||||
if (mode === "advanced") {
|
|
||||||
const advCandidates = [
|
|
||||||
baseName + "Short",
|
|
||||||
baseName + "Upper",
|
|
||||||
baseName + "Lower",
|
|
||||||
baseName + "Initials",
|
|
||||||
baseName + "First"
|
|
||||||
];
|
|
||||||
for (let v of advCandidates) {
|
|
||||||
let vn = v;
|
|
||||||
let k = 2;
|
|
||||||
while (usedNames.has(vn)) {
|
|
||||||
vn = v + "_" + k;
|
|
||||||
k++;
|
|
||||||
}
|
|
||||||
usedNames.add(vn);
|
|
||||||
variants.push(vn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fields.push({ label, baseName, variants });
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectăm câmpuri pentru metrici (în special categoria Suprafete)
|
|
||||||
const extraMetricFields = [];
|
|
||||||
if (computeMetrics && cat.toLowerCase().includes("suprafete")) {
|
|
||||||
const hasTeren = fields.some(f => f.baseName.toLowerCase().includes("suprafatateren"));
|
|
||||||
const hasLaSol = fields.some(f => f.baseName.toLowerCase().includes("suprafataconstruitalasol"));
|
|
||||||
const hasDesf = fields.some(f => f.baseName.toLowerCase().includes("suprafatadesfasurata"));
|
|
||||||
|
|
||||||
if (hasTeren && hasLaSol) {
|
|
||||||
if (!usedNames.has("POT")) {
|
|
||||||
usedNames.add("POT");
|
|
||||||
extraMetricFields.push({ label: "Procent Ocupare Teren", baseName: "POT", variants: ["POT"] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasTeren && hasDesf) {
|
|
||||||
if (!usedNames.has("CUT")) {
|
|
||||||
usedNames.add("CUT");
|
|
||||||
extraMetricFields.push({ label: "Coeficient Utilizare Teren", baseName: "CUT", variants: ["CUT"] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// generăm XML
|
|
||||||
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
||||||
xml += `<${root} xmlns="${ns}">\n`;
|
|
||||||
|
|
||||||
const allFieldEntries = fields.concat(extraMetricFields);
|
|
||||||
|
|
||||||
for (const f of allFieldEntries) {
|
|
||||||
for (const v of f.variants) {
|
|
||||||
xml += ` <${v}></${v}>\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
xml += `</${root}>\n`;
|
|
||||||
|
|
||||||
// generăm XPaths
|
|
||||||
let xp = `Categorie: ${cat}\nNamespace: ${ns}\nRoot: /${root}\n\n`;
|
|
||||||
for (const f of fields) {
|
|
||||||
xp += `# ${f.label}\n`;
|
|
||||||
for (const v of f.variants) {
|
|
||||||
xp += `/${root}/${v}\n`;
|
|
||||||
}
|
|
||||||
xp += `\n`;
|
|
||||||
}
|
|
||||||
if (extraMetricFields.length > 0) {
|
|
||||||
xp += `# Metrici auto (POT / CUT)\n`;
|
|
||||||
for (const f of extraMetricFields) {
|
|
||||||
for (const v of f.variants) {
|
|
||||||
xp += `/${root}/${v}\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
xp += `\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { xml, xpaths: xp };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- GENERARE PENTRU TOATE CATEGORIILE ---
|
|
||||||
function generateAll(showForCurrent = true) {
|
|
||||||
saveCurrentCategoryFields();
|
|
||||||
Object.keys(categories).forEach(cat => {
|
|
||||||
const { xml, xpaths } = generateCategory(cat);
|
|
||||||
xmlParts[cat] = xml;
|
|
||||||
xpathParts[cat] = xpaths;
|
|
||||||
});
|
|
||||||
if (showForCurrent) {
|
|
||||||
showPreview(currentCategory);
|
|
||||||
}
|
|
||||||
persistCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- PREVIEW ---
|
|
||||||
function showPreview(cat) {
|
|
||||||
document.getElementById("xmlPreview").textContent = xmlParts[cat] || "<!-- Niciun XML generat încă pentru această categorie. -->";
|
|
||||||
document.getElementById("xpathPreview").textContent = xpathParts[cat] || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- DOWNLOAD: XML CATEGORIE ---
|
|
||||||
function downloadCurrentXml() {
|
|
||||||
generateAll(false);
|
|
||||||
const xml = xmlParts[currentCategory];
|
|
||||||
if (!xml) {
|
|
||||||
alert("Nu există XML generat pentru categoria curentă. Apasă întâi „Generează XML pentru toate categoriile”.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const root = getCategoryRoot(currentCategory);
|
|
||||||
const fileName = root + ".xml";
|
|
||||||
const blob = new Blob([xml], { type: "application/xml" });
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = URL.createObjectURL(blob);
|
|
||||||
a.download = fileName;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(a.href);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- DOWNLOAD: ZIP CU TOATE XML-URILE ---
|
|
||||||
async function downloadZipAll() {
|
|
||||||
generateAll(false);
|
|
||||||
const cats = Object.keys(categories);
|
|
||||||
if (cats.length === 0) {
|
|
||||||
alert("Nu există categorii.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const zip = new JSZip();
|
|
||||||
const folder = zip.folder("customXmlParts");
|
|
||||||
|
|
||||||
let hasAny = false;
|
|
||||||
for (const cat of cats) {
|
|
||||||
const xml = xmlParts[cat];
|
|
||||||
if (!xml) continue;
|
|
||||||
hasAny = true;
|
|
||||||
const root = getCategoryRoot(cat);
|
|
||||||
const fileName = root + ".xml";
|
|
||||||
folder.file(fileName, xml);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasAny) {
|
|
||||||
alert("Nu există XML generat încă. Apasă întâi „Generează XML pentru toate categoriile”.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = await zip.generateAsync({ type: "blob" });
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = URL.createObjectURL(content);
|
|
||||||
a.download = "beletage_custom_xml_parts.zip";
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(a.href);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- INIT ---
|
|
||||||
window.addEventListener("DOMContentLoaded", () => {
|
|
||||||
initCategories();
|
|
||||||
updateNsRootInfo();
|
|
||||||
generateAll();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ro">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Generator XML Word – Versiune Extinsă</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
padding: 1.5rem;
|
|
||||||
background: #0f172a;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
background: #020617;
|
|
||||||
border-radius: 1rem;
|
|
||||||
padding: 1.25rem;
|
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
|
||||||
border: 1px solid #1e293b;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
label { font-size: .85rem; color: #94a3b8; }
|
|
||||||
input, textarea {
|
|
||||||
width: 100%; padding: .55rem .7rem;
|
|
||||||
border-radius: .5rem; border: 1px solid #334155;
|
|
||||||
background: #020617; color: #e5e7eb;
|
|
||||||
}
|
|
||||||
textarea { min-height: 120px; }
|
|
||||||
button {
|
|
||||||
padding: .6rem 1.2rem; border-radius: 999px; border: none;
|
|
||||||
background: linear-gradient(135deg,#38bdf8,#6366f1);
|
|
||||||
font-weight: 600; color: white; cursor: pointer;
|
|
||||||
}
|
|
||||||
pre {
|
|
||||||
background: #000; padding: .8rem; border-radius: .7rem;
|
|
||||||
border: 1px solid #1e293b; max-height: 350px; overflow: auto;
|
|
||||||
font-size: .85rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<h1>Generator Word XML – Varianta Extinsă (cu Short / Upper / Lower / Initials)</h1>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<label>Namespace URI</label>
|
|
||||||
<input id="nsUri" value="http://schemas.beletage.ro/word/contract">
|
|
||||||
|
|
||||||
<label style="margin-top:1rem;">Element rădăcină</label>
|
|
||||||
<input id="rootElement" value="ContractData">
|
|
||||||
|
|
||||||
<label style="margin-top:1rem;">Lista de câmpuri (unul pe linie)</label>
|
|
||||||
<textarea id="fieldList">NumeClient
|
|
||||||
TitluProiect
|
|
||||||
Adresa</textarea>
|
|
||||||
|
|
||||||
<button onclick="generateXML()" style="margin-top:1rem;">Generează XML complet</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h3>Custom XML Part (item1.xml)</h3>
|
|
||||||
<pre id="xmlOutput"></pre>
|
|
||||||
<button onclick="downloadXML()">Descarcă XML</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h3>XPaths pentru mapping</h3>
|
|
||||||
<pre id="xpathOutput"></pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function sanitize(name) {
|
|
||||||
if (!name) return null;
|
|
||||||
let n = name.trim();
|
|
||||||
if (!n) return null;
|
|
||||||
n = n.replace(/\s+/g,"_").replace(/[^A-Za-z0-9_.-]/g,"");
|
|
||||||
if (!/^[A-Za-z_]/.test(n)) n = "_" + n;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initials(str) {
|
|
||||||
return str.split(/\s+/).map(s => s[0]?.toUpperCase() + ".").join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateXML() {
|
|
||||||
const ns = document.getElementById("nsUri").value.trim();
|
|
||||||
const root = sanitize(document.getElementById("rootElement").value) || "Root";
|
|
||||||
const fieldRaw = document.getElementById("fieldList").value;
|
|
||||||
|
|
||||||
const lines = fieldRaw.split(/\r?\n/)
|
|
||||||
.map(l => l.trim()).filter(l => l.length);
|
|
||||||
|
|
||||||
const fields = [];
|
|
||||||
|
|
||||||
for (let l of lines) {
|
|
||||||
const base = sanitize(l);
|
|
||||||
if (!base) continue;
|
|
||||||
|
|
||||||
fields.push({
|
|
||||||
base,
|
|
||||||
variants: [
|
|
||||||
base, // original
|
|
||||||
base + "Short", // prescurtat
|
|
||||||
base + "Upper", // caps
|
|
||||||
base + "Lower", // lowercase
|
|
||||||
base + "Initials", // inițiale
|
|
||||||
base + "First" // primul cuvânt
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// === GENERĂM XML ===
|
|
||||||
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
||||||
xml += `<${root} xmlns="${ns}">\n`;
|
|
||||||
|
|
||||||
for (const f of fields) {
|
|
||||||
for (const v of f.variants) {
|
|
||||||
xml += ` <${v}></${v}>\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
xml += `</${root}>`;
|
|
||||||
|
|
||||||
document.getElementById("xmlOutput").textContent = xml;
|
|
||||||
|
|
||||||
// === GENERĂM XPATHS ===
|
|
||||||
let xp = `Namespace: ${ns}\nRoot: /${root}\n\n`;
|
|
||||||
for (const f of fields) {
|
|
||||||
xp += `# ${f.base}\n`;
|
|
||||||
xp += `/${root}/${f.base}\n`;
|
|
||||||
xp += `/${root}/${f.base}Short\n`;
|
|
||||||
xp += `/${root}/${f.base}Upper\n`;
|
|
||||||
xp += `/${root}/${f.base}Lower\n`;
|
|
||||||
xp += `/${root}/${f.base}Initials\n`;
|
|
||||||
xp += `/${root}/${f.base}First\n\n`;
|
|
||||||
}
|
|
||||||
document.getElementById("xpathOutput").textContent = xp;
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadXML() {
|
|
||||||
const text = document.getElementById("xmlOutput").textContent;
|
|
||||||
const blob = new Blob([text], { type: "application/xml" });
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = URL.createObjectURL(blob);
|
|
||||||
a.download = "item1.xml";
|
|
||||||
a.click();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ro">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Generator Word XML Custom Part</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
margin: 0;
|
|
||||||
padding: 1.5rem;
|
|
||||||
background: #0f172a;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-size: 1.6rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
background: #020617;
|
|
||||||
border-radius: 1rem;
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
box-shadow: 0 15px 40px rgba(0,0,0,0.35);
|
|
||||||
border: 1px solid #1f2937;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
input, textarea {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 0.5rem 0.6rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
border: 1px solid #374151;
|
|
||||||
background: #020617;
|
|
||||||
color: #e5e7eb;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
input:focus, textarea:focus {
|
|
||||||
border-color: #38bdf8;
|
|
||||||
box-shadow: 0 0 0 1px #38bdf8;
|
|
||||||
}
|
|
||||||
textarea {
|
|
||||||
min-height: 140px;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
.col-6 {
|
|
||||||
flex: 1 1 260px;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
padding: 0.6rem 1.2rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: none;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
background: linear-gradient(135deg, #38bdf8, #6366f1);
|
|
||||||
color: white;
|
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: 0 10px 25px rgba(37, 99, 235, 0.4);
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
filter: brightness(1.05);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
button:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
box-shadow: 0 6px 18px rgba(37,99,235,0.6);
|
|
||||||
}
|
|
||||||
pre {
|
|
||||||
background: #020617;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
overflow: auto;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
border: 1px solid #1f2937;
|
|
||||||
max-height: 360px;
|
|
||||||
}
|
|
||||||
.subtitle {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 0.2rem 0.5rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(148, 163, 184, 0.2);
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
.pill span {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
.small {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
}
|
|
||||||
.btn-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
.btn-secondary {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid #4b5563;
|
|
||||||
box-shadow: none;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: #111827;
|
|
||||||
box-shadow: 0 8px 18px rgba(0,0,0,0.5);
|
|
||||||
}
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
body {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<h1>Generator XML pentru Word Custom XML Part</h1>
|
|
||||||
<p class="subtitle">
|
|
||||||
Introdu câmpurile (unul pe linie) și obții XML pentru <strong>Custom XML Part</strong>, plus XPaths pentru mapping în Word.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-6">
|
|
||||||
<label for="nsUri">Namespace URI (obligatoriu)</label>
|
|
||||||
<input id="nsUri" type="text"
|
|
||||||
value="http://schemas.beletage.ro/word/data">
|
|
||||||
<div class="small">
|
|
||||||
Exemplu: <code>http://schemas.firma-ta.ro/word/contract</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label for="rootElement">Nume element rădăcină</label>
|
|
||||||
<input id="rootElement" type="text" value="Root">
|
|
||||||
<div class="small">
|
|
||||||
Exemplu: <code>ContractData</code>, <code>ClientInfo</code> etc.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-top:1rem;">
|
|
||||||
<label for="fieldList">Lista de câmpuri (unul pe linie)</label>
|
|
||||||
<textarea id="fieldList" placeholder="Exemplu:
|
|
||||||
NumeClient
|
|
||||||
Adresa
|
|
||||||
DataContract
|
|
||||||
ValoareTotala"></textarea>
|
|
||||||
<div class="small">
|
|
||||||
Numele va fi curățat automat pentru a fi valid ca nume de element XML
|
|
||||||
(spațiile devin <code>_</code>, caracterele ciudate se elimină).
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-row">
|
|
||||||
<button type="button" onclick="generateXML()">Generează XML</button>
|
|
||||||
<button type="button" class="btn-secondary" onclick="fillDemo()">Exemplu demo</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="pill"><strong>1</strong><span>Custom XML Part (item1.xml)</span></div>
|
|
||||||
<pre id="xmlOutput"></pre>
|
|
||||||
<div class="btn-row">
|
|
||||||
<button type="button" class="btn-secondary" onclick="copyToClipboard('xmlOutput')">
|
|
||||||
Copiază XML
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn-secondary" onclick="downloadXML()">
|
|
||||||
Descarcă item1.xml
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="pill"><strong>2</strong><span>XPaths pentru mapping în Word</span></div>
|
|
||||||
<pre id="xpathOutput"></pre>
|
|
||||||
<button type="button" class="btn-secondary" onclick="copyToClipboard('xpathOutput')">
|
|
||||||
Copiază XPaths
|
|
||||||
</button>
|
|
||||||
<p class="small">
|
|
||||||
În Word → <strong>Developer</strong> → <strong>XML Mapping Pane</strong> → alegi Custom XML Part-ul
|
|
||||||
→ pentru fiecare câmp, click dreapta → <em>Insert Content Control</em> → tipul dorit.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function sanitizeXmlName(name) {
|
|
||||||
if (!name) return null;
|
|
||||||
let n = name.trim();
|
|
||||||
if (!n) return null;
|
|
||||||
|
|
||||||
// înlocuim spații cu underscore
|
|
||||||
n = n.replace(/\s+/g, "_");
|
|
||||||
|
|
||||||
// eliminăm caractere invalide pentru nume de element XML
|
|
||||||
n = n.replace(/[^A-Za-z0-9_.-]/g, "");
|
|
||||||
|
|
||||||
// numele XML nu are voie să înceapă cu cifră sau punct sau cratimă
|
|
||||||
if (!/^[A-Za-z_]/.test(n)) {
|
|
||||||
n = "_" + n;
|
|
||||||
}
|
|
||||||
|
|
||||||
return n || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateXML() {
|
|
||||||
const nsUri = document.getElementById("nsUri").value.trim();
|
|
||||||
const root = sanitizeXmlName(document.getElementById("rootElement").value) || "Root";
|
|
||||||
const fieldRaw = document.getElementById("fieldList").value;
|
|
||||||
const xmlOutput = document.getElementById("xmlOutput");
|
|
||||||
const xpathOutput = document.getElementById("xpathOutput");
|
|
||||||
|
|
||||||
if (!nsUri) {
|
|
||||||
alert("Te rog completează Namespace URI.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = fieldRaw.split(/\r?\n/)
|
|
||||||
.map(l => l.trim())
|
|
||||||
.filter(l => l.length > 0);
|
|
||||||
|
|
||||||
const fields = [];
|
|
||||||
const used = new Set();
|
|
||||||
|
|
||||||
for (let line of lines) {
|
|
||||||
const clean = sanitizeXmlName(line);
|
|
||||||
if (!clean) continue;
|
|
||||||
let finalName = clean;
|
|
||||||
let idx = 2;
|
|
||||||
while (used.has(finalName)) {
|
|
||||||
finalName = clean + "_" + idx;
|
|
||||||
idx++;
|
|
||||||
}
|
|
||||||
used.add(finalName);
|
|
||||||
fields.push({ original: line, xmlName: finalName });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fields.length === 0) {
|
|
||||||
xmlOutput.textContent = "<!-- Niciun câmp valid. Completează lista de câmpuri. -->";
|
|
||||||
xpathOutput.textContent = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generăm XML-ul pentru Custom XML Part
|
|
||||||
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
||||||
xml += `<${root} xmlns="${nsUri}">\n`;
|
|
||||||
for (const f of fields) {
|
|
||||||
xml += ` <${f.xmlName}></${f.xmlName}>\n`;
|
|
||||||
}
|
|
||||||
xml += `</${root}>\n`;
|
|
||||||
|
|
||||||
xmlOutput.textContent = xml;
|
|
||||||
|
|
||||||
// Generăm lista de XPaths
|
|
||||||
let xpaths = `Namespace: ${nsUri}\nRoot: /${root}\n\nCâmpuri:\n`;
|
|
||||||
for (const f of fields) {
|
|
||||||
xpaths += `- ${f.original} => /${root}/${f.xmlName}\n`;
|
|
||||||
}
|
|
||||||
xpathOutput.textContent = xpaths;
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyToClipboard(elementId) {
|
|
||||||
const el = document.getElementById(elementId);
|
|
||||||
if (!el || !el.textContent) return;
|
|
||||||
navigator.clipboard.writeText(el.textContent)
|
|
||||||
.then(() => alert("Copiat în clipboard."))
|
|
||||||
.catch(() => alert("Nu am reușit să copiez în clipboard."));
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadXML() {
|
|
||||||
const xmlText = document.getElementById("xmlOutput").textContent;
|
|
||||||
if (!xmlText || xmlText.startsWith("<!--")) {
|
|
||||||
alert("Nu există XML valid de descărcat.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const blob = new Blob([xmlText], { type: "application/xml" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = "item1.xml";
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fillDemo() {
|
|
||||||
document.getElementById("nsUri").value = "http://schemas.beletage.ro/word/contract";
|
|
||||||
document.getElementById("rootElement").value = "ContractData";
|
|
||||||
document.getElementById("fieldList").value = [
|
|
||||||
"NumeClient",
|
|
||||||
"AdresaClient",
|
|
||||||
"Proiect",
|
|
||||||
"DataContract",
|
|
||||||
"ValoareTotala",
|
|
||||||
"Moneda",
|
|
||||||
"TermenExecutie"
|
|
||||||
].join("\n");
|
|
||||||
generateXML();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -6,6 +6,19 @@ const nextConfig: NextConfig = {
|
|||||||
experimental: {
|
experimental: {
|
||||||
middlewareClientMaxBodySize: '500mb',
|
middlewareClientMaxBodySize: '500mb',
|
||||||
},
|
},
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/(.*)',
|
||||||
|
headers: [
|
||||||
|
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
|
||||||
|
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||||
|
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||||
|
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
const martinUrl = process.env.MARTIN_URL || 'http://martin:3000';
|
const martinUrl = process.env.MARTIN_URL || 'http://martin:3000';
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ model GisFeature {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
syncRun GisSyncRun? @relation(fields: [syncRunId], references: [id])
|
syncRun GisSyncRun? @relation(fields: [syncRunId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@unique([layerId, objectId])
|
@@unique([layerId, objectId])
|
||||||
@@index([siruta])
|
@@index([siruta])
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,20 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "./auth-check";
|
||||||
|
|
||||||
const STIRLING_PDF_URL =
|
const STIRLING_PDF_URL = process.env.STIRLING_PDF_URL;
|
||||||
process.env.STIRLING_PDF_URL ?? "http://10.10.10.166:8087";
|
const STIRLING_PDF_API_KEY = process.env.STIRLING_PDF_API_KEY;
|
||||||
const STIRLING_PDF_API_KEY =
|
|
||||||
process.env.STIRLING_PDF_API_KEY ?? "cd829f62-6eef-43eb-a64d-c91af727b53a";
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const authErr = await requireAuth(req);
|
||||||
|
if (authErr) return authErr;
|
||||||
|
|
||||||
|
if (!STIRLING_PDF_URL || !STIRLING_PDF_API_KEY) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Stirling PDF nu este configurat" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Buffer the full body then forward to Stirling — streaming passthrough
|
// Buffer the full body then forward to Stirling — streaming passthrough
|
||||||
// (req.body + duplex:half) is unreliable for large files in Next.js.
|
// (req.body + duplex:half) is unreliable for large files in Next.js.
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "../auth-check";
|
||||||
|
|
||||||
const STIRLING_PDF_URL =
|
const STIRLING_PDF_URL = process.env.STIRLING_PDF_URL;
|
||||||
process.env.STIRLING_PDF_URL ?? "http://10.10.10.166:8087";
|
const STIRLING_PDF_API_KEY = process.env.STIRLING_PDF_API_KEY;
|
||||||
const STIRLING_PDF_API_KEY =
|
|
||||||
process.env.STIRLING_PDF_API_KEY ?? "cd829f62-6eef-43eb-a64d-c91af727b53a";
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const authErr = await requireAuth(req);
|
||||||
|
if (authErr) return authErr;
|
||||||
|
|
||||||
|
if (!STIRLING_PDF_URL || !STIRLING_PDF_API_KEY) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Stirling PDF nu este configurat" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Stream body directly to Stirling — avoids FormData re-serialization
|
// Stream body directly to Stirling — avoids FormData re-serialization
|
||||||
// failure on large files ("Failed to parse body as FormData")
|
// failure on large files ("Failed to parse body as FormData")
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export async function GET(req: Request) {
|
|||||||
WHERE geom IS NOT NULL
|
WHERE geom IS NOT NULL
|
||||||
AND "layerId" LIKE 'TERENURI%'
|
AND "layerId" LIKE 'TERENURI%'
|
||||||
AND ("cadastralRef" ILIKE ${pattern}
|
AND ("cadastralRef" ILIKE ${pattern}
|
||||||
OR enrichment::text ILIKE ${'%"NR_CAD":"' + q + '%'})
|
OR enrichment::text ILIKE ${`%"NR_CAD":"${q}%`})
|
||||||
ORDER BY "cadastralRef"
|
ORDER BY "cadastralRef"
|
||||||
LIMIT ${limit}
|
LIMIT ${limit}
|
||||||
` as Array<{
|
` as Array<{
|
||||||
|
|||||||
@@ -11,11 +11,21 @@ import { NextResponse } from "next/server";
|
|||||||
import { prisma } from "@/core/storage/prisma";
|
import { prisma } from "@/core/storage/prisma";
|
||||||
import { getAuthSession } from "@/core/auth";
|
import { getAuthSession } from "@/core/auth";
|
||||||
|
|
||||||
export async function GET() {
|
async function requireAdmin(): Promise<NextResponse | null> {
|
||||||
const session = await getAuthSession();
|
const session = await getAuthSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
const u = session.user as { role?: string } | undefined;
|
||||||
|
if (u?.role !== "admin") {
|
||||||
|
return NextResponse.json({ error: "Admin access required" }, { status: 403 });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const denied = await requireAdmin();
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
// Get all sequence counters
|
// Get all sequence counters
|
||||||
const counters = await prisma.$queryRaw<
|
const counters = await prisma.$queryRaw<
|
||||||
@@ -79,10 +89,8 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
const session = await getAuthSession();
|
const denied = await requireAdmin();
|
||||||
if (!session) {
|
if (denied) return denied;
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete ALL old counters
|
// Delete ALL old counters
|
||||||
const deleted = await prisma.$executeRaw`DELETE FROM "RegistrySequence"`;
|
const deleted = await prisma.$executeRaw`DELETE FROM "RegistrySequence"`;
|
||||||
@@ -146,10 +154,8 @@ export async function POST() {
|
|||||||
* Rewrites the "number" field inside the JSONB value for matching entries.
|
* Rewrites the "number" field inside the JSONB value for matching entries.
|
||||||
*/
|
*/
|
||||||
export async function PATCH() {
|
export async function PATCH() {
|
||||||
const session = await getAuthSession();
|
const denied = await requireAdmin();
|
||||||
if (!session) {
|
if (denied) return denied;
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map old 3-letter prefixes to new single-letter
|
// Map old 3-letter prefixes to new single-letter
|
||||||
const migrations: Array<{ old: string; new: string }> = [
|
const migrations: Array<{ old: string; new: string }> = [
|
||||||
|
|||||||
@@ -213,27 +213,33 @@ export async function POST(req: NextRequest) {
|
|||||||
let claimedSlotId: string | undefined;
|
let claimedSlotId: string | undefined;
|
||||||
|
|
||||||
if (isPastMonth && direction === "intrat") {
|
if (isPastMonth && direction === "intrat") {
|
||||||
// Try to claim a reserved slot
|
// Try to claim a reserved slot — use advisory lock to prevent concurrent claims
|
||||||
const allEntries = await loadAllEntries(true);
|
const lockKey = `reserved:${company}-${docDate.getFullYear()}-${docDate.getMonth()}`;
|
||||||
const slot = findAvailableReservedSlot(
|
const claimed = await prisma.$transaction(async (tx) => {
|
||||||
allEntries,
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${lockKey}))`;
|
||||||
company,
|
const allEntries = await loadAllEntries(true);
|
||||||
docDate.getFullYear(),
|
const slot = findAvailableReservedSlot(
|
||||||
docDate.getMonth(),
|
allEntries,
|
||||||
);
|
company,
|
||||||
|
docDate.getFullYear(),
|
||||||
|
docDate.getMonth(),
|
||||||
|
);
|
||||||
|
if (!slot) return null;
|
||||||
|
// Delete the placeholder slot within the lock
|
||||||
|
await tx.keyValueStore.delete({
|
||||||
|
where: { namespace_key: { namespace: "registratura", key: slot.id } },
|
||||||
|
});
|
||||||
|
return slot;
|
||||||
|
});
|
||||||
|
|
||||||
if (slot) {
|
if (claimed) {
|
||||||
// Claim the reserved slot — reuse its number
|
registryNumber = claimed.number;
|
||||||
registryNumber = slot.number;
|
|
||||||
registrationType = "reserved-claimed";
|
registrationType = "reserved-claimed";
|
||||||
claimedSlotId = slot.id;
|
claimedSlotId = claimed.id;
|
||||||
|
|
||||||
// Delete the placeholder slot
|
|
||||||
await deleteEntryFromDB(slot.id);
|
|
||||||
|
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
entryId: slot.id,
|
entryId: claimed.id,
|
||||||
entryNumber: slot.number,
|
entryNumber: claimed.number,
|
||||||
action: "reserved_claimed",
|
action: "reserved_claimed",
|
||||||
actor: actor.id,
|
actor: actor.id,
|
||||||
actorName: actor.name,
|
actorName: actor.name,
|
||||||
|
|||||||
@@ -144,8 +144,8 @@ export async function DELETE(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((err: { code?: string }) => {
|
||||||
// Ignore error if item doesn't exist
|
if (err.code !== "P2025") throw err;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Clear namespace
|
// Clear namespace
|
||||||
|
|||||||
@@ -18,16 +18,3 @@ if (process.env.NODE_ENV !== "production")
|
|||||||
globalForMinio.minioClient = minioClient;
|
globalForMinio.minioClient = minioClient;
|
||||||
|
|
||||||
export const MINIO_BUCKET_NAME = process.env.MINIO_BUCKET_NAME || "tools";
|
export const MINIO_BUCKET_NAME = process.env.MINIO_BUCKET_NAME || "tools";
|
||||||
|
|
||||||
// Helper to ensure bucket exists
|
|
||||||
export async function ensureBucketExists() {
|
|
||||||
try {
|
|
||||||
const exists = await minioClient.bucketExists(MINIO_BUCKET_NAME);
|
|
||||||
if (!exists) {
|
|
||||||
await minioClient.makeBucket(MINIO_BUCKET_NAME, "eu-west-1");
|
|
||||||
console.log(`Bucket '${MINIO_BUCKET_NAME}' created successfully.`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error checking/creating MinIO bucket:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ export async function middleware(request: NextRequest) {
|
|||||||
if (token) {
|
if (token) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
// Portal-only users: redirect to /portal when accessing main app
|
// Portal-only users: redirect to /portal when accessing main app
|
||||||
const portalUsers = ["dtiurbe", "d.tiurbe"];
|
const portalUsers = (process.env.PORTAL_ONLY_USERS ?? "dtiurbe,d.tiurbe").split(",").map(s => s.trim().toLowerCase());
|
||||||
const tokenEmail = String(token.email ?? "").toLowerCase();
|
const tokenEmail = String(token.email ?? "").toLowerCase();
|
||||||
const tokenName = String(token.name ?? "").toLowerCase();
|
const tokenName = String(token.name ?? "").toLowerCase();
|
||||||
const isPortalUser = portalUsers.some(
|
const isPortalUser = portalUsers.some(
|
||||||
|
|||||||
@@ -54,11 +54,24 @@ type SessionEntry = {
|
|||||||
|
|
||||||
const globalStore = globalThis as {
|
const globalStore = globalThis as {
|
||||||
__epaySessionCache?: Map<string, SessionEntry>;
|
__epaySessionCache?: Map<string, SessionEntry>;
|
||||||
|
__epayCleanupTimer?: ReturnType<typeof setInterval>;
|
||||||
};
|
};
|
||||||
const sessionCache =
|
const sessionCache =
|
||||||
globalStore.__epaySessionCache ?? new Map<string, SessionEntry>();
|
globalStore.__epaySessionCache ?? new Map<string, SessionEntry>();
|
||||||
globalStore.__epaySessionCache = sessionCache;
|
globalStore.__epaySessionCache = sessionCache;
|
||||||
|
|
||||||
|
// Periodic cleanup of expired sessions (every 5 minutes, 9-min TTL)
|
||||||
|
if (!globalStore.__epayCleanupTimer) {
|
||||||
|
globalStore.__epayCleanupTimer = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of sessionCache.entries()) {
|
||||||
|
if (now - entry.lastUsed > 9 * 60_000) {
|
||||||
|
sessionCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 5 * 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
const makeCacheKey = (u: string, p: string) =>
|
const makeCacheKey = (u: string, p: string) =>
|
||||||
crypto.createHash("sha256").update(`epay:${u}:${p}`).digest("hex");
|
crypto.createHash("sha256").update(`epay:${u}:${p}`).digest("hex");
|
||||||
|
|
||||||
|
|||||||
@@ -117,27 +117,29 @@ export async function enqueueBatch(
|
|||||||
const items: QueueItem[] = [];
|
const items: QueueItem[] = [];
|
||||||
|
|
||||||
for (const input of inputs) {
|
for (const input of inputs) {
|
||||||
// Create DB record in "queued" status
|
// Create DB record in "queued" status — use transaction + advisory lock
|
||||||
const record = await prisma.cfExtract.create({
|
// to prevent duplicate version numbers from concurrent requests
|
||||||
data: {
|
const record = await prisma.$transaction(async (tx) => {
|
||||||
nrCadastral: input.nrCadastral,
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'cfextract:' + input.nrCadastral}))`;
|
||||||
nrCF: input.nrCF ?? input.nrCadastral,
|
const agg = await tx.cfExtract.aggregate({
|
||||||
siruta: input.siruta,
|
where: { nrCadastral: input.nrCadastral },
|
||||||
judetIndex: input.judetIndex,
|
_max: { version: true },
|
||||||
judetName: input.judetName,
|
});
|
||||||
uatId: input.uatId,
|
return tx.cfExtract.create({
|
||||||
uatName: input.uatName,
|
data: {
|
||||||
gisFeatureId: input.gisFeatureId,
|
nrCadastral: input.nrCadastral,
|
||||||
prodId: input.prodId ?? 14200,
|
nrCF: input.nrCF ?? input.nrCadastral,
|
||||||
status: "queued",
|
siruta: input.siruta,
|
||||||
version:
|
judetIndex: input.judetIndex,
|
||||||
((
|
judetName: input.judetName,
|
||||||
await prisma.cfExtract.aggregate({
|
uatId: input.uatId,
|
||||||
where: { nrCadastral: input.nrCadastral },
|
uatName: input.uatName,
|
||||||
_max: { version: true },
|
gisFeatureId: input.gisFeatureId,
|
||||||
})
|
prodId: input.prodId ?? 14200,
|
||||||
)._max.version ?? 0) + 1,
|
status: "queued",
|
||||||
},
|
version: (agg._max.version ?? 0) + 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
items.push({ extractId: record.id, input });
|
items.push({ extractId: record.id, input });
|
||||||
@@ -418,7 +420,10 @@ async function processBatch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Complete
|
// Complete — require document date from ANCPI for accurate expiry
|
||||||
|
if (!doc.dataDocument) {
|
||||||
|
console.warn(`[epay-queue] Missing dataDocument for extract ${item.extractId}, using download date`);
|
||||||
|
}
|
||||||
const documentDate = doc.dataDocument
|
const documentDate = doc.dataDocument
|
||||||
? new Date(doc.dataDocument)
|
? new Date(doc.dataDocument)
|
||||||
: new Date();
|
: new Date();
|
||||||
|
|||||||
@@ -79,11 +79,24 @@ type SessionEntry = {
|
|||||||
|
|
||||||
const globalStore = globalThis as {
|
const globalStore = globalThis as {
|
||||||
__eterraSessionStore?: Map<string, SessionEntry>;
|
__eterraSessionStore?: Map<string, SessionEntry>;
|
||||||
|
__eterraCleanupTimer?: ReturnType<typeof setInterval>;
|
||||||
};
|
};
|
||||||
const sessionStore =
|
const sessionStore =
|
||||||
globalStore.__eterraSessionStore ?? new Map<string, SessionEntry>();
|
globalStore.__eterraSessionStore ?? new Map<string, SessionEntry>();
|
||||||
globalStore.__eterraSessionStore = sessionStore;
|
globalStore.__eterraSessionStore = sessionStore;
|
||||||
|
|
||||||
|
// Periodic cleanup of expired sessions (every 5 minutes, 9-min TTL)
|
||||||
|
if (!globalStore.__eterraCleanupTimer) {
|
||||||
|
globalStore.__eterraCleanupTimer = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of sessionStore.entries()) {
|
||||||
|
if (now - entry.lastUsed > 9 * 60_000) {
|
||||||
|
sessionStore.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 5 * 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
const makeCacheKey = (u: string, p: string) =>
|
const makeCacheKey = (u: string, p: string) =>
|
||||||
crypto.createHash("sha256").update(`${u}:${p}`).digest("hex");
|
crypto.createHash("sha256").update(`${u}:${p}`).digest("hex");
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,24 @@ export type SyncProgress = {
|
|||||||
|
|
||||||
type ProgressStore = Map<string, SyncProgress>;
|
type ProgressStore = Map<string, SyncProgress>;
|
||||||
|
|
||||||
const g = globalThis as { __parcelSyncProgressStore?: ProgressStore };
|
const g = globalThis as {
|
||||||
|
__parcelSyncProgressStore?: ProgressStore;
|
||||||
|
__progressCleanupTimer?: ReturnType<typeof setInterval>;
|
||||||
|
};
|
||||||
const store: ProgressStore = g.__parcelSyncProgressStore ?? new Map();
|
const store: ProgressStore = g.__parcelSyncProgressStore ?? new Map();
|
||||||
g.__parcelSyncProgressStore = store;
|
g.__parcelSyncProgressStore = store;
|
||||||
|
|
||||||
|
// Periodic cleanup of stale progress entries (every 30 minutes)
|
||||||
|
if (!g.__progressCleanupTimer) {
|
||||||
|
g.__progressCleanupTimer = setInterval(() => {
|
||||||
|
for (const [jobId, p] of store.entries()) {
|
||||||
|
if (p.status === "done" || p.status === "error") {
|
||||||
|
store.delete(jobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30 * 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
export const setProgress = (p: SyncProgress) => store.set(p.jobId, p);
|
export const setProgress = (p: SyncProgress) => store.set(p.jobId, p);
|
||||||
export const getProgress = (jobId: string) => store.get(jobId);
|
export const getProgress = (jobId: string) => store.get(jobId);
|
||||||
export const clearProgress = (jobId: string) => store.delete(jobId);
|
export const clearProgress = (jobId: string) => store.delete(jobId);
|
||||||
|
|||||||
@@ -237,8 +237,16 @@ export async function syncLayer(
|
|||||||
},
|
},
|
||||||
create: item,
|
create: item,
|
||||||
update: {
|
update: {
|
||||||
...item,
|
siruta: item.siruta,
|
||||||
|
inspireId: item.inspireId,
|
||||||
|
cadastralRef: item.cadastralRef,
|
||||||
|
areaValue: item.areaValue,
|
||||||
|
isActive: item.isActive,
|
||||||
|
attributes: item.attributes,
|
||||||
|
geometry: item.geometry,
|
||||||
|
syncRunId: item.syncRunId,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
// enrichment + enrichedAt preserved — not overwritten
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
FileText,
|
FileText,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useAuth } from "@/core/auth";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
import { Label } from "@/shared/components/ui/label";
|
import { Label } from "@/shared/components/ui/label";
|
||||||
@@ -66,6 +67,7 @@ export function CloseGuardDialog({
|
|||||||
activeDeadlines,
|
activeDeadlines,
|
||||||
onConfirmClose,
|
onConfirmClose,
|
||||||
}: CloseGuardDialogProps) {
|
}: CloseGuardDialogProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [selectedEntryId, setSelectedEntryId] = useState("");
|
const [selectedEntryId, setSelectedEntryId] = useState("");
|
||||||
const [resolution, setResolution] = useState<ClosureResolution>("finalizat");
|
const [resolution, setResolution] = useState<ClosureResolution>("finalizat");
|
||||||
@@ -130,7 +132,7 @@ export function CloseGuardDialog({
|
|||||||
onConfirmClose({
|
onConfirmClose({
|
||||||
resolution,
|
resolution,
|
||||||
reason: reason.trim(),
|
reason: reason.trim(),
|
||||||
closedBy: "Utilizator", // TODO: replace with SSO identity
|
closedBy: user?.name ?? "Utilizator",
|
||||||
closedAt: new Date().toISOString(),
|
closedAt: new Date().toISOString(),
|
||||||
linkedEntryId: selectedEntryId || undefined,
|
linkedEntryId: selectedEntryId || undefined,
|
||||||
linkedEntryNumber: selectedEntry?.number,
|
linkedEntryNumber: selectedEntry?.number,
|
||||||
|
|||||||
Reference in New Issue
Block a user