Compare commits
16 Commits
501de5161e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b0ad5c2d7 | ||
|
|
4502a01aa1 | ||
|
|
c940fab4e9 | ||
|
|
d89db0fa3b | ||
|
|
8a2c5fa298 | ||
|
|
0fe53a566b | ||
|
|
41036db659 | ||
|
|
3154eb7f4a | ||
|
|
124887bee6 | ||
|
|
4bc5832458 | ||
|
|
7a5206e771 | ||
|
|
81cfdd6aa8 | ||
|
|
b8b9c7cf97 | ||
|
|
42260a17a4 | ||
|
|
5330ea536b | ||
|
|
1db61d87f4 |
128
ROADMAP.md
128
ROADMAP.md
@@ -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
|
||||
|
||||
@@ -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
133
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
43
src/app/api/compress-pdf/route.ts
Normal file
43
src/app/api/compress-pdf/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 că vrei să ștergi acest element? Acțiunea este ireversibilă.</p>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmare ștergere</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm">
|
||||
Ești sigur că vrei să ș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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user