Compare commits

..

3 Commits

Author SHA1 Message Date
AI Assistant 8f65efd5d1 feat: add /prompts page — Claude Code prompt library
Personal prompt library at /prompts with:
- 6 categories: Module Work, API & Backend, Quality & Security,
  Session & Continue, Documentation & Meta, Quick Actions
- 22 optimized prompt templates for ArchiTools development
- Copy-to-clipboard on every prompt
- One-time prompts with checkbox persistence (localStorage)
- Search/filter across all prompts
- Best practices sidebar (10 tips from Claude Code research)
- Module name quick-copy badges
- Variable placeholders highlighted ({MODULE_NAME}, etc.)
- Deploy prep checklist, debug unknown errors, and more

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 06:40:34 +02:00
27 changed files with 1462 additions and 3405 deletions
+123 -389
View File
@@ -1,54 +1,43 @@
# ArchiTools — Project Context for AI Assistants
> This file provides all context needed for Claude Code, Sonnet, or any AI model to work on this project from scratch.
---
## Quick Start
```bash
npm install
npm run dev # http://localhost:3000
npx next build # verify zero errors before pushing
git push origin main # auto-deploys via Portainer webhook
git push origin main # manual redeploy via Portainer UI
```
---
## Project Overview
**ArchiTools** is a modular internal web dashboard for an architecture/engineering office group of 3 companies:
- **Beletage** (architecture)
- **Urban Switch** (urbanism)
- **Studii de Teren** (geotechnics)
It runs on two on-premise servers, containerized with Docker, managed via Portainer CE.
**ArchiTools** is a modular internal web dashboard for 3 architecture/engineering companies:
**Beletage** (architecture), **Urban Switch** (urbanism), **Studii de Teren** (geotechnics).
Production: `tools.beletage.ro` — Docker on-premise, Portainer CE, Traefik v3 proxy.
### Stack
| Layer | Technology |
| ------------ | ---------------------------------------------------------------------------- |
| ---------- | ------------------------------------------------------- |
| Framework | Next.js 16.x, App Router, TypeScript (strict) |
| Styling | Tailwind CSS v4, shadcn/ui |
| Database | PostgreSQL (10.10.10.166:5432) via Prisma v6 ORM |
| Storage | `DatabaseStorageAdapter` (PostgreSQL) — localStorage fallback available |
| File Storage | MinIO (10.10.10.166:9002 API / 9003 UI) — client configured, adapter pending |
| Database | PostgreSQL + PostGIS via Prisma v6 ORM |
| Storage | `DatabaseStorageAdapter` (Prisma), localStorage fallback |
| Files | MinIO (S3-compatible object storage) |
| Auth | NextAuth v4 + Authentik OIDC (auth.beletage.ro) |
| Proxy | Traefik v3 on `10.10.10.199` (proxy server), SSL via Let's Encrypt |
| Deploy | Docker multi-stage, Portainer CE on `10.10.10.166` (satra) |
| Repo | Gitea at `https://git.beletage.ro/gitadmin/ArchiTools` |
| Language | Code in **English**, UI in **Romanian** |
| Deploy | Docker multi-stage → Portainer CE → Traefik v3 + SSL |
| Repo | Gitea at `git.beletage.ro/gitadmin/ArchiTools` |
| Language | Code: **English**, UI: **Romanian** |
### Architecture Principles
- **Module platform, 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)
- **Cross-module tagging system** as shared service
- **Auth via Authentik SSO** — NextAuth v4 + OIDC, group→role/company mapping
- **All entities** include `visibility` / `createdBy` fields from day one
- **Company logos** — theme-aware (light/dark variants), dual-rendered for SSR safety
- **Module platform** — each module isolated: own types/services/hooks/components
- **Feature flags** gate loading (disabled = zero bundle cost)
- **Storage abstraction** via `StorageService` interface + adapters
- **Auth via Authentik SSO** — group → role/company mapping
- **All entities** include `visibility` / `createdBy` from day one
---
@@ -56,245 +45,45 @@ It runs on two on-premise servers, containerized with Docker, managed via Portai
```
src/
├── app/ # Routing only (thin wrappers)
│ ├── (modules)/ # Module route pages
│ └── layout.tsx # App shell
├── core/ # Platform services
│ ├── module-registry/ # Module registration + types
│ ├── feature-flags/ # Flag evaluation + env override
│ ├── storage/ # StorageService + adapters
│ └── adapters/ # localStorage adapter (+ future DB/MinIO)
── tagging/ # Cross-module tag service
│ ├── i18n/ # Romanian translations
│ ├── theme/ # Light/dark theme
│ └── auth/ # Auth types + stub (future Authentik)
├── modules/ # Module business logic
│ ├── <module-name>/
│ │ ├── components/ # Module UI components
│ │ ├── hooks/ # Module-specific hooks
│ │ ├── services/ # Module business logic
│ │ ├── types.ts # Module types
│ │ ├── config.ts # Module metadata
│ │ └── index.ts # Public exports
│ └── ...
├── shared/ # Shared UI
│ ├── components/
│ │ ├── ui/ # shadcn/ui primitives
│ │ ├── layout/ # Sidebar, Header
│ │ └── common/ # Reusable app components
│ ├── hooks/ # Shared hooks
│ └── lib/ # Utils (cn, etc.)
├── config/ # Global config
│ ├── modules.ts # Module registry entries
│ ├── flags.ts # Default feature flags
│ ├── navigation.ts # Sidebar nav structure
│ └── companies.ts # Company definitions
docs/ # 16 internal technical docs
legacy/ # Original HTML tools for reference
├── app/(modules)/ # Route pages (thin wrappers)
├── core/ # Platform: auth, storage, flags, tagging, i18n, theme
├── modules/<name>/ # Module business logic (see MODULE-MAP.md)
├── components/ # UI components
│ ├── hooks/ # Module hooks
│ ├── services/ # Business logic
│ ├── types.ts # Interfaces
├── config.ts # Module metadata
── index.ts # Public exports
├── shared/components/ # ui/ (shadcn), layout/ (sidebar/header), common/
├── config/ # modules.ts, flags.ts, navigation.ts, companies.ts
docs/ # Architecture, guides, module deep-dives
```
---
## Implemented Modules (16 total — 14 original + 2 new)
## Modules (17 total)
| # | Module | Route | Version | Key Features |
| --- | ---------------------- | --------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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 |
| 3 | **Word XML Generator** | `/word-xml` | 0.1.0 | Category-based XML gen, simple/advanced mode, ZIP export |
| 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) |
| 5 | **Tag Manager** | `/tag-manager` | 0.2.0 | CRUD tags, category/scope/color, US/SDT seeds, mandatory categories, **ManicTime bidirectional sync** |
| 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) |
| 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) |
| 10 | **Prompt Generator** | `/prompt-generator` | 0.2.0 | Template-driven prompt builder, **18 templates** (14 text + 4 image), search bar, target type filter |
| 11 | **Digital Signatures** | `/digital-signatures` | 0.1.0 | CRUD assets, drag-and-drop file upload, tag chips |
| 12 | **Word Templates** | `/word-templates` | 0.1.0 | Template library, 8 categories, version tracking, .docx placeholder auto-detection |
| 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 |
| 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 |
| Module | Route | Key Features |
| ------------------ | ------------------- | --------------------------------------------------- |
| Dashboard | `/` | KPI cards, activity feed, module grid |
| Email Signature | `/email-signature` | Multi-company, live preview, copy/download |
| Word XML | `/word-xml` | Category-based XML, simple/advanced, ZIP export |
| Registratura | `/registratura` | Registry CRUD, legal deadlines, notifications, NAS |
| Tag Manager | `/tag-manager` | Tags CRUD, ManicTime sync |
| IT Inventory | `/it-inventory` | Equipment, rack visualization, filters |
| Address Book | `/address-book` | Contacts, vCard, Registratura integration |
| Password Vault | `/password-vault` | AES-256-GCM encrypted, WiFi QR, multi-user |
| Mini Utilities | `/mini-utilities` | 12+ tools: PDF compress, OCR, converters, calc |
| Prompt Generator | `/prompt-generator` | 18 templates, text + image targets |
| Digital Signatures | `/digital-signatures` | Assets CRUD, file upload, tags |
| Word Templates | `/word-templates` | Template library, .docx placeholder detection |
| AI Chat | `/ai-chat` | Multi-provider (OpenAI/Claude/Ollama) |
| Hot Desk | `/hot-desk` | 4 desks, week calendar, room layout |
| ParcelSync | `/parcel-sync` | eTerra ANCPI, PostGIS, enrichment, ePay ordering |
| Geoportal | `/geoportal` | MapLibre viewer, parcel search, UAT layers |
| Visual CoPilot | `/visual-copilot` | Placeholder — separate repo |
### Registratura — Legal Deadline Tracking (Termene Legale)
The Registratura module includes a full legal deadline tracking engine for Romanian construction permitting:
- **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)
- **Chain deadlines** (resolving one prompts adding the next — e.g., CU analiza → emitere, PUZ/PUD analiza → post-CTATU → emitere)
- **Tacit approval** (auto-detected when overdue + applicable type)
- **Tabbed UI**: "Registru" tab (existing registry) + "Termene legale" tab (deadline dashboard)
- **Email notifications**: daily digest via Brevo SMTP, per-user opt-in/opt-out preferences, N8N cron trigger
Key files:
- `services/working-days.ts` — Romanian holidays, `addWorkingDays()`, `isWorkingDay()`
- `services/deadline-catalog.ts` — 18 `DeadlineTypeDef` entries across 6 categories
- `services/deadline-service.ts``createTrackedDeadline()`, `resolveDeadline()`, `aggregateDeadlines()`
- `components/attachment-preview.tsx` — QuickLook-style fullscreen preview (images: zoom/pan, PDFs: blob URL iframe, multi-file nav)
- `components/deadline-dashboard.tsx` — Stats + filters + table
- `components/deadline-add-dialog.tsx` — 3-step wizard (category → type → date preview)
- `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
The Address Book supports both persons and institutions:
- **Flexible validation**: either `name` OR `company` required (not both mandatory)
- **Auto-type detection**: when only company is set via quick-create, type defaults to "institution"
- **ContactPerson sub-entities**: each has `name`, `department`, `role`, `email`, `phone`
- **Quick contact creation from Registratura**: inline dialog with name + company + phone + email
- **Display logic**: if no name, company shows as primary; if both, shows "Name (Company)"
- **Creatable types**: dropdown with defaults (client/supplier/institution/collaborator/internal) + user-created custom types
Key files:
- `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
- `modules/address-book/services/vcard-export.ts` — vCard 3.0 export
- `modules/registratura/components/quick-contact-dialog.tsx` — Quick create from registry
### PDF Compression — Dual Mode (Local + Cloud)
Two compression routes, both with streaming upload support for large files (tested up to 287MB):
- **Local (qpdf)**: lossless structural optimization — stream compression, object dedup, linearization. Safe, no font corruption. Typical reduction: 3-15%.
- **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.
**Architecture** (zero-memory for any file size):
1. `parseMultipartUpload()` streams request body to disk (constant 64KB memory)
2. Scans raw file for multipart boundaries using `findInFile()` with 64KB sliding window
3. Stream-copies PDF bytes to separate file
4. Route handler processes (qpdf exec or iLovePDF API) and streams response back
**Critical gotchas**:
- Middleware body buffering: `api/compress-pdf` routes are **excluded from middleware matcher** (middleware buffers entire body at 10MB default)
- Auth: route-level `requireAuth()` instead of middleware (in `auth-check.ts`)
- Unicode filenames: `Content-Disposition` header uses `encodeURIComponent()` to avoid ByteString errors with Romanian chars (Ș, Ț, etc.)
- Ghostscript `-sDEVICE=pdfwrite` destroys font encodings — **never use GS for compression**, only qpdf
Key files:
- `app/api/compress-pdf/parse-upload.ts` — Streaming multipart parser (zero memory)
- `app/api/compress-pdf/extreme/route.ts` — qpdf local compression
- `app/api/compress-pdf/cloud/route.ts` — iLovePDF API integration
- `app/api/compress-pdf/auth-check.ts` — Shared auth for routes excluded from middleware
### Email Notifications (Brevo SMTP)
Platform-level notification service for daily email digests:
- **Brevo SMTP relay** via nodemailer (port 587, STARTTLS)
- **N8N cron**: weekdays 8:00 → POST `/api/notifications/digest` with Bearer token
- **Per-user preferences**: stored in KeyValueStore (`notifications` namespace), toggle global opt-out + 3 notification types
- **Digest content**: urgent deadlines (<=5 days), overdue deadlines, expiring documents (CU/AC)
- **HTML email**: inline-styled table layout, color-coded rows (red/yellow/blue), per-company grouping
- **Sender**: "Alerte Termene" &lt;noreply@beletage.ro&gt;, test mode via `?test=true` query param
Key files:
- `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
See `docs/MODULE-MAP.md` for entry points, API routes, and cross-module deps.
---
@@ -302,91 +91,82 @@ git push origin main
### 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
- `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 }>`
- `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
- Prisma `$queryRaw` returns `unknown[]`cast with `as Array<{ field: type }>`
- `?? ""` on `{}` field produces `{}` not `string` — use `typeof` 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
- **Code**: English | **UI text**: Romanian | **IDs**: uuid v4
- **Dates**: ISO strings (`YYYY-MM-DD` display, full ISO timestamps)
- **Components**: functional, `'use client'` where needed
- **No emojis** in code or UI
### Storage Performance Rules
### Storage Performance (CRITICAL)
- **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
- **NEVER** `storage.list()` + `storage.get()` in loop — N+1 bug
- **ALWAYS** use `storage.exportAll()` or `storage.export(namespace)` for batch-load
- **NEVER** store base64 files in entity JSON — use `lightweight: true` for listing
- After mutations: optimistic update OR single `refresh()` — never both
### Module Development Pattern
### Middleware & Large Uploads
Every module follows:
- Middleware buffers entire body — exclude large-upload routes from matcher
- Excluded routes: `api/auth|api/notifications/digest|api/compress-pdf|api/address-book|api/projects`
- Excluded routes use `requireAuth()` from `auth-check.ts` instead
- To add new upload route: (1) exclude from middleware, (2) add `requireAuth()`
```
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
```
### eTerra / ANCPI Rules
### 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 — `&quot;idDocument&quot;: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`
- ArcGIS: paginate with `resultOffset`/`resultRecordCount` (max 1000)
- Sessions expire ~10min — cache TTL 9min, auto-relogin on 401
- Health check detects maintenance — block login when down
- `WORKSPACE_TO_COUNTY` (42 entries in `county-refresh.ts`) is authoritative
- `GisUat.geometry` is huge — always `select` to exclude in list queries
- Feature counts cached 5-min TTL
- ePay: form-urlencoded body, OpenAM auth, MinIO metadata must be ASCII
### Before Pushing
1. `npx next build` must pass with zero errors
2. Test the feature manually on `localhost:3000`
1. `npx next build` — zero errors
2. Test on `localhost:3000`
3. Commit with descriptive message
4. `git push origin main` Portainer auto-deploys
4. `git push origin main` → manual Portainer redeploy
---
## Common Pitfalls (Top 10)
1. **Middleware body buffering** — upload routes >10MB must be excluded from matcher
2. **N+1 storage queries** — use `exportAll()`, never `list()` + `get()` loop
3. **GisUat geometry in queries** — exclude with `select`, or 50ms → 5+ seconds
4. **Enrichment data loss on re-sync** — upsert must preserve enrichment field
5. **Ghostscript corrupts fonts** — use qpdf for PDF compression, never GS
6. **eTerra timeout too low** — geometry pages need 60-90s; default 120s
7. **Traefik 60s readTimeout** — must set 600s in static config for uploads
8. **Portainer CE can't inject env vars** — all env in docker-compose.yml
9. **`@prisma/client` in dependencies** (not devDeps) — runtime requirement
10. **`output: 'standalone'`** in next.config.ts — required for Docker
---
## Infrastructure Quick Reference
| Service | Address | Purpose |
| ----------- | ------------------------ | -------------------------- |
| App | 10.10.10.166:3000 | ArchiTools (tools.beletage.ro) |
| PostgreSQL | 10.10.10.166:5432 | Database (Prisma) |
| MinIO | 10.10.10.166:9002/9003 | Object storage |
| Authentik | 10.10.10.166:9100 | SSO (auth.beletage.ro) |
| Portainer | 10.10.10.166:9000 | Docker management |
| Gitea | 10.10.10.166:3002 | Git (git.beletage.ro) |
| Traefik | 10.10.10.199 | Reverse proxy + SSL |
| N8N | 10.10.10.166:5678 | Workflow automation |
| Stirling PDF | 10.10.10.166:8087 | PDF tools (needs env vars!) |
## Company IDs
| ID | Name | Prefix |
@@ -398,66 +178,20 @@ src/modules/<name>/
---
## Current Integrations
## Documentation
| 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`) |
| Doc | Path |
| ------------------- | ------------------------------------------ |
| Module Map | `docs/MODULE-MAP.md` |
| Architecture Quick | `docs/ARCHITECTURE-QUICK.md` |
| System Architecture | `docs/architecture/SYSTEM-ARCHITECTURE.md` |
| Module System | `docs/architecture/MODULE-SYSTEM.md` |
| Feature Flags | `docs/architecture/FEATURE-FLAGS.md` |
| Storage Layer | `docs/architecture/STORAGE-LAYER.md` |
| Security & Roles | `docs/architecture/SECURITY-AND-ROLES.md` |
| Module Dev Guide | `docs/guides/MODULE-DEVELOPMENT.md` |
| Docker Deployment | `docs/guides/DOCKER-DEPLOYMENT.md` |
| Coding Standards | `docs/guides/CODING-STANDARDS.md` |
| Data Model | `docs/DATA-MODEL.md` |
---
## 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 |
For module-specific deep dives, see `docs/modules/`.
+5
View File
@@ -51,6 +51,9 @@ services:
- ANCPI_LOGIN_URL=https://oassl.ancpi.ro/openam/UI/Login
- ANCPI_DEFAULT_SOLICITANT_ID=14452
- MINIO_BUCKET_ANCPI=ancpi-documente
# Stirling PDF (local PDF tools)
- STIRLING_PDF_URL=http://10.10.10.166:8087
- STIRLING_PDF_API_KEY=cd829f62-6eef-43eb-a64d-c91af727b53a
# iLovePDF cloud compression (free: 250 files/month)
- ILOVEPDF_PUBLIC_KEY=${ILOVEPDF_PUBLIC_KEY:-}
# Martin vector tile server (geoportal)
@@ -65,6 +68,8 @@ services:
- NOTIFICATION_FROM_EMAIL=noreply@beletage.ro
- NOTIFICATION_FROM_NAME=Alerte Termene
- NOTIFICATION_CRON_SECRET=1547a198feca43af6c05622588c6d3b820bad5163b8c20175b2b5bbf8fc1a987
# Portal-only users (comma-separated, redirected to /portal)
- PORTAL_ONLY_USERS=dtiurbe,d.tiurbe
# Address Book API (inter-service auth for external tools)
- ADDRESSBOOK_API_KEY=abook-7f3e9a2b4c1d8e5f6a0b3c7d9e2f4a1b5c8d0e3f6a9b2c5d8e1f4a7b0c3d6e
depends_on:
+80
View File
@@ -0,0 +1,80 @@
# ArchiTools — Architecture Quick Reference
## Data Flow
```
Browser → Traefik (tools.beletage.ro) → Next.js :3000
├── App Router (pages)
├── API Routes (/api/*)
│ ├── Prisma → PostgreSQL + PostGIS
│ ├── MinIO (file storage)
│ ├── eTerra ANCPI (external GIS API)
│ └── Brevo SMTP (email notifications)
└── Auth: NextAuth → Authentik OIDC
```
## Module Dependencies
```
registratura ←→ address-book (bidirectional: contacts + reverse lookup)
parcel-sync → geoportal (map components reuse)
geoportal → PostGIS (spatial queries, vector tiles)
parcel-sync → eTerra API (external: ANCPI cadastral data)
parcel-sync → ePay API (external: ANCPI CF extract ordering)
parcel-sync → MinIO (CF extract PDF storage)
notifications → registratura (deadline digest data)
all modules → core/storage (KeyValueStore via Prisma)
all modules → core/auth (Authentik SSO session)
```
## Critical API Routes (Write Operations)
| Route | Method | What it does | Auth |
| ---------------------------------- | ------ | ----------------------------------- | --------- |
| `/api/storage` | PUT/DELETE | KeyValueStore CRUD | Middleware |
| `/api/registratura` | POST/PUT/DELETE | Registry entries + audit | Middleware + Bearer |
| `/api/registratura/reserved` | POST | Reserve future registry slots | Middleware |
| `/api/registratura/debug-sequences`| POST/PATCH | Reset sequence counters | Admin only |
| `/api/vault` | PUT/DELETE | Encrypted vault entries | Middleware |
| `/api/address-book` | PUT/DELETE | Contact CRUD | Middleware + Bearer |
| `/api/eterra/sync-background` | POST | Start GIS sync job | Middleware |
| `/api/eterra/uats` | POST/PATCH | UAT management + county refresh | Middleware |
| `/api/ancpi/order` | POST | ePay CF extract order | Middleware |
| `/api/notifications/digest` | POST | Trigger email digest | Bearer |
| `/api/notifications/preferences` | PUT | User notification prefs | Middleware |
| `/api/compress-pdf/*` | POST | PDF compression/unlock | requireAuth |
## Storage Architecture
```
KeyValueStore (Prisma) GisFeature (PostGIS) MinIO
├── namespace: module-id ├── layerId + objectId ├── bucket: tools
├── key: entity UUID ├── geometry (GeoJSON) ├── bucket: ancpi-cf
└── value: JSON blob ├── enrichment (JSONB) └── PDF files
└── geom (native PostGIS)
```
## Auth Flow
```
User → /auth/signin → Authentik OIDC → callback → NextAuth session
├── Middleware: checks JWT token, redirects if unauthenticated
├── Portal-only users: env PORTAL_ONLY_USERS → redirected to /portal
└── API routes excluded from middleware: use requireAuth() or Bearer token
```
## Environment Variables (Critical)
| Var | Required | Used by |
| ---------------------- | -------- | -------------------------- |
| `DATABASE_URL` | Yes | Prisma |
| `NEXTAUTH_SECRET` | Yes | NextAuth JWT |
| `NEXTAUTH_URL` | Yes | Auth redirects |
| `ENCRYPTION_SECRET` | Yes | Password Vault AES-256 |
| `STIRLING_PDF_URL` | Yes | PDF compression/unlock |
| `STIRLING_PDF_API_KEY` | Yes | Stirling PDF auth |
| `NOTIFICATION_CRON_SECRET` | Yes | Digest endpoint Bearer |
| `MINIO_*` | Yes | MinIO connection |
| `ANCPI_*` | For ePay | ePay CF ordering |
| `ILOVEPDF_PUBLIC_KEY` | Optional | Cloud PDF compression |
| `PORTAL_ONLY_USERS` | Optional | Comma-separated usernames |
+140
View File
@@ -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>&lt;${root}&gt;</code>`;
}
// --- GENERARE XML PENTRU O CATEGORIE ---
function generateCategory(cat) {
const entry = categories[cat];
if (!entry) return { xml: "", xpaths: "" };
const raw = (entry.fieldsText || "").split(/\r?\n/)
.map(l => l.trim())
.filter(l => l.length > 0);
if (raw.length === 0) {
return { xml: "", xpaths: "" };
}
const ns = getCategoryNamespace(cat);
const root = getCategoryRoot(cat);
const computeMetrics = document.getElementById("computeMetrics").checked;
const usedNames = new Set();
const fields = []; // { label, baseName, variants: [] }
for (const label of raw) {
const base = sanitizeName(label);
if (!base) continue;
let baseName = base;
let idx = 2;
while (usedNames.has(baseName)) {
baseName = base + "_" + idx;
idx++;
}
usedNames.add(baseName);
const variants = [baseName];
if (mode === "advanced") {
const advCandidates = [
baseName + "Short",
baseName + "Upper",
baseName + "Lower",
baseName + "Initials",
baseName + "First"
];
for (let v of advCandidates) {
let vn = v;
let k = 2;
while (usedNames.has(vn)) {
vn = v + "_" + k;
k++;
}
usedNames.add(vn);
variants.push(vn);
}
}
fields.push({ label, baseName, variants });
}
// detectăm câmpuri pentru metrici (în special categoria Suprafete)
const extraMetricFields = [];
if (computeMetrics && cat.toLowerCase().includes("suprafete")) {
const hasTeren = fields.some(f => f.baseName.toLowerCase().includes("suprafatateren"));
const hasLaSol = fields.some(f => f.baseName.toLowerCase().includes("suprafataconstruitalasol"));
const hasDesf = fields.some(f => f.baseName.toLowerCase().includes("suprafatadesfasurata"));
if (hasTeren && hasLaSol) {
if (!usedNames.has("POT")) {
usedNames.add("POT");
extraMetricFields.push({ label: "Procent Ocupare Teren", baseName: "POT", variants: ["POT"] });
}
}
if (hasTeren && hasDesf) {
if (!usedNames.has("CUT")) {
usedNames.add("CUT");
extraMetricFields.push({ label: "Coeficient Utilizare Teren", baseName: "CUT", variants: ["CUT"] });
}
}
}
// generăm XML
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += `<${root} xmlns="${ns}">\n`;
const allFieldEntries = fields.concat(extraMetricFields);
for (const f of allFieldEntries) {
for (const v of f.variants) {
xml += ` <${v}></${v}>\n`;
}
}
xml += `</${root}>\n`;
// generăm XPaths
let xp = `Categorie: ${cat}\nNamespace: ${ns}\nRoot: /${root}\n\n`;
for (const f of fields) {
xp += `# ${f.label}\n`;
for (const v of f.variants) {
xp += `/${root}/${v}\n`;
}
xp += `\n`;
}
if (extraMetricFields.length > 0) {
xp += `# Metrici auto (POT / CUT)\n`;
for (const f of extraMetricFields) {
for (const v of f.variants) {
xp += `/${root}/${v}\n`;
}
}
xp += `\n`;
}
return { xml, xpaths: xp };
}
// --- GENERARE PENTRU TOATE CATEGORIILE ---
function generateAll(showForCurrent = true) {
saveCurrentCategoryFields();
Object.keys(categories).forEach(cat => {
const { xml, xpaths } = generateCategory(cat);
xmlParts[cat] = xml;
xpathParts[cat] = xpaths;
});
if (showForCurrent) {
showPreview(currentCategory);
}
persistCategories();
}
// --- PREVIEW ---
function showPreview(cat) {
document.getElementById("xmlPreview").textContent = xmlParts[cat] || "<!-- Niciun XML generat încă pentru această categorie. -->";
document.getElementById("xpathPreview").textContent = xpathParts[cat] || "";
}
// --- DOWNLOAD: XML CATEGORIE ---
function downloadCurrentXml() {
generateAll(false);
const xml = xmlParts[currentCategory];
if (!xml) {
alert("Nu există XML generat pentru categoria curentă. Apasă întâi „Generează XML pentru toate categoriile”.");
return;
}
const root = getCategoryRoot(currentCategory);
const fileName = root + ".xml";
const blob = new Blob([xml], { type: "application/xml" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = fileName;
a.click();
URL.revokeObjectURL(a.href);
}
// --- DOWNLOAD: ZIP CU TOATE XML-URILE ---
async function downloadZipAll() {
generateAll(false);
const cats = Object.keys(categories);
if (cats.length === 0) {
alert("Nu există categorii.");
return;
}
const zip = new JSZip();
const folder = zip.folder("customXmlParts");
let hasAny = false;
for (const cat of cats) {
const xml = xmlParts[cat];
if (!xml) continue;
hasAny = true;
const root = getCategoryRoot(cat);
const fileName = root + ".xml";
folder.file(fileName, xml);
}
if (!hasAny) {
alert("Nu există XML generat încă. Apasă întâi „Generează XML pentru toate categoriile”.");
return;
}
const content = await zip.generateAsync({ type: "blob" });
const a = document.createElement("a");
a.href = URL.createObjectURL(content);
a.download = "beletage_custom_xml_parts.zip";
a.click();
URL.revokeObjectURL(a.href);
}
// --- INIT ---
window.addEventListener("DOMContentLoaded", () => {
initCategories();
updateNsRootInfo();
generateAll();
});
</script>
</body>
</html>
@@ -1,151 +0,0 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<title>Generator XML Word Versiune Extinsă</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: system-ui, sans-serif;
padding: 1.5rem;
background: #0f172a;
color: #e5e7eb;
}
.card {
background: #020617;
border-radius: 1rem;
padding: 1.25rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
border: 1px solid #1e293b;
margin-bottom: 1rem;
}
label { font-size: .85rem; color: #94a3b8; }
input, textarea {
width: 100%; padding: .55rem .7rem;
border-radius: .5rem; border: 1px solid #334155;
background: #020617; color: #e5e7eb;
}
textarea { min-height: 120px; }
button {
padding: .6rem 1.2rem; border-radius: 999px; border: none;
background: linear-gradient(135deg,#38bdf8,#6366f1);
font-weight: 600; color: white; cursor: pointer;
}
pre {
background: #000; padding: .8rem; border-radius: .7rem;
border: 1px solid #1e293b; max-height: 350px; overflow: auto;
font-size: .85rem;
}
</style>
</head>
<body>
<h1>Generator Word XML Varianta Extinsă (cu Short / Upper / Lower / Initials)</h1>
<div class="card">
<label>Namespace URI</label>
<input id="nsUri" value="http://schemas.beletage.ro/word/contract">
<label style="margin-top:1rem;">Element rădăcină</label>
<input id="rootElement" value="ContractData">
<label style="margin-top:1rem;">Lista de câmpuri (unul pe linie)</label>
<textarea id="fieldList">NumeClient
TitluProiect
Adresa</textarea>
<button onclick="generateXML()" style="margin-top:1rem;">Generează XML complet</button>
</div>
<div class="card">
<h3>Custom XML Part (item1.xml)</h3>
<pre id="xmlOutput"></pre>
<button onclick="downloadXML()">Descarcă XML</button>
</div>
<div class="card">
<h3>XPaths pentru mapping</h3>
<pre id="xpathOutput"></pre>
</div>
<script>
function sanitize(name) {
if (!name) return null;
let n = name.trim();
if (!n) return null;
n = n.replace(/\s+/g,"_").replace(/[^A-Za-z0-9_.-]/g,"");
if (!/^[A-Za-z_]/.test(n)) n = "_" + n;
return n;
}
function initials(str) {
return str.split(/\s+/).map(s => s[0]?.toUpperCase() + ".").join("");
}
function generateXML() {
const ns = document.getElementById("nsUri").value.trim();
const root = sanitize(document.getElementById("rootElement").value) || "Root";
const fieldRaw = document.getElementById("fieldList").value;
const lines = fieldRaw.split(/\r?\n/)
.map(l => l.trim()).filter(l => l.length);
const fields = [];
for (let l of lines) {
const base = sanitize(l);
if (!base) continue;
fields.push({
base,
variants: [
base, // original
base + "Short", // prescurtat
base + "Upper", // caps
base + "Lower", // lowercase
base + "Initials", // inițiale
base + "First" // primul cuvânt
]
});
}
// === GENERĂM XML ===
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += `<${root} xmlns="${ns}">\n`;
for (const f of fields) {
for (const v of f.variants) {
xml += ` <${v}></${v}>\n`;
}
}
xml += `</${root}>`;
document.getElementById("xmlOutput").textContent = xml;
// === GENERĂM XPATHS ===
let xp = `Namespace: ${ns}\nRoot: /${root}\n\n`;
for (const f of fields) {
xp += `# ${f.base}\n`;
xp += `/${root}/${f.base}\n`;
xp += `/${root}/${f.base}Short\n`;
xp += `/${root}/${f.base}Upper\n`;
xp += `/${root}/${f.base}Lower\n`;
xp += `/${root}/${f.base}Initials\n`;
xp += `/${root}/${f.base}First\n\n`;
}
document.getElementById("xpathOutput").textContent = xp;
}
function downloadXML() {
const text = document.getElementById("xmlOutput").textContent;
const blob = new Blob([text], { type: "application/xml" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "item1.xml";
a.click();
}
</script>
</body>
</html>
@@ -1,330 +0,0 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<title>Generator Word XML Custom Part</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
margin: 0;
padding: 1.5rem;
background: #0f172a;
color: #e5e7eb;
}
h1 {
font-size: 1.6rem;
margin-bottom: 0.5rem;
}
.container {
max-width: 1100px;
margin: 0 auto;
}
.card {
background: #020617;
border-radius: 1rem;
padding: 1.25rem 1.5rem;
box-shadow: 0 15px 40px rgba(0,0,0,0.35);
border: 1px solid #1f2937;
margin-bottom: 1rem;
}
label {
display: block;
font-size: 0.85rem;
color: #9ca3af;
margin-bottom: 0.25rem;
}
input, textarea {
width: 100%;
box-sizing: border-box;
padding: 0.5rem 0.6rem;
border-radius: 0.5rem;
border: 1px solid #374151;
background: #020617;
color: #e5e7eb;
font-family: inherit;
font-size: 0.9rem;
outline: none;
}
input:focus, textarea:focus {
border-color: #38bdf8;
box-shadow: 0 0 0 1px #38bdf8;
}
textarea {
min-height: 140px;
resize: vertical;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.col-6 {
flex: 1 1 260px;
}
button {
padding: 0.6rem 1.2rem;
border-radius: 999px;
border: none;
font-size: 0.9rem;
cursor: pointer;
margin-top: 0.75rem;
background: linear-gradient(135deg, #38bdf8, #6366f1);
color: white;
font-weight: 600;
box-shadow: 0 10px 25px rgba(37, 99, 235, 0.4);
}
button:hover {
filter: brightness(1.05);
transform: translateY(-1px);
}
button:active {
transform: translateY(0);
box-shadow: 0 6px 18px rgba(37,99,235,0.6);
}
pre {
background: #020617;
border-radius: 0.75rem;
padding: 0.75rem 1rem;
overflow: auto;
font-size: 0.8rem;
border: 1px solid #1f2937;
max-height: 360px;
}
.subtitle {
font-size: 0.85rem;
color: #9ca3af;
margin-bottom: 1rem;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
border-radius: 999px;
background: rgba(148, 163, 184, 0.2);
margin-right: 0.25rem;
margin-bottom: 0.25rem;
}
.pill span {
opacity: 0.8;
}
.small {
font-size: 0.8rem;
color: #9ca3af;
margin-top: 0.4rem;
}
.btn-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
}
.btn-secondary {
background: transparent;
border: 1px solid #4b5563;
box-shadow: none;
color: #e5e7eb;
}
.btn-secondary:hover {
background: #111827;
box-shadow: 0 8px 18px rgba(0,0,0,0.5);
}
@media (max-width: 640px) {
body {
padding: 1rem;
}
.card {
padding: 1rem;
}
}
</style>
</head>
<body>
<div class="container">
<h1>Generator XML pentru Word Custom XML Part</h1>
<p class="subtitle">
Introdu câmpurile (unul pe linie) și obții XML pentru <strong>Custom XML Part</strong>, plus XPaths pentru mapping în Word.
</p>
<div class="card">
<div class="row">
<div class="col-6">
<label for="nsUri">Namespace URI (obligatoriu)</label>
<input id="nsUri" type="text"
value="http://schemas.beletage.ro/word/data">
<div class="small">
Exemplu: <code>http://schemas.firma-ta.ro/word/contract</code>
</div>
</div>
<div class="col-6">
<label for="rootElement">Nume element rădăcină</label>
<input id="rootElement" type="text" value="Root">
<div class="small">
Exemplu: <code>ContractData</code>, <code>ClientInfo</code> etc.
</div>
</div>
</div>
<div style="margin-top:1rem;">
<label for="fieldList">Lista de câmpuri (unul pe linie)</label>
<textarea id="fieldList" placeholder="Exemplu:
NumeClient
Adresa
DataContract
ValoareTotala"></textarea>
<div class="small">
Numele va fi curățat automat pentru a fi valid ca nume de element XML
(spațiile devin <code>_</code>, caracterele ciudate se elimină).
</div>
</div>
<div class="btn-row">
<button type="button" onclick="generateXML()">Generează XML</button>
<button type="button" class="btn-secondary" onclick="fillDemo()">Exemplu demo</button>
</div>
</div>
<div class="card">
<div class="pill"><strong>1</strong><span>Custom XML Part (item1.xml)</span></div>
<pre id="xmlOutput"></pre>
<div class="btn-row">
<button type="button" class="btn-secondary" onclick="copyToClipboard('xmlOutput')">
Copiază XML
</button>
<button type="button" class="btn-secondary" onclick="downloadXML()">
Descarcă item1.xml
</button>
</div>
</div>
<div class="card">
<div class="pill"><strong>2</strong><span>XPaths pentru mapping în Word</span></div>
<pre id="xpathOutput"></pre>
<button type="button" class="btn-secondary" onclick="copyToClipboard('xpathOutput')">
Copiază XPaths
</button>
<p class="small">
În Word &rarr; <strong>Developer</strong> &rarr; <strong>XML Mapping Pane</strong> &rarr; alegi Custom XML Part-ul
&rarr; pentru fiecare câmp, click dreapta &rarr; <em>Insert Content Control</em> &rarr; tipul dorit.
</p>
</div>
</div>
<script>
function sanitizeXmlName(name) {
if (!name) return null;
let n = name.trim();
if (!n) return null;
// înlocuim spații cu underscore
n = n.replace(/\s+/g, "_");
// eliminăm caractere invalide pentru nume de element XML
n = n.replace(/[^A-Za-z0-9_.-]/g, "");
// numele XML nu are voie să înceapă cu cifră sau punct sau cratimă
if (!/^[A-Za-z_]/.test(n)) {
n = "_" + n;
}
return n || null;
}
function generateXML() {
const nsUri = document.getElementById("nsUri").value.trim();
const root = sanitizeXmlName(document.getElementById("rootElement").value) || "Root";
const fieldRaw = document.getElementById("fieldList").value;
const xmlOutput = document.getElementById("xmlOutput");
const xpathOutput = document.getElementById("xpathOutput");
if (!nsUri) {
alert("Te rog completează Namespace URI.");
return;
}
const lines = fieldRaw.split(/\r?\n/)
.map(l => l.trim())
.filter(l => l.length > 0);
const fields = [];
const used = new Set();
for (let line of lines) {
const clean = sanitizeXmlName(line);
if (!clean) continue;
let finalName = clean;
let idx = 2;
while (used.has(finalName)) {
finalName = clean + "_" + idx;
idx++;
}
used.add(finalName);
fields.push({ original: line, xmlName: finalName });
}
if (fields.length === 0) {
xmlOutput.textContent = "<!-- Niciun câmp valid. Completează lista de câmpuri. -->";
xpathOutput.textContent = "";
return;
}
// Generăm XML-ul pentru Custom XML Part
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += `<${root} xmlns="${nsUri}">\n`;
for (const f of fields) {
xml += ` <${f.xmlName}></${f.xmlName}>\n`;
}
xml += `</${root}>\n`;
xmlOutput.textContent = xml;
// Generăm lista de XPaths
let xpaths = `Namespace: ${nsUri}\nRoot: /${root}\n\nCâmpuri:\n`;
for (const f of fields) {
xpaths += `- ${f.original} => /${root}/${f.xmlName}\n`;
}
xpathOutput.textContent = xpaths;
}
function copyToClipboard(elementId) {
const el = document.getElementById(elementId);
if (!el || !el.textContent) return;
navigator.clipboard.writeText(el.textContent)
.then(() => alert("Copiat în clipboard."))
.catch(() => alert("Nu am reușit să copiez în clipboard."));
}
function downloadXML() {
const xmlText = document.getElementById("xmlOutput").textContent;
if (!xmlText || xmlText.startsWith("<!--")) {
alert("Nu există XML valid de descărcat.");
return;
}
const blob = new Blob([xmlText], { type: "application/xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "item1.xml";
a.click();
URL.revokeObjectURL(url);
}
function fillDemo() {
document.getElementById("nsUri").value = "http://schemas.beletage.ro/word/contract";
document.getElementById("rootElement").value = "ContractData";
document.getElementById("fieldList").value = [
"NumeClient",
"AdresaClient",
"Proiect",
"DataContract",
"ValoareTotala",
"Moneda",
"TermenExecutie"
].join("\n");
generateXML();
}
</script>
</body>
</html>
+13
View File
@@ -6,6 +6,19 @@ const nextConfig: NextConfig = {
experimental: {
middlewareClientMaxBodySize: '500mb',
},
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
],
},
];
},
async rewrites() {
const martinUrl = process.env.MARTIN_URL || 'http://martin:3000';
return [
+1 -1
View File
@@ -42,7 +42,7 @@ model GisFeature {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
syncRun GisSyncRun? @relation(fields: [syncRunId], references: [id])
syncRun GisSyncRun? @relation(fields: [syncRunId], references: [id], onDelete: SetNull)
@@unique([layerId, objectId])
@@index([siruta])
+878
View File
@@ -0,0 +1,878 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/shared/components/ui/button";
import { Badge } from "@/shared/components/ui/badge";
import { Card, CardContent } from "@/shared/components/ui/card";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/shared/components/ui/tabs";
import {
Copy,
Check,
ChevronDown,
ChevronRight,
Terminal,
Bug,
Sparkles,
Shield,
TestTube,
Plug,
RefreshCw,
FileText,
Zap,
Rocket,
ListChecks,
Brain,
Wrench,
} from "lucide-react";
// ─── Types ─────────────────────────────────────────────────────────
type PromptCategory = {
id: string;
label: string;
icon: React.ReactNode;
description: string;
prompts: PromptTemplate[];
};
type PromptTemplate = {
id: string;
title: string;
description: string;
prompt: string;
tags: string[];
oneTime?: boolean;
variables?: string[];
};
// ─── Module list for variable substitution hints ───────────────────
const MODULES = [
"registratura", "address-book", "parcel-sync", "geoportal", "password-vault",
"mini-utilities", "email-signature", "word-xml", "word-templates", "tag-manager",
"it-inventory", "digital-signatures", "prompt-generator", "ai-chat", "hot-desk",
"visual-copilot", "dashboard",
] as const;
// ─── Prompt Templates ──────────────────────────────────────────────
const CATEGORIES: PromptCategory[] = [
{
id: "module-work",
label: "Lucru pe modul",
icon: <Terminal className="size-4" />,
description: "Prompturi pentru lucru general, bugfix-uri si features pe module existente",
prompts: [
{
id: "module-work-general",
title: "Lucru general pe modul",
description: "Sesiune de lucru pe un modul specific — citeste contextul, propune, implementeaza",
tags: ["regular", "module"],
variables: ["MODULE_NAME"],
prompt: `Scopul acestei sesiuni este sa lucram pe modulul {MODULE_NAME} din ArchiTools.
Citeste CLAUDE.md si docs/MODULE-MAP.md inainte de orice.
Apoi citeste tipurile si componentele modulului:
- src/modules/{MODULE_NAME}/types.ts
- src/modules/{MODULE_NAME}/config.ts
- src/modules/{MODULE_NAME}/components/ (fisierele principale)
- src/modules/{MODULE_NAME}/services/ (daca exista)
Dupa ce ai inteles codul existent, intreaba-ma ce vreau sa fac.
Nu propune schimbari pana nu intelegi modulul complet.
npx next build TREBUIE sa treaca dupa fiecare schimbare.`,
},
{
id: "bugfix",
title: "Bugfix pe modul",
description: "Investigheaza si rezolva un bug specific",
tags: ["regular", "bugfix"],
variables: ["MODULE_NAME", "BUG_DESCRIPTION"],
prompt: `Am un bug in modulul {MODULE_NAME}: {BUG_DESCRIPTION}
Citeste CLAUDE.md si docs/MODULE-MAP.md pentru context.
Pasi:
1. Citeste fisierele relevante ale modulului
2. Identifica cauza root — nu ghici, citeste codul
3. Propune fix-ul INAINTE sa-l aplici
4. Aplica fix-ul minimal (nu refactoriza alte lucruri)
5. Verifica ca npx next build trece
6. Explica ce s-a schimbat si de ce
Daca bug-ul e in interactiunea cu alt modul sau API, citeste si acel cod.
Nu adauga features sau "imbunatatiri" — doar fix bug-ul raportat.`,
},
{
id: "feature-existing",
title: "Feature nou in modul existent",
description: "Adauga o functionalitate noua intr-un modul care exista deja",
tags: ["regular", "feature"],
variables: ["MODULE_NAME", "FEATURE_DESCRIPTION"],
prompt: `Vreau sa adaug un feature in modulul {MODULE_NAME}: {FEATURE_DESCRIPTION}
Citeste CLAUDE.md si docs/MODULE-MAP.md pentru context.
Pasi obligatorii:
1. Citeste types.ts, config.ts si componentele existente ale modulului
2. Verifica daca feature-ul necesita schimbari de tip (types.ts)
3. Verifica daca necesita API route nou sau modificare la cel existent
4. Propune planul de implementare INAINTE de a scrie cod
5. Implementeaza pas cu pas, verificand build dupa fiecare fisier major
6. Pastreaza conventiile existente (English code, Romanian UI)
7. npx next build TREBUIE sa treaca
Reguli:
- Nu schimba structura modulului fara motiv
- Nu adauga dependinte noi daca nu e necesar
- Pastreaza compatibilitatea cu datele existente in storage/DB
- Daca trebuie migrare de date, propune-o separat`,
},
{
id: "feature-new-module",
title: "Modul complet nou",
description: "Creeaza un modul nou de la zero urmand pattern-ul standard",
tags: ["one-time", "feature", "architecture"],
oneTime: true,
variables: ["MODULE_NAME", "MODULE_DESCRIPTION"],
prompt: `Vreau sa creez un modul nou: {MODULE_NAME} — {MODULE_DESCRIPTION}
Citeste CLAUDE.md, docs/MODULE-MAP.md si docs/guides/MODULE-DEVELOPMENT.md.
Studiaza un modul existent similar ca referinta (ex: it-inventory sau hot-desk pentru module simple, registratura pentru module complexe).
Creeaza structura standard:
src/modules/{MODULE_NAME}/
components/{MODULE_NAME}-module.tsx
hooks/use-{MODULE_NAME}.ts (daca e nevoie)
services/ (daca e nevoie)
types.ts
config.ts
index.ts
Plus:
- src/app/(modules)/{MODULE_NAME}/page.tsx (route page)
- Adauga config in src/config/modules.ts
- Adauga flag in src/config/flags.ts
- Adauga navigare in src/config/navigation.ts
Reguli:
- Urmeaza EXACT pattern-ul celorlalte module
- English code, Romanian UI text
- Feature flag enabled by default
- Storage via useStorage('{MODULE_NAME}') hook
- npx next build TREBUIE sa treaca
- Nu implementa mai mult decat MVP-ul — pot adauga dupa`,
},
],
},
{
id: "api",
label: "API & Backend",
icon: <Plug className="size-4" />,
description: "Creare si modificare API routes, integrari externe, Prisma schema",
prompts: [
{
id: "api-new-route",
title: "API route nou",
description: "Creeaza un endpoint API nou cu auth, validare, error handling",
tags: ["regular", "api"],
variables: ["ROUTE_PATH", "ROUTE_DESCRIPTION"],
prompt: `Creeaza un API route nou: /api/{ROUTE_PATH} — {ROUTE_DESCRIPTION}
Citeste CLAUDE.md (sectiunea Middleware & Large Uploads) si docs/ARCHITECTURE-QUICK.md.
Cerinte obligatorii:
1. Auth: middleware coverage SAU requireAuth() pentru rute excluse
2. Input validation pe toate parametrii
3. Error handling: try/catch cu mesaje utile (nu stack traces)
4. Prisma queries: parametrizate ($queryRaw cu template literals, NU string concat)
5. TypeScript strict: toate return types explicit
Pattern de referinta — citeste un API route existent similar:
- CRUD simplu: src/app/api/storage/route.ts
- Cu Prisma raw: src/app/api/registratura/route.ts
- Cu external API: src/app/api/eterra/search/route.ts
Daca ruta accepta uploads mari:
- Exclude din middleware matcher (src/middleware.ts)
- Adauga requireAuth() manual
- Documenteaza in CLAUDE.md sectiunea Middleware
npx next build TREBUIE sa treaca.`,
},
{
id: "prisma-schema",
title: "Modificare Prisma schema",
description: "Adauga model nou sau modifica schema existenta",
tags: ["regular", "database"],
variables: ["CHANGE_DESCRIPTION"],
prompt: `Vreau sa modific Prisma schema: {CHANGE_DESCRIPTION}
Citeste prisma/schema.prisma complet inainte.
Pasi:
1. Propune schimbarea de schema INAINTE de a o aplica
2. Verifica impactul asupra codului existent (grep pentru modelul afectat)
3. Aplica in schema.prisma
4. Ruleaza: npx prisma generate
5. Actualizeaza codul care foloseste modelul
6. npx next build TREBUIE sa treaca
Reguli:
- Adauga @@index pe coloane folosite in WHERE/ORDER BY
- Adauga @@unique pe combinatii care trebuie sa fie unice
- onDelete: SetNull sau Cascade — niciodata default (restrict)
- Foloseste Json? pentru campuri flexibile (enrichment pattern)
- DateTime cu @default(now()) pe createdAt, @updatedAt pe updatedAt
IMPORTANT: Aceasta schimbare necesita si migrare pe server (prisma migrate).
Nu face breaking changes fara plan de migrare.`,
},
{
id: "external-integration",
title: "Integrare API extern",
description: "Conectare la un serviciu extern (pattern eTerra/ePay)",
tags: ["one-time", "api", "architecture"],
oneTime: true,
variables: ["SERVICE_NAME", "SERVICE_DESCRIPTION"],
prompt: `Vreau sa integrez un serviciu extern: {SERVICE_NAME} — {SERVICE_DESCRIPTION}
Citeste CLAUDE.md sectiunile eTerra/ANCPI Rules si Middleware.
Studiaza pattern-ul din src/modules/parcel-sync/services/eterra-client.ts ca referinta.
Pattern obligatoriu pentru integrari externe:
1. Client class separat in services/ (nu inline in route)
2. Session/token caching cu TTL (global singleton pattern)
3. Periodic cleanup pe cache (setInterval)
4. Health check daca serviciul e instabil
5. Retry logic pentru erori tranziente (ECONNRESET, 500)
6. Timeout explicit pe toate request-urile
7. Error handling granular (nu catch-all generic)
8. Logging cu prefix: console.log("[{SERVICE_NAME}] ...")
Env vars:
- Adauga in docker-compose.yml
- NU hardcoda credentials in cod
- Documenteaza in docs/ARCHITECTURE-QUICK.md
npx next build TREBUIE sa treaca.`,
},
],
},
{
id: "quality",
label: "Calitate & Securitate",
icon: <Shield className="size-4" />,
description: "Audituri de securitate, testing, performance, code review",
prompts: [
{
id: "security-audit",
title: "Audit securitate complet",
description: "Scanare completa de securitate pe tot codebase-ul",
tags: ["periodic", "security"],
prompt: `Scopul acestei sesiuni este un audit complet de securitate al ArchiTools.
Aplicatia este IN PRODUCTIE la https://tools.beletage.ro.
Citeste CLAUDE.md si docs/ARCHITECTURE-QUICK.md inainte de orice.
Scaneaza cu agenti in paralel:
1. API AUTH: Verifica ca TOATE rutele din src/app/api/ au auth check
(middleware matcher + requireAuth fallback)
2. SQL INJECTION: Cauta $queryRaw/$executeRaw cu string concatenation
3. INPUT VALIDATION: Verifica sanitizarea pe toate endpoint-urile
4. SECRETS: Cauta credentials hardcoded, env vars expuse in client
5. ERROR HANDLING: Catch goale, stack traces in responses
6. RACE CONDITIONS: Write operations concurente fara locks
7. DATA INTEGRITY: Upsert-uri care pot suprascrie date
Grupeaza in: CRITICAL / IMPORTANT / NICE-TO-HAVE
Pentru fiecare: fisier, linia, problema, solutia propusa.
NU aplica fix-uri fara sa le listezi mai intai.
npx next build TREBUIE sa treaca dupa fiecare fix.`,
},
{
id: "security-module",
title: "Audit securitate pe modul",
description: "Review de securitate focusat pe un singur modul",
tags: ["regular", "security"],
variables: ["MODULE_NAME"],
prompt: `Fa un audit de securitate pe modulul {MODULE_NAME}.
Citeste CLAUDE.md si docs/MODULE-MAP.md.
Verifica:
1. API routes folosite de modul — au auth? Input validation?
2. Prisma queries — SQL injection posibil?
3. User input — sanitizat inainte de stocare/afisare?
4. File uploads (daca exista) — validare tip/dimensiune?
5. Storage operations — race conditions la concurrent access?
6. Error handling — erori silentioase? Stack traces expuse?
7. Cross-module deps — sunt corecte si necesare?
Raporteaza gasirile cu: fisier, linia, severitate, fix propus.`,
},
{
id: "testing-hardcore",
title: "Testing hardcore",
description: "Edge cases, stress testing, error scenarios pentru un modul",
tags: ["periodic", "testing"],
variables: ["MODULE_NAME"],
prompt: `Vreau sa testez hardcore modulul {MODULE_NAME}.
Citeste codul modulului complet, apoi gandeste:
1. EDGE CASES: Ce se intampla cu input gol? Cu caractere speciale (diacritice, emoji)? Cu valori extreme (numar foarte mare, string foarte lung)?
2. CONCURRENT ACCESS: Ce se intampla daca 2 useri fac aceeasi operatie simultan? Race conditions la write/update/delete?
3. ERROR PATHS: Ce se intampla daca DB-ul e down? Daca API-ul extern nu raspunde? Daca sesiunea expira mid-operation?
4. DATA INTEGRITY: Pot pierde date? Pot crea duplicate? Pot suprascrie datele altcuiva?
5. UI STATE: Ce se intampla daca user-ul da click dublu pe buton? Daca navigheaza away in timpul unui save? Daca face refresh?
6. STORAGE: Ce se intampla cu date legacy (format vechi)? Cu valori null/undefined in JSON?
Pentru fiecare problema gasita: descrie scenariul, impactul, si propune fix.
Aplica doar ce e CRITICAL dupa aprobare.`,
},
{
id: "performance-audit",
title: "Audit performanta",
description: "Identificare bottleneck-uri, optimizare queries, bundle size",
tags: ["periodic", "performance"],
variables: ["MODULE_NAME"],
prompt: `Analizeaza performanta modulului {MODULE_NAME}.
Citeste CLAUDE.md (Storage Performance Rules) si codul modulului.
Verifica:
1. N+1 QUERIES: storage.list() + get() in loop? Ar trebui exportAll()
2. LARGE PAYLOADS: Se incarca date inutile? lightweight: true folosit?
3. RE-RENDERS: useEffect-uri care trigger-uiesc re-render excesiv?
4. BUNDLE SIZE: Import-uri heavy (librarii intregi vs tree-shaking)?
5. API CALLS: Request-uri redundante? Lipseste caching?
6. DB QUERIES: Lipsesc indexuri? SELECT * in loc de select specific?
7. MEMORY: Global singletons care cresc nelimitat? Cache fara TTL?
Propune optimizarile ordonate dupa impact.
Aplica doar dupa aprobare.`,
},
],
},
{
id: "session",
label: "Sesiune & Continuare",
icon: <RefreshCw className="size-4" />,
description: "Prompturi pentru inceperea sau continuarea sesiunilor de lucru",
prompts: [
{
id: "continue-tasklist",
title: "Continuare din sesiunea anterioara",
description: "Reia lucrul de unde am ramas, cu verificare task list",
tags: ["regular", "session"],
prompt: `Continuam din sesiunea anterioara.
Citeste CLAUDE.md, MEMORY.md si docs/MODULE-MAP.md.
Verifica memory/ pentru context despre ce s-a lucrat recent.
Apoi:
1. Citeste ROADMAP.md (daca exista) pentru task list-ul curent
2. Verifica git log --oneline -20 sa vezi ce s-a comis recent
3. Verifica git status sa vezi daca sunt schimbari uncommited
4. Rezuma ce s-a facut si ce a ramas
5. Intreaba-ma cum vreau sa continuam
Nu incepe sa lucrezi fara confirmare.
npx next build TREBUIE sa treaca inainte de orice schimbare.`,
},
{
id: "fresh-session",
title: "Sesiune noua — orientare",
description: "Prima sesiune sau sesiune dupa pauza lunga — ia-ti bearings",
tags: ["regular", "session"],
prompt: `Sesiune noua pe ArchiTools.
Citeste in ordine:
1. CLAUDE.md (context proiect + reguli)
2. memory/MEMORY.md (index memorii)
3. Fiecare fisier din memory/ (context sesiuni anterioare)
4. git log --oneline -20 (activitate recenta)
5. git status (stare curenta)
6. docs/MODULE-MAP.md (harta module)
Dupa ce ai citit tot, da-mi un rezumat de 5-10 randuri:
- Ce s-a facut recent
- Ce e in progress / neterminat
- Ce probleme sunt cunoscute
- Recomandarea ta pentru ce sa facem azi
Asteapta confirmarea mea inainte de a incepe.`,
},
{
id: "review-refactor",
title: "Code review & refactoring",
description: "Review si curatare cod pe o zona specifica",
tags: ["periodic", "review"],
variables: ["TARGET"],
prompt: `Fa code review pe: {TARGET}
Citeste CLAUDE.md si codul tinta complet.
Verifica:
1. PATTERN COMPLIANCE: Urmeaza conventiile din CLAUDE.md?
2. TYPE SAFETY: TypeScript strict — sunt tipuri corecte? Null checks?
3. ERROR HANDLING: Catch blocks complete? Promise-uri handled?
4. NAMING: English code, Romanian UI? Consistent cu restul?
5. COMPLEXITY: Functii prea lungi? Logica duplicata?
6. SECURITY: Input validation? Auth checks?
7. PERFORMANCE: N+1 queries? Re-renders inutile?
Raporteaza gasirile ordonate dupa severitate.
NU aplica refactoring fara listarea schimbarilor propuse si aprobare.
Refactoring-ul trebuie sa fie minimal — nu rescrie ce functioneaza.`,
},
{
id: "full-audit",
title: "Audit complet codebase",
description: "Scanare completa: cod mort, consistenta, securitate, documentatie",
tags: ["periodic", "audit"],
oneTime: true,
prompt: `Scopul acestei sesiuni este un audit complet al codebase-ului ArchiTools cu 3 obiective:
1. REVIEW & CLEANUP: Cod mort, dependinte neutilizate, TODO/FIXME, consistenta module
2. SIGURANTA IN PRODUCTIE: SQL injection, auth gaps, race conditions, data integrity
3. DOCUMENTATIE: CLAUDE.md actualizat, docs/ la zi, memory/ updatat
Citeste CLAUDE.md si MEMORY.md inainte de orice.
Foloseste agenti Explore in paralel (minim 5 simultan) pentru scanare.
Grupeaza gasirile in: CRITICAL / IMPORTANT / NICE-TO-HAVE
NU modifica cod fara sa listezi mai intai toate schimbarile propuse.
npx next build TREBUIE sa treaca dupa fiecare schimbare.
Commit frecvent cu mesaje descriptive.`,
},
],
},
{
id: "docs",
label: "Documentatie & Meta",
icon: <FileText className="size-4" />,
description: "Update documentatie, CLAUDE.md, memory, si meta-prompting",
prompts: [
{
id: "update-claudemd",
title: "Actualizeaza CLAUDE.md",
description: "Sincronizeaza CLAUDE.md cu starea actuala a codului",
tags: ["periodic", "docs"],
prompt: `CLAUDE.md trebuie actualizat sa reflecte starea curenta a proiectului.
Pasi:
1. Citeste CLAUDE.md curent
2. Verifica fiecare sectiune contra codului real:
- Module table: sunt toate modulele? Versiuni corecte?
- Stack: versiuni la zi?
- Conventions: se respecta?
- Common Pitfalls: mai sunt relevante? Lipsesc altele noi?
- Infrastructure: porturi/servicii corecte?
3. Citeste docs/MODULE-MAP.md — e la zi?
4. Citeste docs/ARCHITECTURE-QUICK.md — e la zi?
Propune schimbarile necesare inainte de a le aplica.
Target: CLAUDE.md sub 200 linii, informatii derivabile din cod mutate in docs/.`,
},
{
id: "update-memory",
title: "Actualizeaza memory/",
description: "Curata memorii vechi si adauga context nou",
tags: ["periodic", "meta"],
prompt: `Verifica si actualizeaza memory/ files.
Citeste memory/MEMORY.md si fiecare fisier indexat.
Pentru fiecare memorie:
1. E inca relevanta? Daca nu, sterge-o.
2. Informatia e la zi? Daca nu, actualizeaz-o.
3. Informatia e derivabila din cod? Daca da, sterge-o (redundanta).
Adauga memorii NOI pentru:
- Decizii arhitecturale recente care nu sunt in CLAUDE.md
- Feedback-ul meu din aceasta sesiune (daca am corectat ceva)
- Starea task-urilor in progress
NU salva in memory: cod, structura fisierelor, git history — astea se pot citi direct.`,
},
{
id: "improve-prompts",
title: "Imbunatateste prompturile",
description: "Meta-prompt: analizeaza si rafineaza prompturile din aceasta pagina",
tags: ["periodic", "meta"],
prompt: `Citeste codul paginii /prompts (src/app/(modules)/prompts/page.tsx).
Analizeaza fiecare prompt din CATEGORIES:
1. E clar si specific? Lipseste context?
2. Pasii sunt in ordine logica?
3. Include safety nets (build check, aprobare)?
4. E prea lung/scurt?
5. Variabilele sunt utile?
Apoi gandeste: ce prompturi noi ar fi utile bazat pe:
- Tipurile de task-uri care apar frecvent in git log
- Module care sunt modificate des
- Greseli care se repeta (din memory/ feedback)
Propune imbunatatiri si prompturi noi. Aplica dupa aprobare.`,
},
],
},
{
id: "quick",
label: "Quick Actions",
icon: <Zap className="size-4" />,
description: "Prompturi scurte pentru actiuni rapide si frecvente",
prompts: [
{
id: "quick-build",
title: "Verifica build",
description: "Build check rapid",
tags: ["quick"],
prompt: `Ruleaza npx next build si raporteaza rezultatul. Daca sunt erori, propune fix-uri.`,
},
{
id: "deploy-prep",
title: "Pregatire deploy",
description: "Checklist complet inainte de push la productie",
tags: ["regular", "deploy"],
prompt: `Pregateste deploy-ul pe productie (tools.beletage.ro).
Checklist:
1. git status — totul comis? Fisiere untracked suspecte?
2. npx next build — zero erori?
3. docker-compose.yml — env vars noi necesare?
4. prisma/schema.prisma — s-a schimbat? Necesita migrate?
5. middleware.ts — rute noi excluse daca e cazul?
6. Verifica ca nu sunt credentials hardcoded in cod
7. git log --oneline -5 — commit messages descriptive?
Daca totul e ok, confirma "Ready to push".
Daca sunt probleme, listeaza-le cu fix propus.
IMPORTANT: Dupa push, deploy-ul e MANUAL in Portainer.
Daca schema Prisma s-a schimbat, trebuie migrate pe server.`,
},
{
id: "debug-unknown",
title: "Debug eroare necunoscuta",
description: "Investigheaza o eroare fara cauza evidenta",
tags: ["regular", "debug"],
variables: ["ERROR_DESCRIPTION"],
prompt: `Am o eroare: {ERROR_DESCRIPTION}
Investigheaza:
1. Citeste stack trace-ul (daca exista) — gaseste fisierul root cause
2. Citeste codul relevant — nu ghici, verifica
3. Cauta pattern-uri similare in codebase (grep)
4. Verifica git log recent — s-a schimbat ceva care ar cauza asta?
5. Verifica env vars — lipseste ceva?
6. Verifica Prisma schema — model-ul e in sync?
Dupa investigatie:
- Explica cauza root (nu simptomul)
- Propune fix minim
- Aplica fix dupa aprobare
- npx next build TREBUIE sa treaca`,
},
{
id: "quick-deps",
title: "Update dependinte",
description: "Verifica si actualizeaza package.json",
tags: ["quick"],
prompt: `Verifica daca sunt update-uri disponibile pentru dependintele din package.json.
Ruleaza: npm outdated
Listeaza ce se poate actualiza safe (minor/patch).
NU actualiza major versions fara discutie.
Dupa update: npx next build TREBUIE sa treaca.`,
},
{
id: "quick-git-cleanup",
title: "Git cleanup",
description: "Verifica starea repo-ului si curata",
tags: ["quick"],
prompt: `Verifica starea repo-ului:
1. git status — fisiere uncommited?
2. git log --oneline -10 — commit-uri recente ok?
3. Fisiere untracked suspecte? (.env, tmp files, build artifacts)
4. .gitignore — lipseste ceva?
Propune cleanup daca e nevoie.`,
},
{
id: "quick-type-check",
title: "Verificare tipuri modul",
description: "Verifica typesafety pe un modul specific",
tags: ["quick"],
variables: ["MODULE_NAME"],
prompt: `Citeste src/modules/{MODULE_NAME}/types.ts si verifica:
1. Toate interfetele sunt folosite? (grep imports)
2. Sunt tipuri any sau unknown neutipizate?
3. Optional fields corect marcate cu ?
4. Consistenta cu Prisma schema (daca modulul foloseste DB direct)
Raporteaza rapid ce nu e in regula.`,
},
],
},
];
// ─── Copy button component ─────────────────────────────────────────
function CopyButton({ text, className }: { text: string; className?: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [text]);
return (
<Button
variant="ghost"
size="icon-xs"
onClick={handleCopy}
className={className}
title="Copiaza"
>
{copied ? <Check className="size-3 text-green-500" /> : <Copy className="size-3" />}
</Button>
);
}
// ─── Prompt Card ───────────────────────────────────────────────────
function PromptCard({ prompt }: { prompt: PromptTemplate }) {
const [expanded, setExpanded] = useState(false);
const [done, setDone] = useState(false);
// Persist one-time completion in localStorage
useEffect(() => {
if (prompt.oneTime) {
const stored = localStorage.getItem(`prompt-done-${prompt.id}`);
if (stored === "true") setDone(true);
}
}, [prompt.id, prompt.oneTime]);
const toggleDone = useCallback(() => {
const next = !done;
setDone(next);
localStorage.setItem(`prompt-done-${prompt.id}`, String(next));
}, [done, prompt.id]);
return (
<Card className={`transition-all ${done ? "opacity-50" : ""}`}>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{prompt.oneTime && (
<input
type="checkbox"
checked={done}
onChange={toggleDone}
className="size-4 rounded accent-primary cursor-pointer"
title={done ? "Marcheaza ca nefacut" : "Marcheaza ca facut"}
/>
)}
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 text-sm font-semibold hover:text-primary transition-colors text-left"
>
{expanded ? <ChevronDown className="size-3.5 shrink-0" /> : <ChevronRight className="size-3.5 shrink-0" />}
{prompt.title}
</button>
</div>
<p className="text-xs text-muted-foreground ml-5">{prompt.description}</p>
<div className="flex flex-wrap gap-1 mt-2 ml-5">
{prompt.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px] px-1.5 py-0">
{tag}
</Badge>
))}
{prompt.variables?.map((v) => (
<Badge key={v} variant="outline" className="text-[10px] px-1.5 py-0 border-amber-500/50 text-amber-600 dark:text-amber-400">
{`{${v}}`}
</Badge>
))}
</div>
</div>
<CopyButton text={prompt.prompt} className="shrink-0 mt-0.5" />
</div>
{expanded && (
<div className="mt-3 ml-5">
<div className="relative group">
<pre className="text-xs bg-muted/50 border rounded-md p-3 whitespace-pre-wrap font-mono leading-relaxed overflow-x-auto max-h-[500px] overflow-y-auto">
{prompt.prompt}
</pre>
<CopyButton
text={prompt.prompt}
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-background/80"
/>
</div>
</div>
)}
</CardContent>
</Card>
);
}
// ─── Stats bar ─────────────────────────────────────────────────────
function StatsBar() {
const totalPrompts = CATEGORIES.reduce((s, c) => s + c.prompts.length, 0);
const oneTimePrompts = CATEGORIES.reduce(
(s, c) => s + c.prompts.filter((p) => p.oneTime).length,
0,
);
return (
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Brain className="size-3" /> {totalPrompts} prompturi</span>
<span className="flex items-center gap-1"><ListChecks className="size-3" /> {oneTimePrompts} one-time</span>
<span className="flex items-center gap-1"><Wrench className="size-3" /> {CATEGORIES.length} categorii</span>
</div>
);
}
// ─── Best practices sidebar ────────────────────────────────────────
const BEST_PRACTICES = [
{ icon: <Rocket className="size-3" />, text: "Incepe cu CLAUDE.md — da context inainte de task" },
{ icon: <Brain className="size-3" />, text: "Cere plan inainte de implementare" },
{ icon: <Shield className="size-3" />, text: "npx next build dupa fiecare schimbare" },
{ icon: <Bug className="size-3" />, text: "Nu refactoriza cand faci bugfix" },
{ icon: <Sparkles className="size-3" />, text: "O sesiune = un obiectiv clar" },
{ icon: <TestTube className="size-3" />, text: "Verifica-ti munca: teste, build, manual" },
{ icon: <ListChecks className="size-3" />, text: "Listeaza schimbarile inainte de a le aplica" },
{ icon: <RefreshCw className="size-3" />, text: "Actualizeaza memory/ la sfarsit de sesiune" },
{ icon: <Zap className="size-3" />, text: "/clear intre task-uri diferite" },
{ icon: <Terminal className="size-3" />, text: "Dupa 2 corectii → /clear + prompt mai bun" },
];
// ─── Main Page ─────────────────────────────────────────────────────
export default function PromptsPage() {
const [search, setSearch] = useState("");
const filtered = CATEGORIES.map((cat) => ({
...cat,
prompts: cat.prompts.filter(
(p) =>
!search ||
p.title.toLowerCase().includes(search.toLowerCase()) ||
p.description.toLowerCase().includes(search.toLowerCase()) ||
p.tags.some((t) => t.toLowerCase().includes(search.toLowerCase())),
),
})).filter((cat) => cat.prompts.length > 0);
return (
<div className="max-w-5xl mx-auto p-6 space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Terminal className="size-6" />
Claude Code Prompts
</h1>
<p className="text-sm text-muted-foreground mt-1">
Biblioteca de prompturi optimizate pentru ArchiTools. Click pe titlu pentru a vedea, buton pentru a copia.
</p>
<StatsBar />
</div>
{/* Search */}
<div className="flex gap-3">
<input
type="text"
placeholder="Cauta prompt... (ex: bugfix, security, parcel-sync)"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 h-9 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
{search && (
<Button variant="ghost" size="sm" onClick={() => setSearch("")}>
Sterge
</Button>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_240px] gap-6">
{/* Main content */}
<Tabs defaultValue={CATEGORIES[0]?.id} className="w-full">
<TabsList className="w-full flex flex-wrap h-auto gap-1 bg-transparent p-0 mb-4">
{filtered.map((cat) => (
<TabsTrigger
key={cat.id}
value={cat.id}
className="flex items-center gap-1.5 text-xs data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-md px-3 py-1.5 border"
>
{cat.icon}
{cat.label}
<Badge variant="secondary" className="text-[10px] px-1 py-0 ml-1">
{cat.prompts.length}
</Badge>
</TabsTrigger>
))}
</TabsList>
{filtered.map((cat) => (
<TabsContent key={cat.id} value={cat.id} className="space-y-3 mt-0">
<p className="text-xs text-muted-foreground mb-3">{cat.description}</p>
{cat.prompts.map((prompt) => (
<PromptCard key={prompt.id} prompt={prompt} />
))}
</TabsContent>
))}
</Tabs>
{/* Sidebar */}
<div className="space-y-4">
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-1.5">
<Sparkles className="size-3.5" />
Best Practices
</h3>
<div className="space-y-2.5">
{BEST_PRACTICES.map((bp, i) => (
<div key={i} className="flex items-start gap-2 text-xs text-muted-foreground">
<span className="mt-0.5 shrink-0">{bp.icon}</span>
<span>{bp.text}</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-1.5">
<Terminal className="size-3.5" />
Module disponibile
</h3>
<div className="flex flex-wrap gap-1">
{MODULES.map((m) => (
<Badge key={m} variant="outline" className="text-[10px] px-1.5 py-0 cursor-pointer hover:bg-accent" onClick={() => {
navigator.clipboard.writeText(m);
}}>
{m}
</Badge>
))}
</div>
<p className="text-[10px] text-muted-foreground mt-2">Click pe modul = copiaza numele</p>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+13 -4
View File
@@ -1,11 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAuth } from "./auth-check";
const STIRLING_PDF_URL =
process.env.STIRLING_PDF_URL ?? "http://10.10.10.166:8087";
const STIRLING_PDF_API_KEY =
process.env.STIRLING_PDF_API_KEY ?? "cd829f62-6eef-43eb-a64d-c91af727b53a";
const STIRLING_PDF_URL = process.env.STIRLING_PDF_URL;
const STIRLING_PDF_API_KEY = process.env.STIRLING_PDF_API_KEY;
export async function POST(req: NextRequest) {
const authErr = await requireAuth(req);
if (authErr) return authErr;
if (!STIRLING_PDF_URL || !STIRLING_PDF_API_KEY) {
return NextResponse.json(
{ error: "Stirling PDF nu este configurat" },
{ status: 503 },
);
}
try {
// Buffer the full body then forward to Stirling — streaming passthrough
// (req.body + duplex:half) is unreliable for large files in Next.js.
+13 -4
View File
@@ -1,11 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAuth } from "../auth-check";
const STIRLING_PDF_URL =
process.env.STIRLING_PDF_URL ?? "http://10.10.10.166:8087";
const STIRLING_PDF_API_KEY =
process.env.STIRLING_PDF_API_KEY ?? "cd829f62-6eef-43eb-a64d-c91af727b53a";
const STIRLING_PDF_URL = process.env.STIRLING_PDF_URL;
const STIRLING_PDF_API_KEY = process.env.STIRLING_PDF_API_KEY;
export async function POST(req: NextRequest) {
const authErr = await requireAuth(req);
if (authErr) return authErr;
if (!STIRLING_PDF_URL || !STIRLING_PDF_API_KEY) {
return NextResponse.json(
{ error: "Stirling PDF nu este configurat" },
{ status: 503 },
);
}
try {
// Stream body directly to Stirling — avoids FormData re-serialization
// failure on large files ("Failed to parse body as FormData")
+1 -1
View File
@@ -78,7 +78,7 @@ export async function GET(req: Request) {
WHERE geom IS NOT NULL
AND "layerId" LIKE 'TERENURI%'
AND ("cadastralRef" ILIKE ${pattern}
OR enrichment::text ILIKE ${'%"NR_CAD":"' + q + '%'})
OR enrichment::text ILIKE ${`%"NR_CAD":"${q}%`})
ORDER BY "cadastralRef"
LIMIT ${limit}
` as Array<{
@@ -11,11 +11,21 @@ import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
import { getAuthSession } from "@/core/auth";
export async function GET() {
async function requireAdmin(): Promise<NextResponse | null> {
const session = await getAuthSession();
if (!session) {
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
const counters = await prisma.$queryRaw<
@@ -79,10 +89,8 @@ export async function GET() {
}
export async function POST() {
const session = await getAuthSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const denied = await requireAdmin();
if (denied) return denied;
// Delete ALL old counters
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.
*/
export async function PATCH() {
const session = await getAuthSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const denied = await requireAdmin();
if (denied) return denied;
// Map old 3-letter prefixes to new single-letter
const migrations: Array<{ old: string; new: string }> = [
+16 -10
View File
@@ -213,7 +213,10 @@ export async function POST(req: NextRequest) {
let claimedSlotId: string | undefined;
if (isPastMonth && direction === "intrat") {
// Try to claim a reserved slot
// Try to claim a reserved slot — use advisory lock to prevent concurrent claims
const lockKey = `reserved:${company}-${docDate.getFullYear()}-${docDate.getMonth()}`;
const claimed = await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${lockKey}))`;
const allEntries = await loadAllEntries(true);
const slot = findAvailableReservedSlot(
allEntries,
@@ -221,19 +224,22 @@ export async function POST(req: NextRequest) {
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) {
// Claim the reserved slot — reuse its number
registryNumber = slot.number;
if (claimed) {
registryNumber = claimed.number;
registrationType = "reserved-claimed";
claimedSlotId = slot.id;
// Delete the placeholder slot
await deleteEntryFromDB(slot.id);
claimedSlotId = claimed.id;
await logAuditEvent({
entryId: slot.id,
entryNumber: slot.number,
entryId: claimed.id,
entryNumber: claimed.number,
action: "reserved_claimed",
actor: actor.id,
actorName: actor.name,
+2 -2
View File
@@ -144,8 +144,8 @@ export async function DELETE(request: NextRequest) {
},
},
})
.catch(() => {
// Ignore error if item doesn't exist
.catch((err: { code?: string }) => {
if (err.code !== "P2025") throw err;
});
} else {
// Clear namespace
-13
View File
@@ -18,16 +18,3 @@ if (process.env.NODE_ENV !== "production")
globalForMinio.minioClient = minioClient;
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
View File
@@ -16,7 +16,7 @@ export async function middleware(request: NextRequest) {
if (token) {
const { pathname } = request.nextUrl;
// 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 tokenName = String(token.name ?? "").toLowerCase();
const isPortalUser = portalUsers.some(
@@ -54,11 +54,24 @@ type SessionEntry = {
const globalStore = globalThis as {
__epaySessionCache?: Map<string, SessionEntry>;
__epayCleanupTimer?: ReturnType<typeof setInterval>;
};
const sessionCache =
globalStore.__epaySessionCache ?? new Map<string, SessionEntry>();
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) =>
crypto.createHash("sha256").update(`epay:${u}:${p}`).digest("hex");
+15 -10
View File
@@ -117,8 +117,15 @@ export async function enqueueBatch(
const items: QueueItem[] = [];
for (const input of inputs) {
// Create DB record in "queued" status
const record = await prisma.cfExtract.create({
// Create DB record in "queued" status — use transaction + advisory lock
// to prevent duplicate version numbers from concurrent requests
const record = await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'cfextract:' + input.nrCadastral}))`;
const agg = await tx.cfExtract.aggregate({
where: { nrCadastral: input.nrCadastral },
_max: { version: true },
});
return tx.cfExtract.create({
data: {
nrCadastral: input.nrCadastral,
nrCF: input.nrCF ?? input.nrCadastral,
@@ -130,15 +137,10 @@ export async function enqueueBatch(
gisFeatureId: input.gisFeatureId,
prodId: input.prodId ?? 14200,
status: "queued",
version:
((
await prisma.cfExtract.aggregate({
where: { nrCadastral: input.nrCadastral },
_max: { version: true },
})
)._max.version ?? 0) + 1,
version: (agg._max.version ?? 0) + 1,
},
});
});
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
? new Date(doc.dataDocument)
: new Date();
@@ -79,11 +79,24 @@ type SessionEntry = {
const globalStore = globalThis as {
__eterraSessionStore?: Map<string, SessionEntry>;
__eterraCleanupTimer?: ReturnType<typeof setInterval>;
};
const sessionStore =
globalStore.__eterraSessionStore ?? new Map<string, SessionEntry>();
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) =>
crypto.createHash("sha256").update(`${u}:${p}`).digest("hex");
@@ -16,10 +16,24 @@ export type 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();
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 getProgress = (jobId: string) => store.get(jobId);
export const clearProgress = (jobId: string) => store.delete(jobId);
@@ -237,8 +237,16 @@ export async function syncLayer(
},
create: item,
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(),
// enrichment + enrichedAt preserved — not overwritten
},
});
}
@@ -9,6 +9,7 @@ import {
X,
FileText,
} from "lucide-react";
import { useAuth } from "@/core/auth";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
@@ -66,6 +67,7 @@ export function CloseGuardDialog({
activeDeadlines,
onConfirmClose,
}: CloseGuardDialogProps) {
const { user } = useAuth();
const [search, setSearch] = useState("");
const [selectedEntryId, setSelectedEntryId] = useState("");
const [resolution, setResolution] = useState<ClosureResolution>("finalizat");
@@ -130,7 +132,7 @@ export function CloseGuardDialog({
onConfirmClose({
resolution,
reason: reason.trim(),
closedBy: "Utilizator", // TODO: replace with SSO identity
closedBy: user?.name ?? "Utilizator",
closedAt: new Date().toISOString(),
linkedEntryId: selectedEntryId || undefined,
linkedEntryNumber: selectedEntry?.number,