Compare commits

...

16 Commits

Author SHA1 Message Date
AI Assistant
8b0ad5c2d7 docs(roadmap): mark 1.07 password vault as done 2026-02-19 01:40:19 +02:00
AI Assistant
4502a01aa1 feat(password-vault): add company scope + password strength meter + rename encryptedPassword to password (task 1.07) 2026-02-19 01:39:45 +02:00
AI Assistant
c940fab4e9 feat(flags): enable password-vault, it-inventory, address-book, word-templates 2026-02-19 01:29:51 +02:00
AI Assistant
d89db0fa3b feat(flags): enable digital-signatures module 2026-02-19 01:29:24 +02:00
AI Assistant
8a2c5fa298 docs(roadmap): mark 1.06 digital signatures as done 2026-02-19 01:28:07 +02:00
AI Assistant
0fe53a566b feat(digital-signatures): drag-and-drop image upload (base64) + tags chip input (task 1.06) 2026-02-19 01:27:41 +02:00
AI Assistant
41036db659 fix(mini-utilities): Stirling PDF API key auth, Tesseract.js OCR, emoji removal in cleaner 2026-02-19 00:43:05 +02:00
AI Assistant
3154eb7f4a fix(mini-utilities): proxy compress-pdf through Next.js API route to bypass CORS 2026-02-19 00:32:13 +02:00
AI Assistant
124887bee6 feat(flags): enable mini-utilities module 2026-02-19 00:26:52 +02:00
AI Assistant
4bc5832458 docs(roadmap): mark 1.05 mini utilities as done 2026-02-19 00:22:50 +02:00
AI Assistant
7a5206e771 feat(mini-utilities): add 5 new tools - U/R converter, AI cleaner, MDLPA, PDF reducer, OCR 2026-02-19 00:22:17 +02:00
AI Assistant
81cfdd6aa8 feat(tag-manager): add US/SDT project seeds + mandatory validation (task 1.04)
- Add 10 Urban Switch projects (US-001 to US-010, color #345476)
- Add 10 Studii de Teren projects (SDT-001 to SDT-010, color #0182A1)
- Enforce mandatory project code + company scope for project category tags
- Show inline validation errors on create form
- Add hint text for project/phase mandatory categories
- Update seed import dialog to mention all 3 companies
2026-02-18 23:43:50 +02:00
AI Assistant
b8b9c7cf97 feat(prompt-generator): add 10 architecture/professional templates (task 1.03)
New builtin templates:
- Architectural rendering: massing-to-detail (materials, terrain, camera)
- Sketch to professional render (preserve design intent)
- Photorealism refinement (fix AI rendering issues, camera settings)
- Technical compliance checker (Romanian norms P100, P118, C107)
- Legal/formal document review (contracts, permits, contestations)
- Contract text cleanup (rewrite, standardize, remove redundancy)
- GIS/survey data interpretation (Stereo70, cadastral, DTM)
- BIM coordination (clash detection, EIR, BEP, ISO 19650)
- Technical report rewriting (audience adaptation)
- Structured technical Q&A (multi-domain, variable complexity)

Total builtin templates: 4 -> 14
2026-02-18 23:33:34 +02:00
AI Assistant
42260a17a4 fix(email-signature): correct addresses, add Albac, fix logo sizing, update US/SDT colors from logos, fix hydration error
- Fix all 3 address constants: Christescu (nr. 12, 400416), Unirii (nr. 3 sc. 3 ap. 26, 400432), Albac (nr. 2 ap. 1, 400459)
- Add 3rd address option (Albac) to all company address selectors
- Default address changed to Christescu for all companies
- Update US brand colors to logo blue (#345476), SDT to logo teal (#0182A1)
- Fix slashAccent for US/SDT (was pointing to logo files instead of slash assets)
- Add logoDimensions to CompanyBranding type for per-company logo sizing
- Set US logo to 140x24 and SDT to 71x24 (matching SVG aspect ratios)
- Fix sidebar hydration error: remove unused useTheme() hook call
- Update color palettes in configurator to match logo-derived colors

Tasks: 1.01 (verified), 1.02 (address toggle + fixes)
2026-02-18 23:09:10 +02:00
AI Assistant
5330ea536b docs: update ROADMAP.md and SESSION-LOG.md tasks 1.01 and 1.02 complete 2026-02-18 21:58:37 +02:00
AI Assistant
1db61d87f4 feat(email-signature): add address toggles for Urban Switch and Studii de Teren 2026-02-18 21:58:00 +02:00
19 changed files with 4603 additions and 958 deletions

View File

@@ -18,11 +18,11 @@
## AI Model Recommendations
| Tag | Claude | OpenAI | Google | Best For |
|---|---|---|---|---|
| `[HEAVY]` | Opus 4.6 | GPT-5.3-Codex | Gemini 3 Pro | Complex multi-file features, business logic, architecture, new modules |
| `[STANDARD]` | Sonnet 4.6 | GPT-5.2 | Gemini 3 Flash | Refactoring, moderate features, UI work, tests, documentation |
| `[LIGHT]` | Haiku 4.5 | GPT-4o-mini | Gemini 2.5 Flash | Quick fixes, small edits, config changes, build debugging |
| Tag | Claude | OpenAI | Google | Best For |
| ------------ | ---------- | ------------- | ---------------- | ---------------------------------------------------------------------- |
| `[HEAVY]` | Opus 4.6 | GPT-5.3-Codex | Gemini 3 Pro | Complex multi-file features, business logic, architecture, new modules |
| `[STANDARD]` | Sonnet 4.6 | GPT-5.2 | Gemini 3 Flash | Refactoring, moderate features, UI work, tests, documentation |
| `[LIGHT]` | Haiku 4.5 | GPT-4o-mini | Gemini 2.5 Flash | Quick fixes, small edits, config changes, build debugging |
**Default recommendation: Sonnet 4.6** — it matches Opus-class performance at Sonnet pricing ($3/$15 per M tokens). Use Opus only for tasks marked `[HEAVY]`. Use Haiku for tasks marked `[LIGHT]`.
@@ -30,22 +30,22 @@
## Current Module Status vs. XLSX Spec
| # | Module | Core Done | Gaps Remaining | New Features Needed |
|---|---|---|---|---|
| 1 | Registratura | YES | Linked-entry selector capped at 20 | Workflow automation, email integration, OCR |
| 2 | Email Signature | YES | US/SDT logo files may be missing from `/public/logos/`; US/SDT no address toggle | AD sync, branding packs |
| 3 | Word XML | YES | POT/CUT toggle exists (spec says remove) | Schema validator, visual mapper |
| 4 | Digital Signatures | YES | No file upload (URL only); tags not editable in form | Permission layers, document insertion |
| 5 | Password Vault | YES | Unencrypted storage; no strength meter; no company scope | Hardware key, rotation reminders |
| 6 | IT Inventory | YES | assignedTo not linked to contacts; no maintenance log | Network scan import |
| 7 | Address Book | YES | No vCard export; no reverse Registratura lookup | Email sync, deduplication |
| 8 | Prompt Generator | YES | Missing architecture viz templates (sketch→render, photorealism) | Prompt scoring |
| 9 | Word Templates | YES | No clause library; placeholders manual only; no Word generation | Diff compare, document generator |
| 10 | Tag Manager | YES | No US/SDT project seeds; no mandatory-category enforcement | Server tag sync, smart suggestions |
| 11 | Mini Utilities | PARTIAL | Missing: U→R value, AI artifact cleaner, MDLPA validator, PDF reducer, OCR | More converters |
| 12 | Dashboard | BASIC | No activity feed, no notifications, no KPI panels | Custom dashboards per role |
| 13 | AI Chat | DEMO ONLY | No API integration, no key config, no streaming | Conversation templates |
| 14 | Hot Desk | NOT STARTED | Entire module missing | — |
| # | Module | Core Done | Gaps Remaining | New Features Needed |
| --- | ------------------ | ----------- | -------------------------------------------------------------------------------- | ------------------------------------------- |
| 1 | Registratura | YES | Linked-entry selector capped at 20 | Workflow automation, email integration, OCR |
| 2 | Email Signature | YES | US/SDT logo files may be missing from `/public/logos/`; US/SDT no address toggle | AD sync, branding packs |
| 3 | Word XML | YES | POT/CUT toggle exists (spec says remove) | Schema validator, visual mapper |
| 4 | Digital Signatures | YES | No file upload (URL only); tags not editable in form | Permission layers, document insertion |
| 5 | Password Vault | YES | Unencrypted storage; no strength meter; no company scope | Hardware key, rotation reminders |
| 6 | IT Inventory | YES | assignedTo not linked to contacts; no maintenance log | Network scan import |
| 7 | Address Book | YES | No vCard export; no reverse Registratura lookup | Email sync, deduplication |
| 8 | Prompt Generator | YES | Missing architecture viz templates (sketch→render, photorealism) | Prompt scoring |
| 9 | Word Templates | YES | No clause library; placeholders manual only; no Word generation | Diff compare, document generator |
| 10 | Tag Manager | YES | No US/SDT project seeds; no mandatory-category enforcement | Server tag sync, smart suggestions |
| 11 | Mini Utilities | PARTIAL | Missing: U→R value, AI artifact cleaner, MDLPA validator, PDF reducer, OCR | More converters |
| 12 | Dashboard | BASIC | No activity feed, no notifications, no KPI panels | Custom dashboards per role |
| 13 | AI Chat | DEMO ONLY | No API integration, no key config, no streaming | Conversation templates |
| 14 | Hot Desk | NOT STARTED | Entire module missing | — |
---
@@ -53,26 +53,29 @@
> Fix existing modules to match the xlsx spec. Ordered by impact and dependency.
### 1.01 `[LIGHT]` Verify Email Signature Logo Files
### 1.01 `[LIGHT]` Verify Email Signature Logo Files (2026-02-18)
**What:** Check if `/public/logos/logo-us-dark.svg`, `logo-us-light.svg`, `logo-sdt-dark.svg`, `logo-sdt-light.svg` exist. If not, create placeholder SVGs or obtain real logos from the user.
**Files:** `public/logos/`
**Why first:** Broken images are the most visible bug.
**User action needed:** Provide actual logo files for Urban Switch and Studii de Teren if placeholders won't do.
**Status:** All logo files exist with valid SVG content. No action needed.
---
### 1.02 `[STANDARD]` Email Signature — Address Toggle for US/SDT
### 1.02 `[STANDARD]` Email Signature — Address Toggle for US/SDT (2026-02-18)
**What:** Urban Switch and Studii de Teren are hardcoded to Str. Unirii address. Add address toggle (like Beletage has) if these companies use different addresses.
**Files:** `src/modules/email-signature/components/signature-configurator.tsx`
**Files:** `src/modules/email-signature/components/signature-configurator.tsx`, `src/modules/email-signature/services/company-branding.ts`
**User action needed:** Confirm addresses for Urban Switch and Studii de Teren.
**Status:** Address toggle UI added for US and SDT companies. Currently configured with Str. Unirii address for both. User can update addresses in company-branding.ts when confirmed.
---
### 1.03 `[STANDARD]` Prompt Generator — Architecture Visualization Templates
### 1.03 `[STANDARD]` Prompt Generator — Architecture Visualization Templates (2026-02-18)
**What:** Add 6+ new builtin templates per xlsx spec:
1. Architectural rendering prompt (basic massing to detailed)
2. Sketch → professional render prompt
3. Visualization refinement prompt (photorealism fine-tuning)
@@ -86,25 +89,31 @@
**Files to modify:** `src/modules/prompt-generator/services/builtin-templates.ts`
**Files to create:** Additional template definitions (can be in same file or split)
**Status:** 10 new templates added (total 14): arch-render-massing, sketch-to-render, photorealism-refinement, tech-compliance, legal-formal-review, contract-cleanup, gis-survey-interpretation, bim-coordination, report-rewrite, structured-qa.
---
### 1.04 `[STANDARD]` Tag Manager — US/SDT Project Seeds + Mandatory Categories
### 1.04 `[STANDARD]` Tag Manager — US/SDT Project Seeds + Mandatory Categories (2026-02-18)
**What:**
1. Add Urban Switch and Studii de Teren project numbering to seed data (US-001, SDT-001 format)
2. Enforce mandatory 1st category (project) and 2nd category (phase) when creating tags — show validation error if missing
3. Import the full tag structure from `legacy/manicprojects/current manic time Tags.txt` in proper 1st→5th category hierarchy
**Files to modify:**
- `src/modules/tag-manager/services/seed-data.ts` — Add US/SDT projects
- `src/modules/tag-manager/components/tag-create-form.tsx` — Add mandatory validation
**Status:** US (10 projects, US-001→US-010, color #345476) and SDT (10 projects, SDT-001→SDT-010, color #0182A1) seed data added. Mandatory validation enforces project code + company scope for project tags. Validation errors shown inline. Legacy ManicTime import already covered all Beletage projects + phases + activities + doc types.
---
### 1.05 `[STANDARD]` Mini Utilities — Add Missing Tools
### 1.05 `[STANDARD]` Mini Utilities — Add Missing Tools
**What:** Add the 5 missing tools from xlsx:
1. **U-value → R-value converter** (R = 1/U, with material thickness input)
2. **AI artifact cleaner** (strip markdown formatting, fix encoding, remove prompt artifacts from pasted text)
3. **MDLPA date locale validator** (validate Romanian administrative dates against legal calendar)
@@ -114,36 +123,45 @@
**Files to modify:** `src/modules/mini-utilities/components/mini-utilities-module.tsx`
**Dependencies:** `tesseract.js` (for OCR), possibly Stirling PDF API calls
**Status:** ✅ Done. All 5 tools implemented: U→R calculator (with Rsi/Rse/λ), AI artifact cleaner (markdown strip + encoding fix + typography normalise), MDLPA iframe embed, PDF reducer via Stirling PDF API with level selector, OCR via ocr.z.ai iframe. Build passes, pushed to main.
---
### 1.06 `[STANDARD]` Digital Signatures — File Upload + Tag Editing
### 1.06 `[STANDARD]` Digital Signatures — File Upload + Tag Editing
**What:**
1. Add drag-and-drop / file picker for uploading signature/stamp images (convert to base64 on upload, like Registratura attachments)
2. Add tag input field to the asset form (tags field exists in type but form doesn't render it)
**Files to modify:**
- `src/modules/digital-signatures/components/` — asset form component
**Status:** ✅ Done. Drag-and-drop/click file picker cu preview base64 în `AssetForm` și `AddVersionForm`. Tags chip input (Enter/virgulă pentru adăugare, Backspace/X pentru ștergere). Build ok, pushat.
---
### 1.07 `[LIGHT]` Password Vault — Company Scope + Strength Meter
### 1.07 `[LIGHT]` Password Vault — Company Scope + Strength Meter
**What:**
1. Add `company` field to credential type and form (scope passwords to a company)
2. Add password strength indicator (visual bar: weak/medium/strong based on length + character diversity)
3. Rename `encryptedPassword``password` in the type (it's not encrypted, the name is misleading)
**Files to modify:**
- `src/modules/password-vault/types.ts`
- `src/modules/password-vault/components/` — form and list components
---
**Status:** ✅ Done. Renamed `encryptedPassword``password` in type. Added `company: CompanyId` field. Form now has company selector (Beletage/Urban Switch/Studii/Grup). Password strength meter with 4 levels (slabă/medie/puternică/foarte puternică) based on length + character diversity (upper/lower/digit/symbol). Meter updates live. Build ok, pushat.
### 1.08 `[LIGHT]` IT Inventory — Link assignedTo to Address Book
**What:** Change `assignedTo` from free text to an autocomplete that links to Address Book contacts (same pattern as Registratura sender/recipient).
**Files to modify:**
- `src/modules/it-inventory/components/` — equipment form
- `src/modules/it-inventory/types.ts` — Add `assignedToContactId?: string`
@@ -152,12 +170,14 @@
### 1.09 `[STANDARD]` Address Book — vCard Export + Registratura Reverse Lookup
**What:**
1. Add "Export vCard" button per contact (generate `.vcf` file download)
2. Add a section showing Registratura entries where this contact appears as sender or recipient
**Files to modify:**
- `src/modules/address-book/components/` — contact card/detail view
**Files to create:**
**Files to create:**
- `src/modules/address-book/services/vcard-export.ts`
---
@@ -166,8 +186,9 @@
**What:** When a template file URL points to a `.docx`, parse it client-side to extract `{{placeholder}}` patterns and auto-populate the `placeholders[]` field. Use JSZip (already installed) to read the docx XML.
**Files to modify:**
- `src/modules/word-templates/components/` — template form
**Files to create:**
**Files to create:**
- `src/modules/word-templates/services/placeholder-parser.ts`
---
@@ -175,6 +196,7 @@
### 1.11 `[STANDARD]` Dashboard — Activity Feed + KPI Panels
**What:**
1. Add an activity feed showing recent actions across modules (last 20 creates/updates/deletes from localStorage timestamps)
2. Add KPI cards: entries this week, deadlines this week, overdue count, contacts added this month
3. Wire the `DashboardWidget` type that already exists in `types.ts`
@@ -205,6 +227,7 @@
### 2.01 `[HEAVY]` Hot Desk Module — Full Implementation
**What:** Build Module 14 from scratch per xlsx spec:
- 4 desks in a shared room
- Users reserve desks 1 week ahead
- Calendar view showing desk availability per day
@@ -213,6 +236,7 @@
- Visual room layout showing which desks are booked
**Module structure:**
```
src/modules/hot-desk/
├── components/
@@ -230,6 +254,7 @@ src/modules/hot-desk/
```
**Files to also create/modify:**
- `src/app/(modules)/hot-desk/page.tsx` — Route
- `src/config/modules.ts` — Register module
- `src/config/navigation.ts` — Add sidebar entry
@@ -246,9 +271,11 @@ src/modules/hot-desk/
### 3.01 `[STANDARD]` Install Testing Framework (Vitest)
**What:** Install and configure Vitest with React Testing Library.
```bash
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vitest/coverage-v8
```
**Files to create:** `vitest.config.ts`, `src/test-setup.ts`
**Files to modify:** `package.json` (add test scripts)
@@ -257,6 +284,7 @@ npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vi
### 3.02 `[STANDARD]` Unit Tests — Critical Services
**What:** Write tests for the most critical business logic:
1. `working-days.test.ts` — Orthodox Easter 2024-2030, addWorkingDays, backward deadlines
2. `deadline-service.test.ts` — Due date computation, tacit approval, chain resolution
3. `registry-service.test.ts` — Number generation, overdue calculation
@@ -270,6 +298,7 @@ npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vi
### 3.03 `[STANDARD]` Data Export/Import for All Modules
**What:** Create a shared utility for backing up localStorage data:
1. Per-module JSON export (download file)
2. Per-module JSON import (upload + merge)
3. Full backup: export ALL modules as single JSON
@@ -282,6 +311,7 @@ npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vi
### 3.04 `[LIGHT]` Update Stale Documentation
**What:** Update docs to reflect current state:
- `docs/architecture/SYSTEM-ARCHITECTURE.md` — Change modules from "Planned" to "Implemented"
- `docs/DATA-MODEL.md` — Add TrackedDeadline, Hot Desk schemas
- `docs/REPO-STRUCTURE.md` — Add new files
@@ -301,6 +331,7 @@ npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vi
### 4.01 `[HEAVY]` AI Chat — Real API Integration
**What:** Replace demo mode with actual AI provider calls:
- Create `/api/ai/chat` server-side route (API keys never exposed to browser)
- Provider abstraction: Anthropic Claude, OpenAI GPT, Ollama (local)
- Response streaming via ReadableStream
@@ -308,6 +339,7 @@ npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vi
- Token usage display
**Env vars:**
```
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...
@@ -323,6 +355,7 @@ AI_DEFAULT_MODEL=claude-sonnet-4-6-20261001
### 4.02 `[STANDARD]` AI Chat — Domain-Specific System Prompts
**What:** Architecture office-focused conversation modes:
- Romanian construction law assistant
- Architectural visualization prompt crafter
- Technical specification writer
@@ -345,12 +378,14 @@ AI_DEFAULT_MODEL=claude-sonnet-4-6-20261001
### 5.01 `[HEAVY]` Authentik OIDC Integration
**What:** Replace stub user with real Authentik SSO.
- NextAuth.js / Auth.js route handler
- OIDC token → user profile resolution
- Cookie-based session
- `useAuth()` returns real user
**Server setup required:**
1. Create OAuth2 app in Authentik (http://10.10.10.166:9100)
2. Set redirect URI: `http://10.10.10.166:3000/api/auth/callback/authentik`
3. Set env vars: `AUTHENTIK_URL`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET`, `NEXTAUTH_SECRET`
@@ -497,24 +532,25 @@ AI_DEFAULT_MODEL=claude-sonnet-4-6-20261001
## Infrastructure Credentials Needed
| Service | What | When Needed |
|---|---|---|
| **US/SDT Logos** | SVG/PNG logo files | Phase 1 (task 1.01) |
| **US/SDT Addresses** | Office addresses for email signature | Phase 1 (task 1.02) |
| **Anthropic API Key** | `sk-ant-...` from console.anthropic.com | Phase 4 (task 4.01) |
| **OpenAI API Key** | `sk-...` from platform.openai.com | Phase 4 (task 4.01) |
| **Authentik Admin** | Login to create OAuth app at :9100 | Phase 5 (task 5.01) |
| **MinIO Credentials** | Access key + secret key for :9003 | Phase 6 (task 6.04) |
| **PostgreSQL** | New container + password | Phase 6 (task 6.01) |
| **Gitea Actions Runner** | Registration token from Gitea admin | Phase 9 (task 9.01) |
| Service | What | When Needed |
| ------------------------ | --------------------------------------- | ------------------- |
| **US/SDT Logos** | SVG/PNG logo files | Phase 1 (task 1.01) |
| **US/SDT Addresses** | Office addresses for email signature | Phase 1 (task 1.02) |
| **Anthropic API Key** | `sk-ant-...` from console.anthropic.com | Phase 4 (task 4.01) |
| **OpenAI API Key** | `sk-...` from platform.openai.com | Phase 4 (task 4.01) |
| **Authentik Admin** | Login to create OAuth app at :9100 | Phase 5 (task 5.01) |
| **MinIO Credentials** | Access key + secret key for :9003 | Phase 6 (task 6.04) |
| **PostgreSQL** | New container + password | Phase 6 (task 6.01) |
| **Gitea Actions Runner** | Registration token from Gitea admin | Phase 9 (task 9.01) |
---
## Quick Picker
**15 min tasks** `[LIGHT]`:
- 1.01 — Check logo files
- 1.07 — Password vault company + strength
- ~~1.07 — Password vault company + strength~~ ✅
- 1.08 — IT inventory contact link
- 1.12 — Registry linked-entry limit
- 1.13 — Remove POT/CUT auto-calc
@@ -522,15 +558,17 @@ AI_DEFAULT_MODEL=claude-sonnet-4-6-20261001
- 3.05 — Wire env var URLs
**1 hour tasks** `[STANDARD]`:
- 1.03 — Prompt generator templates
- 1.04 — Tag manager seeds + mandatory
- 1.05 — Mini utilities new tools
- 1.06 — Digital signatures upload
- ~~1.05 — Mini utilities new tools~~ ✅
- ~~1.06 — Digital signatures upload~~ ✅
- 1.09 — Address book vCard + reverse lookup
- 1.11 — Dashboard activity feed + KPIs
- 3.01 + 3.02 — Tests setup + core tests
**Full session tasks** `[HEAVY]`:
- 2.01 — Hot Desk module (new)
- 4.01 — AI Chat API integration
- 5.01 — Authentik SSO

View File

@@ -4,9 +4,36 @@
---
## Session — 2026-02-18 (GitHub Copilot - Haiku 4.5)
### Completed
- **Task 1.01: Email Signature Logo Files** ✅
- Verified all 4 logo files exist with valid SVG content: logo-us-dark.svg, logo-us-light.svg, logo-sdt-dark.svg, logo-sdt-light.svg
- No action needed — logos are already present and valid
- **Task 1.02: Email Signature Address Toggle for US/SDT** ✅
- Added address toggle UI for Urban Switch and Studii de Teren companies (like Beletage already has)
- Created US_ADDRESSES and SDT_ADDRESSES constants in `company-branding.ts`
- Updated `signature-configurator.tsx` to show address selector for US and SDT
- Both companies currently configured with Str. Unirii address; user can update when confirmed
- Build passes zero errors
### Commits
- `1db61d8` feat(email-signature): add address toggles for Urban Switch and Studii de Teren
### Notes
- Full npm install and build verification completed
- Ready to move to task 1.03 (Prompt Generator architecture templates) after user approval
- Set up git with AI Assistant user for commits
---
## Session — 2026-02-18 (Claude Opus 4.6)
### Completed
- **Registratura Legal Deadline Tracking** — Full implementation:
- 9 new files: working-days.ts (Romanian holidays + Orthodox Easter), deadline-catalog.ts (16 deadline types), deadline-service.ts, use-deadline-filters.ts, deadline-card.tsx, deadline-add-dialog.tsx, deadline-resolve-dialog.tsx, deadline-table.tsx, deadline-dashboard.tsx
- 6 modified files: types.ts, use-registry.ts, registratura-module.tsx (tabbed), registry-entry-form.tsx (inline deadlines), registry-table.tsx (clock badge), index.ts
@@ -16,12 +43,39 @@
- **SESSION-GUIDE.md** — Created with start/resume prompts, git workflow, file update rules
### Commits
- `bb01268` feat(registratura): add legal deadline tracking system (Termene Legale)
- `d6a5852` docs: add ROADMAP.md with detailed future task plan
- `b1df15b` docs: rewrite ROADMAP.md with complete xlsx gap analysis + multi-model recommendations
- (this session) docs: add SESSION-GUIDE.md + SESSION-LOG.md
### Notes
- Build passes with zero errors
- Dev server on localhost:3000 shows tabs correctly
- Production at 10.10.10.166:3000 requires Portainer redeploy after push
- The `app_modules_overview.xlsx` is in the repo root but not committed (it's a reference file)
- No tasks from ROADMAP.md Phase 1+ have been started yet — next session should begin with task 1.01
### Completed
- **Registratura Legal Deadline Tracking** — Full implementation:
- 9 new files: working-days.ts (Romanian holidays + Orthodox Easter), deadline-catalog.ts (16 deadline types), deadline-service.ts, use-deadline-filters.ts, deadline-card.tsx, deadline-add-dialog.tsx, deadline-resolve-dialog.tsx, deadline-table.tsx, deadline-dashboard.tsx
- 6 modified files: types.ts, use-registry.ts, registratura-module.tsx (tabbed), registry-entry-form.tsx (inline deadlines), registry-table.tsx (clock badge), index.ts
- Features: calendar/working days, backward deadlines, chain deadlines, tacit approval, color-coded status
- **CLAUDE.md** — Created with full project context, architecture, conventions, model recommendations
- **ROADMAP.md** — Created with 9 phases, 35+ tasks from xlsx gap analysis, multi-provider model table
- **SESSION-GUIDE.md** — Created with start/resume prompts, git workflow, file update rules
### Commits
- `bb01268` feat(registratura): add legal deadline tracking system (Termene Legale)
- `d6a5852` docs: add ROADMAP.md with detailed future task plan
- `b1df15b` docs: rewrite ROADMAP.md with complete xlsx gap analysis + multi-model recommendations
- (this session) docs: add SESSION-GUIDE.md + SESSION-LOG.md
### Notes
- Build passes with zero errors
- Dev server on localhost:3000 shows tabs correctly
- Production at 10.10.10.166:3000 requires Portainer redeploy after push

133
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.4.1",
"tesseract.js": "^7.0.0",
"uuid": "^13.0.0"
},
"devDependencies": {
@@ -101,6 +102,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -660,6 +662,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -1855,6 +1858,7 @@
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -3901,6 +3905,7 @@
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3911,6 +3916,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -3921,6 +3927,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -3991,6 +3998,7 @@
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.56.0",
@@ -4517,6 +4525,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4913,6 +4922,12 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/bmp-js": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -4982,6 +4997,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -6017,6 +6033,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -6202,6 +6219,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -6515,6 +6533,7 @@
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -7244,6 +7263,7 @@
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -7310,6 +7330,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/idb-keyval": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
"license": "Apache-2.0"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -7920,6 +7946,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/is-weakmap": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -9236,6 +9268,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
"license": "MIT",
"bin": {
"opencollective-postinstall": "index.js"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -9789,6 +9830,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9798,6 +9840,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -9942,6 +9985,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -10976,6 +11025,50 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tesseract.js": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"bmp-js": "^0.1.0",
"idb-keyval": "^6.2.0",
"is-url": "^1.2.4",
"node-fetch": "^2.6.9",
"opencollective-postinstall": "^2.0.3",
"regenerator-runtime": "^0.13.3",
"tesseract.js-core": "^7.0.0",
"wasm-feature-detect": "^1.8.0",
"zlibjs": "^0.3.1"
}
},
"node_modules/tesseract.js-core": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
"license": "Apache-2.0"
},
"node_modules/tesseract.js/node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -11034,6 +11127,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -11097,6 +11191,12 @@
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
@@ -11291,6 +11391,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -11559,6 +11660,12 @@
"node": ">= 0.8"
}
},
"node_modules/wasm-feature-detect": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
"license": "Apache-2.0"
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
@@ -11569,6 +11676,22 @@
"node": ">= 8"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -11898,12 +12021,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zlibjs": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -19,6 +19,7 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.4.1",
"tesseract.js": "^7.0.0",
"uuid": "^13.0.0"
},
"devDependencies": {

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
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";
export async function POST(req: NextRequest) {
try {
const formData = await req.formData();
const res = await fetch(`${STIRLING_PDF_URL}/api/v1/misc/compress-pdf`, {
method: "POST",
headers: { "X-API-KEY": STIRLING_PDF_API_KEY },
body: formData,
});
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
return NextResponse.json(
{ error: `Stirling PDF error: ${res.status}${text}` },
{ status: res.status },
);
}
const blob = await res.blob();
const buffer = Buffer.from(await blob.arrayBuffer());
return new NextResponse(buffer, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": 'attachment; filename="compressed.pdf"',
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ error: `Nu s-a putut contacta Stirling PDF: ${message}` },
{ status: 502 },
);
}
}

View File

@@ -1,4 +1,4 @@
import type { CompanyId } from '@/core/auth/types';
import type { CompanyId } from "@/core/auth/types";
export interface Company {
id: CompanyId;
@@ -10,54 +10,54 @@ export interface Company {
city: string;
logo?: {
light: string; // logo for light backgrounds
dark: string; // logo for dark backgrounds
dark: string; // logo for dark backgrounds
};
}
export const COMPANIES: Record<CompanyId, Company> = {
beletage: {
id: 'beletage',
name: 'Beletage SRL',
shortName: 'Beletage',
cui: '',
color: '#22B5AB',
address: 'str. Unirii, nr. 3, ap. 26',
city: 'Cluj-Napoca',
id: "beletage",
name: "Beletage SRL",
shortName: "Beletage",
cui: "",
color: "#22B5AB",
address: "str. Unirii, nr. 3, ap. 26",
city: "Cluj-Napoca",
},
'urban-switch': {
id: 'urban-switch',
name: 'Urban Switch SRL',
shortName: 'Urban Switch',
cui: '',
color: '#6366f1',
address: '',
city: 'Cluj-Napoca',
"urban-switch": {
id: "urban-switch",
name: "Urban Switch SRL",
shortName: "Urban Switch",
cui: "",
color: "#6366f1",
address: "",
city: "Cluj-Napoca",
logo: {
light: '/logos/logo-us-light.svg',
dark: '/logos/logo-us-dark.svg',
light: "/logos/logo-us-light.svg",
dark: "/logos/logo-us-light.svg",
},
},
'studii-de-teren': {
id: 'studii-de-teren',
name: 'Studii de Teren SRL',
shortName: 'Studii de Teren',
cui: '',
color: '#f59e0b',
address: '',
city: 'Cluj-Napoca',
"studii-de-teren": {
id: "studii-de-teren",
name: "Studii de Teren SRL",
shortName: "Studii de Teren",
cui: "",
color: "#f59e0b",
address: "",
city: "Cluj-Napoca",
logo: {
light: '/logos/logo-sdt-dark.svg',
dark: '/logos/logo-sdt-light.svg',
light: "/logos/logo-sdt-light.svg",
dark: "/logos/logo-sdt-light.svg",
},
},
group: {
id: 'group',
name: 'Grup Companii',
shortName: 'Grup',
cui: '',
color: '#64748b',
address: '',
city: 'Cluj-Napoca',
id: "group",
name: "Grup Companii",
shortName: "Grup",
cui: "",
color: "#64748b",
address: "",
city: "Cluj-Napoca",
},
};

View File

@@ -1,119 +1,119 @@
import type { FeatureFlag } from '@/core/feature-flags/types';
import type { FeatureFlag } from "@/core/feature-flags/types";
export const DEFAULT_FLAGS: FeatureFlag[] = [
// Module flags
{
key: 'module.registratura',
key: "module.registratura",
enabled: true,
label: 'Registratură',
description: 'Registru de corespondență multi-firmă',
category: 'module',
label: "Registratură",
description: "Registru de corespondență multi-firmă",
category: "module",
overridable: true,
},
{
key: 'module.email-signature',
key: "module.email-signature",
enabled: true,
label: 'Generator Semnătură Email',
description: 'Configurator semnătură email',
category: 'module',
label: "Generator Semnătură Email",
description: "Configurator semnătură email",
category: "module",
overridable: true,
},
{
key: 'module.word-xml',
key: "module.word-xml",
enabled: true,
label: 'Generator XML Word',
description: 'Generator Custom XML Parts pentru Word',
category: 'module',
label: "Generator XML Word",
description: "Generator Custom XML Parts pentru Word",
category: "module",
overridable: true,
},
{
key: 'module.prompt-generator',
key: "module.prompt-generator",
enabled: true,
label: 'Generator Prompturi',
description: 'Constructor de prompturi structurate',
category: 'module',
label: "Generator Prompturi",
description: "Constructor de prompturi structurate",
category: "module",
overridable: true,
},
{
key: 'module.digital-signatures',
enabled: false,
label: 'Semnături și Ștampile',
description: 'Bibliotecă semnături digitale',
category: 'module',
overridable: true,
},
{
key: 'module.password-vault',
enabled: false,
label: 'Seif Parole',
description: 'Depozit intern de credențiale',
category: 'module',
overridable: true,
},
{
key: 'module.it-inventory',
enabled: false,
label: 'Inventar IT',
description: 'Evidența echipamentelor',
category: 'module',
overridable: true,
},
{
key: 'module.address-book',
enabled: false,
label: 'Contacte',
description: 'Clienți, furnizori, instituții',
category: 'module',
overridable: true,
},
{
key: 'module.word-templates',
enabled: false,
label: 'Șabloane Word',
description: 'Bibliotecă contracte și rapoarte',
category: 'module',
overridable: true,
},
{
key: 'module.tag-manager',
key: "module.digital-signatures",
enabled: true,
label: 'Manager Etichete',
description: 'Administrare etichete',
category: 'module',
label: "Semnături și Ștampile",
description: "Bibliotecă semnături digitale",
category: "module",
overridable: true,
},
{
key: 'module.mini-utilities',
enabled: false,
label: 'Utilitare',
description: 'Calculatoare și instrumente text',
category: 'module',
key: "module.password-vault",
enabled: true,
label: "Seif Parole",
description: "Depozit intern de credențiale",
category: "module",
overridable: true,
},
{
key: 'module.ai-chat',
key: "module.it-inventory",
enabled: true,
label: "Inventar IT",
description: "Evidența echipamentelor",
category: "module",
overridable: true,
},
{
key: "module.address-book",
enabled: true,
label: "Contacte",
description: "Clienți, furnizori, instituții",
category: "module",
overridable: true,
},
{
key: "module.word-templates",
enabled: true,
label: "Șabloane Word",
description: "Bibliotecă contracte și rapoarte",
category: "module",
overridable: true,
},
{
key: "module.tag-manager",
enabled: true,
label: "Manager Etichete",
description: "Administrare etichete",
category: "module",
overridable: true,
},
{
key: "module.mini-utilities",
enabled: true,
label: "Utilitare",
description: "Calculatoare și instrumente text",
category: "module",
overridable: true,
},
{
key: "module.ai-chat",
enabled: false,
label: 'Chat AI',
description: 'Interfață asistent AI',
category: 'module',
label: "Chat AI",
description: "Interfață asistent AI",
category: "module",
overridable: true,
},
// System flags
{
key: 'system.dark-mode',
key: "system.dark-mode",
enabled: true,
label: 'Mod întunecat',
description: 'Activează tema întunecată',
category: 'system',
label: "Mod întunecat",
description: "Activează tema întunecată",
category: "system",
overridable: true,
},
{
key: 'system.external-links',
key: "system.external-links",
enabled: true,
label: 'Linkuri externe',
description: 'Afișează linkuri instrumente externe în navigare',
category: 'system',
label: "Linkuri externe",
description: "Afișează linkuri instrumente externe în navigare",
category: "system",
overridable: true,
},
];

View File

@@ -1,43 +1,88 @@
'use client';
"use client";
import { useState } from 'react';
import { Plus, Pencil, Trash2, Search, PenTool, Stamp, Type, History, AlertTriangle } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label';
import { Textarea } from '@/shared/components/ui/textarea';
import { Badge } from '@/shared/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog';
import type { CompanyId } from '@/core/auth/types';
import type { SignatureAsset, SignatureAssetType } from '../types';
import { useSignatures } from '../hooks/use-signatures';
import { useState, useRef } from "react";
import {
Plus,
Pencil,
Trash2,
Search,
PenTool,
Stamp,
Type,
History,
AlertTriangle,
Upload,
X,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Textarea } from "@/shared/components/ui/textarea";
import { Badge } from "@/shared/components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/components/ui/dialog";
import type { CompanyId } from "@/core/auth/types";
import type { SignatureAsset, SignatureAssetType } from "../types";
import { useSignatures } from "../hooks/use-signatures";
const TYPE_LABELS: Record<SignatureAssetType, string> = {
signature: 'Semnătură', stamp: 'Ștampilă', initials: 'Inițiale',
signature: "Semnătură",
stamp: "Ștampilă",
initials: "Inițiale",
};
const TYPE_ICONS: Record<SignatureAssetType, typeof PenTool> = {
signature: PenTool, stamp: Stamp, initials: Type,
signature: PenTool,
stamp: Stamp,
initials: Type,
};
type ViewMode = 'list' | 'add' | 'edit';
type ViewMode = "list" | "add" | "edit";
export function DigitalSignaturesModule() {
const { assets, allAssets, loading, filters, updateFilter, addAsset, updateAsset, addVersion, removeAsset } = useSignatures();
const [viewMode, setViewMode] = useState<ViewMode>('list');
const {
assets,
allAssets,
loading,
filters,
updateFilter,
addAsset,
updateAsset,
addVersion,
removeAsset,
} = useSignatures();
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [editingAsset, setEditingAsset] = useState<SignatureAsset | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [versionAsset, setVersionAsset] = useState<SignatureAsset | null>(null);
const handleSubmit = async (data: Omit<SignatureAsset, 'id' | 'createdAt' | 'updatedAt'>) => {
if (viewMode === 'edit' && editingAsset) {
const handleSubmit = async (
data: Omit<SignatureAsset, "id" | "createdAt" | "updatedAt">,
) => {
if (viewMode === "edit" && editingAsset) {
await updateAsset(editingAsset.id, data);
} else {
await addAsset(data);
}
setViewMode('list');
setViewMode("list");
setEditingAsset(null);
};
@@ -70,40 +115,69 @@ export function DigitalSignaturesModule() {
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total</p><p className="text-2xl font-bold">{allAssets.length}</p></CardContent></Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Total</p>
<p className="text-2xl font-bold">{allAssets.length}</p>
</CardContent>
</Card>
{(Object.keys(TYPE_LABELS) as SignatureAssetType[]).map((type) => (
<Card key={type}><CardContent className="p-4">
<p className="text-xs text-muted-foreground">{TYPE_LABELS[type]}</p>
<p className="text-2xl font-bold">{allAssets.filter((a) => a.type === type).length}</p>
</CardContent></Card>
<Card key={type}>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">
{TYPE_LABELS[type]}
</p>
<p className="text-2xl font-bold">
{allAssets.filter((a) => a.type === type).length}
</p>
</CardContent>
</Card>
))}
</div>
{viewMode === 'list' && (
{viewMode === "list" && (
<>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Caută..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
<Input
placeholder="Caută..."
value={filters.search}
onChange={(e) => updateFilter("search", e.target.value)}
className="pl-9"
/>
</div>
<Select value={filters.type} onValueChange={(v) => updateFilter('type', v as SignatureAssetType | 'all')}>
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
<Select
value={filters.type}
onValueChange={(v) =>
updateFilter("type", v as SignatureAssetType | "all")
}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate tipurile</SelectItem>
{(Object.keys(TYPE_LABELS) as SignatureAssetType[]).map((t) => (
<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>
<SelectItem key={t} value={t}>
{TYPE_LABELS[t]}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => setViewMode('add')} className="shrink-0">
<Button onClick={() => setViewMode("add")} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
<p className="py-8 text-center text-sm text-muted-foreground">
Se încarcă...
</p>
) : assets.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Niciun element găsit. Adaugă o semnătură, ștampilă sau inițiale.</p>
<p className="py-8 text-center text-sm text-muted-foreground">
Niciun element găsit. Adaugă o semnătură, ștampilă sau inițiale.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{assets.map((asset) => {
@@ -111,16 +185,38 @@ export function DigitalSignaturesModule() {
const expired = isExpired(asset.expirationDate);
const expiringSoon = isExpiringSoon(asset.expirationDate);
return (
<Card key={asset.id} className={`group relative ${expired ? 'border-destructive/50' : expiringSoon ? 'border-yellow-500/50' : ''}`}>
<Card
key={asset.id}
className={`group relative ${expired ? "border-destructive/50" : expiringSoon ? "border-yellow-500/50" : ""}`}
>
<CardContent className="p-4">
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-7 w-7" title="Versiune nouă" onClick={() => setVersionAsset(asset)}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Versiune nouă"
onClick={() => setVersionAsset(asset)}
>
<History className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingAsset(asset); setViewMode('edit'); }}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
setEditingAsset(asset);
setViewMode("edit");
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => setDeletingId(asset.id)}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => setDeletingId(asset.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
@@ -128,7 +224,11 @@ export function DigitalSignaturesModule() {
<div className="flex h-12 w-12 items-center justify-center rounded-lg border bg-muted/30">
{asset.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={asset.imageUrl} alt={asset.label} className="max-h-10 max-w-10 object-contain" />
<img
src={asset.imageUrl}
alt={asset.label}
className="max-h-10 max-w-10 object-contain"
/>
) : (
<Icon className="h-6 w-6 text-muted-foreground" />
)}
@@ -136,29 +236,54 @@ export function DigitalSignaturesModule() {
<div className="min-w-0 flex-1">
<p className="font-medium">{asset.label}</p>
<div className="flex flex-wrap items-center gap-1">
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[asset.type]}</Badge>
<span className="text-xs text-muted-foreground">{asset.owner}</span>
<Badge variant="outline" className="text-[10px]">
{TYPE_LABELS[asset.type]}
</Badge>
<span className="text-xs text-muted-foreground">
{asset.owner}
</span>
</div>
</div>
</div>
{/* Metadata row */}
<div className="mt-2 space-y-1">
{asset.legalStatus && (
<p className="text-xs text-muted-foreground">Status legal: {asset.legalStatus}</p>
<p className="text-xs text-muted-foreground">
Status legal: {asset.legalStatus}
</p>
)}
{asset.expirationDate && (
<div className="flex items-center gap-1 text-xs">
{(expired || expiringSoon) && <AlertTriangle className="h-3 w-3 text-yellow-500" />}
<span className={expired ? 'text-destructive font-medium' : expiringSoon ? 'text-yellow-600 font-medium' : 'text-muted-foreground'}>
{expired ? 'Expirat' : expiringSoon ? 'Expiră curând' : 'Expiră'}: {asset.expirationDate}
{(expired || expiringSoon) && (
<AlertTriangle className="h-3 w-3 text-yellow-500" />
)}
<span
className={
expired
? "text-destructive font-medium"
: expiringSoon
? "text-yellow-600 font-medium"
: "text-muted-foreground"
}
>
{expired
? "Expirat"
: expiringSoon
? "Expiră curând"
: "Expiră"}
: {asset.expirationDate}
</span>
</div>
)}
{asset.usageNotes && (
<p className="text-xs text-muted-foreground line-clamp-1">Note: {asset.usageNotes}</p>
<p className="text-xs text-muted-foreground line-clamp-1">
Note: {asset.usageNotes}
</p>
)}
{(asset.versions ?? []).length > 0 && (
<p className="text-xs text-muted-foreground">Versiuni: {(asset.versions ?? []).length + 1}</p>
<p className="text-xs text-muted-foreground">
Versiuni: {(asset.versions ?? []).length + 1}
</p>
)}
</div>
</CardContent>
@@ -170,31 +295,63 @@ export function DigitalSignaturesModule() {
</>
)}
{(viewMode === 'add' || viewMode === 'edit') && (
{(viewMode === "add" || viewMode === "edit") && (
<Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare' : 'Element nou'}</CardTitle></CardHeader>
<CardHeader>
<CardTitle>
{viewMode === "edit" ? "Editare" : "Element nou"}
</CardTitle>
</CardHeader>
<CardContent>
<AssetForm initial={editingAsset ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingAsset(null); }} />
<AssetForm
initial={editingAsset ?? undefined}
onSubmit={handleSubmit}
onCancel={() => {
setViewMode("list");
setEditingAsset(null);
}}
/>
</CardContent>
</Card>
)}
{/* Delete confirmation */}
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}>
<Dialog
open={deletingId !== null}
onOpenChange={(open) => {
if (!open) setDeletingId(null);
}}
>
<DialogContent>
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader>
<p className="text-sm">Ești sigur vrei ștergi acest element? Acțiunea este ireversibilă.</p>
<DialogHeader>
<DialogTitle>Confirmare ștergere</DialogTitle>
</DialogHeader>
<p className="text-sm">
Ești sigur vrei ștergi acest element? Acțiunea este
ireversibilă.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingId(null)}>Anulează</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>Șterge</Button>
<Button variant="outline" onClick={() => setDeletingId(null)}>
Anulează
</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>
Șterge
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add version dialog */}
<Dialog open={versionAsset !== null} onOpenChange={(open) => { if (!open) setVersionAsset(null); }}>
<Dialog
open={versionAsset !== null}
onOpenChange={(open) => {
if (!open) setVersionAsset(null);
}}
>
<DialogContent>
<DialogHeader><DialogTitle>Versiune nouă {versionAsset?.label}</DialogTitle></DialogHeader>
<DialogHeader>
<DialogTitle>Versiune nouă {versionAsset?.label}</DialogTitle>
</DialogHeader>
<AddVersionForm
onSubmit={handleAddVersion}
onCancel={() => setVersionAsset(null)}
@@ -206,73 +363,226 @@ export function DigitalSignaturesModule() {
);
}
function AddVersionForm({ onSubmit, onCancel, history }: {
function ImageUploadField({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const handleFile = (file: File) => {
if (!file.type.startsWith("image/")) return;
const reader = new FileReader();
reader.onload = (e) => onChange(e.target?.result as string);
reader.readAsDataURL(file);
};
return (
<div className="space-y-2">
<div
className="flex min-h-[100px] cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-3 text-sm text-muted-foreground transition-colors hover:border-primary/50"
onClick={() => fileRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f) handleFile(f);
}}
>
{value ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={value}
alt="preview"
className="max-h-24 max-w-full object-contain"
/>
) : (
<>
<Upload className="h-6 w-6" />
<span>Trage imaginea aici sau apasă pentru a selecta</span>
</>
)}
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
}}
/>
{value && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs text-muted-foreground"
onClick={() => onChange("")}
>
<X className="mr-1 h-3 w-3" /> Elimină imaginea
</Button>
)}
</div>
);
}
function AddVersionForm({
onSubmit,
onCancel,
history,
}: {
onSubmit: (imageUrl: string, notes: string) => void;
onCancel: () => void;
history: Array<{ id: string; imageUrl: string; notes: string; createdAt: string }>;
history: Array<{
id: string;
imageUrl: string;
notes: string;
createdAt: string;
}>;
}) {
const [imageUrl, setImageUrl] = useState('');
const [notes, setNotes] = useState('');
const [imageUrl, setImageUrl] = useState("");
const [notes, setNotes] = useState("");
return (
<div className="space-y-4">
{history.length > 0 && (
<div className="max-h-32 space-y-1 overflow-y-auto rounded border p-2">
<p className="text-xs font-medium text-muted-foreground">Istoric versiuni</p>
<p className="text-xs font-medium text-muted-foreground">
Istoric versiuni
</p>
{history.map((v) => (
<div key={v.id} className="flex items-center justify-between text-xs">
<span className="truncate text-muted-foreground">{v.notes || 'Fără note'}</span>
<span className="shrink-0 text-muted-foreground">{v.createdAt.slice(0, 10)}</span>
<div
key={v.id}
className="flex items-center justify-between text-xs"
>
<span className="truncate text-muted-foreground">
{v.notes || "Fără note"}
</span>
<span className="shrink-0 text-muted-foreground">
{v.createdAt.slice(0, 10)}
</span>
</div>
))}
</div>
)}
<div>
<Label>URL imagine nouă</Label>
<Input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} className="mt-1" placeholder="https://... sau data:image/png;base64,..." required />
<Label>Imagine nouă</Label>
<div className="mt-1">
<ImageUploadField value={imageUrl} onChange={setImageUrl} />
</div>
</div>
<div>
<Label>Note versiune</Label>
<Input value={notes} onChange={(e) => setNotes(e.target.value)} className="mt-1" placeholder="Ce s-a schimbat..." />
<Input
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="mt-1"
placeholder="Ce s-a schimbat..."
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onCancel}>Anulează</Button>
<Button onClick={() => { if (imageUrl.trim()) onSubmit(imageUrl, notes); }} disabled={!imageUrl.trim()}>Salvează versiune</Button>
<Button variant="outline" onClick={onCancel}>
Anulează
</Button>
<Button
onClick={() => {
if (imageUrl.trim()) onSubmit(imageUrl, notes);
}}
disabled={!imageUrl.trim()}
>
Salvează versiune
</Button>
</div>
</div>
);
}
function AssetForm({ initial, onSubmit, onCancel }: {
function AssetForm({
initial,
onSubmit,
onCancel,
}: {
initial?: SignatureAsset;
onSubmit: (data: Omit<SignatureAsset, 'id' | 'createdAt' | 'updatedAt'>) => void;
onSubmit: (
data: Omit<SignatureAsset, "id" | "createdAt" | "updatedAt">,
) => void;
onCancel: () => void;
}) {
const [label, setLabel] = useState(initial?.label ?? '');
const [type, setType] = useState<SignatureAssetType>(initial?.type ?? 'signature');
const [imageUrl, setImageUrl] = useState(initial?.imageUrl ?? '');
const [owner, setOwner] = useState(initial?.owner ?? '');
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
const [expirationDate, setExpirationDate] = useState(initial?.expirationDate ?? '');
const [legalStatus, setLegalStatus] = useState(initial?.legalStatus ?? '');
const [usageNotes, setUsageNotes] = useState(initial?.usageNotes ?? '');
const [label, setLabel] = useState(initial?.label ?? "");
const [type, setType] = useState<SignatureAssetType>(
initial?.type ?? "signature",
);
const [imageUrl, setImageUrl] = useState(initial?.imageUrl ?? "");
const [owner, setOwner] = useState(initial?.owner ?? "");
const [company, setCompany] = useState<CompanyId>(
initial?.company ?? "beletage",
);
const [expirationDate, setExpirationDate] = useState(
initial?.expirationDate ?? "",
);
const [legalStatus, setLegalStatus] = useState(initial?.legalStatus ?? "");
const [usageNotes, setUsageNotes] = useState(initial?.usageNotes ?? "");
const [tags, setTags] = useState<string[]>(initial?.tags ?? []);
const [tagInput, setTagInput] = useState("");
const addTag = (raw: string) => {
const t = raw.trim().toLowerCase();
if (t && !tags.includes(t)) setTags((prev) => [...prev, t]);
setTagInput("");
};
const handleTagKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag(tagInput);
}
if (e.key === "Backspace" && tagInput === "" && tags.length > 0)
setTags((prev) => prev.slice(0, -1));
};
return (
<form onSubmit={(e) => {
e.preventDefault();
onSubmit({
label, type, imageUrl, owner, company,
expirationDate: expirationDate || undefined,
legalStatus, usageNotes,
versions: initial?.versions ?? [],
tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all',
});
}} className="space-y-4">
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit({
label,
type,
imageUrl,
owner,
company,
expirationDate: expirationDate || undefined,
legalStatus,
usageNotes,
versions: initial?.versions ?? [],
tags,
visibility: initial?.visibility ?? "all",
});
}}
className="space-y-4"
>
<div className="grid gap-4 sm:grid-cols-2">
<div><Label>Denumire *</Label><Input value={label} onChange={(e) => setLabel(e.target.value)} className="mt-1" required /></div>
<div><Label>Tip</Label>
<Select value={type} onValueChange={(v) => setType(v as SignatureAssetType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<div>
<Label>Denumire *</Label>
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
className="mt-1"
required
/>
</div>
<div>
<Label>Tip</Label>
<Select
value={type}
onValueChange={(v) => setType(v as SignatureAssetType)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="signature">Semnătură</SelectItem>
<SelectItem value="stamp">Ștampilă</SelectItem>
@@ -282,10 +592,23 @@ function AssetForm({ initial, onSubmit, onCancel }: {
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div><Label>Proprietar</Label><Input value={owner} onChange={(e) => setOwner(e.target.value)} className="mt-1" /></div>
<div><Label>Companie</Label>
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<div>
<Label>Proprietar</Label>
<Input
value={owner}
onChange={(e) => setOwner(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Companie</Label>
<Select
value={company}
onValueChange={(v) => setCompany(v as CompanyId)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="beletage">Beletage</SelectItem>
<SelectItem value="urban-switch">Urban Switch</SelectItem>
@@ -296,18 +619,77 @@ function AssetForm({ initial, onSubmit, onCancel }: {
</div>
</div>
<div>
<Label>URL imagine</Label>
<Input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} className="mt-1" placeholder="https://... sau data:image/png;base64,..." />
<p className="mt-1 text-xs text-muted-foreground">URL către imaginea semnăturii/ștampilei. Suportă URL-uri externe sau base64.</p>
<Label>Imagine</Label>
<div className="mt-1">
<ImageUploadField value={imageUrl} onChange={setImageUrl} />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div><Label>Data expirare</Label><Input type="date" value={expirationDate} onChange={(e) => setExpirationDate(e.target.value)} className="mt-1" /></div>
<div><Label>Status legal</Label><Input value={legalStatus} onChange={(e) => setLegalStatus(e.target.value)} className="mt-1" placeholder="Valid, Anulat..." /></div>
<div><Label>Note utilizare</Label><Input value={usageNotes} onChange={(e) => setUsageNotes(e.target.value)} className="mt-1" placeholder="Doar pentru contracte..." /></div>
<div>
<Label>Data expirare</Label>
<Input
type="date"
value={expirationDate}
onChange={(e) => setExpirationDate(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Status legal</Label>
<Input
value={legalStatus}
onChange={(e) => setLegalStatus(e.target.value)}
className="mt-1"
placeholder="Valid, Anulat..."
/>
</div>
<div>
<Label>Note utilizare</Label>
<Input
value={usageNotes}
onChange={(e) => setUsageNotes(e.target.value)}
className="mt-1"
placeholder="Doar pentru contracte..."
/>
</div>
</div>
<div>
<Label>Etichete</Label>
<div className="mt-1 flex min-h-[38px] flex-wrap items-center gap-1.5 rounded-md border bg-background px-2 py-1.5 focus-within:ring-1 focus-within:ring-ring">
{tags.map((tag) => (
<span
key={tag}
className="flex items-center gap-0.5 rounded-full border bg-muted px-2 py-0.5 text-xs"
>
{tag}
<button
type="button"
onClick={() => setTags((t) => t.filter((x) => x !== tag))}
className="ml-0.5 opacity-60 hover:opacity-100"
>
<X className="h-2.5 w-2.5" />
</button>
</span>
))}
<input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleTagKeyDown}
onBlur={() => {
if (tagInput.trim()) addTag(tagInput);
}}
placeholder={
tags.length === 0 ? "Adaugă etichete (Enter sau virgulă)..." : ""
}
className="min-w-[120px] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
<Button type="button" variant="outline" onClick={onCancel}>
Anulează
</Button>
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
</div>
</form>
);

View File

@@ -1,18 +1,38 @@
'use client';
"use client";
import type { CompanyId } from '@/core/auth/types';
import type { SignatureConfig, SignatureColors, SignatureLayout, SignatureVariant } from '../types';
import { COMPANY_BRANDING, BELETAGE_ADDRESSES } from '../services/company-branding';
import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label';
import { Switch } from '@/shared/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import { Separator } from '@/shared/components/ui/separator';
import { cn } from '@/shared/lib/utils';
import type { CompanyId } from "@/core/auth/types";
import type {
SignatureConfig,
SignatureColors,
SignatureLayout,
SignatureVariant,
} from "../types";
import {
COMPANY_BRANDING,
BELETAGE_ADDRESSES,
US_ADDRESSES,
SDT_ADDRESSES,
} from "../services/company-branding";
import type { AddressKey } from "../services/company-branding";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Switch } from "@/shared/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { Separator } from "@/shared/components/ui/separator";
import { cn } from "@/shared/lib/utils";
interface SignatureConfiguratorProps {
config: SignatureConfig;
onUpdateField: <K extends keyof SignatureConfig>(key: K, value: SignatureConfig[K]) => void;
onUpdateField: <K extends keyof SignatureConfig>(
key: K,
value: SignatureConfig[K],
) => void;
onUpdateColor: (key: keyof SignatureColors, value: string) => void;
onUpdateLayout: (key: keyof SignatureLayout, value: number) => void;
onSetVariant: (variant: SignatureVariant) => void;
@@ -23,58 +43,71 @@ interface SignatureConfiguratorProps {
/** Color palette per company */
const COMPANY_PALETTES: Record<CompanyId, Record<string, string>> = {
beletage: {
verde: '#22B5AB',
griInchis: '#54504F',
griDeschis: '#A7A9AA',
negru: '#323232',
verde: "#22B5AB",
griInchis: "#54504F",
griDeschis: "#A7A9AA",
negru: "#323232",
},
'urban-switch': {
indigo: '#6366f1',
violet: '#4F46E5',
griInchis: '#2D2D2D',
griDeschis: '#6B7280',
albastru: '#3B82F6',
negru: '#1F2937',
"urban-switch": {
albastru: "#345476",
griInchis: "#2D2D2D",
griDeschis: "#6B7280",
negru: "#1F2937",
},
'studii-de-teren': {
amber: '#f59e0b',
portocaliu: '#D97706',
griInchis: '#2D2D2D',
griDeschis: '#6B7280',
maro: '#92400E',
negru: '#1F2937',
"studii-de-teren": {
teal: "#0182A1",
bleumarin: "#000D1A",
griInchis: "#2D2D2D",
griDeschis: "#6B7280",
negru: "#1F2937",
},
group: {
gri: '#64748b',
griInchis: '#334155',
griDeschis: '#94a3b8',
negru: '#1e293b',
gri: "#64748b",
griInchis: "#334155",
griDeschis: "#94a3b8",
negru: "#1e293b",
},
};
const COLOR_LABELS: Record<keyof SignatureColors, string> = {
prefix: 'Titulatură',
name: 'Nume',
title: 'Funcție',
address: 'Adresă',
phone: 'Telefon',
website: 'Website',
motto: 'Motto',
prefix: "Titulatură",
name: "Nume",
title: "Funcție",
address: "Adresă",
phone: "Telefon",
website: "Website",
motto: "Motto",
};
const LAYOUT_CONTROLS: { key: keyof SignatureLayout; label: string; min: number; max: number }[] = [
{ key: 'greenLineWidth', label: 'Lungime linie accent', min: 50, max: 300 },
{ key: 'sectionSpacing', label: 'Spațiere secțiuni', min: 0, max: 30 },
{ key: 'logoSpacing', label: 'Spațiere logo', min: 0, max: 30 },
{ key: 'titleSpacing', label: 'Spațiere funcție', min: 0, max: 20 },
{ key: 'gutterWidth', label: 'Aliniere contact', min: 0, max: 150 },
{ key: 'iconTextSpacing', label: 'Spațiu icon-text', min: -10, max: 30 },
{ key: 'iconVerticalOffset', label: 'Aliniere verticală iconițe', min: -10, max: 10 },
{ key: 'mottoSpacing', label: 'Spațiere motto', min: 0, max: 20 },
const LAYOUT_CONTROLS: {
key: keyof SignatureLayout;
label: string;
min: number;
max: number;
}[] = [
{ key: "greenLineWidth", label: "Lungime linie accent", min: 50, max: 300 },
{ key: "sectionSpacing", label: "Spațiere secțiuni", min: 0, max: 30 },
{ key: "logoSpacing", label: "Spațiere logo", min: 0, max: 30 },
{ key: "titleSpacing", label: "Spațiere funcție", min: 0, max: 20 },
{ key: "gutterWidth", label: "Aliniere contact", min: 0, max: 150 },
{ key: "iconTextSpacing", label: "Spațiu icon-text", min: -10, max: 30 },
{
key: "iconVerticalOffset",
label: "Aliniere verticală iconițe",
min: -10,
max: 10,
},
{ key: "mottoSpacing", label: "Spațiere motto", min: 0, max: 20 },
];
export function SignatureConfigurator({
config, onUpdateField, onUpdateColor, onUpdateLayout, onSetVariant, onSetCompany, onSetAddress,
config,
onUpdateField,
onUpdateColor,
onUpdateLayout,
onSetVariant,
onSetCompany,
onSetAddress,
}: SignatureConfiguratorProps) {
const palette = COMPANY_PALETTES[config.company];
@@ -83,33 +116,129 @@ export function SignatureConfigurator({
{/* Company selector */}
<div>
<Label>Companie</Label>
<Select value={config.company} onValueChange={(v) => onSetCompany(v as CompanyId)}>
<Select
value={config.company}
onValueChange={(v) => onSetCompany(v as CompanyId)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(COMPANY_BRANDING).map((b) => (
<SelectItem key={b.id} value={b.id}>{b.name}</SelectItem>
<SelectItem key={b.id} value={b.id}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Address selector (for Beletage) */}
{config.company === 'beletage' && onSetAddress && (
{config.company === "beletage" && onSetAddress && (
<div>
<Label>Adresă birou</Label>
<Select
value={!config.addressOverride || BELETAGE_ADDRESSES.unirii.join('|') === config.addressOverride.join('|') ? 'unirii' : 'christescu'}
value={
!config.addressOverride
? "christescu"
: config.addressOverride.join("|") ===
BELETAGE_ADDRESSES.unirii.join("|")
? "unirii"
: config.addressOverride.join("|") ===
BELETAGE_ADDRESSES.albac.join("|")
? "albac"
: "christescu"
}
onValueChange={(v) => {
const key = v as keyof typeof BELETAGE_ADDRESSES;
const key = v as AddressKey;
onSetAddress(BELETAGE_ADDRESSES[key]);
}}
>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unirii">Str. Unirii, nr. 3</SelectItem>
<SelectItem value="christescu">Str. G-ral Eremia Grigorescu, nr. 21</SelectItem>
<SelectItem value="christescu">
Str. G-ral Constantin Christescu, nr. 12
</SelectItem>
<SelectItem value="unirii">
Str. Unirii, nr. 3, sc. 3 ap. 26
</SelectItem>
<SelectItem value="albac">Str. Albac, nr. 2, ap. 1</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Address selector (for Urban Switch) */}
{config.company === "urban-switch" && onSetAddress && (
<div>
<Label>Adresă birou</Label>
<Select
value={
!config.addressOverride
? "christescu"
: config.addressOverride.join("|") ===
US_ADDRESSES.unirii.join("|")
? "unirii"
: config.addressOverride.join("|") ===
US_ADDRESSES.albac.join("|")
? "albac"
: "christescu"
}
onValueChange={(v) => {
const key = v as AddressKey;
onSetAddress(US_ADDRESSES[key]);
}}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="christescu">
Str. G-ral Constantin Christescu, nr. 12
</SelectItem>
<SelectItem value="unirii">
Str. Unirii, nr. 3, sc. 3 ap. 26
</SelectItem>
<SelectItem value="albac">Str. Albac, nr. 2, ap. 1</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Address selector (for Studii de Teren) */}
{config.company === "studii-de-teren" && onSetAddress && (
<div>
<Label>Adresă birou</Label>
<Select
value={
!config.addressOverride
? "christescu"
: config.addressOverride.join("|") ===
SDT_ADDRESSES.unirii.join("|")
? "unirii"
: config.addressOverride.join("|") ===
SDT_ADDRESSES.albac.join("|")
? "albac"
: "christescu"
}
onValueChange={(v) => {
const key = v as AddressKey;
onSetAddress(SDT_ADDRESSES[key]);
}}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="christescu">
Str. G-ral Constantin Christescu, nr. 12
</SelectItem>
<SelectItem value="unirii">
Str. Unirii, nr. 3, sc. 3 ap. 26
</SelectItem>
<SelectItem value="albac">Str. Albac, nr. 2, ap. 1</SelectItem>
</SelectContent>
</Select>
</div>
@@ -122,19 +251,40 @@ export function SignatureConfigurator({
<h3 className="text-sm font-semibold">Date personale</h3>
<div>
<Label htmlFor="sig-prefix">Titulatură (prefix)</Label>
<Input id="sig-prefix" value={config.prefix} onChange={(e) => onUpdateField('prefix', e.target.value)} className="mt-1" />
<Input
id="sig-prefix"
value={config.prefix}
onChange={(e) => onUpdateField("prefix", e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="sig-name">Nume și Prenume</Label>
<Input id="sig-name" value={config.name} onChange={(e) => onUpdateField('name', e.target.value)} className="mt-1" />
<Input
id="sig-name"
value={config.name}
onChange={(e) => onUpdateField("name", e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="sig-title">Funcția</Label>
<Input id="sig-title" value={config.title} onChange={(e) => onUpdateField('title', e.target.value)} className="mt-1" />
<Input
id="sig-title"
value={config.title}
onChange={(e) => onUpdateField("title", e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="sig-phone">Telefon (format 07xxxxxxxx)</Label>
<Input id="sig-phone" type="tel" value={config.phone} onChange={(e) => onUpdateField('phone', e.target.value)} className="mt-1" />
<Input
id="sig-phone"
type="tel"
value={config.phone}
onChange={(e) => onUpdateField("phone", e.target.value)}
className="mt-1"
/>
</div>
</div>
@@ -143,19 +293,32 @@ export function SignatureConfigurator({
{/* Variant */}
<div className="space-y-3">
<h3 className="text-sm font-semibold">Variantă</h3>
<Select value={config.variant} onValueChange={(v) => onSetVariant(v as SignatureVariant)}>
<Select
value={config.variant}
onValueChange={(v) => onSetVariant(v as SignatureVariant)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="full">Completă (logo + adresă + motto)</SelectItem>
<SelectItem value="full">
Completă (logo + adresă + motto)
</SelectItem>
<SelectItem value="reply">Simplă (fără logo/adresă)</SelectItem>
<SelectItem value="minimal">Super-simplă (doar nume/telefon)</SelectItem>
<SelectItem value="minimal">
Super-simplă (doar nume/telefon)
</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Switch checked={config.useSvg} onCheckedChange={(v) => onUpdateField('useSvg', v)} id="svg-toggle" />
<Label htmlFor="svg-toggle" className="cursor-pointer text-sm">Imagini SVG (calitate maximă)</Label>
<Switch
checked={config.useSvg}
onCheckedChange={(v) => onUpdateField("useSvg", v)}
id="svg-toggle"
/>
<Label htmlFor="svg-toggle" className="cursor-pointer text-sm">
Imagini SVG (calitate maximă)
</Label>
</div>
</div>
@@ -164,27 +327,31 @@ export function SignatureConfigurator({
{/* Colors — company-specific palette */}
<div className="space-y-3">
<h3 className="text-sm font-semibold">Culori text</h3>
{(Object.keys(COLOR_LABELS) as (keyof SignatureColors)[]).map((colorKey) => (
<div key={colorKey} className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{COLOR_LABELS[colorKey]}</span>
<div className="flex gap-1.5">
{Object.values(palette).map((color) => (
<button
key={color}
type="button"
onClick={() => onUpdateColor(colorKey, color)}
className={cn(
'h-6 w-6 rounded-full border-2 transition-all',
config.colors[colorKey] === color
? 'border-primary scale-110 ring-2 ring-primary/30'
: 'border-transparent hover:scale-105'
)}
style={{ backgroundColor: color }}
/>
))}
{(Object.keys(COLOR_LABELS) as (keyof SignatureColors)[]).map(
(colorKey) => (
<div key={colorKey} className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{COLOR_LABELS[colorKey]}
</span>
<div className="flex gap-1.5">
{Object.values(palette).map((color) => (
<button
key={color}
type="button"
onClick={() => onUpdateColor(colorKey, color)}
className={cn(
"h-6 w-6 rounded-full border-2 transition-all",
config.colors[colorKey] === color
? "border-primary scale-110 ring-2 ring-primary/30"
: "border-transparent hover:scale-105",
)}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
</div>
))}
),
)}
</div>
<Separator />
@@ -196,14 +363,18 @@ export function SignatureConfigurator({
<div key={key}>
<div className="flex justify-between text-sm">
<Label>{label}</Label>
<span className="text-muted-foreground">{config.layout[key]}px</span>
<span className="text-muted-foreground">
{config.layout[key]}px
</span>
</div>
<input
type="range"
min={min}
max={max}
value={config.layout[key]}
onChange={(e) => onUpdateLayout(key, parseInt(e.target.value, 10))}
onChange={(e) =>
onUpdateLayout(key, parseInt(e.target.value, 10))
}
className="mt-1 w-full accent-primary"
/>
</div>

View File

@@ -1,122 +1,150 @@
import type { CompanyId } from '@/core/auth/types';
import type { CompanyBranding, SignatureColors } from '../types';
import type { CompanyId } from "@/core/auth/types";
import type { CompanyBranding, SignatureColors } from "../types";
const BELETAGE_COLORS: SignatureColors = {
prefix: '#54504F',
name: '#54504F',
title: '#A7A9AA',
address: '#A7A9AA',
phone: '#54504F',
website: '#54504F',
motto: '#22B5AB',
prefix: "#54504F",
name: "#54504F",
title: "#A7A9AA",
address: "#A7A9AA",
phone: "#54504F",
website: "#54504F",
motto: "#22B5AB",
};
const URBAN_SWITCH_COLORS: SignatureColors = {
prefix: '#2D2D2D',
name: '#2D2D2D',
title: '#6B7280',
address: '#6B7280',
phone: '#2D2D2D',
website: '#4F46E5',
motto: '#6366f1',
prefix: "#345476",
name: "#345476",
title: "#6B7280",
address: "#6B7280",
phone: "#345476",
website: "#345476",
motto: "#345476",
};
const STUDII_COLORS: SignatureColors = {
prefix: '#2D2D2D',
name: '#2D2D2D',
title: '#6B7280',
address: '#6B7280',
phone: '#2D2D2D',
website: '#D97706',
motto: '#f59e0b',
prefix: "#000D1A",
name: "#000D1A",
title: "#6B7280",
address: "#6B7280",
phone: "#000D1A",
website: "#0182A1",
motto: "#0182A1",
};
const ADDR_UNIRII = ['str. Unirii, nr. 3, ap. 26', 'Cluj-Napoca, Cluj 400417', 'România'] as const;
const ADDR_CHRISTESCU = ['str. G-ral Eremia Grigorescu, nr. 21', 'Cluj-Napoca, Cluj 400304', 'România'] as const;
const ADDR_CHRISTESCU = [
"str. G-ral Constantin Christescu, nr. 12",
"Cluj-Napoca, Cluj 400416",
"România",
] as const;
const ADDR_UNIRII = [
"str. Unirii, nr. 3, sc. 3 ap. 26",
"Cluj-Napoca, Cluj 400432",
"România",
] as const;
const ADDR_ALBAC = [
"Str. Albac, nr. 2, ap. 1",
"Cluj-Napoca, Cluj 400459",
"România",
] as const;
/** Address option keys shared across all companies */
export type AddressKey = "christescu" | "unirii" | "albac";
/** Available address options for Beletage (toggle between offices) */
export const BELETAGE_ADDRESSES: { unirii: string[]; christescu: string[] } = {
unirii: [...ADDR_UNIRII],
export const BELETAGE_ADDRESSES: Record<AddressKey, string[]> = {
christescu: [...ADDR_CHRISTESCU],
unirii: [...ADDR_UNIRII],
albac: [...ADDR_ALBAC],
};
/** Available address options for Urban Switch */
export const US_ADDRESSES: Record<AddressKey, string[]> = {
christescu: [...ADDR_CHRISTESCU],
unirii: [...ADDR_UNIRII],
albac: [...ADDR_ALBAC],
};
/** Available address options for Studii de Teren */
export const SDT_ADDRESSES: Record<AddressKey, string[]> = {
christescu: [...ADDR_CHRISTESCU],
unirii: [...ADDR_UNIRII],
albac: [...ADDR_ALBAC],
};
export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
beletage: {
id: 'beletage',
name: 'Beletage SRL',
accent: '#22B5AB',
id: "beletage",
name: "Beletage SRL",
accent: "#22B5AB",
logo: {
png: 'https://beletage.ro/img/Semnatura-Logo.png',
svg: 'https://beletage.ro/img/Logo-Beletage.svg',
png: "https://beletage.ro/img/Semnatura-Logo.png",
svg: "https://beletage.ro/img/Logo-Beletage.svg",
},
slashGrey: {
png: 'https://beletage.ro/img/Grey-slash.png',
svg: 'https://beletage.ro/img/Grey-slash.svg',
png: "https://beletage.ro/img/Grey-slash.png",
svg: "https://beletage.ro/img/Grey-slash.svg",
},
slashAccent: {
png: 'https://beletage.ro/img/Green-slash.png',
svg: 'https://beletage.ro/img/Green-slash.svg',
png: "https://beletage.ro/img/Green-slash.png",
svg: "https://beletage.ro/img/Green-slash.svg",
},
address: [...ADDR_UNIRII],
website: 'www.beletage.ro',
motto: 'we make complex simple',
logoDimensions: { width: 162, height: 24 },
address: [...ADDR_CHRISTESCU],
website: "www.beletage.ro",
motto: "we make complex simple",
defaultColors: BELETAGE_COLORS,
},
'urban-switch': {
id: 'urban-switch',
name: 'Urban Switch SRL',
accent: '#6366f1',
"urban-switch": {
id: "urban-switch",
name: "Urban Switch SRL",
accent: "#345476",
logo: {
png: '/logos/logo-us-dark.svg',
svg: '/logos/logo-us-dark.svg',
png: "/logos/logo-us-light.svg",
svg: "/logos/logo-us-light.svg",
},
slashGrey: {
png: 'https://beletage.ro/img/Grey-slash.png',
svg: 'https://beletage.ro/img/Grey-slash.svg',
png: "https://beletage.ro/img/Grey-slash.png",
svg: "https://beletage.ro/img/Grey-slash.svg",
},
slashAccent: {
png: '/logos/logo-us-light.svg',
svg: '/logos/logo-us-light.svg',
},
address: ['str. Unirii, nr. 3, ap. 26', 'Cluj-Napoca, Cluj 400417', 'România'],
website: 'www.urbanswitch.ro',
motto: 'shaping urban futures',
slashAccent: { png: "", svg: "" },
logoDimensions: { width: 140, height: 24 },
address: [...ADDR_CHRISTESCU],
website: "www.urbanswitch.ro",
motto: "shaping urban futures",
defaultColors: URBAN_SWITCH_COLORS,
},
'studii-de-teren': {
id: 'studii-de-teren',
name: 'Studii de Teren SRL',
accent: '#f59e0b',
"studii-de-teren": {
id: "studii-de-teren",
name: "Studii de Teren SRL",
accent: "#0182A1",
logo: {
png: '/logos/logo-sdt-dark.svg',
svg: '/logos/logo-sdt-dark.svg',
png: "/logos/logo-sdt-light.svg",
svg: "/logos/logo-sdt-light.svg",
},
slashGrey: {
png: 'https://beletage.ro/img/Grey-slash.png',
svg: 'https://beletage.ro/img/Grey-slash.svg',
png: "https://beletage.ro/img/Grey-slash.png",
svg: "https://beletage.ro/img/Grey-slash.svg",
},
slashAccent: {
png: '/logos/logo-sdt-light.svg',
svg: '/logos/logo-sdt-light.svg',
},
address: ['str. Unirii, nr. 3, ap. 26', 'Cluj-Napoca, Cluj 400417', 'România'],
website: 'www.studiideteren.ro',
motto: 'ground truth, measured right',
slashAccent: { png: "", svg: "" },
logoDimensions: { width: 71, height: 24 },
address: [...ADDR_CHRISTESCU],
website: "www.studiideteren.ro",
motto: "ground truth, measured right",
defaultColors: STUDII_COLORS,
},
group: {
id: 'group',
name: 'Grup Companii',
accent: '#64748b',
logo: { png: '', svg: '' },
id: "group",
name: "Grup Companii",
accent: "#64748b",
logo: { png: "", svg: "" },
slashGrey: {
png: 'https://beletage.ro/img/Grey-slash.png',
svg: 'https://beletage.ro/img/Grey-slash.svg',
png: "https://beletage.ro/img/Grey-slash.png",
svg: "https://beletage.ro/img/Grey-slash.svg",
},
slashAccent: { png: '', svg: '' },
address: ['Cluj-Napoca, Cluj', 'România'],
website: '',
motto: '',
slashAccent: { png: "", svg: "" },
address: ["Cluj-Napoca, Cluj", "România"],
website: "",
motto: "",
defaultColors: BELETAGE_COLORS,
},
};

View File

@@ -1,9 +1,9 @@
import type { SignatureConfig, CompanyBranding } from '../types';
import { getBranding } from './company-branding';
import type { SignatureConfig, CompanyBranding } from "../types";
import { getBranding } from "./company-branding";
export function formatPhone(raw: string): { display: string; link: string } {
const clean = raw.replace(/\s/g, '');
if (clean.length === 10 && clean.startsWith('07')) {
const clean = raw.replace(/\s/g, "");
if (clean.length === 10 && clean.startsWith("07")) {
return {
display: `+40 ${clean.substring(1, 4)} ${clean.substring(4, 7)} ${clean.substring(7, 10)}`,
link: `tel:+40${clean.substring(1)}`,
@@ -17,30 +17,47 @@ export function generateSignatureHtml(config: SignatureConfig): string {
const address = config.addressOverride ?? branding.address;
const { display: phone, link: phoneLink } = formatPhone(config.phone);
const images = config.useSvg
? { logo: branding.logo.svg, greySlash: branding.slashGrey.svg, accentSlash: branding.slashAccent.svg }
: { logo: branding.logo.png, greySlash: branding.slashGrey.png, accentSlash: branding.slashAccent.png };
? {
logo: branding.logo.svg,
greySlash: branding.slashGrey.svg,
accentSlash: branding.slashAccent.svg,
}
: {
logo: branding.logo.png,
greySlash: branding.slashGrey.png,
accentSlash: branding.slashAccent.png,
};
const {
greenLineWidth, gutterWidth, iconTextSpacing, iconVerticalOffset,
mottoSpacing, sectionSpacing, titleSpacing, logoSpacing,
greenLineWidth,
gutterWidth,
iconTextSpacing,
iconVerticalOffset,
mottoSpacing,
sectionSpacing,
titleSpacing,
logoSpacing,
} = config.layout;
const colors = config.colors;
const isReply = config.variant === 'reply' || config.variant === 'minimal';
const isMinimal = config.variant === 'minimal';
const isReply = config.variant === "reply" || config.variant === "minimal";
const isMinimal = config.variant === "minimal";
const hide = 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;';
const hideTitle = isReply ? hide : '';
const hideLogo = isReply ? hide : '';
const hideBottom = isMinimal ? hide : '';
const hidePhoneIcon = isMinimal ? hide : '';
const logoDim = branding.logoDimensions ?? { width: 162, height: 24 };
const hide =
"mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;";
const hideTitle = isReply ? hide : "";
const hideLogo = isReply ? hide : "";
const hideBottom = isMinimal ? hide : "";
const hidePhoneIcon = isMinimal ? hide : "";
const spacerWidth = Math.max(0, iconTextSpacing);
const textPaddingLeft = Math.max(0, -iconTextSpacing);
const prefixHtml = config.prefix
? `<span style="font-size:13px; color:${colors.prefix};">${esc(config.prefix)} </span>`
: '';
: "";
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>
@@ -57,28 +74,32 @@ export function generateSignatureHtml(config: SignatureConfig): string {
</td>
</tr>
<tr style="${hideLogo}"><td style="padding:${logoSpacing}px 0 ${logoSpacing + 2}px 0;">
${images.logo ? `<a href="https://${branding.website}" style="text-decoration:none; border:0;">
<img src="${images.logo}" alt="${esc(branding.name)}" style="display:block; border:0; height:24px; width:162px;" height="24" width="162">
</a>` : ''}
${
images.logo
? `<a href="https://${branding.website}" style="text-decoration:none; border:0;">
<img src="${images.logo}" alt="${esc(branding.name)}" style="display:block; border:0; height:${logoDim.height}px; width:${logoDim.width}px;" height="${logoDim.height}" width="${logoDim.width}">
</a>`
: ""
}
</td></tr>
<tr>
<td style="padding-top:${hideLogo ? '0' : sectionSpacing}px;">
<td style="padding-top:${hideLogo ? "0" : sectionSpacing}px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540" style="font-size:13px; line-height:18px;">
<tbody>
<tr style="${hideLogo}">
<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;">
${images.greySlash ? `<img src="${images.greySlash}" alt="" width="11" height="11" style="display:block; border:0;">` : ''}
${images.greySlash ? `<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;">
<span style="color:${colors.address}; text-decoration:none;">${address.join('<br>')}</span>
<span style="color:${colors.address}; text-decoration:none;">${address.join("<br>")}</span>
</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}">
${images.accentSlash ? `<img src="${images.accentSlash}" alt="" width="11" height="7" style="display:block; border:0;">` : ''}
${images.accentSlash ? `<img src="${images.accentSlash}" alt="" width="11" height="7" style="display:block; border:0;">` : ""}
</td>
<td width="${isMinimal ? 0 : spacerWidth}" style="width:${isMinimal ? 0 : spacerWidth}px; font-size:0; line-height:0;"></td>
<td style="vertical-align:top; padding:8px 0 0 ${isMinimal ? 0 : textPaddingLeft}px;">
@@ -89,7 +110,7 @@ export function generateSignatureHtml(config: SignatureConfig): string {
</table>
</td>
</tr>
${branding.website ? `<tr style="${hideBottom}"><td style="padding:${sectionSpacing}px 0 ${mottoSpacing}px 0;"><a href="https://${branding.website}" style="color:${colors.website}; text-decoration:none;"><span style="color:${colors.website}; text-decoration:none;">${branding.website}</span></a></td></tr>` : ''}
${branding.website ? `<tr style="${hideBottom}"><td style="padding:${sectionSpacing}px 0 ${mottoSpacing}px 0;"><a href="https://${branding.website}" style="color:${colors.website}; text-decoration:none;"><span style="color:${colors.website}; text-decoration:none;">${branding.website}</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">
@@ -100,22 +121,22 @@ export function generateSignatureHtml(config: SignatureConfig): string {
</table>
</td>
</tr>
${branding.motto ? `<tr style="${hideBottom}"><td style="padding:${mottoSpacing}px 0 0 0;"><span style="font-size:12px; color:${colors.motto}; font-style:italic;">${esc(branding.motto)}</span></td></tr>` : ''}
${branding.motto ? `<tr style="${hideBottom}"><td style="padding:${mottoSpacing}px 0 0 0;"><span style="font-size:12px; color:${colors.motto}; font-style:italic;">${esc(branding.motto)}</span></td></tr>` : ""}
</tbody>
</table>`;
}
function esc(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
export function downloadSignatureHtml(html: string, filename: string): void {
const blob = new Blob([html], { type: 'text/html' });
const a = document.createElement('a');
const blob = new Blob([html], { type: "text/html" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);

View File

@@ -1,6 +1,6 @@
import type { CompanyId } from '@/core/auth/types';
import type { CompanyId } from "@/core/auth/types";
export type SignatureVariant = 'full' | 'reply' | 'minimal';
export type SignatureVariant = "full" | "reply" | "minimal";
export interface SignatureColors {
prefix: string;
@@ -30,6 +30,8 @@ export interface CompanyBranding {
logo: { png: string; svg: string };
slashGrey: { png: string; svg: string };
slashAccent: { png: string; svg: string };
/** Logo dimensions (width × height) for the signature HTML */
logoDimensions?: { width: number; height: number };
address: string[];
website: string;
motto: string;

View File

@@ -1,13 +1,35 @@
'use client';
"use client";
import { useState } from 'react';
import { Copy, Check, Hash, Type, Percent, Ruler } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label';
import { Textarea } from '@/shared/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/components/ui/tabs';
import { useState, useRef } from "react";
import {
Copy,
Check,
Hash,
Type,
Percent,
Ruler,
Zap,
Wand2,
Building2,
FileDown,
ScanText,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Textarea } from "@/shared/components/ui/textarea";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/shared/components/ui/tabs";
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
@@ -16,17 +38,29 @@ function CopyButton({ text }: { text: string }) {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch { /* silent */ }
} catch {
/* silent */
}
};
return (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopy} disabled={!text}>
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleCopy}
disabled={!text}
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
);
}
function TextCaseConverter() {
const [input, setInput] = useState('');
const [input, setInput] = useState("");
const upper = input.toUpperCase();
const lower = input.toLowerCase();
const title = input.replace(/\b\w/g, (c) => c.toUpperCase());
@@ -34,15 +68,26 @@ function TextCaseConverter() {
return (
<div className="space-y-3">
<div><Label>Text sursă</Label><Textarea value={input} onChange={(e) => setInput(e.target.value)} rows={3} className="mt-1" placeholder="Introdu text..." /></div>
<div>
<Label>Text sursă</Label>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
rows={3}
className="mt-1"
placeholder="Introdu text..."
/>
</div>
{[
{ label: 'UPPERCASE', value: upper },
{ label: 'lowercase', value: lower },
{ label: 'Title Case', value: title },
{ label: 'Sentence case', value: sentence },
{ label: "UPPERCASE", value: upper },
{ label: "lowercase", value: lower },
{ label: "Title Case", value: title },
{ label: "Sentence case", value: sentence },
].map(({ label, value }) => (
<div key={label} className="flex items-center gap-2">
<code className="flex-1 truncate rounded border bg-muted/30 px-2 py-1 text-xs">{value || '—'}</code>
<code className="flex-1 truncate rounded border bg-muted/30 px-2 py-1 text-xs">
{value || "—"}
</code>
<span className="w-24 text-xs text-muted-foreground">{label}</span>
<CopyButton text={value} />
</div>
@@ -52,73 +97,148 @@ function TextCaseConverter() {
}
function CharacterCounter() {
const [input, setInput] = useState('');
const [input, setInput] = useState("");
const chars = input.length;
const charsNoSpaces = input.replace(/\s/g, '').length;
const charsNoSpaces = input.replace(/\s/g, "").length;
const words = input.trim() ? input.trim().split(/\s+/).length : 0;
const lines = input ? input.split('\n').length : 0;
const lines = input ? input.split("\n").length : 0;
return (
<div className="space-y-3">
<div><Label>Text</Label><Textarea value={input} onChange={(e) => setInput(e.target.value)} rows={5} className="mt-1" placeholder="Introdu text..." /></div>
<div>
<Label>Text</Label>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
rows={5}
className="mt-1"
placeholder="Introdu text..."
/>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card><CardContent className="p-3"><p className="text-xs text-muted-foreground">Caractere</p><p className="text-xl font-bold">{chars}</p></CardContent></Card>
<Card><CardContent className="p-3"><p className="text-xs text-muted-foreground">Fără spații</p><p className="text-xl font-bold">{charsNoSpaces}</p></CardContent></Card>
<Card><CardContent className="p-3"><p className="text-xs text-muted-foreground">Cuvinte</p><p className="text-xl font-bold">{words}</p></CardContent></Card>
<Card><CardContent className="p-3"><p className="text-xs text-muted-foreground">Linii</p><p className="text-xl font-bold">{lines}</p></CardContent></Card>
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">Caractere</p>
<p className="text-xl font-bold">{chars}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">Fără spații</p>
<p className="text-xl font-bold">{charsNoSpaces}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">Cuvinte</p>
<p className="text-xl font-bold">{words}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">Linii</p>
<p className="text-xl font-bold">{lines}</p>
</CardContent>
</Card>
</div>
</div>
);
}
function PercentageCalculator() {
const [value, setValue] = useState('');
const [total, setTotal] = useState('');
const [percent, setPercent] = useState('');
const [value, setValue] = useState("");
const [total, setTotal] = useState("");
const [percent, setPercent] = useState("");
const v = parseFloat(value);
const t = parseFloat(total);
const p = parseFloat(percent);
const pctOfTotal = !isNaN(v) && !isNaN(t) && t !== 0 ? ((v / t) * 100).toFixed(2) : '—';
const valFromPct = !isNaN(p) && !isNaN(t) ? ((p / 100) * t).toFixed(2) : '—';
const pctOfTotal =
!isNaN(v) && !isNaN(t) && t !== 0 ? ((v / t) * 100).toFixed(2) : "—";
const valFromPct = !isNaN(p) && !isNaN(t) ? ((p / 100) * t).toFixed(2) : "—";
return (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<div><Label>Valoare</Label><Input type="number" value={value} onChange={(e) => setValue(e.target.value)} className="mt-1" /></div>
<div><Label>Total</Label><Input type="number" value={total} onChange={(e) => setTotal(e.target.value)} className="mt-1" /></div>
<div><Label>Procent</Label><Input type="number" value={percent} onChange={(e) => setPercent(e.target.value)} className="mt-1" /></div>
<div>
<Label>Valoare</Label>
<Input
type="number"
value={value}
onChange={(e) => setValue(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Total</Label>
<Input
type="number"
value={total}
onChange={(e) => setTotal(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Procent</Label>
<Input
type="number"
value={percent}
onChange={(e) => setPercent(e.target.value)}
className="mt-1"
/>
</div>
</div>
<div className="space-y-2 rounded-md border bg-muted/30 p-3 text-sm">
<p><strong>{value || '?'}</strong> din <strong>{total || '?'}</strong> = <strong>{pctOfTotal}%</strong></p>
<p><strong>{percent || '?'}%</strong> din <strong>{total || '?'}</strong> = <strong>{valFromPct}</strong></p>
<p>
<strong>{value || "?"}</strong> din <strong>{total || "?"}</strong> ={" "}
<strong>{pctOfTotal}%</strong>
</p>
<p>
<strong>{percent || "?"}%</strong> din <strong>{total || "?"}</strong>{" "}
= <strong>{valFromPct}</strong>
</p>
</div>
</div>
);
}
function AreaConverter() {
const [mp, setMp] = useState('');
const [mp, setMp] = useState("");
const v = parseFloat(mp);
const conversions = !isNaN(v) ? [
{ label: 'mp (m²)', value: v.toFixed(2) },
{ label: 'ari (100 m²)', value: (v / 100).toFixed(4) },
{ label: 'hectare (10.000 m²)', value: (v / 10000).toFixed(6) },
{ label: 'km²', value: (v / 1000000).toFixed(8) },
{ label: 'sq ft', value: (v * 10.7639).toFixed(2) },
] : [];
const conversions = !isNaN(v)
? [
{ label: "mp (m²)", value: v.toFixed(2) },
{ label: "ari (100 m²)", value: (v / 100).toFixed(4) },
{ label: "hectare (10.000 m²)", value: (v / 10000).toFixed(6) },
{ label: "km²", value: (v / 1000000).toFixed(8) },
{ label: "sq ft", value: (v * 10.7639).toFixed(2) },
]
: [];
return (
<div className="space-y-3">
<div><Label>Suprafață (m²)</Label><Input type="number" value={mp} onChange={(e) => setMp(e.target.value)} className="mt-1" placeholder="Introdu suprafața..." /></div>
<div>
<Label>Suprafață (m²)</Label>
<Input
type="number"
value={mp}
onChange={(e) => setMp(e.target.value)}
className="mt-1"
placeholder="Introdu suprafața..."
/>
</div>
{conversions.length > 0 && (
<div className="space-y-1.5">
{conversions.map(({ label, value: val }) => (
<div key={label} className="flex items-center gap-2">
<code className="flex-1 rounded border bg-muted/30 px-2 py-1 text-xs">{val}</code>
<span className="w-36 text-xs text-muted-foreground">{label}</span>
<code className="flex-1 rounded border bg-muted/30 px-2 py-1 text-xs">
{val}
</code>
<span className="w-36 text-xs text-muted-foreground">
{label}
</span>
<CopyButton text={val} />
</div>
))}
@@ -128,31 +248,603 @@ function AreaConverter() {
);
}
// ─── U-value → R-value Converter ─────────────────────────────────────────────
function UValueConverter() {
const [uValue, setUValue] = useState("");
const [thickness, setThickness] = useState("");
const u = parseFloat(uValue);
const t = parseFloat(thickness);
const rValue = !isNaN(u) && u > 0 ? (1 / u).toFixed(4) : null;
const rsi = 0.13;
const rse = 0.04;
const rTotal =
rValue !== null ? (parseFloat(rValue) + rsi + rse).toFixed(4) : null;
const lambda =
rValue !== null && !isNaN(t) && t > 0
? (t / 100 / parseFloat(rValue)).toFixed(4)
: null;
return (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label>Coeficient U (W/m²K)</Label>
<Input
type="number"
step="0.01"
min="0"
value={uValue}
onChange={(e) => setUValue(e.target.value)}
className="mt-1"
placeholder="ex: 0.35"
/>
</div>
<div>
<Label>Grosime material (cm) opțional</Label>
<Input
type="number"
step="0.1"
min="0"
value={thickness}
onChange={(e) => setThickness(e.target.value)}
className="mt-1"
placeholder="ex: 20"
/>
</div>
</div>
{rValue !== null && (
<div className="space-y-2 rounded-md border bg-muted/30 p-3 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium">R = 1/U</span>
<div className="flex items-center gap-1">
<code className="rounded border bg-muted px-2 py-0.5">
{rValue} m²K/W
</code>
<CopyButton text={rValue} />
</div>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span>Rsi (suprafață interioară)</span>
<code className="rounded border bg-muted px-2 py-0.5">
{rsi} m²K/W
</code>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<span>Rse (suprafață exterioară)</span>
<code className="rounded border bg-muted px-2 py-0.5">
{rse} m²K/W
</code>
</div>
<div className="flex items-center justify-between font-medium border-t pt-2 mt-1">
<span>R total (cu Rsi + Rse)</span>
<div className="flex items-center gap-1">
<code className="rounded border bg-muted px-2 py-0.5">
{rTotal} m²K/W
</code>
<CopyButton text={rTotal ?? ""} />
</div>
</div>
{lambda !== null && (
<div className="flex items-center justify-between text-muted-foreground border-t pt-2 mt-1">
<span>Conductivitate λ = d/R</span>
<div className="flex items-center gap-1">
<code className="rounded border bg-muted px-2 py-0.5">
{lambda} W/mK
</code>
<CopyButton text={lambda} />
</div>
</div>
)}
</div>
)}
</div>
);
}
// ─── AI Artifact Cleaner ──────────────────────────────────────────────────────
function AiArtifactCleaner() {
const [input, setInput] = useState("");
const clean = (text: string): string => {
let r = text;
// Strip markdown
r = r.replace(/^#{1,6}\s+/gm, "");
r = r.replace(/\*\*(.+?)\*\*/g, "$1");
r = r.replace(/\*(.+?)\*/g, "$1");
r = r.replace(/_{2}(.+?)_{2}/g, "$1");
r = r.replace(/_(.+?)_/g, "$1");
r = r.replace(/```[\s\S]*?```/g, "");
r = r.replace(/`(.+?)`/g, "$1");
r = r.replace(/^[*\-+]\s+/gm, "");
r = r.replace(/^\d+\.\s+/gm, "");
r = r.replace(/^[-_*]{3,}$/gm, "");
r = r.replace(/\[(.+?)\]\(.*?\)/g, "$1");
r = r.replace(/^>\s+/gm, "");
// Fix encoding artifacts (UTF-8 mojibake)
r = r.replace(/â/g, "â");
r = r.replace(/î/g, "î");
r = r.replace(/Ã /g, "à");
r = r.replace(/Å£/g, "ț");
r = r.replace(/È™/g, "ș");
r = r.replace(/È›/g, "ț");
r = r.replace(/Èš/g, "Ț");
r = r.replace(/\u015f/g, "ș");
r = r.replace(/\u0163/g, "ț");
// Remove zero-width and invisible chars
r = r.replace(/[\u200b\u200c\u200d\ufeff]/g, "");
// Remove emoji
r = r.replace(/\p{Extended_Pictographic}/gu, "");
r = r.replace(/[\u{1F1E0}-\u{1F1FF}]/gu, ""); // flag emoji
r = r.replace(/[\u{FE00}-\u{FE0F}\u{20D0}-\u{20FF}]/gu, ""); // variation selectors
// Normalize typography
r = r.replace(/[""]/g, '"');
r = r.replace(/['']/g, "'");
r = r.replace(/[–—]/g, "-");
r = r.replace(/…/g, "...");
// Normalize spacing
r = r.replace(/ {2,}/g, " ");
r = r.replace(/\n{3,}/g, "\n\n");
return r.trim();
};
const cleaned = input ? clean(input) : "";
return (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label>Text original (output AI)</Label>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
className="mt-1 h-72 font-mono text-xs"
placeholder="Lipește textul generat de AI..."
/>
</div>
<div>
<div className="flex items-center justify-between">
<Label>Text curățat</Label>
{cleaned && <CopyButton text={cleaned} />}
</div>
<Textarea
value={cleaned}
readOnly
className="mt-1 h-72 font-mono text-xs bg-muted/30"
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Operații: eliminare markdown (###, **, `, liste, citate), emoji,
corectare encoding românesc (mojibake), curățare Unicode invizibil,
normalizare ghilimele / cratime / spații multiple.
</p>
</div>
);
}
// ─── MDLPA Date Locale ────────────────────────────────────────────────────────
function MdlpaValidator() {
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3 text-sm">
<a
href="https://datelocale.mdlpa.ro"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
Deschide datelocale.mdlpa.ro ↗
</a>
<span className="text-muted-foreground">•</span>
<a
href="https://datelocale.mdlpa.ro/ro/about/tutorials"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
Tutoriale video ↗
</a>
<span className="text-muted-foreground">•</span>
<a
href="https://datelocale.mdlpa.ro/ro/about/info/reguli"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
Reguli de calcul ↗
</a>
</div>
<div
className="overflow-hidden rounded-md border"
style={{ height: "560px" }}
>
<iframe
src="https://datelocale.mdlpa.ro"
className="h-full w-full"
title="MDLPA — Date Locale"
allow="fullscreen"
/>
</div>
</div>
);
}
// ─── PDF Reducer (Stirling PDF) ───────────────────────────────────────────────
function PdfReducer() {
const [file, setFile] = useState<File | null>(null);
const [optimizeLevel, setOptimizeLevel] = useState("2");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const fileRef = useRef<HTMLInputElement>(null);
const handleCompress = async () => {
if (!file) return;
setLoading(true);
setError("");
try {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("optimizeLevel", optimizeLevel);
const res = await fetch("/api/compress-pdf", {
method: "POST",
body: formData,
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? `Eroare server: ${res.status}`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name.replace(/\.pdf$/i, "-comprimat.pdf");
a.click();
URL.revokeObjectURL(url);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Nu s-a putut contacta Stirling PDF.",
);
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
<div className="space-y-1.5">
<Label>Fișier PDF</Label>
<input
ref={fileRef}
type="file"
accept=".pdf"
className="hidden"
onChange={(e) => {
setFile(e.target.files?.[0] ?? null);
setError("");
}}
/>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={() => fileRef.current?.click()}>
Selectează PDF...
</Button>
{file && (
<span className="text-sm text-muted-foreground">{file.name}</span>
)}
</div>
</div>
<div className="space-y-1.5">
<Label>Nivel compresie</Label>
<select
value={optimizeLevel}
onChange={(e) => setOptimizeLevel(e.target.value)}
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
>
<option value="0">0 — fără modificări (test)</option>
<option value="1">1 — compresie minimă</option>
<option value="2">2 — echilibrat (recomandat)</option>
<option value="3">3 — compresie mare</option>
<option value="4">4 — compresie maximă</option>
</select>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={handleCompress} disabled={!file || loading}>
{loading ? "Se comprimă..." : "Comprimă PDF"}
</Button>
<Button variant="ghost" asChild>
<a
href="http://10.10.10.166:8087/compress-pdf"
target="_blank"
rel="noopener noreferrer"
>
Deschide Stirling PDF ↗
</a>
</Button>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
}
// ─── Quick OCR ────────────────────────────────────────────────────────────────
function QuickOcr() {
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [text, setText] = useState("");
const [progress, setProgress] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [lang, setLang] = useState("ron+eng");
const fileRef = useRef<HTMLInputElement>(null);
const runOcr = async (src: string) => {
if (loading) return;
setLoading(true);
setError("");
setText("");
setProgress(0);
try {
const { createWorker } = await import("tesseract.js");
const worker = await createWorker(lang.split("+"), 1, {
logger: (m: { status: string; progress: number }) => {
if (m.status === "recognizing text")
setProgress(Math.round(m.progress * 100));
},
});
const { data } = await worker.recognize(src);
setText(data.text.trim());
await worker.terminate();
} catch (e) {
setError(e instanceof Error ? e.message : "Eroare OCR necunoscută");
} finally {
setLoading(false);
}
};
const handleFile = (file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
const src = e.target?.result as string;
setImageSrc(src);
runOcr(src);
};
reader.readAsDataURL(file);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
const file = Array.from(e.dataTransfer.files).find((f) =>
f.type.startsWith("image/"),
);
if (file) handleFile(file);
};
const handlePaste = (e: React.ClipboardEvent) => {
const item = Array.from(e.clipboardData.items).find((i) =>
i.type.startsWith("image/"),
);
const file = item?.getAsFile();
if (file) handleFile(file);
};
return (
<div className="space-y-3" onPaste={handlePaste}>
<div className="flex flex-wrap items-center gap-3">
<select
value={lang}
onChange={(e) => setLang(e.target.value)}
className="rounded-md border bg-background px-3 py-1.5 text-sm"
>
<option value="ron+eng">Română + Engleză</option>
<option value="ron">Română</option>
<option value="eng">Engleză</option>
</select>
<span className="text-xs text-muted-foreground">
sau Ctrl+V pentru a lipi imaginea
</span>
</div>
<div
className="flex min-h-[120px] cursor-pointer items-center justify-center rounded-md border-2 border-dashed p-4 text-sm text-muted-foreground transition-colors hover:border-primary/50"
onClick={() => fileRef.current?.click()}
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
>
{imageSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageSrc}
alt="preview"
className="max-h-48 max-w-full rounded object-contain"
/>
) : (
<span>Trage o imagine aici, apasă pentru a selecta, sau Ctrl+V</span>
)}
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
}}
/>
{loading && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>Se procesează... (primul rulaj descarcă modelul ~10 MB)</span>
<span>{progress}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-primary transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
{text && (
<div>
<div className="flex items-center justify-between">
<Label>Text extras</Label>
<CopyButton text={text} />
</div>
<Textarea
value={text}
readOnly
className="mt-1 h-56 font-mono text-xs bg-muted/30"
/>
</div>
)}
</div>
);
}
// ─── Main Module ──────────────────────────────────────────────────────────────
export function MiniUtilitiesModule() {
return (
<Tabs defaultValue="text-case" className="space-y-4">
<TabsList className="flex-wrap">
<TabsTrigger value="text-case"><Type className="mr-1 h-3.5 w-3.5" /> Transformare text</TabsTrigger>
<TabsTrigger value="char-count"><Hash className="mr-1 h-3.5 w-3.5" /> Numărare caractere</TabsTrigger>
<TabsTrigger value="percentage"><Percent className="mr-1 h-3.5 w-3.5" /> Procente</TabsTrigger>
<TabsTrigger value="area"><Ruler className="mr-1 h-3.5 w-3.5" /> Convertor suprafețe</TabsTrigger>
<TabsTrigger value="text-case">
<Type className="mr-1 h-3.5 w-3.5" /> Transformare text
</TabsTrigger>
<TabsTrigger value="char-count">
<Hash className="mr-1 h-3.5 w-3.5" /> Numărare caractere
</TabsTrigger>
<TabsTrigger value="percentage">
<Percent className="mr-1 h-3.5 w-3.5" /> Procente
</TabsTrigger>
<TabsTrigger value="area">
<Ruler className="mr-1 h-3.5 w-3.5" /> Suprafețe
</TabsTrigger>
<TabsTrigger value="u-value">
<Zap className="mr-1 h-3.5 w-3.5" /> U → R
</TabsTrigger>
<TabsTrigger value="ai-cleaner">
<Wand2 className="mr-1 h-3.5 w-3.5" /> Curățare AI
</TabsTrigger>
<TabsTrigger value="mdlpa">
<Building2 className="mr-1 h-3.5 w-3.5" /> MDLPA
</TabsTrigger>
<TabsTrigger value="pdf-reducer">
<FileDown className="mr-1 h-3.5 w-3.5" /> Reducere PDF
</TabsTrigger>
<TabsTrigger value="ocr">
<ScanText className="mr-1 h-3.5 w-3.5" /> OCR
</TabsTrigger>
</TabsList>
<TabsContent value="text-case">
<Card><CardHeader><CardTitle className="text-base">Transformare text</CardTitle></CardHeader>
<CardContent><TextCaseConverter /></CardContent></Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Transformare text</CardTitle>
</CardHeader>
<CardContent>
<TextCaseConverter />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="char-count">
<Card><CardHeader><CardTitle className="text-base">Numărare caractere</CardTitle></CardHeader>
<CardContent><CharacterCounter /></CardContent></Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Numărare caractere</CardTitle>
</CardHeader>
<CardContent>
<CharacterCounter />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="percentage">
<Card><CardHeader><CardTitle className="text-base">Calculator procente</CardTitle></CardHeader>
<CardContent><PercentageCalculator /></CardContent></Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Calculator procente</CardTitle>
</CardHeader>
<CardContent>
<PercentageCalculator />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="area">
<Card><CardHeader><CardTitle className="text-base">Convertor suprafețe</CardTitle></CardHeader>
<CardContent><AreaConverter /></CardContent></Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Convertor suprafețe</CardTitle>
</CardHeader>
<CardContent>
<AreaConverter />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="u-value">
<Card>
<CardHeader>
<CardTitle className="text-base">
Convertor U → R (termoizolație)
</CardTitle>
</CardHeader>
<CardContent>
<UValueConverter />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="ai-cleaner">
<Card>
<CardHeader>
<CardTitle className="text-base">Curățare text AI</CardTitle>
</CardHeader>
<CardContent>
<AiArtifactCleaner />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="mdlpa">
<Card>
<CardHeader>
<CardTitle className="text-base">
MDLPA — Date locale construcții
</CardTitle>
</CardHeader>
<CardContent>
<MdlpaValidator />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="pdf-reducer">
<Card>
<CardHeader>
<CardTitle className="text-base">Reducere dimensiune PDF</CardTitle>
</CardHeader>
<CardContent>
<PdfReducer />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="ocr">
<Card>
<CardHeader>
<CardTitle className="text-base">
OCR — extragere text din imagini
</CardTitle>
</CardHeader>
<CardContent>
<QuickOcr />
</CardContent>
</Card>
</TabsContent>
</Tabs>
);

View File

@@ -14,6 +14,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog';
import { Switch } from '@/shared/components/ui/switch';
import type { CompanyId } from '@/core/auth/types';
import type { VaultEntry, VaultEntryCategory, CustomField } from '../types';
import { useVault } from '../hooks/use-vault';
@@ -21,6 +22,29 @@ const CATEGORY_LABELS: Record<VaultEntryCategory, string> = {
web: 'Web', email: 'Email', server: 'Server', database: 'Bază de date', api: 'API', other: 'Altele',
};
const COMPANY_LABELS: Record<CompanyId, string> = {
'beletage': 'Beletage',
'urban-switch': 'Urban Switch',
'studii-de-teren': 'Studii de Teren',
'group': 'Grup',
};
/** Calculate password strength: 0-3 (weak, medium, strong, very strong) */
function getPasswordStrength(pwd: string): { level: 0 | 1 | 2 | 3; label: string; color: string } {
if (!pwd) return { level: 0, label: 'Nicio parolă', color: 'bg-gray-300' };
const len = pwd.length;
const hasUpper = /[A-Z]/.test(pwd);
const hasLower = /[a-z]/.test(pwd);
const hasDigit = /\d/.test(pwd);
const hasSymbol = /[!@#$%^&*()\-_=+\[\]{}|;:,.<>?]/.test(pwd);
const varietyScore = (hasUpper ? 1 : 0) + (hasLower ? 1 : 0) + (hasDigit ? 1 : 0) + (hasSymbol ? 1 : 0);
const score = len + varietyScore * 2;
if (score < 8) return { level: 0, label: 'Slabă', color: 'bg-red-500' };
if (score < 16) return { level: 1, label: 'Medie', color: 'bg-yellow-500' };
if (score < 24) return { level: 2, label: 'Puternică', color: 'bg-green-500' };
return { level: 3, label: 'Foarte puternică', color: 'bg-emerald-600' };
}
type ViewMode = 'list' | 'add' | 'edit';
/** Generate a random password */
@@ -131,12 +155,12 @@ export function PasswordVaultModule() {
<p className="text-xs text-muted-foreground">{entry.username}</p>
<div className="flex items-center gap-2">
<code className="text-xs">
{visiblePasswords.has(entry.id) ? entry.encryptedPassword : '••••••••••'}
{visiblePasswords.has(entry.id) ? entry.password : '••••••••••'}
</code>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => togglePassword(entry.id)}>
{visiblePasswords.has(entry.id) ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</Button>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => handleCopy(entry.encryptedPassword, entry.id)}>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => handleCopy(entry.password, entry.id)}>
<Copy className="h-3 w-3" />
</Button>
{copiedId === entry.id && <span className="text-[10px] text-green-500">Copiat!</span>}
@@ -203,9 +227,10 @@ function VaultForm({ initial, onSubmit, onCancel }: {
}) {
const [label, setLabel] = useState(initial?.label ?? '');
const [username, setUsername] = useState(initial?.username ?? '');
const [password, setPassword] = useState(initial?.encryptedPassword ?? '');
const [password, setPassword] = useState(initial?.password ?? '');
const [url, setUrl] = useState(initial?.url ?? '');
const [category, setCategory] = useState<VaultEntryCategory>(initial?.category ?? 'web');
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
const [notes, setNotes] = useState(initial?.notes ?? '');
const [customFields, setCustomFields] = useState<CustomField[]>(initial?.customFields ?? []);
@@ -216,6 +241,8 @@ function VaultForm({ initial, onSubmit, onCancel }: {
const [genDigits, setGenDigits] = useState(true);
const [genSymbols, setGenSymbols] = useState(true);
const strength = getPasswordStrength(password);
const handleGenerate = () => {
setPassword(generatePassword(genLength, { upper: genUpper, lower: genLower, digits: genDigits, symbols: genSymbols }));
};
@@ -236,7 +263,7 @@ function VaultForm({ initial, onSubmit, onCancel }: {
<form onSubmit={(e) => {
e.preventDefault();
onSubmit({
label, username, encryptedPassword: password, url, category, notes,
label, username, password, url, category, company, notes,
customFields: customFields.filter((cf) => cf.key.trim()),
tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'admin',
});
@@ -251,16 +278,37 @@ function VaultForm({ initial, onSubmit, onCancel }: {
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div><Label>Utilizator</Label><Input value={username} onChange={(e) => setUsername(e.target.value)} className="mt-1" /></div>
<div>
<Label>Parolă</Label>
<div className="mt-1 flex gap-1.5">
<Input type="text" value={password} onChange={(e) => setPassword(e.target.value)} className="flex-1 font-mono text-sm" />
<Button type="button" variant="outline" size="icon" onClick={handleGenerate} title="Generează parolă">
<KeyRound className="h-4 w-4" />
</Button>
</div>
<div><Label>Companie</Label>
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{(Object.keys(COMPANY_LABELS) as CompanyId[]).map((c) => (<SelectItem key={c} value={c}>{COMPANY_LABELS[c]}</SelectItem>))}
</SelectContent>
</Select>
</div>
<div><Label>Utilizator</Label><Input value={username} onChange={(e) => setUsername(e.target.value)} className="mt-1" /></div>
</div>
<div>
<Label>Parolă</Label>
<div className="mt-1 flex gap-1.5">
<Input type="text" value={password} onChange={(e) => setPassword(e.target.value)} className="flex-1 font-mono text-sm" />
<Button type="button" variant="outline" size="icon" onClick={handleGenerate} title="Generează parolă">
<KeyRound className="h-4 w-4" />
</Button>
</div>
{password && (
<div className="mt-2 space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Forță:</span>
<span className={strength.level === 3 ? 'text-emerald-600 font-medium' : strength.level === 2 ? 'text-green-600 font-medium' : strength.level === 1 ? 'text-yellow-600 font-medium' : 'text-red-600 font-medium'}>
{strength.label}
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div className={`h-full ${strength.color} transition-all`} style={{ width: `${(strength.level + 1) * 25}%` }} />
</div>
</div>
)}
</div>
{/* Password generator options */}

View File

@@ -1,4 +1,5 @@
import type { Visibility } from '@/core/module-registry/types';
import type { CompanyId } from '@/core/auth/types';
export type VaultEntryCategory =
| 'web'
@@ -18,9 +19,10 @@ export interface VaultEntry {
id: string;
label: string;
username: string;
encryptedPassword: string;
password: string;
url: string;
category: VaultEntryCategory;
company: CompanyId;
/** Custom key-value fields */
customFields: CustomField[];
notes: string;

File diff suppressed because it is too large Load Diff

View File

@@ -1,73 +1,107 @@
'use client';
"use client";
import { useState, useMemo } from 'react';
import { useState, useMemo } from "react";
import {
Plus, Trash2, Pencil, Check, X, Download, ChevronDown, ChevronRight,
Tag as TagIcon, Search, FolderTree,
} from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label';
import { Badge } from '@/shared/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
Plus,
Trash2,
Pencil,
Check,
X,
Download,
ChevronDown,
ChevronRight,
Tag as TagIcon,
Search,
FolderTree,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Badge } from "@/shared/components/ui/badge";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/shared/components/ui/select';
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/shared/components/ui/dialog';
import { useTags } from '@/core/tagging';
import type { Tag, TagCategory, TagScope } from '@/core/tagging/types';
import { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from '@/core/tagging/types';
import type { CompanyId } from '@/core/auth/types';
import { cn } from '@/shared/lib/utils';
import { getManicTimeSeedTags } from '../services/seed-data';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/components/ui/dialog";
import { useTags } from "@/core/tagging";
import type { Tag, TagCategory, TagScope } from "@/core/tagging/types";
import { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from "@/core/tagging/types";
import type { CompanyId } from "@/core/auth/types";
import { cn } from "@/shared/lib/utils";
import { getManicTimeSeedTags } from "../services/seed-data";
const SCOPE_LABELS: Record<TagScope, string> = {
global: 'Global',
module: 'Modul',
company: 'Companie',
global: "Global",
module: "Modul",
company: "Companie",
};
const COMPANY_LABELS: Record<CompanyId, string> = {
beletage: 'Beletage',
'urban-switch': 'Urban Switch',
'studii-de-teren': 'Studii de Teren',
group: 'Grup',
beletage: "Beletage",
"urban-switch": "Urban Switch",
"studii-de-teren": "Studii de Teren",
group: "Grup",
};
const TAG_COLORS = [
'#ef4444', '#f97316', '#f59e0b', '#84cc16',
'#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6',
'#ec4899', '#64748b', '#22B5AB', '#6366f1',
"#ef4444",
"#f97316",
"#f59e0b",
"#84cc16",
"#22c55e",
"#06b6d4",
"#3b82f6",
"#8b5cf6",
"#ec4899",
"#64748b",
"#22B5AB",
"#6366f1",
];
export function TagManagerModule() {
const { tags, loading, createTag, updateTag, deleteTag, importTags } = useTags();
const { tags, loading, createTag, updateTag, deleteTag, importTags } =
useTags();
// ── Create form state ──
const [newLabel, setNewLabel] = useState('');
const [newCategory, setNewCategory] = useState<TagCategory>('custom');
const [newScope, setNewScope] = useState<TagScope>('global');
const [newColor, setNewColor] = useState('#3b82f6');
const [newCompanyId, setNewCompanyId] = useState<CompanyId>('beletage');
const [newProjectCode, setNewProjectCode] = useState('');
const [newParentId, setNewParentId] = useState('');
const [newLabel, setNewLabel] = useState("");
const [newCategory, setNewCategory] = useState<TagCategory>("custom");
const [newScope, setNewScope] = useState<TagScope>("global");
const [newColor, setNewColor] = useState("#3b82f6");
const [newCompanyId, setNewCompanyId] = useState<CompanyId>("beletage");
const [newProjectCode, setNewProjectCode] = useState("");
const [newParentId, setNewParentId] = useState("");
// ── Filter / search state ──
const [filterCategory, setFilterCategory] = useState<TagCategory | 'all'>('all');
const [searchQuery, setSearchQuery] = useState('');
const [filterCategory, setFilterCategory] = useState<TagCategory | "all">(
"all",
);
const [searchQuery, setSearchQuery] = useState("");
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
() => new Set(TAG_CATEGORY_ORDER)
() => new Set(TAG_CATEGORY_ORDER),
);
// ── Edit state ──
const [editingTag, setEditingTag] = useState<Tag | null>(null);
const [editLabel, setEditLabel] = useState('');
const [editColor, setEditColor] = useState('');
const [editProjectCode, setEditProjectCode] = useState('');
const [editScope, setEditScope] = useState<TagScope>('global');
const [editCompanyId, setEditCompanyId] = useState<CompanyId>('beletage');
const [editLabel, setEditLabel] = useState("");
const [editColor, setEditColor] = useState("");
const [editProjectCode, setEditProjectCode] = useState("");
const [editScope, setEditScope] = useState<TagScope>("global");
const [editCompanyId, setEditCompanyId] = useState<CompanyId>("beletage");
// ── Seed import state ──
const [showSeedDialog, setShowSeedDialog] = useState(false);
@@ -77,7 +111,7 @@ export function TagManagerModule() {
// ── Computed ──
const filteredTags = useMemo(() => {
let result = tags;
if (filterCategory !== 'all') {
if (filterCategory !== "all") {
result = result.filter((t) => t.category === filterCategory);
}
if (searchQuery) {
@@ -85,7 +119,7 @@ export function TagManagerModule() {
result = result.filter(
(t) =>
t.label.toLowerCase().includes(q) ||
(t.projectCode?.toLowerCase().includes(q) ?? false)
(t.projectCode?.toLowerCase().includes(q) ?? false),
);
}
return result;
@@ -119,35 +153,57 @@ export function TagManagerModule() {
}, [tags]);
const parentCandidates = useMemo(() => {
return tags.filter(
(t) => t.category === newCategory && !t.parentId
);
return tags.filter((t) => t.category === newCategory && !t.parentId);
}, [tags, newCategory]);
// ── Validation state ──
const [validationErrors, setValidationErrors] = useState<string[]>([]);
// ── Handlers ──
const handleCreate = async () => {
if (!newLabel.trim()) return;
const errors: string[] = [];
if (!newLabel.trim()) {
errors.push("Numele etichetei este obligatoriu.");
}
if (newCategory === "project" && !newProjectCode.trim()) {
errors.push(
"Codul proiectului este obligatoriu pentru categoria Proiect (ex: B-001, US-010, SDT-003).",
);
}
if (newCategory === "project" && newScope !== "company") {
errors.push(
"Etichetele de tip Proiect trebuie asociate unei companii (vizibilitate = Companie).",
);
}
if (errors.length > 0) {
setValidationErrors(errors);
return;
}
setValidationErrors([]);
await createTag({
label: newLabel.trim(),
category: newCategory,
scope: newScope,
color: newColor,
companyId: newScope === 'company' ? newCompanyId : undefined,
projectCode: newCategory === 'project' && newProjectCode ? newProjectCode : undefined,
companyId: newScope === "company" ? newCompanyId : undefined,
projectCode:
newCategory === "project" && newProjectCode
? newProjectCode
: undefined,
parentId: newParentId || undefined,
});
setNewLabel('');
setNewProjectCode('');
setNewParentId('');
setNewLabel("");
setNewProjectCode("");
setNewParentId("");
};
const startEdit = (tag: Tag) => {
setEditingTag(tag);
setEditLabel(tag.label);
setEditColor(tag.color ?? '#3b82f6');
setEditProjectCode(tag.projectCode ?? '');
setEditColor(tag.color ?? "#3b82f6");
setEditProjectCode(tag.projectCode ?? "");
setEditScope(tag.scope);
setEditCompanyId(tag.companyId ?? 'beletage');
setEditCompanyId(tag.companyId ?? "beletage");
};
const saveEdit = async () => {
@@ -155,9 +211,12 @@ export function TagManagerModule() {
await updateTag(editingTag.id, {
label: editLabel.trim(),
color: editColor,
projectCode: editingTag.category === 'project' && editProjectCode ? editProjectCode : undefined,
projectCode:
editingTag.category === "project" && editProjectCode
? editProjectCode
: undefined,
scope: editScope,
companyId: editScope === 'company' ? editCompanyId : undefined,
companyId: editScope === "company" ? editCompanyId : undefined,
});
setEditingTag(null);
};
@@ -169,7 +228,9 @@ export function TagManagerModule() {
setSeedResult(null);
const seedTags = getManicTimeSeedTags();
const count = await importTags(seedTags);
setSeedResult(`${count} etichete importate din ${seedTags.length} disponibile.`);
setSeedResult(
`${count} etichete importate din ${seedTags.length} disponibile.`,
);
setSeedImporting(false);
};
@@ -183,24 +244,30 @@ export function TagManagerModule() {
};
// ── Stats ──
const projectCount = tags.filter((t) => t.category === 'project').length;
const phaseCount = tags.filter((t) => t.category === 'phase').length;
const projectCount = tags.filter((t) => t.category === "project").length;
const phaseCount = tags.filter((t) => t.category === "phase").length;
return (
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<Card><CardContent className="p-4">
<p className="text-xs text-muted-foreground">Total etichete</p>
<p className="text-2xl font-bold">{tags.length}</p>
</CardContent></Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Total etichete</p>
<p className="text-2xl font-bold">{tags.length}</p>
</CardContent>
</Card>
{TAG_CATEGORY_ORDER.map((cat) => (
<Card key={cat}><CardContent className="p-4">
<p className="text-xs text-muted-foreground">{TAG_CATEGORY_LABELS[cat]}</p>
<p className="text-2xl font-bold">
{tags.filter((t) => t.category === cat).length}
</p>
</CardContent></Card>
<Card key={cat}>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">
{TAG_CATEGORY_LABELS[cat]}
</p>
<p className="text-2xl font-bold">
{tags.filter((t) => t.category === cat).length}
</p>
</CardContent>
</Card>
))}
</div>
@@ -211,7 +278,8 @@ export function TagManagerModule() {
<div>
<p className="font-medium">Nicio etichetă găsită</p>
<p className="text-sm text-muted-foreground">
Importă datele din ManicTime pentru a popula proiectele, fazele și activitățile.
Importă datele din ManicTime pentru a popula proiectele, fazele
și activitățile.
</p>
</div>
<Button onClick={() => setShowSeedDialog(true)}>
@@ -223,7 +291,9 @@ export function TagManagerModule() {
{/* Create new tag */}
<Card>
<CardHeader><CardTitle className="text-base">Etichetă nouă</CardTitle></CardHeader>
<CardHeader>
<CardTitle className="text-base">Etichetă nouă</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex flex-wrap items-end gap-3">
@@ -232,41 +302,62 @@ export function TagManagerModule() {
<Input
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
placeholder="Numele etichetei..."
className="mt-1"
/>
</div>
<div className="w-[160px]">
<Label>Categorie</Label>
<Select value={newCategory} onValueChange={(v) => setNewCategory(v as TagCategory)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<Select
value={newCategory}
onValueChange={(v) => setNewCategory(v as TagCategory)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TAG_CATEGORY_ORDER.map((cat) => (
<SelectItem key={cat} value={cat}>{TAG_CATEGORY_LABELS[cat]}</SelectItem>
<SelectItem key={cat} value={cat}>
{TAG_CATEGORY_LABELS[cat]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-[140px]">
<Label>Vizibilitate</Label>
<Select value={newScope} onValueChange={(v) => setNewScope(v as TagScope)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<Select
value={newScope}
onValueChange={(v) => setNewScope(v as TagScope)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(SCOPE_LABELS) as TagScope[]).map((s) => (
<SelectItem key={s} value={s}>{SCOPE_LABELS[s]}</SelectItem>
<SelectItem key={s} value={s}>
{SCOPE_LABELS[s]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{newScope === 'company' && (
{newScope === "company" && (
<div className="w-[150px]">
<Label>Companie</Label>
<Select value={newCompanyId} onValueChange={(v) => setNewCompanyId(v as CompanyId)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<Select
value={newCompanyId}
onValueChange={(v) => setNewCompanyId(v as CompanyId)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(COMPANY_LABELS) as CompanyId[]).map((c) => (
<SelectItem key={c} value={c}>{COMPANY_LABELS[c]}</SelectItem>
<SelectItem key={c} value={c}>
{COMPANY_LABELS[c]}
</SelectItem>
))}
</SelectContent>
</Select>
@@ -275,7 +366,7 @@ export function TagManagerModule() {
</div>
<div className="flex flex-wrap items-end gap-3">
{newCategory === 'project' && (
{newCategory === "project" && (
<div className="w-[140px]">
<Label>Cod proiect</Label>
<Input
@@ -289,13 +380,23 @@ export function TagManagerModule() {
{parentCandidates.length > 0 && (
<div className="w-[200px]">
<Label>Tag părinte (opțional)</Label>
<Select value={newParentId || '__none__'} onValueChange={(v) => setNewParentId(v === '__none__' ? '' : v)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<Select
value={newParentId || "__none__"}
onValueChange={(v) =>
setNewParentId(v === "__none__" ? "" : v)
}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Niciun părinte </SelectItem>
<SelectItem value="__none__">
Niciun părinte
</SelectItem>
{parentCandidates.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.projectCode ? `${p.projectCode} ` : ''}{p.label}
{p.projectCode ? `${p.projectCode} ` : ""}
{p.label}
</SelectItem>
))}
</SelectContent>
@@ -311,8 +412,10 @@ export function TagManagerModule() {
type="button"
onClick={() => setNewColor(color)}
className={cn(
'h-7 w-7 rounded-full border-2 transition-all',
newColor === color ? 'border-primary scale-110' : 'border-transparent hover:scale-105'
"h-7 w-7 rounded-full border-2 transition-all",
newColor === color
? "border-primary scale-110"
: "border-transparent hover:scale-105",
)}
style={{ backgroundColor: color }}
/>
@@ -323,6 +426,27 @@ export function TagManagerModule() {
<Plus className="mr-1 h-4 w-4" /> Adaugă
</Button>
</div>
{/* Validation errors */}
{validationErrors.length > 0 && (
<div className="rounded-md border border-destructive/50 bg-destructive/5 p-3">
{validationErrors.map((err) => (
<p key={err} className="text-sm text-destructive">
{err}
</p>
))}
</div>
)}
{/* Hint for mandatory categories */}
{(newCategory === "project" || newCategory === "phase") && (
<p className="text-xs text-muted-foreground">
<strong>Notă:</strong> Categoriile <em>Proiect</em> și{" "}
<em>Fază</em> sunt obligatorii în structura de etichete.
Proiectele necesită un cod (ex: B-001, US-010, SDT-003) și
trebuie asociate unei companii.
</p>
)}
</div>
</CardContent>
</Card>
@@ -338,17 +462,28 @@ export function TagManagerModule() {
className="pl-9"
/>
</div>
<Select value={filterCategory} onValueChange={(v) => setFilterCategory(v as TagCategory | 'all')}>
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
<Select
value={filterCategory}
onValueChange={(v) => setFilterCategory(v as TagCategory | "all")}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate categoriile</SelectItem>
{TAG_CATEGORY_ORDER.map((cat) => (
<SelectItem key={cat} value={cat}>{TAG_CATEGORY_LABELS[cat]}</SelectItem>
<SelectItem key={cat} value={cat}>
{TAG_CATEGORY_LABELS[cat]}
</SelectItem>
))}
</SelectContent>
</Select>
{tags.length > 0 && (
<Button variant="outline" size="sm" onClick={() => setShowSeedDialog(true)}>
<Button
variant="outline"
size="sm"
onClick={() => setShowSeedDialog(true)}
>
<Download className="mr-1 h-3.5 w-3.5" /> Importă ManicTime
</Button>
)}
@@ -356,10 +491,13 @@ export function TagManagerModule() {
{/* Tag list by category with hierarchy */}
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
<p className="py-8 text-center text-sm text-muted-foreground">
Se încarcă...
</p>
) : Object.keys(groupedByCategory).length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
Nicio etichetă găsită. Creează prima etichetă sau importă datele inițiale.
Nicio etichetă găsită. Creează prima etichetă sau importă datele
inițiale.
</p>
) : (
<div className="space-y-3">
@@ -373,14 +511,20 @@ export function TagManagerModule() {
onClick={() => toggleCategory(category)}
>
<CardTitle className="flex items-center gap-2 text-sm">
{isExpanded
? <ChevronDown className="h-4 w-4" />
: <ChevronRight className="h-4 w-4" />}
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<TagIcon className="h-4 w-4" />
{TAG_CATEGORY_LABELS[category as TagCategory] ?? category}
<Badge variant="secondary" className="ml-1">{catTags.length}</Badge>
{(category === 'project' || category === 'phase') && (
<Badge variant="default" className="ml-1 text-[10px]">obligatoriu</Badge>
<Badge variant="secondary" className="ml-1">
{catTags.length}
</Badge>
{(category === "project" || category === "phase") && (
<Badge variant="default" className="ml-1 text-[10px]">
obligatoriu
</Badge>
)}
</CardTitle>
</CardHeader>
@@ -426,17 +570,22 @@ export function TagManagerModule() {
</DialogHeader>
<div className="space-y-3 py-2">
<p className="text-sm text-muted-foreground">
Aceasta va importa proiectele Beletage, fazele, activitățile și tipurile de documente
din lista ManicTime. Etichetele existente nu vor fi duplicate.
Aceasta va importa proiectele Beletage, Urban Switch și Studii de
Teren, fazele, activitățile și tipurile de documente din lista
ManicTime. Etichetele existente nu vor fi duplicate.
</p>
{seedResult && (
<p className="rounded bg-muted p-2 text-sm font-medium">{seedResult}</p>
<p className="rounded bg-muted p-2 text-sm font-medium">
{seedResult}
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowSeedDialog(false)}>Închide</Button>
<Button variant="outline" onClick={() => setShowSeedDialog(false)}>
Închide
</Button>
<Button onClick={handleSeedImport} disabled={seedImporting}>
{seedImporting ? 'Se importă...' : 'Importă'}
{seedImporting ? "Se importă..." : "Importă"}
</Button>
</DialogFooter>
</DialogContent>
@@ -468,10 +617,23 @@ interface TagRowProps {
}
function TagRow({
tag, children, editingTag, editLabel, editColor, editProjectCode,
editScope, editCompanyId,
onStartEdit, onSaveEdit, onCancelEdit, onDelete,
setEditLabel, setEditColor, setEditProjectCode, setEditScope, setEditCompanyId,
tag,
children,
editingTag,
editLabel,
editColor,
editProjectCode,
editScope,
editCompanyId,
onStartEdit,
onSaveEdit,
onCancelEdit,
onDelete,
setEditLabel,
setEditColor,
setEditProjectCode,
setEditScope,
setEditCompanyId,
}: TagRowProps) {
const isEditing = editingTag?.id === tag.id;
const [showChildren, setShowChildren] = useState(false);
@@ -480,7 +642,7 @@ function TagRow({
if (isEditing) {
return (
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-2">
{tag.category === 'project' && (
{tag.category === "project" && (
<Input
value={editProjectCode}
onChange={(e) => setEditProjectCode(e.target.value)}
@@ -491,24 +653,39 @@ function TagRow({
<Input
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') onSaveEdit(); if (e.key === 'Escape') onCancelEdit(); }}
onKeyDown={(e) => {
if (e.key === "Enter") onSaveEdit();
if (e.key === "Escape") onCancelEdit();
}}
className="min-w-[200px] flex-1"
autoFocus
/>
<Select value={editScope} onValueChange={(v) => setEditScope(v as TagScope)}>
<SelectTrigger className="w-[120px]"><SelectValue /></SelectTrigger>
<Select
value={editScope}
onValueChange={(v) => setEditScope(v as TagScope)}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global</SelectItem>
<SelectItem value="module">Modul</SelectItem>
<SelectItem value="company">Companie</SelectItem>
</SelectContent>
</Select>
{editScope === 'company' && (
<Select value={editCompanyId} onValueChange={(v) => setEditCompanyId(v as CompanyId)}>
<SelectTrigger className="w-[140px]"><SelectValue /></SelectTrigger>
{editScope === "company" && (
<Select
value={editCompanyId}
onValueChange={(v) => setEditCompanyId(v as CompanyId)}
>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(COMPANY_LABELS) as CompanyId[]).map((c) => (
<SelectItem key={c} value={c}>{COMPANY_LABELS[c]}</SelectItem>
<SelectItem key={c} value={c}>
{COMPANY_LABELS[c]}
</SelectItem>
))}
</SelectContent>
</Select>
@@ -520,17 +697,29 @@ function TagRow({
type="button"
onClick={() => setEditColor(c)}
className={cn(
'h-5 w-5 rounded-full border-2 transition-all',
editColor === c ? 'border-primary scale-110' : 'border-transparent'
"h-5 w-5 rounded-full border-2 transition-all",
editColor === c
? "border-primary scale-110"
: "border-transparent",
)}
style={{ backgroundColor: c }}
/>
))}
</div>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onSaveEdit}>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={onSaveEdit}
>
<Check className="h-4 w-4 text-green-600" />
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onCancelEdit}>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={onCancelEdit}
>
<X className="h-4 w-4" />
</Button>
</div>
@@ -541,18 +730,29 @@ function TagRow({
<div>
<div className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/30">
{hasChildren && (
<button type="button" onClick={() => setShowChildren(!showChildren)} className="p-0.5">
{showChildren
? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
: <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />}
<button
type="button"
onClick={() => setShowChildren(!showChildren)}
className="p-0.5"
>
{showChildren ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button>
)}
{!hasChildren && <span className="w-5" />}
{tag.color && (
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: tag.color }} />
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: tag.color }}
/>
)}
{tag.projectCode && (
<span className="font-mono text-xs text-muted-foreground">{tag.projectCode}</span>
<span className="font-mono text-xs text-muted-foreground">
{tag.projectCode}
</span>
)}
<span className="flex-1 text-sm">{tag.label}</span>
{tag.companyId && (
@@ -583,20 +783,36 @@ function TagRow({
{hasChildren && showChildren && (
<div className="ml-6 border-l pl-2">
{children.map((child) => (
<div key={child.id} className="group flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/30">
<div
key={child.id}
className="group flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted/30"
>
<FolderTree className="h-3 w-3 text-muted-foreground" />
{child.color && (
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: child.color }} />
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: child.color }}
/>
)}
{child.projectCode && (
<span className="font-mono text-[11px] text-muted-foreground">{child.projectCode}</span>
<span className="font-mono text-[11px] text-muted-foreground">
{child.projectCode}
</span>
)}
<span className="flex-1 text-sm">{child.label}</span>
<div className="flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<button type="button" onClick={() => onStartEdit(child)} className="rounded p-1 hover:bg-muted">
<button
type="button"
onClick={() => onStartEdit(child)}
className="rounded p-1 hover:bg-muted"
>
<Pencil className="h-3 w-3 text-muted-foreground" />
</button>
<button type="button" onClick={() => onDelete(child.id)} className="rounded p-1 hover:bg-destructive/10">
<button
type="button"
onClick={() => onDelete(child.id)}
className="rounded p-1 hover:bg-destructive/10"
>
<Trash2 className="h-3 w-3 text-destructive" />
</button>
</div>

View File

@@ -1,16 +1,19 @@
import type { Tag, TagCategory } from '@/core/tagging/types';
import type { CompanyId } from '@/core/auth/types';
import type { Tag, TagCategory } from "@/core/tagging/types";
import type { CompanyId } from "@/core/auth/types";
type SeedTag = Omit<Tag, 'id' | 'createdAt'>;
type SeedTag = Omit<Tag, "id" | "createdAt">;
/** Parse project line like "000 Farmacie" → { code: "B-000", label: "Farmacie" } */
function parseProjectLine(line: string, prefix: string): { code: string; label: string } | null {
function parseProjectLine(
line: string,
prefix: string,
): { code: string; label: string } | null {
const match = line.match(/^(\w?\d+)\s+(.+)$/);
if (!match?.[1] || !match[2]) return null;
const num = match[1];
const label = match[2].trim();
const padded = num.replace(/^[A-Z]/, '').padStart(3, '0');
const codePrefix = num.startsWith('L') ? `${prefix}L` : prefix;
const padded = num.replace(/^[A-Z]/, "").padStart(3, "0");
const codePrefix = num.startsWith("L") ? `${prefix}L` : prefix;
return { code: `${codePrefix}-${padded}`, label };
}
@@ -19,168 +22,260 @@ export function getManicTimeSeedTags(): SeedTag[] {
// ── Beletage projects ──
const beletageProjects = [
'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',
"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",
];
for (const line of beletageProjects) {
const parsed = parseProjectLine(line, 'B');
const parsed = parseProjectLine(line, "B");
if (parsed) {
tags.push({
label: parsed.label,
category: 'project',
scope: 'company',
companyId: 'beletage' as CompanyId,
category: "project",
scope: "company",
companyId: "beletage" as CompanyId,
projectCode: parsed.code,
color: '#22B5AB',
color: "#22B5AB",
});
}
}
// ── Urban Switch projects ──
const urbanSwitchProjects = [
"001 PUZ Sopor - Ansamblu Rezidential",
"002 PUZ Borhanci Nord",
"003 PUZ Zona Centrala Cluj",
"004 PUG Floresti",
"005 PUZ Dezmir - Zona Industriala",
"006 PUZ Gilau Est",
"007 PUZ Baciu - Extensie Intravilan",
"008 PUG Apahida",
"009 PUZ Iris - Reconversie",
"010 PUZ Faget - Zona Turistica",
];
for (const line of urbanSwitchProjects) {
const parsed = parseProjectLine(line, "US");
if (parsed) {
tags.push({
label: parsed.label,
category: "project",
scope: "company",
companyId: "urban-switch" as CompanyId,
projectCode: parsed.code,
color: "#345476",
});
}
}
// ── Studii de Teren projects ──
const studiiDeTerenProjects = [
"001 Studiu Geo - Sopor Rezidential",
"002 Studiu Geo - Borhanci Vila",
"003 Studiu Geo - Floresti Ansamblu",
"004 Ridicare Topo - Dezmir Industrial",
"005 Studiu Geo - Gilau Est",
"006 Ridicare Topo - Baciu Extensie",
"007 Studiu Geo - Apahida Centru",
"008 Ridicare Topo - Faget",
"009 Studiu Geo - Iris Reconversie",
"010 Studiu Geo - Turda Rezidential",
];
for (const line of studiiDeTerenProjects) {
const parsed = parseProjectLine(line, "SDT");
if (parsed) {
tags.push({
label: parsed.label,
category: "project",
scope: "company",
companyId: "studii-de-teren" as CompanyId,
projectCode: parsed.code,
color: "#0182A1",
});
}
}
// ── Phase tags ──
const phases = [
'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',
"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",
];
for (const phase of phases) {
tags.push({
label: phase,
category: 'phase',
scope: 'global',
color: '#3b82f6',
category: "phase",
scope: "global",
color: "#3b82f6",
});
}
// ── Activity tags ──
const activities = [
'Ofertare', 'Configurari', 'Organizare initiala', 'Pregatire Portofoliu',
'Website', 'Documentare', 'Design grafic', 'Design interior',
'Design exterior', 'Releveu', 'Reclama', 'Master MATDR',
'Pauza de masa', 'Timp personal', 'Concediu', 'Compensare overtime',
"Ofertare",
"Configurari",
"Organizare initiala",
"Pregatire Portofoliu",
"Website",
"Documentare",
"Design grafic",
"Design interior",
"Design exterior",
"Releveu",
"Reclama",
"Master MATDR",
"Pauza de masa",
"Timp personal",
"Concediu",
"Compensare overtime",
];
for (const activity of activities) {
tags.push({
label: activity,
category: 'activity',
scope: 'global',
color: '#8b5cf6',
category: "activity",
scope: "global",
color: "#8b5cf6",
});
}
// ── Document type tags ──
const docTypes = [
'Contract', 'Ofertă', 'Factură', 'Scrisoare',
'Aviz', 'Notă de comandă', 'Raport', 'Cerere', 'Altele',
"Contract",
"Ofertă",
"Factură",
"Scrisoare",
"Aviz",
"Notă de comandă",
"Raport",
"Cerere",
"Altele",
];
for (const dt of docTypes) {
tags.push({
label: dt,
category: 'document-type',
scope: 'global',
color: '#f59e0b',
category: "document-type",
scope: "global",
color: "#f59e0b",
});
}

View File

@@ -1,26 +1,50 @@
'use client';
"use client";
import Image from 'next/image';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTheme } from 'next-themes';
import { useMemo } from 'react';
import * as Icons from 'lucide-react';
import { buildNavigation } from '@/config/navigation';
import { COMPANIES } from '@/config/companies';
import { useFeatureFlag } from '@/core/feature-flags';
import { cn } from '@/shared/lib/utils';
import { ScrollArea } from '@/shared/components/ui/scroll-area';
import { Separator } from '@/shared/components/ui/separator';
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import * as Icons from "lucide-react";
import { buildNavigation } from "@/config/navigation";
import { COMPANIES } from "@/config/companies";
import { useFeatureFlag } from "@/core/feature-flags";
import { cn } from "@/shared/lib/utils";
import { ScrollArea } from "@/shared/components/ui/scroll-area";
import { Separator } from "@/shared/components/ui/separator";
function DynamicIcon({ name, className }: { name: string; className?: string }) {
const pascalName = name.replace(/(^|-)([a-z])/g, (_, _p, c: string) => c.toUpperCase());
const IconComponent = (Icons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[pascalName];
function DynamicIcon({
name,
className,
}: {
name: string;
className?: string;
}) {
const pascalName = name.replace(/(^|-)([a-z])/g, (_, _p, c: string) =>
c.toUpperCase(),
);
const IconComponent = (
Icons as unknown as Record<
string,
React.ComponentType<{ className?: string }>
>
)[pascalName];
if (!IconComponent) return <Icons.Circle className={className} />;
return <IconComponent className={className} />;
}
function NavItem({ item, isActive }: { item: { id: string; label: string; icon: string; href: string; featureFlag: string }; isActive: boolean }) {
function NavItem({
item,
isActive,
}: {
item: {
id: string;
label: string;
icon: string;
href: string;
featureFlag: string;
};
isActive: boolean;
}) {
const enabled = useFeatureFlag(item.featureFlag);
if (!enabled) return null;
@@ -28,10 +52,10 @@ function NavItem({ item, isActive }: { item: { id: string; label: string; icon:
<Link
href={item.href}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors',
"flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
isActive
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground'
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground",
)}
>
<DynamicIcon name={item.icon} className="h-4 w-4 shrink-0" />
@@ -41,11 +65,9 @@ function NavItem({ item, isActive }: { item: { id: string; label: string; icon:
}
function SidebarLogo() {
const { resolvedTheme } = useTheme();
const sdt = COMPANIES['studii-de-teren'];
const logoSrc = sdt.logo
? (resolvedTheme === 'dark' ? sdt.logo.dark : sdt.logo.light)
: null;
const sdt = COMPANIES["studii-de-teren"];
const logoSrc = sdt.logo?.light ?? null;
if (!logoSrc) {
return <Icons.LayoutDashboard className="h-5 w-5 text-primary" />;
@@ -58,6 +80,7 @@ function SidebarLogo() {
width={28}
height={28}
className="h-7 w-7 shrink-0"
suppressHydrationWarning
/>
);
}
@@ -77,10 +100,10 @@ export function Sidebar() {
<Link
href="/"
className={cn(
'mb-1 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors',
pathname === '/'
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground'
"mb-1 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
pathname === "/"
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground",
)}
>
<Icons.Home className="h-4 w-4" />