Compare commits

..

27 Commits

Author SHA1 Message Date
AI Assistant
3b1ba589f0 feat: add Hot Desk module (Phase 2) 4-desk booking with 2-week window, room layout, calendar, subtle unbooked-day alerts 2026-02-19 08:10:50 +02:00
AI Assistant
6cb655a79f docs: mark Phase 1 tasks 1.12 and 1.13 complete 2026-02-19 07:12:53 +02:00
AI Assistant
eaca24aa58 feat(word-xml): remove POT/CUT auto-calculation toggle 2026-02-19 07:12:21 +02:00
AI Assistant
cd4b0de1e9 feat(registratura): linked-entry search filter, remove 20-item cap 2026-02-19 07:08:59 +02:00
AI Assistant
1f2af98f51 feat(dashboard): activity feed and KPI panels 2026-02-19 07:05:41 +02:00
AI Assistant
713a66bcd9 feat(word-templates): placeholder auto-detection from .docx via JSZip 2026-02-19 07:02:12 +02:00
AI Assistant
67fd88813a docs: mark task 1.09 complete in ROADMAP and SESSION-LOG 2026-02-19 06:58:11 +02:00
AI Assistant
da33dc9b81 feat(address-book): vCard export and Registratura reverse lookup 2026-02-19 06:57:40 +02:00
AI Assistant
35305e4389 docs: update SESSION-LOG and ROADMAP for tasks 1.07 and 1.08 2026-02-19 06:44:21 +02:00
AI Assistant
a49dbb2ced feat(it-inventory): link assignedTo to Address Book contacts with autocomplete 2026-02-19 06:43:42 +02:00
AI Assistant
b96b004baf feat(password-vault): add company scope and password strength meter 2026-02-19 06:43:30 +02:00
AI Assistant
8b0ad5c2d7 docs(roadmap): mark 1.07 password vault as done 2026-02-19 01:40:19 +02:00
AI Assistant
4502a01aa1 feat(password-vault): add company scope + password strength meter + rename encryptedPassword to password (task 1.07) 2026-02-19 01:39:45 +02:00
AI Assistant
c940fab4e9 feat(flags): enable password-vault, it-inventory, address-book, word-templates 2026-02-19 01:29:51 +02:00
AI Assistant
d89db0fa3b feat(flags): enable digital-signatures module 2026-02-19 01:29:24 +02:00
AI Assistant
8a2c5fa298 docs(roadmap): mark 1.06 digital signatures as done 2026-02-19 01:28:07 +02:00
AI Assistant
0fe53a566b feat(digital-signatures): drag-and-drop image upload (base64) + tags chip input (task 1.06) 2026-02-19 01:27:41 +02:00
AI Assistant
41036db659 fix(mini-utilities): Stirling PDF API key auth, Tesseract.js OCR, emoji removal in cleaner 2026-02-19 00:43:05 +02:00
AI Assistant
3154eb7f4a fix(mini-utilities): proxy compress-pdf through Next.js API route to bypass CORS 2026-02-19 00:32:13 +02:00
AI Assistant
124887bee6 feat(flags): enable mini-utilities module 2026-02-19 00:26:52 +02:00
AI Assistant
4bc5832458 docs(roadmap): mark 1.05 mini utilities as done 2026-02-19 00:22:50 +02:00
AI Assistant
7a5206e771 feat(mini-utilities): add 5 new tools - U/R converter, AI cleaner, MDLPA, PDF reducer, OCR 2026-02-19 00:22:17 +02:00
AI Assistant
81cfdd6aa8 feat(tag-manager): add US/SDT project seeds + mandatory validation (task 1.04)
- Add 10 Urban Switch projects (US-001 to US-010, color #345476)
- Add 10 Studii de Teren projects (SDT-001 to SDT-010, color #0182A1)
- Enforce mandatory project code + company scope for project category tags
- Show inline validation errors on create form
- Add hint text for project/phase mandatory categories
- Update seed import dialog to mention all 3 companies
2026-02-18 23:43:50 +02:00
AI Assistant
b8b9c7cf97 feat(prompt-generator): add 10 architecture/professional templates (task 1.03)
New builtin templates:
- Architectural rendering: massing-to-detail (materials, terrain, camera)
- Sketch to professional render (preserve design intent)
- Photorealism refinement (fix AI rendering issues, camera settings)
- Technical compliance checker (Romanian norms P100, P118, C107)
- Legal/formal document review (contracts, permits, contestations)
- Contract text cleanup (rewrite, standardize, remove redundancy)
- GIS/survey data interpretation (Stereo70, cadastral, DTM)
- BIM coordination (clash detection, EIR, BEP, ISO 19650)
- Technical report rewriting (audience adaptation)
- Structured technical Q&A (multi-domain, variable complexity)

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

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

View File

@@ -19,7 +19,7 @@
## AI Model Recommendations ## AI Model Recommendations
| Tag | Claude | OpenAI | Google | Best For | | 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 | | `[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 | | `[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 | | `[LIGHT]` | Haiku 4.5 | GPT-4o-mini | Gemini 2.5 Flash | Quick fixes, small edits, config changes, build debugging |
@@ -31,7 +31,7 @@
## Current Module Status vs. XLSX Spec ## Current Module Status vs. XLSX Spec
| # | Module | Core Done | Gaps Remaining | New Features Needed | | # | Module | Core Done | Gaps Remaining | New Features Needed |
|---|---|---|---|---| | --- | ------------------ | ----------- | -------------------------------------------------------------------------------- | ------------------------------------------- |
| 1 | Registratura | YES | Linked-entry selector capped at 20 | Workflow automation, email integration, OCR | | 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 | | 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 | | 3 | Word XML | YES | POT/CUT toggle exists (spec says remove) | Schema validator, visual mapper |
@@ -53,26 +53,29 @@
> Fix existing modules to match the xlsx spec. Ordered by impact and dependency. > 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. **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/` **Files:** `public/logos/`
**Why first:** Broken images are the most visible bug. **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. **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. **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. **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: **What:** Add 6+ new builtin templates per xlsx spec:
1. Architectural rendering prompt (basic massing to detailed) 1. Architectural rendering prompt (basic massing to detailed)
2. Sketch → professional render prompt 2. Sketch → professional render prompt
3. Visualization refinement prompt (photorealism fine-tuning) 3. Visualization refinement prompt (photorealism fine-tuning)
@@ -86,25 +89,31 @@
**Files to modify:** `src/modules/prompt-generator/services/builtin-templates.ts` **Files to modify:** `src/modules/prompt-generator/services/builtin-templates.ts`
**Files to create:** Additional template definitions (can be in same file or split) **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:** **What:**
1. Add Urban Switch and Studii de Teren project numbering to seed data (US-001, SDT-001 format) 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 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 3. Import the full tag structure from `legacy/manicprojects/current manic time Tags.txt` in proper 1st→5th category hierarchy
**Files to modify:** **Files to modify:**
- `src/modules/tag-manager/services/seed-data.ts` — Add US/SDT projects - `src/modules/tag-manager/services/seed-data.ts` — Add US/SDT projects
- `src/modules/tag-manager/components/tag-create-form.tsx` — Add mandatory validation - `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: **What:** Add the 5 missing tools from xlsx:
1. **U-value → R-value converter** (R = 1/U, with material thickness input) 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) 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) 3. **MDLPA date locale validator** (validate Romanian administrative dates against legal calendar)
@@ -114,87 +123,105 @@
**Files to modify:** `src/modules/mini-utilities/components/mini-utilities-module.tsx` **Files to modify:** `src/modules/mini-utilities/components/mini-utilities-module.tsx`
**Dependencies:** `tesseract.js` (for OCR), possibly Stirling PDF API calls **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:** **What:**
1. Add drag-and-drop / file picker for uploading signature/stamp images (convert to base64 on upload, like Registratura attachments) 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) 2. Add tag input field to the asset form (tags field exists in type but form doesn't render it)
**Files to modify:** **Files to modify:**
- `src/modules/digital-signatures/components/` — asset form component - `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:** **What:**
1. Add `company` field to credential type and form (scope passwords to a company) 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) 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) 3. Rename `encryptedPassword``password` in the type (it's not encrypted, the name is misleading)
**Files to modify:** **Files to modify:**
- `src/modules/password-vault/types.ts` - `src/modules/password-vault/types.ts`
- `src/modules/password-vault/components/` — form and list components - `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 ### 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). **What:** Change `assignedTo` from free text to an autocomplete that links to Address Book contacts (same pattern as Registratura sender/recipient).
**Files to modify:** **Files to modify:**
- `src/modules/it-inventory/components/` — equipment form - `src/modules/it-inventory/components/` — equipment form
- `src/modules/it-inventory/types.ts` — Add `assignedToContactId?: string` - `src/modules/it-inventory/types.ts` — Add `assignedToContactId?: string`
**Status:** ✅ Done. Added `assignedToContactId?: string` field to InventoryItem type. Form now shows autocomplete dropdown filtering Address Book contacts (searches by name or company). Up to 5 suggestions shown. Clicking a contact pre-fills both display name and contact ID. Placeholder "Caută după nume..." guides users. Build ok, pushed.
--- ---
### 1.09 `[STANDARD]` Address Book — vCard Export + Registratura Reverse Lookup ### 1.09 `[STANDARD]` Address Book — vCard Export + Registratura Reverse Lookup
**What:** **What:**
1. Add "Export vCard" button per contact (generate `.vcf` file download) 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 2. Add a section showing Registratura entries where this contact appears as sender or recipient
**Files to modify:** **Files to modify:**
- `src/modules/address-book/components/` — contact card/detail view - `src/modules/address-book/components/` — contact card/detail view
**Files to create:** **Files to create:**
- `src/modules/address-book/services/vcard-export.ts` - `src/modules/address-book/services/vcard-export.ts`
--- **Status:** ✅ Done. Created `vcard-export.ts` generating vCard 3.0 (.vcf) with name, org, title, phones, emails, address, website, notes, contact persons. Added Download icon button on card hover. Added detail dialog (FileText icon) showing full contact info + scrollable table of all Registratura entries where this contact appears as sender or recipient (uses `allEntries` to bypass filters). Build ok, pushed.
### 1.10 `[STANDARD]` Word Templates — Placeholder Auto-Detection ### 1.10 `[STANDARD]` Word Templates — Placeholder Auto-Detection
**What:** When a template file URL points to a `.docx`, parse it client-side to extract `{{placeholder}}` patterns and auto-populate the `placeholders[]` field. Use JSZip (already installed) to read the docx XML. **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:** **Files to modify:**
- `src/modules/word-templates/components/` — template form - `src/modules/word-templates/components/` — template form
**Files to create:** **Files to create:**
- `src/modules/word-templates/services/placeholder-parser.ts` - `src/modules/word-templates/services/placeholder-parser.ts`
--- **Status:** ✅ Done. `placeholder-parser.ts` uses JSZip to read all `word/*.xml` files from the .docx ZIP, searches for `{{...}}` patterns in both raw XML and stripped text (handles Words split-run encoding). Form now has: “Alege fișier .docx” button (local file picker, most reliable — no CORS) and a Wand icon on the URL field for URL-based detection (may fail on CORS). Parsing spinner shown during detection. Detected placeholders auto-populate the field. Build ok, pushed.
### 1.11 `[STANDARD]` Dashboard — Activity Feed + KPI Panels ### 1.11 `[STANDARD]` Dashboard — Activity Feed + KPI Panels
**What:** **What:**
1. Add an activity feed showing recent actions across modules (last 20 creates/updates/deletes from localStorage timestamps) 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 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` 3. Wire the `DashboardWidget` type that already exists in `types.ts`
**Files to modify:** `src/modules/dashboard/components/` or `src/app/(modules)/page.tsx` **Files to modify:** `src/modules/dashboard/components/` or `src/app/(modules)/page.tsx`
--- **Status:** ✅ Done. Created `src/modules/dashboard/hooks/use-dashboard-data.ts` — scans all `architools:*` localStorage keys directly, extracts entities with timestamps, builds activity feed (last 20, sorted by `updatedAt`) and KPI counters. Updated `src/app/page.tsx`: KPI grid (6 cards: registratura this week, open dosare, deadlines this week, overdue in red, new contacts this month, active IT equipment), activity feed with module icon + label + action + relative time (Romanian locale). Build ok, pushed.
### 1.12 `[LIGHT]` Registratura — Increase Linked-Entry Selector Limit
**What:** The linked-entry selector in `registry-entry-form.tsx` shows only first 20 entries (`.slice(0, 20)`). Add a search/filter field to find entries by number or subject, and remove the 20 limit.
**Files to modify:** `src/modules/registratura/components/registry-entry-form.tsx`
--- ---
### 1.13 `[LIGHT]` Word XML — Remove POT/CUT Auto-Calculation ### 1.12 `[LIGHT]` Registratura — Increase Linked-Entry Selector Limit
**What:** The xlsx says POT/CUT auto-calculation is "not needed". The toggle exists but the auto-injection code in `xml-generator.ts` should be removed. Keep the fields, just remove the auto-compute logic. **What:** Added search/filter input (by number, subject, sender) to the linked-entry selector. Removed `.slice(0, 20)` cap. Also improved chip labels to show truncated subject.
**Files to modify:** `src/modules/word-xml/services/xml-generator.ts` **Commit:** `cd4b0de`
**User action needed:** Confirm this should be removed. **Status:** Done ✅
---
### ✅ 1.13 `[LIGHT]` Word XML — Remove POT/CUT Auto-Calculation
**What:** Removed `computeMetrics` entirely from `XmlGeneratorConfig`, `generateCategoryXml`, `generateAllCategories`, `downloadZipAll`, `useXmlConfig`, `XmlSettings`, and `WordXmlModule`. Fields kept; auto-injection removed.
**Commit:** `eaca24a`
**Status:** Done ✅
--- ---
@@ -205,6 +232,7 @@
### 2.01 `[HEAVY]` Hot Desk Module — Full Implementation ### 2.01 `[HEAVY]` Hot Desk Module — Full Implementation
**What:** Build Module 14 from scratch per xlsx spec: **What:** Build Module 14 from scratch per xlsx spec:
- 4 desks in a shared room - 4 desks in a shared room
- Users reserve desks 1 week ahead - Users reserve desks 1 week ahead
- Calendar view showing desk availability per day - Calendar view showing desk availability per day
@@ -213,6 +241,7 @@
- Visual room layout showing which desks are booked - Visual room layout showing which desks are booked
**Module structure:** **Module structure:**
``` ```
src/modules/hot-desk/ src/modules/hot-desk/
├── components/ ├── components/
@@ -230,6 +259,7 @@ src/modules/hot-desk/
``` ```
**Files to also create/modify:** **Files to also create/modify:**
- `src/app/(modules)/hot-desk/page.tsx` — Route - `src/app/(modules)/hot-desk/page.tsx` — Route
- `src/config/modules.ts` — Register module - `src/config/modules.ts` — Register module
- `src/config/navigation.ts` — Add sidebar entry - `src/config/navigation.ts` — Add sidebar entry
@@ -246,9 +276,11 @@ src/modules/hot-desk/
### 3.01 `[STANDARD]` Install Testing Framework (Vitest) ### 3.01 `[STANDARD]` Install Testing Framework (Vitest)
**What:** Install and configure Vitest with React Testing Library. **What:** Install and configure Vitest with React Testing Library.
```bash ```bash
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vitest/coverage-v8 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 create:** `vitest.config.ts`, `src/test-setup.ts`
**Files to modify:** `package.json` (add test scripts) **Files to modify:** `package.json` (add test scripts)
@@ -257,6 +289,7 @@ npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vi
### 3.02 `[STANDARD]` Unit Tests — Critical Services ### 3.02 `[STANDARD]` Unit Tests — Critical Services
**What:** Write tests for the most critical business logic: **What:** Write tests for the most critical business logic:
1. `working-days.test.ts` — Orthodox Easter 2024-2030, addWorkingDays, backward deadlines 1. `working-days.test.ts` — Orthodox Easter 2024-2030, addWorkingDays, backward deadlines
2. `deadline-service.test.ts` — Due date computation, tacit approval, chain resolution 2. `deadline-service.test.ts` — Due date computation, tacit approval, chain resolution
3. `registry-service.test.ts` — Number generation, overdue calculation 3. `registry-service.test.ts` — Number generation, overdue calculation
@@ -270,6 +303,7 @@ npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vi
### 3.03 `[STANDARD]` Data Export/Import for All Modules ### 3.03 `[STANDARD]` Data Export/Import for All Modules
**What:** Create a shared utility for backing up localStorage data: **What:** Create a shared utility for backing up localStorage data:
1. Per-module JSON export (download file) 1. Per-module JSON export (download file)
2. Per-module JSON import (upload + merge) 2. Per-module JSON import (upload + merge)
3. Full backup: export ALL modules as single JSON 3. Full backup: export ALL modules as single JSON
@@ -282,6 +316,7 @@ npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vi
### 3.04 `[LIGHT]` Update Stale Documentation ### 3.04 `[LIGHT]` Update Stale Documentation
**What:** Update docs to reflect current state: **What:** Update docs to reflect current state:
- `docs/architecture/SYSTEM-ARCHITECTURE.md` — Change modules from "Planned" to "Implemented" - `docs/architecture/SYSTEM-ARCHITECTURE.md` — Change modules from "Planned" to "Implemented"
- `docs/DATA-MODEL.md` — Add TrackedDeadline, Hot Desk schemas - `docs/DATA-MODEL.md` — Add TrackedDeadline, Hot Desk schemas
- `docs/REPO-STRUCTURE.md` — Add new files - `docs/REPO-STRUCTURE.md` — Add new files
@@ -301,6 +336,7 @@ npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vi
### 4.01 `[HEAVY]` AI Chat — Real API Integration ### 4.01 `[HEAVY]` AI Chat — Real API Integration
**What:** Replace demo mode with actual AI provider calls: **What:** Replace demo mode with actual AI provider calls:
- Create `/api/ai/chat` server-side route (API keys never exposed to browser) - Create `/api/ai/chat` server-side route (API keys never exposed to browser)
- Provider abstraction: Anthropic Claude, OpenAI GPT, Ollama (local) - Provider abstraction: Anthropic Claude, OpenAI GPT, Ollama (local)
- Response streaming via ReadableStream - Response streaming via ReadableStream
@@ -308,6 +344,7 @@ npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vi
- Token usage display - Token usage display
**Env vars:** **Env vars:**
``` ```
ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-... OPENAI_API_KEY=sk-...
@@ -323,6 +360,7 @@ AI_DEFAULT_MODEL=claude-sonnet-4-6-20261001
### 4.02 `[STANDARD]` AI Chat — Domain-Specific System Prompts ### 4.02 `[STANDARD]` AI Chat — Domain-Specific System Prompts
**What:** Architecture office-focused conversation modes: **What:** Architecture office-focused conversation modes:
- Romanian construction law assistant - Romanian construction law assistant
- Architectural visualization prompt crafter - Architectural visualization prompt crafter
- Technical specification writer - Technical specification writer
@@ -345,12 +383,14 @@ AI_DEFAULT_MODEL=claude-sonnet-4-6-20261001
### 5.01 `[HEAVY]` Authentik OIDC Integration ### 5.01 `[HEAVY]` Authentik OIDC Integration
**What:** Replace stub user with real Authentik SSO. **What:** Replace stub user with real Authentik SSO.
- NextAuth.js / Auth.js route handler - NextAuth.js / Auth.js route handler
- OIDC token → user profile resolution - OIDC token → user profile resolution
- Cookie-based session - Cookie-based session
- `useAuth()` returns real user - `useAuth()` returns real user
**Server setup required:** **Server setup required:**
1. Create OAuth2 app in Authentik (http://10.10.10.166:9100) 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` 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` 3. Set env vars: `AUTHENTIK_URL`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET`, `NEXTAUTH_SECRET`
@@ -498,7 +538,7 @@ AI_DEFAULT_MODEL=claude-sonnet-4-6-20261001
## Infrastructure Credentials Needed ## Infrastructure Credentials Needed
| Service | What | When Needed | | Service | What | When Needed |
|---|---|---| | ------------------------ | --------------------------------------- | ------------------- |
| **US/SDT Logos** | SVG/PNG logo files | Phase 1 (task 1.01) | | **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) | | **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) | | **Anthropic API Key** | `sk-ant-...` from console.anthropic.com | Phase 4 (task 4.01) |
@@ -513,8 +553,9 @@ AI_DEFAULT_MODEL=claude-sonnet-4-6-20261001
## Quick Picker ## Quick Picker
**15 min tasks** `[LIGHT]`: **15 min tasks** `[LIGHT]`:
- 1.01 — Check logo files - 1.01 — Check logo files
- 1.07 — Password vault company + strength - ~~1.07 — Password vault company + strength~~ ✅
- 1.08 — IT inventory contact link - 1.08 — IT inventory contact link
- 1.12 — Registry linked-entry limit - 1.12 — Registry linked-entry limit
- 1.13 — Remove POT/CUT auto-calc - 1.13 — Remove POT/CUT auto-calc
@@ -522,15 +563,17 @@ AI_DEFAULT_MODEL=claude-sonnet-4-6-20261001
- 3.05 — Wire env var URLs - 3.05 — Wire env var URLs
**1 hour tasks** `[STANDARD]`: **1 hour tasks** `[STANDARD]`:
- 1.03 — Prompt generator templates - 1.03 — Prompt generator templates
- 1.04 — Tag manager seeds + mandatory - 1.04 — Tag manager seeds + mandatory
- 1.05 — Mini utilities new tools - ~~1.05 — Mini utilities new tools~~ ✅
- 1.06 — Digital signatures upload - ~~1.06 — Digital signatures upload~~ ✅
- 1.09 — Address book vCard + reverse lookup - 1.09 — Address book vCard + reverse lookup
- 1.11 — Dashboard activity feed + KPIs - 1.11 — Dashboard activity feed + KPIs
- 3.01 + 3.02 — Tests setup + core tests - 3.01 + 3.02 — Tests setup + core tests
**Full session tasks** `[HEAVY]`: **Full session tasks** `[HEAVY]`:
- 2.01 — Hot Desk module (new) - 2.01 — Hot Desk module (new)
- 4.01 — AI Chat API integration - 4.01 — AI Chat API integration
- 5.01 — Authentik SSO - 5.01 — Authentik SSO

View File

@@ -4,9 +4,172 @@
--- ---
## Session — 2026-02-19 (GitHub Copilot - Claude Sonnet 4.6) [continued 2]
### Completed
- **Task 1.12: Registratura — Linked-Entry Selector Search** ✅
- Added search input (by number, subject, sender) to `registry-entry-form.tsx`
- Removed `.slice(0, 20)` cap — all entries now searchable
- Chip labels now show truncated subject alongside entry number
- Commit: `cd4b0de`
- **Task 1.13: Word XML — Remove POT/CUT Auto-Calculation** ✅
- Removed `computeMetrics` from `XmlGeneratorConfig` type, `generateCategoryXml`, `generateAllCategories`, `downloadZipAll`, `useXmlConfig`, `XmlSettings`, `WordXmlModule`
- Removed POT/CUT auto-injection logic entirely; fields can still be added manually
- Removed unused `Switch` import from `xml-settings.tsx`
- Commit: `eaca24a`
### Notes
- Phase 1 (13 tasks) now fully complete ✅
- Next: **Phase 2** — Hot Desk module (new module from scratch)
---
### Completed
- **Task 1.11: Dashboard — Activity Feed + KPI Panels** ✅
- Created `src/modules/dashboard/hooks/use-dashboard-data.ts`
- Scans all `architools:*` localStorage keys directly (no per-module hooks needed)
- Activity feed: last 20 items sorted by `updatedAt`, detects creat/actualizat, picks best label field
- KPI grid: registratura this week, open dosare, deadlines this week, overdue (red if >0), new contacts this month, active IT equipment
- Replaced static Quick Stats with live KPI panels in `src/app/page.tsx`
- Relative timestamps in Romanian via `Intl.RelativeTimeFormat`
- Build passes zero errors
### Commits
- (this session) feat(dashboard): activity feed and KPI panels
### Notes
- Build verified: `npx next build` → ✓ Compiled successfully
- Next task: **1.12** — Registratura linked-entry selector fix
---
## Session — 2026-02-19 (GitHub Copilot - Claude Sonnet 4.6)
### Completed
- **Task 1.09: Address Book — vCard Export + Registratura Reverse Lookup** ✅
- Created `src/modules/address-book/services/vcard-export.ts` — generates vCard 3.0 with all contact fields
- Download icon button on card hover → triggers `.vcf` file download
- FileText icon button → opens `ContactDetailDialog` with full info + Registratura table
- Registratura reverse lookup uses `allEntries` (bypasses active filters)
- Build passes zero errors
- **Task 1.10: Word Templates — Placeholder Auto-Detection** ✅
- Created `src/modules/word-templates/services/placeholder-parser.ts`
- Reads `.docx` (ZIP) via JSZip, scans all `word/*.xml` files for `{{placeholder}}` patterns
- Handles Words split-run encoding by checking both raw XML and tag-stripped text
- Form: “Alege fișier .docx” button (local file picker, CORS-free) auto-populates placeholders field
- Form: Wand icon next to URL field tries URL-based fetch detection
- Spinner during parsing, error message if detection fails
- Build passes zero errors
### Commits
- `da33dc9` feat(address-book): vCard export and Registratura reverse lookup
- `67fd888` docs: mark task 1.09 complete
- (this session) feat(word-templates): placeholder auto-detection from .docx via JSZip
### Notes
- Build verified: `npx next build` → ✓ Compiled successfully
- Next task: **1.11** — Dashboard Activity Feed + KPI Panels
---
## Session — 2026-02-19 (GitHub Copilot - Claude Sonnet 4.6) [earlier]
### Completed
- **Task 1.09: Address Book — vCard Export + Registratura Reverse Lookup** ✅
- Created `src/modules/address-book/services/vcard-export.ts` — generates vCard 3.0 (`.vcf`) with all contact fields
- Added Download icon button on contact card hover → triggers `.vcf` file download
- Added FileText (detail) icon button → opens `ContactDetailDialog`
- `ContactDetailDialog` shows full contact info, contact persons, notes, and scrollable Registratura table
- Registratura reverse lookup uses `allEntries` (bypasses active filters) and matches `senderContactId` or `recipientContactId`
- Build passes zero errors
### Commits
- `da33dc9` feat(address-book): vCard export and Registratura reverse lookup
### Notes
- Build verified: `npx next build` → ✓ Compiled successfully
- Push pending — see below
- Next task: **1.10** — Word Templates Placeholder Auto-Detection
---
## Session — 2026-02-19 (GitHub Copilot - Haiku 4.5)
### Completed
- **Task 1.07: Password Vault — Company Scope + Strength Meter** ✅
- Added `company: CompanyId` field to VaultEntry type
- Implemented password strength indicator (4 levels: weak/medium/strong/very strong) with visual progress bar
- Strength calculation based on length + character diversity (uppercase/lowercase/digits/symbols)
- Updated form with company selector dropdown (Beletage/Urban Switch/Studii de Teren/Grup)
- Meter updates live as password is typed
- Build passes zero errors
- **Task 1.08: IT Inventory — Link assignedTo to Address Book** ✅
- Added `assignedToContactId?: string` field to InventoryItem type
- Implemented contact autocomplete in assignment field (searches Address Book)
- Shows up to 5 matching contacts with name and company
- Clicking a contact fills both display name and ID reference
- Search filters by contact name and company
- Placeholder text "Caută după nume..." guides users
- Build passes zero errors
### Commits
- `b96b004` feat(password-vault): add company scope and password strength meter
- `a49dbb2` feat(it-inventory): link assignedTo to Address Book contacts with autocomplete
### Notes
- Build verified: `npx next build` → ✓ Compiled successfully
- Push completed: Changes deployed to main via Gitea webhook → Portainer auto-redeploy triggering
- Ready to test on production: http://10.10.10.166:3000/password-vault and http://10.10.10.166:3000/it-inventory
---
## Session — 2026-02-18 (GitHub Copilot - Haiku 4.5)
### Completed
- **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) ## Session — 2026-02-18 (Claude Opus 4.6)
### Completed ### Completed
- **Registratura Legal Deadline Tracking** — Full implementation: - **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 - 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 - 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 +179,39 @@
- **SESSION-GUIDE.md** — Created with start/resume prompts, git workflow, file update rules - **SESSION-GUIDE.md** — Created with start/resume prompts, git workflow, file update rules
### Commits ### Commits
- `bb01268` feat(registratura): add legal deadline tracking system (Termene Legale) - `bb01268` feat(registratura): add legal deadline tracking system (Termene Legale)
- `d6a5852` docs: add ROADMAP.md with detailed future task plan - `d6a5852` docs: add ROADMAP.md with detailed future task plan
- `b1df15b` docs: rewrite ROADMAP.md with complete xlsx gap analysis + multi-model recommendations - `b1df15b` docs: rewrite ROADMAP.md with complete xlsx gap analysis + multi-model recommendations
- (this session) docs: add SESSION-GUIDE.md + SESSION-LOG.md - (this session) docs: add SESSION-GUIDE.md + SESSION-LOG.md
### Notes ### 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 - Build passes with zero errors
- Dev server on localhost:3000 shows tabs correctly - Dev server on localhost:3000 shows tabs correctly
- Production at 10.10.10.166:3000 requires Portainer redeploy after push - Production at 10.10.10.166:3000 requires Portainer redeploy after push

133
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"tailwind-merge": "^3.4.1", "tailwind-merge": "^3.4.1",
"tesseract.js": "^7.0.0",
"uuid": "^13.0.0" "uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
@@ -101,6 +102,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -660,6 +662,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -1855,6 +1858,7 @@
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": "^14.21.3 || >=16" "node": "^14.21.3 || >=16"
}, },
@@ -3901,6 +3905,7 @@
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -3911,6 +3916,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -3921,6 +3927,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -3991,6 +3998,7 @@
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.56.0", "@typescript-eslint/types": "8.56.0",
@@ -4517,6 +4525,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -4913,6 +4922,12 @@
"baseline-browser-mapping": "dist/cli.js" "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": { "node_modules/body-parser": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -4982,6 +4997,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -6017,6 +6033,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -6202,6 +6219,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -6515,6 +6533,7 @@
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "^2.0.0", "accepts": "^2.0.0",
"body-parser": "^2.2.1", "body-parser": "^2.2.1",
@@ -7244,6 +7263,7 @@
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
} }
@@ -7310,6 +7330,12 @@
"url": "https://opencollective.com/express" "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": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -7920,6 +7946,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/is-weakmap": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -9236,6 +9268,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "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", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -9798,6 +9840,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -9942,6 +9985,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/regexp.prototype.flags": {
"version": "1.5.4", "version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -10976,6 +11025,50 @@
"url": "https://opencollective.com/webpack" "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": { "node_modules/tiny-invariant": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -11034,6 +11127,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -11097,6 +11191,12 @@
"node": ">=16" "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": { "node_modules/ts-api-utils": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
@@ -11291,6 +11391,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -11559,6 +11660,12 @@
"node": ">= 0.8" "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": { "node_modules/web-streams-polyfill": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
@@ -11569,6 +11676,22 @@
"node": ">= 8" "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -11898,12 +12021,22 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/zod": {
"version": "4.3.6", "version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

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

View File

@@ -0,0 +1,31 @@
"use client";
import { FeatureGate } from "@/core/feature-flags";
import { useI18n } from "@/core/i18n";
import { HotDeskModule } from "@/modules/hot-desk";
export default function HotDeskPage() {
const { t } = useI18n();
return (
<FeatureGate flag="module.hot-desk" fallback={<ModuleDisabled />}>
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("hot-desk.title")}
</h1>
<p className="text-muted-foreground">{t("hot-desk.description")}</p>
</div>
<HotDeskModule />
</div>
</FeatureGate>
);
}
function ModuleDisabled() {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-muted-foreground">Modul dezactivat</p>
</div>
);
}

View File

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

View File

@@ -1,22 +1,53 @@
'use client'; "use client";
import Link from 'next/link'; import Link from "next/link";
import * as Icons from 'lucide-react'; import * as Icons from "lucide-react";
import { getAllModules } from '@/core/module-registry'; import { getAllModules } from "@/core/module-registry";
import { useFeatureFlag } from '@/core/feature-flags'; import { useFeatureFlag } from "@/core/feature-flags";
import { useI18n } from '@/core/i18n'; import { useI18n } from "@/core/i18n";
import { EXTERNAL_TOOLS } from '@/config/external-tools'; import { EXTERNAL_TOOLS } from "@/config/external-tools";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/shared/components/ui/card'; import {
import { Badge } from '@/shared/components/ui/badge'; Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/shared/components/ui/card";
import { Badge } from "@/shared/components/ui/badge";
import { useDashboardData } from "@/modules/dashboard/hooks/use-dashboard-data";
function DynamicIcon({ name, className }: { name: string; className?: string }) { function DynamicIcon({
const pascalName = name.replace(/(^|-)([a-z])/g, (_, _p, c: string) => c.toUpperCase()); name,
const IconComponent = (Icons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[pascalName]; 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} />; if (!IconComponent) return <Icons.Circle className={className} />;
return <IconComponent className={className} />; return <IconComponent className={className} />;
} }
function ModuleCard({ module }: { module: { id: string; name: string; description: string; icon: string; route: string; featureFlag: string } }) { function ModuleCard({
module,
}: {
module: {
id: string;
name: string;
description: string;
icon: string;
route: string;
featureFlag: string;
};
}) {
const enabled = useFeatureFlag(module.featureFlag); const enabled = useFeatureFlag(module.featureFlag);
if (!enabled) return null; if (!enabled) return null;
@@ -29,7 +60,9 @@ function ModuleCard({ module }: { module: { id: string; name: string; descriptio
</div> </div>
<div> <div>
<CardTitle className="text-base">{module.name}</CardTitle> <CardTitle className="text-base">{module.name}</CardTitle>
<CardDescription className="text-sm">{module.description}</CardDescription> <CardDescription className="text-sm">
{module.description}
</CardDescription>
</div> </div>
</CardHeader> </CardHeader>
</Card> </Card>
@@ -37,59 +70,166 @@ function ModuleCard({ module }: { module: { id: string; name: string; descriptio
); );
} }
const RELATIVE_LABELS: Intl.RelativeTimeFormatUnit[] = [
"year",
"month",
"week",
"day",
"hour",
"minute",
"second",
];
const RELATIVE_MS = [
31536000000, 2592000000, 604800000, 86400000, 3600000, 60000, 1000,
];
function relativeTime(isoString: string): string {
const diff = new Date(isoString).getTime() - Date.now();
const rtf = new Intl.RelativeTimeFormat("ro", { numeric: "auto" });
for (let i = 0; i < RELATIVE_MS.length; i++) {
const ms = RELATIVE_MS[i];
if (ms === undefined) continue;
const absMs = RELATIVE_LABELS[i];
if (absMs === undefined) continue;
if (Math.abs(diff) >= ms || i === RELATIVE_MS.length - 1) {
return rtf.format(Math.round(diff / ms), absMs);
}
}
return "acum";
}
const MODULE_ICONS: Record<string, string> = {
registratura: "BookOpen",
"address-book": "Users",
"it-inventory": "Monitor",
"password-vault": "KeyRound",
"digital-signatures": "PenLine",
"word-templates": "FileText",
"tag-manager": "Tag",
"prompt-generator": "Wand2",
};
const CATEGORY_LABELS: Record<string, string> = { const CATEGORY_LABELS: Record<string, string> = {
dev: 'Dezvoltare', dev: "Dezvoltare",
tools: 'Instrumente', tools: "Instrumente",
monitoring: 'Monitorizare', monitoring: "Monitorizare",
security: 'Securitate', security: "Securitate",
}; };
export default function DashboardPage() { export default function DashboardPage() {
const { t } = useI18n(); const { t } = useI18n();
const modules = getAllModules(); const modules = getAllModules();
const { activity, kpis } = useDashboardData();
const toolCategories = Object.keys(CATEGORY_LABELS).filter( const toolCategories = Object.keys(CATEGORY_LABELS).filter((cat) =>
(cat) => EXTERNAL_TOOLS.some((tool) => tool.category === cat) EXTERNAL_TOOLS.some((tool) => tool.category === cat),
); );
return ( return (
<div className="mx-auto max-w-6xl space-y-8"> <div className="mx-auto max-w-6xl space-y-8">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">{t('dashboard.welcome')}</h1> <h1 className="text-3xl font-bold tracking-tight">
<p className="mt-1 text-muted-foreground">{t('dashboard.subtitle')}</p> {t("dashboard.welcome")}
</h1>
<p className="mt-1 text-muted-foreground">{t("dashboard.subtitle")}</p>
</div> </div>
{/* Quick stats */} {/* KPI panels */}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> <div>
<h2 className="mb-3 text-lg font-semibold">Indicatori cheie</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-xs text-muted-foreground">Module active</p> <p className="text-xs text-muted-foreground">
<p className="text-2xl font-bold">{modules.length}</p> Registratură intrări săptămâna aceasta
</p>
<p className="text-2xl font-bold">{kpis.registraturaThisWeek}</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-xs text-muted-foreground">Companii</p> <p className="text-xs text-muted-foreground">Dosare deschise</p>
<p className="text-2xl font-bold">3</p> <p className="text-2xl font-bold">{kpis.registraturaOpen}</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-xs text-muted-foreground">Instrumente externe</p> <p className="text-xs text-muted-foreground">
<p className="text-2xl font-bold">{EXTERNAL_TOOLS.length}</p> Termene legale săptămâna aceasta
</p>
<p className="text-2xl font-bold">{kpis.deadlinesThisWeek}</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-xs text-muted-foreground">Stocare</p> <p className="text-xs text-muted-foreground">Termene depășite</p>
<p className="text-2xl font-bold">localStorage</p> <p
className={`text-2xl font-bold ${kpis.overdueDeadlines > 0 ? "text-destructive" : ""}`}
>
{kpis.overdueDeadlines}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">
Contacte noi luna aceasta
</p>
<p className="text-2xl font-bold">{kpis.contactsThisMonth}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">
Echipamente IT active
</p>
<p className="text-2xl font-bold">{kpis.inventoryActive}</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div>
{/* Activity feed */}
{activity.length > 0 && (
<div>
<h2 className="mb-3 text-lg font-semibold">Activitate recentă</h2>
<Card>
<CardContent className="divide-y p-0">
{activity.map((item) => (
<div
key={item.id}
className="flex items-center gap-3 px-4 py-2.5"
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted">
<DynamicIcon
name={MODULE_ICONS[item.namespace] ?? "Circle"}
className="h-3.5 w-3.5 text-muted-foreground"
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm">
<span className="font-medium">{item.label}</span>
<span className="ml-1 text-muted-foreground text-xs">
{item.action}
</span>
</p>
<p className="text-[11px] text-muted-foreground">
{item.moduleLabel}
</p>
</div>
<span className="shrink-0 text-[11px] text-muted-foreground">
{relativeTime(item.timestamp)}
</span>
</div>
))}
</CardContent>
</Card>
</div>
)}
{/* Modules grid */} {/* Modules grid */}
<div> <div>
<h2 className="mb-4 text-lg font-semibold">{t('dashboard.modules')}</h2> <h2 className="mb-4 text-lg font-semibold">{t("dashboard.modules")}</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{modules.map((m) => ( {modules.map((m) => (
<ModuleCard key={m.id} module={m} /> <ModuleCard key={m.id} module={m} />
@@ -103,27 +243,44 @@ export default function DashboardPage() {
<div className="space-y-4"> <div className="space-y-4">
{toolCategories.map((cat) => ( {toolCategories.map((cat) => (
<div key={cat}> <div key={cat}>
<Badge variant="outline" className="mb-2">{CATEGORY_LABELS[cat]}</Badge> <Badge variant="outline" className="mb-2">
{CATEGORY_LABELS[cat]}
</Badge>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{EXTERNAL_TOOLS.filter((tool) => tool.category === cat).map((tool) => { {EXTERNAL_TOOLS.filter((tool) => tool.category === cat).map(
(tool) => {
const cardContent = ( const cardContent = (
<Card key={tool.id} className="transition-colors hover:bg-accent/30"> <Card
key={tool.id}
className="transition-colors hover:bg-accent/30"
>
<CardHeader className="flex flex-row items-center gap-3 space-y-0 p-4"> <CardHeader className="flex flex-row items-center gap-3 space-y-0 p-4">
<DynamicIcon name={tool.icon} className="h-4 w-4 text-muted-foreground" /> <DynamicIcon
name={tool.icon}
className="h-4 w-4 text-muted-foreground"
/>
<div> <div>
<p className="text-sm font-medium">{tool.name}</p> <p className="text-sm font-medium">{tool.name}</p>
<p className="text-xs text-muted-foreground">{tool.description}</p> <p className="text-xs text-muted-foreground">
{tool.description}
</p>
</div> </div>
</CardHeader> </CardHeader>
</Card> </Card>
); );
if (!tool.url) return cardContent; if (!tool.url) return cardContent;
return ( return (
<a key={tool.id} href={tool.url} target="_blank" rel="noopener noreferrer"> <a
key={tool.id}
href={tool.url}
target="_blank"
rel="noopener noreferrer"
>
{cardContent} {cardContent}
</a> </a>
); );
})} },
)}
</div> </div>
</div> </div>
))} ))}

View File

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

View File

@@ -1,119 +1,127 @@
import type { FeatureFlag } from '@/core/feature-flags/types'; import type { FeatureFlag } from "@/core/feature-flags/types";
export const DEFAULT_FLAGS: FeatureFlag[] = [ export const DEFAULT_FLAGS: FeatureFlag[] = [
// Module flags // Module flags
{ {
key: 'module.registratura', key: "module.registratura",
enabled: true, enabled: true,
label: 'Registratură', label: "Registratură",
description: 'Registru de corespondență multi-firmă', description: "Registru de corespondență multi-firmă",
category: 'module', category: "module",
overridable: true, overridable: true,
}, },
{ {
key: 'module.email-signature', key: "module.email-signature",
enabled: true, enabled: true,
label: 'Generator Semnătură Email', label: "Generator Semnătură Email",
description: 'Configurator semnătură email', description: "Configurator semnătură email",
category: 'module', category: "module",
overridable: true, overridable: true,
}, },
{ {
key: 'module.word-xml', key: "module.word-xml",
enabled: true, enabled: true,
label: 'Generator XML Word', label: "Generator XML Word",
description: 'Generator Custom XML Parts pentru Word', description: "Generator Custom XML Parts pentru Word",
category: 'module', category: "module",
overridable: true, overridable: true,
}, },
{ {
key: 'module.prompt-generator', key: "module.prompt-generator",
enabled: true, enabled: true,
label: 'Generator Prompturi', label: "Generator Prompturi",
description: 'Constructor de prompturi structurate', description: "Constructor de prompturi structurate",
category: 'module', category: "module",
overridable: true, overridable: true,
}, },
{ {
key: 'module.digital-signatures', 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',
enabled: true, enabled: true,
label: 'Manager Etichete', label: "Semnături și Ștampile",
description: 'Administrare etichete', description: "Bibliotecă semnături digitale",
category: 'module', category: "module",
overridable: true, overridable: true,
}, },
{ {
key: 'module.mini-utilities', key: "module.password-vault",
enabled: false, enabled: true,
label: 'Utilitare', label: "Seif Parole",
description: 'Calculatoare și instrumente text', description: "Depozit intern de credențiale",
category: 'module', category: "module",
overridable: true, 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, enabled: false,
label: 'Chat AI', label: "Chat AI",
description: 'Interfață asistent AI', description: "Interfață asistent AI",
category: 'module', category: "module",
overridable: true,
},
{
key: "module.hot-desk",
enabled: true,
label: "Birouri Partajate",
description: "Rezervare birouri în camera partajată",
category: "module",
overridable: true, overridable: true,
}, },
// System flags // System flags
{ {
key: 'system.dark-mode', key: "system.dark-mode",
enabled: true, enabled: true,
label: 'Mod întunecat', label: "Mod întunecat",
description: 'Activează tema întunecată', description: "Activează tema întunecată",
category: 'system', category: "system",
overridable: true, overridable: true,
}, },
{ {
key: 'system.external-links', key: "system.external-links",
enabled: true, enabled: true,
label: 'Linkuri externe', label: "Linkuri externe",
description: 'Afișează linkuri instrumente externe în navigare', description: "Afișează linkuri instrumente externe în navigare",
category: 'system', category: "system",
overridable: true, overridable: true,
}, },
]; ];

View File

@@ -1,18 +1,19 @@
import type { ModuleConfig } from '@/core/module-registry/types'; import type { ModuleConfig } from "@/core/module-registry/types";
import { registerModules } from '@/core/module-registry'; import { registerModules } from "@/core/module-registry";
import { registraturaConfig } from '@/modules/registratura/config'; import { registraturaConfig } from "@/modules/registratura/config";
import { emailSignatureConfig } from '@/modules/email-signature/config'; import { emailSignatureConfig } from "@/modules/email-signature/config";
import { wordXmlConfig } from '@/modules/word-xml/config'; import { wordXmlConfig } from "@/modules/word-xml/config";
import { promptGeneratorConfig } from '@/modules/prompt-generator/config'; import { promptGeneratorConfig } from "@/modules/prompt-generator/config";
import { digitalSignaturesConfig } from '@/modules/digital-signatures/config'; import { digitalSignaturesConfig } from "@/modules/digital-signatures/config";
import { passwordVaultConfig } from '@/modules/password-vault/config'; import { passwordVaultConfig } from "@/modules/password-vault/config";
import { itInventoryConfig } from '@/modules/it-inventory/config'; import { itInventoryConfig } from "@/modules/it-inventory/config";
import { addressBookConfig } from '@/modules/address-book/config'; import { addressBookConfig } from "@/modules/address-book/config";
import { wordTemplatesConfig } from '@/modules/word-templates/config'; import { wordTemplatesConfig } from "@/modules/word-templates/config";
import { tagManagerConfig } from '@/modules/tag-manager/config'; import { tagManagerConfig } from "@/modules/tag-manager/config";
import { miniUtilitiesConfig } from '@/modules/mini-utilities/config'; import { miniUtilitiesConfig } from "@/modules/mini-utilities/config";
import { aiChatConfig } from '@/modules/ai-chat/config'; import { aiChatConfig } from "@/modules/ai-chat/config";
import { hotDeskConfig } from "@/modules/hot-desk/config";
/** /**
* Toate configurările modulelor ArchiTools, ordonate după navOrder. * Toate configurările modulelor ArchiTools, ordonate după navOrder.
@@ -27,6 +28,7 @@ export const MODULE_CONFIGS: ModuleConfig[] = [
digitalSignaturesConfig, // navOrder: 30 | management digitalSignaturesConfig, // navOrder: 30 | management
itInventoryConfig, // navOrder: 31 | management itInventoryConfig, // navOrder: 31 | management
addressBookConfig, // navOrder: 32 | management addressBookConfig, // navOrder: 32 | management
hotDeskConfig, // navOrder: 33 | management
tagManagerConfig, // navOrder: 40 | tools tagManagerConfig, // navOrder: 40 | tools
miniUtilitiesConfig, // navOrder: 41 | tools miniUtilitiesConfig, // navOrder: 41 | tools
promptGeneratorConfig, // navOrder: 50 | ai promptGeneratorConfig, // navOrder: 50 | ai

View File

@@ -1,109 +1,113 @@
import type { Labels } from '../types'; import type { Labels } from "../types";
export const ro: Labels = { export const ro: Labels = {
common: { common: {
save: 'Salvează', save: "Salvează",
cancel: 'Anulează', cancel: "Anulează",
delete: 'Șterge', delete: "Șterge",
edit: 'Editează', edit: "Editează",
create: 'Creează', create: "Creează",
search: 'Caută', search: "Caută",
filter: 'Filtrează', filter: "Filtrează",
export: 'Exportă', export: "Exportă",
import: 'Importă', import: "Importă",
copy: 'Copiază', copy: "Copiază",
close: 'Închide', close: "Închide",
confirm: 'Confirmă', confirm: "Confirmă",
back: 'Înapoi', back: "Înapoi",
next: 'Următorul', next: "Următorul",
loading: 'Se încarcă...', loading: "Se încarcă...",
noResults: 'Niciun rezultat', noResults: "Niciun rezultat",
error: 'Eroare', error: "Eroare",
success: 'Succes', success: "Succes",
actions: 'Acțiuni', actions: "Acțiuni",
settings: 'Setări', settings: "Setări",
all: 'Toate', all: "Toate",
yes: 'Da', yes: "Da",
no: 'Nu', no: "Nu",
}, },
nav: { nav: {
dashboard: 'Panou principal', dashboard: "Panou principal",
operations: 'Operațiuni', operations: "Operațiuni",
generators: 'Generatoare', generators: "Generatoare",
management: 'Management', management: "Management",
tools: 'Instrumente', tools: "Instrumente",
ai: 'AI & Automatizări', ai: "AI & Automatizări",
externalTools: 'Instrumente externe', externalTools: "Instrumente externe",
}, },
dashboard: { dashboard: {
title: 'Panou principal', title: "Panou principal",
welcome: 'Bine ai venit în ArchiTools', welcome: "Bine ai venit în ArchiTools",
subtitle: 'Platforma internă de instrumente pentru birou', subtitle: "Platforma internă de instrumente pentru birou",
quickActions: 'Acțiuni rapide', quickActions: "Acțiuni rapide",
recentActivity: 'Activitate recentă', recentActivity: "Activitate recentă",
modules: 'Module', modules: "Module",
infrastructure: 'Infrastructură', infrastructure: "Infrastructură",
}, },
registratura: { registratura: {
title: 'Registratură', title: "Registratură",
description: 'Registru de corespondență multi-firmă', description: "Registru de corespondență multi-firmă",
newEntry: 'Înregistrare nouă', newEntry: "Înregistrare nouă",
entries: 'Înregistrări', entries: "Înregistrări",
incoming: 'Intrare', incoming: "Intrare",
outgoing: 'Ieșire', outgoing: "Ieșire",
internal: 'Intern', internal: "Intern",
}, },
'email-signature': { "email-signature": {
title: 'Generator Semnătură Email', title: "Generator Semnătură Email",
description: 'Configurator semnătură email pentru companii', description: "Configurator semnătură email pentru companii",
preview: 'Previzualizare', preview: "Previzualizare",
downloadHtml: 'Descarcă HTML', downloadHtml: "Descarcă HTML",
}, },
'word-xml': { "word-xml": {
title: 'Generator XML Word', title: "Generator XML Word",
description: 'Generator Custom XML Parts pentru Word', description: "Generator Custom XML Parts pentru Word",
generate: 'Generează XML', generate: "Generează XML",
downloadXml: 'Descarcă XML', downloadXml: "Descarcă XML",
downloadZip: 'Descarcă ZIP', downloadZip: "Descarcă ZIP",
}, },
'prompt-generator': { "prompt-generator": {
title: 'Generator Prompturi', title: "Generator Prompturi",
description: 'Constructor de prompturi structurate pentru AI', description: "Constructor de prompturi structurate pentru AI",
templates: 'Șabloane', templates: "Șabloane",
compose: 'Compune', compose: "Compune",
history: 'Istoric', history: "Istoric",
preview: 'Previzualizare', preview: "Previzualizare",
}, },
'digital-signatures': { "digital-signatures": {
title: 'Semnături și Ștampile', title: "Semnături și Ștampile",
description: 'Bibliotecă semnături digitale și ștampile scanate', description: "Bibliotecă semnături digitale și ștampile scanate",
}, },
'password-vault': { "password-vault": {
title: 'Seif Parole', title: "Seif Parole",
description: 'Depozit intern de credențiale partajate', description: "Depozit intern de credențiale partajate",
}, },
'it-inventory': { "it-inventory": {
title: 'Inventar IT', title: "Inventar IT",
description: 'Evidența echipamentelor și dispozitivelor', description: "Evidența echipamentelor și dispozitivelor",
}, },
'address-book': { "address-book": {
title: 'Contacte', title: "Contacte",
description: 'Clienți, furnizori, instituții', description: "Clienți, furnizori, instituții",
}, },
'word-templates': { "word-templates": {
title: 'Șabloane Word', title: "Șabloane Word",
description: 'Bibliotecă contracte, oferte, rapoarte', description: "Bibliotecă contracte, oferte, rapoarte",
}, },
'tag-manager': { "tag-manager": {
title: 'Manager Etichete', title: "Manager Etichete",
description: 'Administrare etichete proiecte și categorii', description: "Administrare etichete proiecte și categorii",
}, },
'mini-utilities': { "mini-utilities": {
title: 'Utilitare', title: "Utilitare",
description: 'Calculatoare tehnice și instrumente text', description: "Calculatoare tehnice și instrumente text",
}, },
'ai-chat': { "ai-chat": {
title: 'Chat AI', title: "Chat AI",
description: 'Interfață asistent AI', description: "Interfață asistent AI",
},
"hot-desk": {
title: "Birouri Partajate",
description: "Rezervare birouri în camera partajată",
}, },
}; };

View File

@@ -1,45 +1,89 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import { import {
Plus, Pencil, Trash2, Search, Mail, Phone, MapPin, Plus,
Globe, Building2, UserPlus, X, Pencil,
} from 'lucide-react'; Trash2,
import { Button } from '@/shared/components/ui/button'; Search,
import { Input } from '@/shared/components/ui/input'; Mail,
import { Label } from '@/shared/components/ui/label'; Phone,
import { Textarea } from '@/shared/components/ui/textarea'; MapPin,
import { Badge } from '@/shared/components/ui/badge'; Globe,
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card'; Building2,
UserPlus,
X,
Download,
FileText,
} 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 { import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Card,
} from '@/shared/components/ui/select'; CardContent,
import type { AddressContact, ContactType, ContactPerson } from '../types'; CardHeader,
import { useContacts } from '../hooks/use-contacts'; CardTitle,
import { useTags } from '@/core/tagging'; } from "@/shared/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import type { AddressContact, ContactType, ContactPerson } from "../types";
import { useContacts } from "../hooks/use-contacts";
import { useTags } from "@/core/tagging";
import { downloadVCard } from "../services/vcard-export";
import { useRegistry } from "@/modules/registratura/hooks/use-registry";
const TYPE_LABELS: Record<ContactType, string> = { const TYPE_LABELS: Record<ContactType, string> = {
client: 'Client', client: "Client",
supplier: 'Furnizor', supplier: "Furnizor",
institution: 'Instituție', institution: "Instituție",
collaborator: 'Colaborator', collaborator: "Colaborator",
internal: 'Intern', internal: "Intern",
}; };
type ViewMode = 'list' | 'add' | 'edit'; type ViewMode = "list" | "add" | "edit";
export function AddressBookModule() { export function AddressBookModule() {
const { contacts, allContacts, loading, filters, updateFilter, addContact, updateContact, removeContact } = useContacts(); const {
const [viewMode, setViewMode] = useState<ViewMode>('list'); contacts,
const [editingContact, setEditingContact] = useState<AddressContact | null>(null); allContacts,
loading,
filters,
updateFilter,
addContact,
updateContact,
removeContact,
} = useContacts();
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [editingContact, setEditingContact] = useState<AddressContact | null>(
null,
);
const [viewingContact, setViewingContact] = useState<AddressContact | null>(
null,
);
const handleSubmit = async (data: Omit<AddressContact, 'id' | 'createdAt' | 'updatedAt'>) => { const handleSubmit = async (
if (viewMode === 'edit' && editingContact) { data: Omit<AddressContact, "id" | "createdAt" | "updatedAt">,
) => {
if (viewMode === "edit" && editingContact) {
await updateContact(editingContact.id, data); await updateContact(editingContact.id, data);
} else { } else {
await addContact(data); await addContact(data);
} }
setViewMode('list'); setViewMode("list");
setEditingContact(null); setEditingContact(null);
}; };
@@ -47,48 +91,81 @@ export function AddressBookModule() {
<div className="space-y-6"> <div className="space-y-6">
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5"> <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</p><p className="text-2xl font-bold">{allContacts.length}</p></CardContent></Card> <Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Total</p>
<p className="text-2xl font-bold">{allContacts.length}</p>
</CardContent>
</Card>
{(Object.keys(TYPE_LABELS) as ContactType[]).slice(0, 4).map((type) => ( {(Object.keys(TYPE_LABELS) as ContactType[]).slice(0, 4).map((type) => (
<Card key={type}><CardContent className="p-4"> <Card key={type}>
<p className="text-xs text-muted-foreground">{TYPE_LABELS[type]}</p> <CardContent className="p-4">
<p className="text-2xl font-bold">{allContacts.filter((c) => c.type === type).length}</p> <p className="text-xs text-muted-foreground">
</CardContent></Card> {TYPE_LABELS[type]}
</p>
<p className="text-2xl font-bold">
{allContacts.filter((c) => c.type === type).length}
</p>
</CardContent>
</Card>
))} ))}
</div> </div>
{viewMode === 'list' && ( {viewMode === "list" && (
<> <>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1"> <div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Caută contact..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" /> <Input
placeholder="Caută contact..."
value={filters.search}
onChange={(e) => updateFilter("search", e.target.value)}
className="pl-9"
/>
</div> </div>
<Select value={filters.type} onValueChange={(v) => updateFilter('type', v as ContactType | 'all')}> <Select
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger> value={filters.type}
onValueChange={(v) =>
updateFilter("type", v as ContactType | "all")
}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Toate tipurile</SelectItem> <SelectItem value="all">Toate tipurile</SelectItem>
{(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => ( {(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => (
<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem> <SelectItem key={t} value={t}>
{TYPE_LABELS[t]}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </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ă <Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button> </Button>
</div> </div>
{loading ? ( {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>
) : contacts.length === 0 ? ( ) : contacts.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Niciun contact găsit.</p> <p className="py-8 text-center text-sm text-muted-foreground">
Niciun contact găsit.
</p>
) : ( ) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{contacts.map((contact) => ( {contacts.map((contact) => (
<ContactCard <ContactCard
key={contact.id} key={contact.id}
contact={contact} contact={contact}
onEdit={() => { setEditingContact(contact); setViewMode('edit'); }} onEdit={() => {
setEditingContact(contact);
setViewMode("edit");
}}
onDelete={() => removeContact(contact.id)} onDelete={() => removeContact(contact.id)}
onViewDetail={() => setViewingContact(contact)}
/> />
))} ))}
</div> </div>
@@ -96,37 +173,89 @@ export function AddressBookModule() {
</> </>
)} )}
{(viewMode === 'add' || viewMode === 'edit') && ( {(viewMode === "add" || viewMode === "edit") && (
<Card> <Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare contact' : 'Contact nou'}</CardTitle></CardHeader> <CardHeader>
<CardTitle>
{viewMode === "edit" ? "Editare contact" : "Contact nou"}
</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<ContactForm <ContactForm
initial={editingContact ?? undefined} initial={editingContact ?? undefined}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onCancel={() => { setViewMode('list'); setEditingContact(null); }} onCancel={() => {
setViewMode("list");
setEditingContact(null);
}}
/> />
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Contact Detail Dialog */}
<ContactDetailDialog
contact={viewingContact}
onClose={() => setViewingContact(null)}
onEdit={(c) => {
setViewingContact(null);
setEditingContact(c);
setViewMode("edit");
}}
/>
</div> </div>
); );
} }
// ── Contact Card ── // ── Contact Card ──
function ContactCard({ contact, onEdit, onDelete }: { function ContactCard({
contact,
onEdit,
onDelete,
onViewDetail,
}: {
contact: AddressContact; contact: AddressContact;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
onViewDetail: () => void;
}) { }) {
return ( return (
<Card className="group relative"> <Card className="group relative">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <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" onClick={onEdit}> <Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Detalii"
onClick={onViewDetail}
>
<FileText className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Descarcă vCard"
onClick={() => downloadVCard(contact)}
>
<Download className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onEdit}
>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={onDelete}> <Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={onDelete}
>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -134,44 +263,60 @@ function ContactCard({ contact, onEdit, onDelete }: {
<div> <div>
<p className="font-medium">{contact.name}</p> <p className="font-medium">{contact.name}</p>
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{contact.company && <p className="text-xs text-muted-foreground">{contact.company}</p>} {contact.company && (
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[contact.type]}</Badge> <p className="text-xs text-muted-foreground">
{contact.company}
</p>
)}
<Badge variant="outline" className="text-[10px]">
{TYPE_LABELS[contact.type]}
</Badge>
{contact.department && ( {contact.department && (
<Badge variant="secondary" className="text-[10px]">{contact.department}</Badge> <Badge variant="secondary" className="text-[10px]">
{contact.department}
</Badge>
)} )}
</div> </div>
{contact.role && ( {contact.role && (
<p className="text-xs text-muted-foreground italic">{contact.role}</p> <p className="text-xs text-muted-foreground italic">
{contact.role}
</p>
)} )}
</div> </div>
{contact.email && ( {contact.email && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Mail className="h-3 w-3 shrink-0" /><span className="truncate">{contact.email}</span> <Mail className="h-3 w-3 shrink-0" />
<span className="truncate">{contact.email}</span>
</div> </div>
)} )}
{contact.email2 && ( {contact.email2 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Mail className="h-3 w-3 shrink-0" /><span className="truncate">{contact.email2}</span> <Mail className="h-3 w-3 shrink-0" />
<span className="truncate">{contact.email2}</span>
</div> </div>
)} )}
{contact.phone && ( {contact.phone && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Phone className="h-3 w-3 shrink-0" /><span>{contact.phone}</span> <Phone className="h-3 w-3 shrink-0" />
<span>{contact.phone}</span>
</div> </div>
)} )}
{contact.phone2 && ( {contact.phone2 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Phone className="h-3 w-3 shrink-0" /><span>{contact.phone2}</span> <Phone className="h-3 w-3 shrink-0" />
<span>{contact.phone2}</span>
</div> </div>
)} )}
{contact.address && ( {contact.address && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPin className="h-3 w-3 shrink-0" /><span className="truncate">{contact.address}</span> <MapPin className="h-3 w-3 shrink-0" />
<span className="truncate">{contact.address}</span>
</div> </div>
)} )}
{contact.website && ( {contact.website && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Globe className="h-3 w-3 shrink-0" /><span className="truncate">{contact.website}</span> <Globe className="h-3 w-3 shrink-0" />
<span className="truncate">{contact.website}</span>
</div> </div>
)} )}
{(contact.contactPersons ?? []).length > 0 && ( {(contact.contactPersons ?? []).length > 0 && (
@@ -181,7 +326,8 @@ function ContactCard({ contact, onEdit, onDelete }: {
</p> </p>
{contact.contactPersons.slice(0, 2).map((cp, i) => ( {contact.contactPersons.slice(0, 2).map((cp, i) => (
<p key={i} className="text-xs text-muted-foreground"> <p key={i} className="text-xs text-muted-foreground">
{cp.name}{cp.role ? `${cp.role}` : ''} {cp.name}
{cp.role ? `${cp.role}` : ""}
</p> </p>
))} ))}
{contact.contactPersons.length > 2 && ( {contact.contactPersons.length > 2 && (
@@ -197,39 +343,278 @@ function ContactCard({ contact, onEdit, onDelete }: {
); );
} }
// ── Contact Detail Dialog (with Registratura reverse lookup) ──
const DIRECTION_LABELS: Record<string, string> = {
intrat: "Intrat",
iesit: "Ieșit",
};
function ContactDetailDialog({
contact,
onClose,
onEdit,
}: {
contact: AddressContact | null;
onClose: () => void;
onEdit: (c: AddressContact) => void;
}) {
const { allEntries } = useRegistry();
if (!contact) return null;
// Find registratura entries linked to this contact (search all, ignoring active filters)
const linkedEntries = allEntries.filter(
(e) =>
e.senderContactId === contact.id || e.recipientContactId === contact.id,
);
return (
<Dialog
open={contact !== null}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-3">
<span>{contact.name}</span>
<Badge variant="outline">{TYPE_LABELS[contact.type]}</Badge>
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Contact info */}
<div className="grid gap-2 text-sm">
{contact.company && (
<div className="flex items-center gap-2 text-muted-foreground">
<Building2 className="h-4 w-4 shrink-0" />
<span>
{contact.company}
{contact.department ? `${contact.department}` : ""}
</span>
</div>
)}
{contact.role && (
<p className="text-xs italic text-muted-foreground pl-6">
{contact.role}
</p>
)}
{contact.email && (
<div className="flex items-center gap-2 text-muted-foreground">
<Mail className="h-4 w-4 shrink-0" />
<a
href={`mailto:${contact.email}`}
className="hover:text-foreground"
>
{contact.email}
</a>
</div>
)}
{contact.email2 && (
<div className="flex items-center gap-2 text-muted-foreground">
<Mail className="h-4 w-4 shrink-0" />
<a
href={`mailto:${contact.email2}`}
className="hover:text-foreground"
>
{contact.email2}
</a>
</div>
)}
{contact.phone && (
<div className="flex items-center gap-2 text-muted-foreground">
<Phone className="h-4 w-4 shrink-0" />
<span>{contact.phone}</span>
</div>
)}
{contact.phone2 && (
<div className="flex items-center gap-2 text-muted-foreground">
<Phone className="h-4 w-4 shrink-0" />
<span>{contact.phone2}</span>
</div>
)}
{contact.address && (
<div className="flex items-center gap-2 text-muted-foreground">
<MapPin className="h-4 w-4 shrink-0" />
<span>{contact.address}</span>
</div>
)}
{contact.website && (
<div className="flex items-center gap-2 text-muted-foreground">
<Globe className="h-4 w-4 shrink-0" />
<a
href={contact.website}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground truncate"
>
{contact.website}
</a>
</div>
)}
</div>
{/* Contact persons */}
{contact.contactPersons && contact.contactPersons.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
Persoane de contact
</p>
<div className="space-y-1">
{contact.contactPersons.map((cp, i) => (
<div
key={i}
className="flex flex-wrap items-center gap-2 text-sm"
>
<span className="font-medium">{cp.name}</span>
{cp.role && (
<span className="text-muted-foreground text-xs">
{cp.role}
</span>
)}
{cp.email && (
<a
href={`mailto:${cp.email}`}
className="text-xs text-muted-foreground hover:text-foreground"
>
{cp.email}
</a>
)}
{cp.phone && (
<span className="text-xs text-muted-foreground">
{cp.phone}
</span>
)}
</div>
))}
</div>
</div>
)}
{contact.notes && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">
Note
</p>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{contact.notes}
</p>
</div>
)}
{/* Registratura reverse lookup */}
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
Registratură ({linkedEntries.length})
</p>
{linkedEntries.length === 0 ? (
<p className="text-xs text-muted-foreground">
Nicio înregistrare în registratură pentru acest contact.
</p>
) : (
<div className="space-y-1.5 max-h-52 overflow-y-auto pr-1">
{linkedEntries.map((entry) => (
<div
key={entry.id}
className="flex items-center gap-3 rounded border p-2 text-xs"
>
<Badge variant="outline" className="shrink-0 text-[10px]">
{DIRECTION_LABELS[entry.direction] ?? entry.direction}
</Badge>
<span className="font-mono shrink-0 text-muted-foreground">
{entry.number}
</span>
<span className="flex-1 truncate font-medium">
{entry.subject}
</span>
<span className="shrink-0 text-muted-foreground">
{entry.date}
</span>
<Badge
variant={
entry.status === "deschis" ? "default" : "secondary"
}
className="shrink-0 text-[10px]"
>
{entry.status === "deschis" ? "Deschis" : "Închis"}
</Badge>
</div>
))}
</div>
)}
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-2 border-t">
<Button
variant="outline"
size="sm"
onClick={() => downloadVCard(contact)}
>
<Download className="mr-1.5 h-3.5 w-3.5" /> Descarcă vCard
</Button>
<Button variant="outline" size="sm" onClick={() => onEdit(contact)}>
<Pencil className="mr-1.5 h-3.5 w-3.5" /> Editează
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
// ── Contact Form ── // ── Contact Form ──
function ContactForm({ initial, onSubmit, onCancel }: { function ContactForm({
initial,
onSubmit,
onCancel,
}: {
initial?: AddressContact; initial?: AddressContact;
onSubmit: (data: Omit<AddressContact, 'id' | 'createdAt' | 'updatedAt'>) => void; onSubmit: (
data: Omit<AddressContact, "id" | "createdAt" | "updatedAt">,
) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const { tags: projectTags } = useTags('project'); const { tags: projectTags } = useTags("project");
const [name, setName] = useState(initial?.name ?? ''); const [name, setName] = useState(initial?.name ?? "");
const [company, setCompany] = useState(initial?.company ?? ''); const [company, setCompany] = useState(initial?.company ?? "");
const [type, setType] = useState<ContactType>(initial?.type ?? 'client'); const [type, setType] = useState<ContactType>(initial?.type ?? "client");
const [email, setEmail] = useState(initial?.email ?? ''); const [email, setEmail] = useState(initial?.email ?? "");
const [email2, setEmail2] = useState(initial?.email2 ?? ''); const [email2, setEmail2] = useState(initial?.email2 ?? "");
const [phone, setPhone] = useState(initial?.phone ?? ''); const [phone, setPhone] = useState(initial?.phone ?? "");
const [phone2, setPhone2] = useState(initial?.phone2 ?? ''); const [phone2, setPhone2] = useState(initial?.phone2 ?? "");
const [address, setAddress] = useState(initial?.address ?? ''); const [address, setAddress] = useState(initial?.address ?? "");
const [department, setDepartment] = useState(initial?.department ?? ''); const [department, setDepartment] = useState(initial?.department ?? "");
const [role, setRole] = useState(initial?.role ?? ''); const [role, setRole] = useState(initial?.role ?? "");
const [website, setWebsite] = useState(initial?.website ?? ''); const [website, setWebsite] = useState(initial?.website ?? "");
const [notes, setNotes] = useState(initial?.notes ?? ''); const [notes, setNotes] = useState(initial?.notes ?? "");
const [projectIds, setProjectIds] = useState<string[]>(initial?.projectIds ?? []); const [projectIds, setProjectIds] = useState<string[]>(
initial?.projectIds ?? [],
);
const [contactPersons, setContactPersons] = useState<ContactPerson[]>( const [contactPersons, setContactPersons] = useState<ContactPerson[]>(
initial?.contactPersons ?? [] initial?.contactPersons ?? [],
); );
const addContactPerson = () => { const addContactPerson = () => {
setContactPersons([...contactPersons, { name: '', role: '', email: '', phone: '' }]); setContactPersons([
...contactPersons,
{ name: "", role: "", email: "", phone: "" },
]);
}; };
const updateContactPerson = (index: number, field: keyof ContactPerson, value: string) => { const updateContactPerson = (
setContactPersons(contactPersons.map((cp, i) => index: number,
i === index ? { ...cp, [field]: value } : cp field: keyof ContactPerson,
)); value: string,
) => {
setContactPersons(
contactPersons.map((cp, i) =>
i === index ? { ...cp, [field]: value } : cp,
),
);
}; };
const removeContactPerson = (index: number) => { const removeContactPerson = (index: number) => {
@@ -238,7 +623,9 @@ function ContactForm({ initial, onSubmit, onCancel }: {
const toggleProject = (projectId: string) => { const toggleProject = (projectId: string) => {
setProjectIds((prev) => setProjectIds((prev) =>
prev.includes(projectId) ? prev.filter((id) => id !== projectId) : [...prev, projectId] prev.includes(projectId)
? prev.filter((id) => id !== projectId)
: [...prev, projectId],
); );
}; };
@@ -247,26 +634,56 @@ function ContactForm({ initial, onSubmit, onCancel }: {
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
onSubmit({ onSubmit({
name, company, type, email, email2, phone, phone2, name,
address, department, role, website, notes, company,
type,
email,
email2,
phone,
phone2,
address,
department,
role,
website,
notes,
projectIds, projectIds,
contactPersons: contactPersons.filter((cp) => cp.name.trim()), contactPersons: contactPersons.filter((cp) => cp.name.trim()),
tags: initial?.tags ?? [], tags: initial?.tags ?? [],
visibility: initial?.visibility ?? 'all', visibility: initial?.visibility ?? "all",
}); });
}} }}
className="space-y-4" className="space-y-4"
> >
{/* Row 1: Name + Company + Type */} {/* Row 1: Name + Company + Type */}
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div><Label>Nume *</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div> <div>
<div><Label>Companie/Organizație</Label><Input value={company} onChange={(e) => setCompany(e.target.value)} className="mt-1" /></div> <Label>Nume *</Label>
<div><Label>Tip</Label> <Input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1"
required
/>
</div>
<div>
<Label>Companie/Organizație</Label>
<Input
value={company}
onChange={(e) => setCompany(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Tip</Label>
<Select value={type} onValueChange={(v) => setType(v as ContactType)}> <Select value={type} onValueChange={(v) => setType(v as ContactType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> <SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
{(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => ( {(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => (
<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem> <SelectItem key={t} value={t}>
{TYPE_LABELS[t]}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -275,25 +692,87 @@ function ContactForm({ initial, onSubmit, onCancel }: {
{/* Row 2: Department + Role + Website */} {/* Row 2: Department + Role + Website */}
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div><Label>Departament</Label><Input value={department} onChange={(e) => setDepartment(e.target.value)} className="mt-1" /></div> <div>
<div><Label>Funcție/Rol</Label><Input value={role} onChange={(e) => setRole(e.target.value)} className="mt-1" /></div> <Label>Departament</Label>
<div><Label>Website</Label><Input type="url" value={website} onChange={(e) => setWebsite(e.target.value)} className="mt-1" placeholder="https://" /></div> <Input
value={department}
onChange={(e) => setDepartment(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Funcție/Rol</Label>
<Input
value={role}
onChange={(e) => setRole(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Website</Label>
<Input
type="url"
value={website}
onChange={(e) => setWebsite(e.target.value)}
className="mt-1"
placeholder="https://"
/>
</div>
</div> </div>
{/* Row 3: Emails + Phones */} {/* Row 3: Emails + Phones */}
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<div><Label>Email principal</Label><Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="mt-1" /></div> <div>
<div><Label>Email secundar</Label><Input type="email" value={email2} onChange={(e) => setEmail2(e.target.value)} className="mt-1" /></div> <Label>Email principal</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Email secundar</Label>
<Input
type="email"
value={email2}
onChange={(e) => setEmail2(e.target.value)}
className="mt-1"
/>
</div>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<div><Label>Telefon principal</Label><Input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} className="mt-1" /></div> <div>
<div><Label>Telefon secundar</Label><Input type="tel" value={phone2} onChange={(e) => setPhone2(e.target.value)} className="mt-1" /></div> <Label>Telefon principal</Label>
<Input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Telefon secundar</Label>
<Input
type="tel"
value={phone2}
onChange={(e) => setPhone2(e.target.value)}
className="mt-1"
/>
</div>
</div> </div>
</div> </div>
{/* Address */} {/* Address */}
<div><Label>Adresă</Label><Input value={address} onChange={(e) => setAddress(e.target.value)} className="mt-1" /></div> <div>
<Label>Adresă</Label>
<Input
value={address}
onChange={(e) => setAddress(e.target.value)}
className="mt-1"
/>
</div>
{/* Project links */} {/* Project links */}
{projectTags.length > 0 && ( {projectTags.length > 0 && (
@@ -307,11 +786,12 @@ function ContactForm({ initial, onSubmit, onCancel }: {
onClick={() => toggleProject(pt.id)} onClick={() => toggleProject(pt.id)}
className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${ className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${
projectIds.includes(pt.id) projectIds.includes(pt.id)
? 'border-primary bg-primary/10 text-primary' ? "border-primary bg-primary/10 text-primary"
: 'border-muted-foreground/30 text-muted-foreground hover:border-primary/50' : "border-muted-foreground/30 text-muted-foreground hover:border-primary/50"
}`} }`}
> >
{pt.projectCode ? `${pt.projectCode} ` : ''}{pt.label} {pt.projectCode ? `${pt.projectCode} ` : ""}
{pt.label}
</button> </button>
))} ))}
</div> </div>
@@ -322,19 +802,61 @@ function ContactForm({ initial, onSubmit, onCancel }: {
<div> <div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label>Persoane de contact</Label> <Label>Persoane de contact</Label>
<Button type="button" variant="outline" size="sm" onClick={addContactPerson}> <Button
type="button"
variant="outline"
size="sm"
onClick={addContactPerson}
>
<UserPlus className="mr-1 h-3.5 w-3.5" /> Adaugă persoană <UserPlus className="mr-1 h-3.5 w-3.5" /> Adaugă persoană
</Button> </Button>
</div> </div>
{contactPersons.length > 0 && ( {contactPersons.length > 0 && (
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
{contactPersons.map((cp, i) => ( {contactPersons.map((cp, i) => (
<div key={i} className="flex flex-wrap items-start gap-2 rounded border p-2"> <div
<Input placeholder="Nume" value={cp.name} onChange={(e) => updateContactPerson(i, 'name', e.target.value)} className="min-w-[150px] flex-1 text-sm" /> key={i}
<Input placeholder="Funcție" value={cp.role} onChange={(e) => updateContactPerson(i, 'role', e.target.value)} className="w-[140px] text-sm" /> className="flex flex-wrap items-start gap-2 rounded border p-2"
<Input placeholder="Email" value={cp.email} onChange={(e) => updateContactPerson(i, 'email', e.target.value)} className="w-[180px] text-sm" /> >
<Input placeholder="Telefon" value={cp.phone} onChange={(e) => updateContactPerson(i, 'phone', e.target.value)} className="w-[140px] text-sm" /> <Input
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0 text-destructive" onClick={() => removeContactPerson(i)}> placeholder="Nume"
value={cp.name}
onChange={(e) =>
updateContactPerson(i, "name", e.target.value)
}
className="min-w-[150px] flex-1 text-sm"
/>
<Input
placeholder="Funcție"
value={cp.role}
onChange={(e) =>
updateContactPerson(i, "role", e.target.value)
}
className="w-[140px] text-sm"
/>
<Input
placeholder="Email"
value={cp.email}
onChange={(e) =>
updateContactPerson(i, "email", e.target.value)
}
className="w-[180px] text-sm"
/>
<Input
placeholder="Telefon"
value={cp.phone}
onChange={(e) =>
updateContactPerson(i, "phone", e.target.value)
}
className="w-[140px] text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-destructive"
onClick={() => removeContactPerson(i)}
>
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -344,11 +866,21 @@ function ContactForm({ initial, onSubmit, onCancel }: {
</div> </div>
{/* Notes */} {/* Notes */}
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div> <div>
<Label>Note</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="mt-1"
/>
</div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button> <Button type="button" variant="outline" onClick={onCancel}>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button> Anulează
</Button>
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
</div> </div>
</form> </form>
); );

View File

@@ -0,0 +1,98 @@
import type { AddressContact } from "../types";
/**
* Generates a vCard 3.0 string for a contact and triggers a file download.
*/
export function downloadVCard(contact: AddressContact): void {
const lines: string[] = ["BEGIN:VCARD", "VERSION:3.0"];
// Full name
lines.push(`FN:${esc(contact.name)}`);
// Structured name — try to split first/last (best-effort)
const nameParts = contact.name.trim().split(/\s+/);
const last =
nameParts.length > 1 ? (nameParts[nameParts.length - 1] ?? "") : "";
const first =
nameParts.length > 1
? nameParts.slice(0, -1).join(" ")
: (nameParts[0] ?? "");
lines.push(`N:${esc(last)};${esc(first)};;;`);
if (contact.company) {
lines.push(
`ORG:${esc(contact.company)}${contact.department ? `;${esc(contact.department)}` : ""}`,
);
}
if (contact.role) {
lines.push(`TITLE:${esc(contact.role)}`);
}
if (contact.phone) {
lines.push(`TEL;TYPE=WORK,VOICE:${esc(contact.phone)}`);
}
if (contact.phone2) {
lines.push(`TEL;TYPE=WORK,VOICE,pref:${esc(contact.phone2)}`);
}
if (contact.email) {
lines.push(`EMAIL;TYPE=WORK,INTERNET:${esc(contact.email)}`);
}
if (contact.email2) {
lines.push(`EMAIL;TYPE=WORK,INTERNET,pref:${esc(contact.email2)}`);
}
if (contact.address) {
// ADR: PO Box;Extended;Street;City;Region;PostCode;Country
lines.push(`ADR;TYPE=WORK:;;${esc(contact.address)};;;;`);
}
if (contact.website) {
lines.push(`URL:${esc(contact.website)}`);
}
if (contact.notes) {
// Fold long notes
lines.push(`NOTE:${esc(contact.notes)}`);
}
// Contact persons as additional notes
if (contact.contactPersons && contact.contactPersons.length > 0) {
const cpNote = contact.contactPersons
.map(
(cp) =>
`${cp.name}${cp.role ? ` (${cp.role})` : ""}${cp.email ? ` <${cp.email}>` : ""}${cp.phone ? ` ${cp.phone}` : ""}`,
)
.join("; ");
lines.push(`NOTE:Persoane contact: ${esc(cpNote)}`);
}
lines.push(`REV:${new Date().toISOString()}`);
lines.push("END:VCARD");
const vcfContent = lines.join("\r\n");
const blob = new Blob([vcfContent], { type: "text/vcard;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${contact.name
.replace(/[^a-zA-Z0-9\s-]/g, "")
.trim()
.replace(/\s+/g, "_")}.vcf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/** Escape special characters for vCard values */
function esc(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/,/g, "\\,")
.replace(/;/g, "\\;")
.replace(/\n/g, "\\n");
}

View File

@@ -0,0 +1,193 @@
"use client";
import { useEffect, useState } from "react";
export interface ActivityItem {
id: string;
namespace: string;
moduleLabel: string;
label: string;
action: "creat" | "actualizat";
timestamp: string; // ISO
}
export interface DashboardKpis {
registraturaThisWeek: number;
registraturaOpen: number;
deadlinesThisWeek: number;
overdueDeadlines: number;
contactsThisMonth: number;
inventoryActive: number;
}
const MODULE_LABELS: Record<string, string> = {
registratura: "Registratură",
"address-book": "Agendă",
"it-inventory": "IT Inventory",
"password-vault": "Parole",
"digital-signatures": "Semnături",
"word-templates": "Șabloane Word",
"tag-manager": "Tag Manager",
"prompt-generator": "Prompt Generator",
};
/** Extract a human-readable label from a stored entity */
function pickLabel(obj: Record<string, unknown>): string {
const candidates = ["subject", "name", "label", "title", "number"];
for (const key of candidates) {
const val = obj[key];
if (typeof val === "string" && val.trim()) return val.trim();
}
return "(fără titlu)";
}
function startOfWeek(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() - d.getDay() + 1); // Monday
return d;
}
function startOfMonth(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(1);
return d;
}
function readAllNamespaceItems(): Record<string, Record<string, unknown>[]> {
if (typeof window === "undefined") return {};
const PREFIX = "architools:";
const result: Record<string, Record<string, unknown>[]> = {};
for (let i = 0; i < window.localStorage.length; i++) {
const fullKey = window.localStorage.key(i);
if (!fullKey?.startsWith(PREFIX)) continue;
const rest = fullKey.slice(PREFIX.length);
const colonIdx = rest.indexOf(":");
if (colonIdx === -1) continue;
const ns = rest.slice(0, colonIdx);
try {
const raw = window.localStorage.getItem(fullKey);
if (!raw) continue;
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
if (!result[ns]) result[ns] = [];
result[ns].push(parsed as Record<string, unknown>);
}
} catch {
// ignore malformed entries
}
}
return result;
}
export function useDashboardData() {
const [activity, setActivity] = useState<ActivityItem[]>([]);
const [kpis, setKpis] = useState<DashboardKpis>({
registraturaThisWeek: 0,
registraturaOpen: 0,
deadlinesThisWeek: 0,
overdueDeadlines: 0,
contactsThisMonth: 0,
inventoryActive: 0,
});
useEffect(() => {
const allItems = readAllNamespaceItems();
const weekStart = startOfWeek();
const monthStart = startOfMonth();
const now = new Date();
// --- Activity feed ---
const activityItems: ActivityItem[] = [];
for (const [ns, items] of Object.entries(allItems)) {
const moduleLabel = MODULE_LABELS[ns] ?? ns;
for (const item of items) {
const updatedAt =
typeof item.updatedAt === "string" ? item.updatedAt : null;
const createdAt =
typeof item.createdAt === "string" ? item.createdAt : null;
const id =
typeof item.id === "string" ? item.id : String(Math.random());
if (!updatedAt && !createdAt) continue;
const timestamp = updatedAt ?? createdAt ?? "";
const created = createdAt ? new Date(createdAt) : null;
const updated = updatedAt ? new Date(updatedAt) : null;
const action: "creat" | "actualizat" =
created &&
updated &&
Math.abs(updated.getTime() - created.getTime()) < 2000
? "creat"
: "actualizat";
activityItems.push({
id: `${ns}:${id}`,
namespace: ns,
moduleLabel,
label: pickLabel(item),
action,
timestamp,
});
}
}
activityItems.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
setActivity(activityItems.slice(0, 20));
// --- KPIs ---
const registraturaItems = allItems["registratura"] ?? [];
const registraturaThisWeek = registraturaItems.filter((e) => {
const d = typeof e.date === "string" ? new Date(e.date) : null;
return d && d >= weekStart;
}).length;
const registraturaOpen = registraturaItems.filter(
(e) => e.status === "deschis",
).length;
// Deadlines
let deadlinesThisWeek = 0;
let overdueDeadlines = 0;
for (const entry of registraturaItems) {
const deadlines = Array.isArray(entry.trackedDeadlines)
? (entry.trackedDeadlines as Record<string, unknown>[])
: [];
for (const dl of deadlines) {
if (dl.resolution !== "pending") continue;
const dueDate =
typeof dl.dueDate === "string" ? new Date(dl.dueDate) : null;
if (!dueDate) continue;
if (dueDate < now) overdueDeadlines++;
if (
dueDate >= weekStart &&
dueDate <= new Date(weekStart.getTime() + 7 * 86400000)
)
deadlinesThisWeek++;
}
}
const contactItems = allItems["address-book"] ?? [];
const contactsThisMonth = contactItems.filter((c) => {
const d = typeof c.createdAt === "string" ? new Date(c.createdAt) : null;
return d && d >= monthStart;
}).length;
const inventoryItems = allItems["it-inventory"] ?? [];
const inventoryActive = inventoryItems.filter(
(i) => i.status === "active",
).length;
setKpis({
registraturaThisWeek,
registraturaOpen,
deadlinesThisWeek,
overdueDeadlines,
contactsThisMonth,
inventoryActive,
});
}, []);
return { activity, kpis };
}

View File

@@ -1,43 +1,88 @@
'use client'; "use client";
import { useState } from 'react'; import { useState, useRef } from "react";
import { Plus, Pencil, Trash2, Search, PenTool, Stamp, Type, History, AlertTriangle } from 'lucide-react'; import {
import { Button } from '@/shared/components/ui/button'; Plus,
import { Input } from '@/shared/components/ui/input'; Pencil,
import { Label } from '@/shared/components/ui/label'; Trash2,
import { Textarea } from '@/shared/components/ui/textarea'; Search,
import { Badge } from '@/shared/components/ui/badge'; PenTool,
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card'; Stamp,
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'; Type,
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog'; History,
import type { CompanyId } from '@/core/auth/types'; AlertTriangle,
import type { SignatureAsset, SignatureAssetType } from '../types'; Upload,
import { useSignatures } from '../hooks/use-signatures'; 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> = { 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> = { 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() { export function DigitalSignaturesModule() {
const { assets, allAssets, loading, filters, updateFilter, addAsset, updateAsset, addVersion, removeAsset } = useSignatures(); const {
const [viewMode, setViewMode] = useState<ViewMode>('list'); assets,
allAssets,
loading,
filters,
updateFilter,
addAsset,
updateAsset,
addVersion,
removeAsset,
} = useSignatures();
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [editingAsset, setEditingAsset] = useState<SignatureAsset | null>(null); const [editingAsset, setEditingAsset] = useState<SignatureAsset | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const [versionAsset, setVersionAsset] = useState<SignatureAsset | null>(null); const [versionAsset, setVersionAsset] = useState<SignatureAsset | null>(null);
const handleSubmit = async (data: Omit<SignatureAsset, 'id' | 'createdAt' | 'updatedAt'>) => { const handleSubmit = async (
if (viewMode === 'edit' && editingAsset) { data: Omit<SignatureAsset, "id" | "createdAt" | "updatedAt">,
) => {
if (viewMode === "edit" && editingAsset) {
await updateAsset(editingAsset.id, data); await updateAsset(editingAsset.id, data);
} else { } else {
await addAsset(data); await addAsset(data);
} }
setViewMode('list'); setViewMode("list");
setEditingAsset(null); setEditingAsset(null);
}; };
@@ -70,40 +115,69 @@ export function DigitalSignaturesModule() {
<div className="space-y-6"> <div className="space-y-6">
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <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) => ( {(Object.keys(TYPE_LABELS) as SignatureAssetType[]).map((type) => (
<Card key={type}><CardContent className="p-4"> <Card key={type}>
<p className="text-xs text-muted-foreground">{TYPE_LABELS[type]}</p> <CardContent className="p-4">
<p className="text-2xl font-bold">{allAssets.filter((a) => a.type === type).length}</p> <p className="text-xs text-muted-foreground">
</CardContent></Card> {TYPE_LABELS[type]}
</p>
<p className="text-2xl font-bold">
{allAssets.filter((a) => a.type === type).length}
</p>
</CardContent>
</Card>
))} ))}
</div> </div>
{viewMode === 'list' && ( {viewMode === "list" && (
<> <>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1"> <div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <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> </div>
<Select value={filters.type} onValueChange={(v) => updateFilter('type', v as SignatureAssetType | 'all')}> <Select
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger> value={filters.type}
onValueChange={(v) =>
updateFilter("type", v as SignatureAssetType | "all")
}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Toate tipurile</SelectItem> <SelectItem value="all">Toate tipurile</SelectItem>
{(Object.keys(TYPE_LABELS) as SignatureAssetType[]).map((t) => ( {(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> </SelectContent>
</Select> </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ă <Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button> </Button>
</div> </div>
{loading ? ( {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 ? ( ) : 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"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{assets.map((asset) => { {assets.map((asset) => {
@@ -111,16 +185,38 @@ export function DigitalSignaturesModule() {
const expired = isExpired(asset.expirationDate); const expired = isExpired(asset.expirationDate);
const expiringSoon = isExpiringSoon(asset.expirationDate); const expiringSoon = isExpiringSoon(asset.expirationDate);
return ( 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"> <CardContent className="p-4">
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <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" /> <History className="h-3.5 w-3.5" />
</Button> </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" /> <Pencil className="h-3.5 w-3.5" />
</Button> </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" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </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"> <div className="flex h-12 w-12 items-center justify-center rounded-lg border bg-muted/30">
{asset.imageUrl ? ( {asset.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element // 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" /> <Icon className="h-6 w-6 text-muted-foreground" />
)} )}
@@ -136,29 +236,54 @@ export function DigitalSignaturesModule() {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-medium">{asset.label}</p> <p className="font-medium">{asset.label}</p>
<div className="flex flex-wrap items-center gap-1"> <div className="flex flex-wrap items-center gap-1">
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[asset.type]}</Badge> <Badge variant="outline" className="text-[10px]">
<span className="text-xs text-muted-foreground">{asset.owner}</span> {TYPE_LABELS[asset.type]}
</Badge>
<span className="text-xs text-muted-foreground">
{asset.owner}
</span>
</div> </div>
</div> </div>
</div> </div>
{/* Metadata row */} {/* Metadata row */}
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
{asset.legalStatus && ( {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 && ( {asset.expirationDate && (
<div className="flex items-center gap-1 text-xs"> <div className="flex items-center gap-1 text-xs">
{(expired || expiringSoon) && <AlertTriangle className="h-3 w-3 text-yellow-500" />} {(expired || expiringSoon) && (
<span className={expired ? 'text-destructive font-medium' : expiringSoon ? 'text-yellow-600 font-medium' : 'text-muted-foreground'}> <AlertTriangle className="h-3 w-3 text-yellow-500" />
{expired ? 'Expirat' : expiringSoon ? 'Expiră curând' : 'Expiră'}: {asset.expirationDate} )}
<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> </span>
</div> </div>
)} )}
{asset.usageNotes && ( {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 && ( {(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> </div>
</CardContent> </CardContent>
@@ -170,31 +295,63 @@ export function DigitalSignaturesModule() {
</> </>
)} )}
{(viewMode === 'add' || viewMode === 'edit') && ( {(viewMode === "add" || viewMode === "edit") && (
<Card> <Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare' : 'Element nou'}</CardTitle></CardHeader> <CardHeader>
<CardTitle>
{viewMode === "edit" ? "Editare" : "Element nou"}
</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<AssetForm initial={editingAsset ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingAsset(null); }} /> <AssetForm
initial={editingAsset ?? undefined}
onSubmit={handleSubmit}
onCancel={() => {
setViewMode("list");
setEditingAsset(null);
}}
/>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Delete confirmation */} {/* Delete confirmation */}
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}> <Dialog
open={deletingId !== null}
onOpenChange={(open) => {
if (!open) setDeletingId(null);
}}
>
<DialogContent> <DialogContent>
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader> <DialogHeader>
<p className="text-sm">Ești sigur vrei ștergi acest element? Acțiunea este ireversibilă.</p> <DialogTitle>Confirmare ștergere</DialogTitle>
</DialogHeader>
<p className="text-sm">
Ești sigur vrei ștergi acest element? Acțiunea este
ireversibilă.
</p>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeletingId(null)}>Anulează</Button> <Button variant="outline" onClick={() => setDeletingId(null)}>
<Button variant="destructive" onClick={handleDeleteConfirm}>Șterge</Button> Anulează
</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>
Șterge
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Add version 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> <DialogContent>
<DialogHeader><DialogTitle>Versiune nouă {versionAsset?.label}</DialogTitle></DialogHeader> <DialogHeader>
<DialogTitle>Versiune nouă {versionAsset?.label}</DialogTitle>
</DialogHeader>
<AddVersionForm <AddVersionForm
onSubmit={handleAddVersion} onSubmit={handleAddVersion}
onCancel={() => setVersionAsset(null)} 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; onSubmit: (imageUrl: string, notes: string) => void;
onCancel: () => 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 [imageUrl, setImageUrl] = useState("");
const [notes, setNotes] = useState(''); const [notes, setNotes] = useState("");
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{history.length > 0 && ( {history.length > 0 && (
<div className="max-h-32 space-y-1 overflow-y-auto rounded border p-2"> <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) => ( {history.map((v) => (
<div key={v.id} className="flex items-center justify-between text-xs"> <div
<span className="truncate text-muted-foreground">{v.notes || 'Fără note'}</span> key={v.id}
<span className="shrink-0 text-muted-foreground">{v.createdAt.slice(0, 10)}</span> 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> </div>
)} )}
<div> <div>
<Label>URL imagine nouă</Label> <Label>Imagine nouă</Label>
<Input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} className="mt-1" placeholder="https://... sau data:image/png;base64,..." required /> <div className="mt-1">
<ImageUploadField value={imageUrl} onChange={setImageUrl} />
</div>
</div> </div>
<div> <div>
<Label>Note versiune</Label> <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>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="outline" onClick={onCancel}>Anulează</Button> <Button variant="outline" onClick={onCancel}>
<Button onClick={() => { if (imageUrl.trim()) onSubmit(imageUrl, notes); }} disabled={!imageUrl.trim()}>Salvează versiune</Button> Anulează
</Button>
<Button
onClick={() => {
if (imageUrl.trim()) onSubmit(imageUrl, notes);
}}
disabled={!imageUrl.trim()}
>
Salvează versiune
</Button>
</div> </div>
</div> </div>
); );
} }
function AssetForm({ initial, onSubmit, onCancel }: { function AssetForm({
initial,
onSubmit,
onCancel,
}: {
initial?: SignatureAsset; initial?: SignatureAsset;
onSubmit: (data: Omit<SignatureAsset, 'id' | 'createdAt' | 'updatedAt'>) => void; onSubmit: (
data: Omit<SignatureAsset, "id" | "createdAt" | "updatedAt">,
) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const [label, setLabel] = useState(initial?.label ?? ''); const [label, setLabel] = useState(initial?.label ?? "");
const [type, setType] = useState<SignatureAssetType>(initial?.type ?? 'signature'); const [type, setType] = useState<SignatureAssetType>(
const [imageUrl, setImageUrl] = useState(initial?.imageUrl ?? ''); initial?.type ?? "signature",
const [owner, setOwner] = useState(initial?.owner ?? ''); );
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage'); const [imageUrl, setImageUrl] = useState(initial?.imageUrl ?? "");
const [expirationDate, setExpirationDate] = useState(initial?.expirationDate ?? ''); const [owner, setOwner] = useState(initial?.owner ?? "");
const [legalStatus, setLegalStatus] = useState(initial?.legalStatus ?? ''); const [company, setCompany] = useState<CompanyId>(
const [usageNotes, setUsageNotes] = useState(initial?.usageNotes ?? ''); 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 ( return (
<form onSubmit={(e) => { <form
onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
onSubmit({ onSubmit({
label, type, imageUrl, owner, company, label,
type,
imageUrl,
owner,
company,
expirationDate: expirationDate || undefined, expirationDate: expirationDate || undefined,
legalStatus, usageNotes, legalStatus,
usageNotes,
versions: initial?.versions ?? [], versions: initial?.versions ?? [],
tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all', tags,
visibility: initial?.visibility ?? "all",
}); });
}} className="space-y-4"> }}
className="space-y-4"
>
<div className="grid gap-4 sm:grid-cols-2"> <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>
<div><Label>Tip</Label> <Label>Denumire *</Label>
<Select value={type} onValueChange={(v) => setType(v as SignatureAssetType)}> <Input
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> 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> <SelectContent>
<SelectItem value="signature">Semnătură</SelectItem> <SelectItem value="signature">Semnătură</SelectItem>
<SelectItem value="stamp">Ștampilă</SelectItem> <SelectItem value="stamp">Ștampilă</SelectItem>
@@ -282,10 +592,23 @@ function AssetForm({ initial, onSubmit, onCancel }: {
</div> </div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <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>
<div><Label>Companie</Label> <Label>Proprietar</Label>
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}> <Input
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> 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> <SelectContent>
<SelectItem value="beletage">Beletage</SelectItem> <SelectItem value="beletage">Beletage</SelectItem>
<SelectItem value="urban-switch">Urban Switch</SelectItem> <SelectItem value="urban-switch">Urban Switch</SelectItem>
@@ -296,18 +619,77 @@ function AssetForm({ initial, onSubmit, onCancel }: {
</div> </div>
</div> </div>
<div> <div>
<Label>URL imagine</Label> <Label>Imagine</Label>
<Input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} className="mt-1" placeholder="https://... sau data:image/png;base64,..." /> <div className="mt-1">
<p className="mt-1 text-xs text-muted-foreground">URL către imaginea semnăturii/ștampilei. Suportă URL-uri externe sau base64.</p> <ImageUploadField value={imageUrl} onChange={setImageUrl} />
</div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-3"> <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>
<div><Label>Status legal</Label><Input value={legalStatus} onChange={(e) => setLegalStatus(e.target.value)} className="mt-1" placeholder="Valid, Anulat..." /></div> <Label>Data expirare</Label>
<div><Label>Note utilizare</Label><Input value={usageNotes} onChange={(e) => setUsageNotes(e.target.value)} className="mt-1" placeholder="Doar pentru contracte..." /></div> <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>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button> <Button type="button" variant="outline" onClick={onCancel}>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button> Anulează
</Button>
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
</div> </div>
</form> </form>
); );

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,161 @@
"use client";
import { useMemo } from "react";
import { cn } from "@/shared/lib/utils";
import type { DeskReservation } from "../types";
import { DESKS } from "../types";
import {
getMonday,
toDateKey,
formatDateShort,
getReservationsForDate,
isDateBookable,
} from "../services/reservation-service";
interface DeskCalendarProps {
/** The week anchor date */
weekStart: Date;
selectedDate: string;
reservations: DeskReservation[];
onSelectDate: (dateKey: string) => void;
onPrevWeek: () => void;
onNextWeek: () => void;
canGoPrev: boolean;
canGoNext: boolean;
}
export function DeskCalendar({
weekStart,
selectedDate,
reservations,
onSelectDate,
onPrevWeek,
onNextWeek,
canGoPrev,
canGoNext,
}: DeskCalendarProps) {
const monday = useMemo(() => getMonday(weekStart), [weekStart]);
const weekDays = useMemo(() => {
const days: string[] = [];
for (let i = 0; i < 5; i++) {
const d = new Date(monday);
d.setDate(monday.getDate() + i);
days.push(toDateKey(d));
}
return days;
}, [monday]);
const todayKey = toDateKey(new Date());
return (
<div className="flex flex-col gap-2">
{/* Week navigation */}
<div className="flex items-center justify-between">
<button
type="button"
onClick={onPrevWeek}
disabled={!canGoPrev}
className={cn(
"rounded-md px-2 py-1 text-xs font-medium transition-colors",
canGoPrev
? "text-muted-foreground hover:text-foreground hover:bg-muted/60"
: "text-muted-foreground/30 cursor-not-allowed",
)}
>
Săpt. anterioară
</button>
<button
type="button"
onClick={onNextWeek}
disabled={!canGoNext}
className={cn(
"rounded-md px-2 py-1 text-xs font-medium transition-colors",
canGoNext
? "text-muted-foreground hover:text-foreground hover:bg-muted/60"
: "text-muted-foreground/30 cursor-not-allowed",
)}
>
Săpt. următoare
</button>
</div>
{/* Day cells */}
<div className="grid grid-cols-5 gap-1.5">
{weekDays.map((dateKey) => {
const dayReservations = getReservationsForDate(dateKey, reservations);
const bookedCount = dayReservations.length;
const totalDesks = DESKS.length;
const isSelected = dateKey === selectedDate;
const isToday = dateKey === todayKey;
const bookable = isDateBookable(dateKey);
const isPast = dateKey < todayKey;
const hasNoBookings = bookedCount === 0 && !isPast;
return (
<button
key={dateKey}
type="button"
onClick={() => onSelectDate(dateKey)}
disabled={!bookable && !isPast}
className={cn(
"relative flex flex-col items-center gap-0.5 rounded-lg border px-2 py-2 text-center transition-all",
isSelected
? "border-primary/50 bg-primary/8 ring-1 ring-primary/20"
: "border-border/40 hover:border-border/70 hover:bg-muted/30",
isPast && !isSelected && "opacity-50",
!bookable && !isPast && "opacity-40 cursor-not-allowed",
)}
>
{/* Today dot */}
{isToday && (
<div className="absolute top-1 right-1 h-1.5 w-1.5 rounded-full bg-primary" />
)}
{/* Day name + date */}
<span
className={cn(
"text-[11px] font-medium leading-tight",
isSelected ? "text-primary" : "text-muted-foreground",
)}
>
{formatDateShort(dateKey)}
</span>
{/* Occupancy indicator */}
<div className="flex gap-0.5 mt-0.5">
{Array.from({ length: totalDesks }).map((_, i) => (
<div
key={i}
className={cn(
"h-1 w-3 rounded-full transition-colors",
i < bookedCount
? "bg-primary/60"
: hasNoBookings
? "bg-amber-400/40"
: "bg-muted-foreground/15",
)}
/>
))}
</div>
{/* Count label */}
<span
className={cn(
"text-[10px] leading-tight",
bookedCount === totalDesks
? "text-primary/70"
: hasNoBookings
? "text-amber-500/70"
: "text-muted-foreground/60",
)}
>
{bookedCount}/{totalDesks}
</span>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,137 @@
"use client";
import { DESKS, getDeskLabel } from "../types";
import type { DeskId, DeskReservation } from "../types";
import { getReservationForDesk } from "../services/reservation-service";
import { cn } from "@/shared/lib/utils";
interface DeskRoomLayoutProps {
selectedDate: string;
reservations: DeskReservation[];
onDeskClick: (deskId: DeskId) => void;
}
export function DeskRoomLayout({
selectedDate,
reservations,
onDeskClick,
}: DeskRoomLayoutProps) {
return (
<div className="flex flex-col items-center gap-3">
{/* Room container — styled like a top-down floor plan */}
<div className="relative w-full max-w-[340px] rounded-xl border border-border/60 bg-muted/20 p-5">
{/* Window indicator — top edge */}
<div className="absolute top-0 left-4 right-4 h-1.5 rounded-b-sm bg-muted-foreground/15" />
<div className="absolute top-0 left-6 right-6 flex justify-between">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="mt-0.5 h-0.5 w-3 rounded-full bg-muted-foreground/10"
/>
))}
</div>
{/* Central table */}
<div className="mx-auto mt-4 mb-4 flex flex-col items-center">
{/* Top row desks */}
<div className="flex gap-3 mb-2">
{DESKS.filter((d) => d.position.startsWith("top")).map((desk) => {
const reservation = getReservationForDesk(
desk.id,
selectedDate,
reservations,
);
return (
<DeskSlot
key={desk.id}
deskId={desk.id}
label={getDeskLabel(desk.id)}
reservation={reservation}
side="top"
onClick={() => onDeskClick(desk.id)}
/>
);
})}
</div>
{/* The table surface */}
<div className="h-12 w-full max-w-[280px] rounded-md border border-border/50 bg-muted/40" />
{/* Bottom row desks */}
<div className="flex gap-3 mt-2">
{DESKS.filter((d) => d.position.startsWith("bottom")).map(
(desk) => {
const reservation = getReservationForDesk(
desk.id,
selectedDate,
reservations,
);
return (
<DeskSlot
key={desk.id}
deskId={desk.id}
label={getDeskLabel(desk.id)}
reservation={reservation}
side="bottom"
onClick={() => onDeskClick(desk.id)}
/>
);
},
)}
</div>
</div>
</div>
</div>
);
}
interface DeskSlotProps {
deskId: DeskId;
label: string;
reservation: DeskReservation | undefined;
side: "top" | "bottom";
onClick: () => void;
}
function DeskSlot({ label, reservation, side, onClick }: DeskSlotProps) {
const isBooked = !!reservation;
return (
<button
type="button"
onClick={onClick}
className={cn(
"group relative flex w-[125px] cursor-pointer flex-col items-center rounded-lg border p-3 transition-all",
side === "top" ? "rounded-b-sm" : "rounded-t-sm",
isBooked
? "border-primary/30 bg-primary/8 hover:border-primary/50 hover:bg-primary/12"
: "border-dashed border-border/60 bg-background/60 hover:border-primary/40 hover:bg-muted/50",
)}
>
{/* Chair indicator */}
<div
className={cn(
"absolute left-1/2 -translate-x-1/2 h-1.5 w-8 rounded-full transition-colors",
side === "top" ? "-top-2.5" : "-bottom-2.5",
isBooked
? "bg-primary/40"
: "bg-muted-foreground/15 group-hover:bg-muted-foreground/25",
)}
/>
{/* Desk label */}
<span className="text-[11px] font-medium text-muted-foreground">
{label}
</span>
{/* Status */}
{isBooked ? (
<span className="mt-1 text-xs font-medium text-primary truncate max-w-full">
{reservation.userName}
</span>
) : (
<span className="mt-1 text-[11px] text-muted-foreground/50">Liber</span>
)}
</button>
);
}

View File

@@ -0,0 +1,305 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import { Card, CardContent } from "@/shared/components/ui/card";
import { useReservations } from "../hooks/use-reservations";
import { DeskRoomLayout } from "./desk-room-layout";
import { DeskCalendar } from "./desk-calendar";
import { ReservationDialog } from "./reservation-dialog";
import type { DeskId } from "../types";
import { DESKS } from "../types";
import {
toDateKey,
getMonday,
getReservationsForDate,
getReservationForDesk,
getUnbookedCurrentWeekDays,
formatDateRo,
formatDateShort,
MAX_ADVANCE_DAYS,
} from "../services/reservation-service";
import { cn } from "@/shared/lib/utils";
export function HotDeskModule() {
const { reservations, loading, addReservation, cancelReservation } =
useReservations();
const today = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
const todayKey = useMemo(() => toDateKey(today), [today]);
const [selectedDate, setSelectedDate] = useState(todayKey);
const [weekStartDate, setWeekStartDate] = useState(() => getMonday(today));
// Dialog state
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogDeskId, setDialogDeskId] = useState<DeskId>("desk-1");
// --- Week navigation ---
const currentMonday = useMemo(() => getMonday(today), [today]);
const maxDate = useMemo(() => {
const d = new Date(today);
d.setDate(d.getDate() + MAX_ADVANCE_DAYS);
return d;
}, [today]);
const canGoPrev = useMemo(() => {
return weekStartDate > currentMonday;
}, [weekStartDate, currentMonday]);
const canGoNext = useMemo(() => {
const nextMonday = new Date(weekStartDate);
nextMonday.setDate(nextMonday.getDate() + 7);
return nextMonday <= maxDate;
}, [weekStartDate, maxDate]);
const handlePrevWeek = useCallback(() => {
setWeekStartDate((prev) => {
const d = new Date(prev);
d.setDate(d.getDate() - 7);
if (d < currentMonday) return currentMonday;
return d;
});
}, [currentMonday]);
const handleNextWeek = useCallback(() => {
setWeekStartDate((prev) => {
const d = new Date(prev);
d.setDate(d.getDate() + 7);
return d;
});
}, []);
// --- Stats ---
const todayReservations = useMemo(
() => getReservationsForDate(todayKey, reservations),
[todayKey, reservations],
);
const selectedDayReservations = useMemo(
() => getReservationsForDate(selectedDate, reservations),
[selectedDate, reservations],
);
const unbookedDays = useMemo(
() => getUnbookedCurrentWeekDays(reservations),
[reservations],
);
// --- Desk click ---
const handleDeskClick = useCallback((deskId: DeskId) => {
setDialogDeskId(deskId);
setDialogOpen(true);
}, []);
const handleBook = useCallback(
async (userName: string, notes: string) => {
await addReservation(dialogDeskId, selectedDate, userName, notes);
},
[addReservation, dialogDeskId, selectedDate],
);
const handleCancelReservation = useCallback(
async (reservationId: string) => {
await cancelReservation(reservationId);
},
[cancelReservation],
);
const existingReservation = useMemo(
() => getReservationForDesk(dialogDeskId, selectedDate, reservations),
[dialogDeskId, selectedDate, reservations],
);
if (loading) {
return (
<div className="flex min-h-[30vh] items-center justify-center">
<p className="text-sm text-muted-foreground">Se încarcă...</p>
</div>
);
}
return (
<div className="space-y-5">
{/* Subtle alert for unbooked work days */}
{unbookedDays.length > 0 && (
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/20 bg-amber-500/5 px-3.5 py-2.5">
<div className="mt-0.5 h-1.5 w-1.5 shrink-0 rounded-full bg-amber-500/60" />
<p className="text-xs text-amber-600/80 dark:text-amber-400/70">
{unbookedDays.length === 1
? `${formatDateShort(unbookedDays[0] ?? "")} nu are nicio rezervare.`
: `${unbookedDays.length} zile din această săptămână nu au rezervări: ${unbookedDays.map((d) => formatDateShort(d)).join(", ")}.`}
</p>
</div>
)}
{/* Stats row */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Birouri" value={DESKS.length} sub="total cameră" />
<StatCard
label="Azi ocupate"
value={todayReservations.length}
sub={`din ${DESKS.length}`}
highlight={todayReservations.length === DESKS.length}
/>
<StatCard
label="Azi libere"
value={DESKS.length - todayReservations.length}
sub="disponibile"
/>
<StatCard
label="Săpt. curentă"
value={unbookedDays.length === 0 ? "✓" : unbookedDays.length}
sub={unbookedDays.length === 0 ? "acoperit" : "zile neacoperite"}
warn={unbookedDays.length > 0}
/>
</div>
{/* Main content: calendar + room layout */}
<div className="grid gap-5 lg:grid-cols-[1fr_380px]">
{/* Left: Calendar */}
<Card className="border-border/50">
<CardContent className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Calendar</h3>
<span className="text-xs text-muted-foreground">
{formatDateRo(selectedDate)}
</span>
</div>
<DeskCalendar
weekStart={weekStartDate}
selectedDate={selectedDate}
reservations={reservations}
onSelectDate={setSelectedDate}
onPrevWeek={handlePrevWeek}
onNextWeek={handleNextWeek}
canGoPrev={canGoPrev}
canGoNext={canGoNext}
/>
{/* Day detail table */}
{selectedDayReservations.length > 0 ? (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground">
Rezervări {formatDateRo(selectedDate)}
</h4>
<div className="rounded-lg border border-border/40 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/30 bg-muted/30">
<th className="px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">
Birou
</th>
<th className="px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">
Persoana
</th>
<th className="px-3 py-1.5 text-left text-xs font-medium text-muted-foreground">
Note
</th>
</tr>
</thead>
<tbody>
{selectedDayReservations.map((r) => (
<tr
key={r.id}
className="border-b border-border/20 last:border-0 hover:bg-muted/20 cursor-pointer transition-colors"
onClick={() => handleDeskClick(r.deskId)}
>
<td className="px-3 py-1.5 text-xs font-medium">
{DESKS.find((d) => d.id === r.deskId)?.label ??
r.deskId}
</td>
<td className="px-3 py-1.5 text-xs">{r.userName}</td>
<td className="px-3 py-1.5 text-xs text-muted-foreground truncate max-w-[120px]">
{r.notes || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<p className="text-xs text-muted-foreground/60 text-center py-2">
Nicio rezervare în {formatDateRo(selectedDate)}.
</p>
)}
</CardContent>
</Card>
{/* Right: Room layout */}
<Card className="border-border/50">
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Cameră</h3>
<span className="text-xs text-muted-foreground">
{selectedDayReservations.length}/{DESKS.length} ocupate
</span>
</div>
<DeskRoomLayout
selectedDate={selectedDate}
reservations={reservations}
onDeskClick={handleDeskClick}
/>
<p className="text-[11px] text-muted-foreground/50 text-center">
Click pe un birou pentru a rezerva sau anula
</p>
</CardContent>
</Card>
</div>
{/* Reservation dialog */}
<ReservationDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
deskId={dialogDeskId}
dateKey={selectedDate}
existingReservation={existingReservation}
onBook={handleBook}
onCancel={handleCancelReservation}
/>
</div>
);
}
// --- Stat Card ---
interface StatCardProps {
label: string;
value: string | number;
sub: string;
highlight?: boolean;
warn?: boolean;
}
function StatCard({ label, value, sub, highlight, warn }: StatCardProps) {
return (
<Card
className={cn(
"border-border/40",
highlight && "border-primary/30 bg-primary/5",
warn && "border-amber-500/20 bg-amber-500/5",
)}
>
<CardContent className="p-3">
<p className="text-[11px] font-medium text-muted-foreground">{label}</p>
<p
className={cn(
"text-xl font-bold",
highlight && "text-primary",
warn && "text-amber-600 dark:text-amber-400",
)}
>
{value}
</p>
<p className="text-[10px] text-muted-foreground/60">{sub}</p>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/components/ui/dialog";
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 type { DeskId, DeskReservation } from "../types";
import { getDeskLabel } from "../types";
import { formatDateRo, isDateBookable } from "../services/reservation-service";
interface ReservationDialogProps {
open: boolean;
onClose: () => void;
deskId: DeskId;
dateKey: string;
existingReservation: DeskReservation | undefined;
onBook: (userName: string, notes: string) => Promise<void>;
onCancel: (reservationId: string) => Promise<void>;
}
export function ReservationDialog({
open,
onClose,
deskId,
dateKey,
existingReservation,
onBook,
onCancel,
}: ReservationDialogProps) {
const [userName, setUserName] = useState("");
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const bookable = isDateBookable(dateKey);
const isBooked = !!existingReservation;
const handleBook = async () => {
if (!userName.trim()) return;
setSubmitting(true);
try {
await onBook(userName.trim(), notes.trim());
setUserName("");
setNotes("");
onClose();
} finally {
setSubmitting(false);
}
};
const handleCancel = async () => {
if (!existingReservation) return;
setSubmitting(true);
try {
await onCancel(existingReservation.id);
onClose();
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>
{getDeskLabel(deskId)} {formatDateRo(dateKey)}
</DialogTitle>
</DialogHeader>
{isBooked ? (
<div className="space-y-3 py-2">
<div className="rounded-lg border border-border/50 bg-muted/30 p-3 space-y-1.5">
<div className="text-sm">
<span className="text-muted-foreground">Rezervat de: </span>
<span className="font-medium">
{existingReservation.userName}
</span>
</div>
{existingReservation.notes && (
<div className="text-sm">
<span className="text-muted-foreground">Note: </span>
<span>{existingReservation.notes}</span>
</div>
)}
</div>
</div>
) : bookable ? (
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="userName">Nume</Label>
<Input
id="userName"
placeholder="Numele tău"
value={userName}
onChange={(e) => setUserName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleBook()}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="notes">Note (opțional)</Label>
<Textarea
id="notes"
placeholder="Ex: lucrez la proiect X, am nevoie de monitor extern..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="resize-none"
/>
</div>
</div>
) : (
<div className="py-4 text-center text-sm text-muted-foreground">
Această dată nu mai este disponibilă pentru rezervări.
</div>
)}
<DialogFooter>
{isBooked ? (
<>
<Button variant="outline" onClick={onClose} disabled={submitting}>
Închide
</Button>
<Button
variant="destructive"
onClick={handleCancel}
disabled={submitting}
>
{submitting ? "Se anulează..." : "Anulează rezervarea"}
</Button>
</>
) : bookable ? (
<>
<Button variant="outline" onClick={onClose} disabled={submitting}>
Renunță
</Button>
<Button
onClick={handleBook}
disabled={submitting || !userName.trim()}
>
{submitting ? "Se rezervă..." : "Rezervă"}
</Button>
</>
) : (
<Button variant="outline" onClick={onClose}>
Închide
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,17 @@
import type { ModuleConfig } from "@/core/module-registry/types";
export const hotDeskConfig: ModuleConfig = {
id: "hot-desk",
name: "Birouri Partajate",
description: "Rezervare birouri în camera partajată",
icon: "armchair",
route: "/hot-desk",
category: "management",
featureFlag: "module.hot-desk",
visibility: "all",
version: "0.1.0",
dependencies: [],
storageNamespace: "hot-desk",
navOrder: 33,
tags: ["birouri", "rezervare", "hot-desk"],
};

View File

@@ -0,0 +1,77 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useStorage } from "@/core/storage";
import { v4 as uuid } from "uuid";
import type { DeskId, DeskReservation } from "../types";
const PREFIX = "res:";
export function useReservations() {
const storage = useStorage("hot-desk");
const [reservations, setReservations] = useState<DeskReservation[]>([]);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
setLoading(true);
const keys = await storage.list();
const results: DeskReservation[] = [];
for (const key of keys) {
if (key.startsWith(PREFIX)) {
const item = await storage.get<DeskReservation>(key);
if (item) results.push(item);
}
}
results.sort((a, b) => a.date.localeCompare(b.date));
setReservations(results);
setLoading(false);
}, [storage]);
useEffect(() => {
refresh();
}, [refresh]);
const addReservation = useCallback(
async (deskId: DeskId, date: string, userName: string, notes: string) => {
// Check for conflict
const existing = reservations.find(
(r) => r.deskId === deskId && r.date === date,
);
if (existing) {
throw new Error(`Biroul este deja rezervat pe ${date}`);
}
const now = new Date().toISOString();
const reservation: DeskReservation = {
id: uuid(),
deskId,
date,
userName,
notes,
visibility: "all",
createdAt: now,
updatedAt: now,
};
await storage.set(`${PREFIX}${reservation.id}`, reservation);
await refresh();
return reservation;
},
[storage, refresh, reservations],
);
const cancelReservation = useCallback(
async (id: string) => {
await storage.delete(`${PREFIX}${id}`);
await refresh();
},
[storage, refresh],
);
return {
reservations,
loading,
addReservation,
cancelReservation,
refresh,
};
}

View File

@@ -0,0 +1,3 @@
export { hotDeskConfig } from "./config";
export { HotDeskModule } from "./components/hot-desk-module";
export type { DeskReservation, DeskId } from "./types";

View File

@@ -0,0 +1,206 @@
import type { DeskId, DeskReservation } from "../types";
import { DESKS } from "../types";
/** Maximum number of days in advance a reservation can be made */
export const MAX_ADVANCE_DAYS = 14;
/**
* Check if a date string falls on a weekday (Mon-Fri).
*/
export function isWeekday(dateStr: string): boolean {
const day = new Date(dateStr).getDay();
return day >= 1 && day <= 5;
}
/**
* Format a date as YYYY-MM-DD.
*/
export function toDateKey(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
/**
* Get the Monday of the week containing `date`.
*/
export function getMonday(date: Date): Date {
const d = new Date(date);
const day = d.getDay();
const diff = day === 0 ? -6 : 1 - day;
d.setDate(d.getDate() + diff);
d.setHours(0, 0, 0, 0);
return d;
}
/**
* Build an array of weekday date keys for the week containing `date`.
*/
export function getWeekDays(date: Date): string[] {
const monday = getMonday(date);
const days: string[] = [];
for (let i = 0; i < 5; i++) {
const d = new Date(monday);
d.setDate(monday.getDate() + i);
days.push(toDateKey(d));
}
return days;
}
/**
* Build all bookable date keys: today + up to MAX_ADVANCE_DAYS, weekdays only.
*/
export function getBookableDates(): string[] {
const today = new Date();
today.setHours(0, 0, 0, 0);
const dates: string[] = [];
for (let i = 0; i <= MAX_ADVANCE_DAYS; i++) {
const d = new Date(today);
d.setDate(today.getDate() + i);
const key = toDateKey(d);
if (isWeekday(key)) {
dates.push(key);
}
}
return dates;
}
/**
* Check if a desk is available on a specific date.
*/
export function isDeskAvailable(
deskId: DeskId,
dateKey: string,
reservations: DeskReservation[],
): boolean {
return !reservations.some((r) => r.deskId === deskId && r.date === dateKey);
}
/**
* Check if a specific date can be booked (not in the past, within 2-week window, is a weekday).
*/
export function isDateBookable(dateKey: string): boolean {
const today = new Date();
today.setHours(0, 0, 0, 0);
const target = new Date(dateKey);
target.setHours(0, 0, 0, 0);
if (target < today) return false;
const diffMs = target.getTime() - today.getTime();
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
if (diffDays > MAX_ADVANCE_DAYS) return false;
return isWeekday(dateKey);
}
/**
* Get reservations for a specific date.
*/
export function getReservationsForDate(
dateKey: string,
reservations: DeskReservation[],
): DeskReservation[] {
return reservations.filter((r) => r.date === dateKey);
}
/**
* Get reservations for a specific desk on a date.
*/
export function getReservationForDesk(
deskId: DeskId,
dateKey: string,
reservations: DeskReservation[],
): DeskReservation | undefined {
return reservations.find((r) => r.deskId === deskId && r.date === dateKey);
}
/**
* Detect workdays in the current week that have zero reservations.
* Returns date keys that need attention.
*/
export function getUnbookedCurrentWeekDays(
reservations: DeskReservation[],
): string[] {
const today = new Date();
today.setHours(0, 0, 0, 0);
const weekDays = getWeekDays(today);
const todayKey = toDateKey(today);
return weekDays.filter((dateKey) => {
// Only check today and future days in the current week
if (dateKey < todayKey) return false;
const dayReservations = getReservationsForDate(dateKey, reservations);
return dayReservations.length === 0;
});
}
/**
* Count available desks for a date.
*/
export function countAvailableDesks(
dateKey: string,
reservations: DeskReservation[],
): number {
const booked = reservations.filter((r) => r.date === dateKey).length;
return DESKS.length - booked;
}
/**
* Format a date key to a human-friendly Romanian string.
*/
export function formatDateRo(dateKey: string): string {
const date = new Date(dateKey);
const days = [
"Duminică",
"Luni",
"Marți",
"Miercuri",
"Joi",
"Vineri",
"Sâmbătă",
];
const months = [
"ianuarie",
"februarie",
"martie",
"aprilie",
"mai",
"iunie",
"iulie",
"august",
"septembrie",
"octombrie",
"noiembrie",
"decembrie",
];
const dayName = days[date.getDay()] ?? "";
const monthName = months[date.getMonth()] ?? "";
return `${dayName}, ${date.getDate()} ${monthName}`;
}
/**
* Short format: "Lun 19 feb"
*/
export function formatDateShort(dateKey: string): string {
const date = new Date(dateKey);
const days = ["Dum", "Lun", "Mar", "Mie", "Joi", "Vin", "Sâm"];
const months = [
"ian",
"feb",
"mar",
"apr",
"mai",
"iun",
"iul",
"aug",
"sep",
"oct",
"noi",
"dec",
];
const dayName = days[date.getDay()] ?? "";
const monthName = months[date.getMonth()] ?? "";
return `${dayName} ${date.getDate()} ${monthName}`;
}

View File

@@ -0,0 +1,38 @@
import type { Visibility } from "@/core/module-registry/types";
export type DeskId = "desk-1" | "desk-2" | "desk-3" | "desk-4";
export type DeskPosition =
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right";
export interface DeskDefinition {
id: DeskId;
label: string;
position: DeskPosition;
}
export interface DeskReservation {
id: string;
deskId: DeskId;
date: string; // YYYY-MM-DD
userName: string;
notes: string;
visibility: Visibility;
createdAt: string;
updatedAt: string;
}
export const DESKS: DeskDefinition[] = [
{ id: "desk-1", label: "Birou 1", position: "top-left" },
{ id: "desk-2", label: "Birou 2", position: "top-right" },
{ id: "desk-3", label: "Birou 3", position: "bottom-left" },
{ id: "desk-4", label: "Birou 4", position: "bottom-right" },
];
export function getDeskLabel(deskId: DeskId): string {
const desk = DESKS.find((d) => d.id === deskId);
return desk?.label ?? deskId;
}

View File

@@ -1,43 +1,86 @@
'use client'; "use client";
import { useState } from 'react'; import { useState, useMemo } from "react";
import { Plus, Pencil, Trash2, Search } from 'lucide-react'; import { Plus, Pencil, Trash2, Search } from "lucide-react";
import { Button } from '@/shared/components/ui/button'; import { Button } from "@/shared/components/ui/button";
import { Input } from '@/shared/components/ui/input'; import { Input } from "@/shared/components/ui/input";
import { Label } from '@/shared/components/ui/label'; import { Label } from "@/shared/components/ui/label";
import { Textarea } from '@/shared/components/ui/textarea'; import { Textarea } from "@/shared/components/ui/textarea";
import { Badge } from '@/shared/components/ui/badge'; import { Badge } from "@/shared/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card'; import {
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'; Card,
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog'; CardContent,
import type { CompanyId } from '@/core/auth/types'; CardHeader,
import type { InventoryItem, InventoryItemType, InventoryItemStatus } from '../types'; CardTitle,
import { useInventory } from '../hooks/use-inventory'; } 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 {
InventoryItem,
InventoryItemType,
InventoryItemStatus,
} from "../types";
import { useInventory } from "../hooks/use-inventory";
import { useContacts } from "@/modules/address-book/hooks/use-contacts";
const TYPE_LABELS: Record<InventoryItemType, string> = { const TYPE_LABELS: Record<InventoryItemType, string> = {
laptop: 'Laptop', desktop: 'Desktop', monitor: 'Monitor', printer: 'Imprimantă', laptop: "Laptop",
phone: 'Telefon', tablet: 'Tabletă', network: 'Rețea', peripheral: 'Periferic', other: 'Altele', desktop: "Desktop",
monitor: "Monitor",
printer: "Imprimantă",
phone: "Telefon",
tablet: "Tabletă",
network: "Rețea",
peripheral: "Periferic",
other: "Altele",
}; };
const STATUS_LABELS: Record<InventoryItemStatus, string> = { const STATUS_LABELS: Record<InventoryItemStatus, string> = {
active: 'Activ', 'in-repair': 'În reparație', storage: 'Depozitat', decommissioned: 'Dezafectat', active: "Activ",
"in-repair": "În reparație",
storage: "Depozitat",
decommissioned: "Dezafectat",
}; };
type ViewMode = 'list' | 'add' | 'edit'; type ViewMode = "list" | "add" | "edit";
export function ItInventoryModule() { export function ItInventoryModule() {
const { items, allItems, loading, filters, updateFilter, addItem, updateItem, removeItem } = useInventory(); const {
const [viewMode, setViewMode] = useState<ViewMode>('list'); items,
allItems,
loading,
filters,
updateFilter,
addItem,
updateItem,
removeItem,
} = useInventory();
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null); const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const handleSubmit = async (data: Omit<InventoryItem, 'id' | 'createdAt' | 'updatedAt'>) => { const handleSubmit = async (
if (viewMode === 'edit' && editingItem) { data: Omit<InventoryItem, "id" | "createdAt" | "updatedAt">,
) => {
if (viewMode === "edit" && editingItem) {
await updateItem(editingItem.id, data); await updateItem(editingItem.id, data);
} else { } else {
await addItem(data); await addItem(data);
} }
setViewMode('list'); setViewMode("list");
setEditingItem(null); setEditingItem(null);
}; };
@@ -52,81 +95,180 @@ export function ItInventoryModule() {
<div className="space-y-6"> <div className="space-y-6">
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <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">{allItems.length}</p></CardContent></Card> <Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Active</p><p className="text-2xl font-bold">{allItems.filter((i) => i.status === 'active').length}</p></CardContent></Card> <CardContent className="p-4">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">În reparație</p><p className="text-2xl font-bold">{allItems.filter((i) => i.status === 'in-repair').length}</p></CardContent></Card> <p className="text-xs text-muted-foreground">Total</p>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Dezafectate</p><p className="text-2xl font-bold">{allItems.filter((i) => i.status === 'decommissioned').length}</p></CardContent></Card> <p className="text-2xl font-bold">{allItems.length}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Active</p>
<p className="text-2xl font-bold">
{allItems.filter((i) => i.status === "active").length}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">În reparație</p>
<p className="text-2xl font-bold">
{allItems.filter((i) => i.status === "in-repair").length}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Dezafectate</p>
<p className="text-2xl font-bold">
{allItems.filter((i) => i.status === "decommissioned").length}
</p>
</CardContent>
</Card>
</div> </div>
{viewMode === 'list' && ( {viewMode === "list" && (
<> <>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1"> <div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <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> </div>
<Select value={filters.type} onValueChange={(v) => updateFilter('type', v as InventoryItemType | 'all')}> <Select
<SelectTrigger className="w-[140px]"><SelectValue /></SelectTrigger> value={filters.type}
onValueChange={(v) =>
updateFilter("type", v as InventoryItemType | "all")
}
>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Toate tipurile</SelectItem> <SelectItem value="all">Toate tipurile</SelectItem>
{(Object.keys(TYPE_LABELS) as InventoryItemType[]).map((t) => ( {(Object.keys(TYPE_LABELS) as InventoryItemType[]).map((t) => (
<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem> <SelectItem key={t} value={t}>
{TYPE_LABELS[t]}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={filters.status} onValueChange={(v) => updateFilter('status', v as InventoryItemStatus | 'all')}> <Select
<SelectTrigger className="w-[140px]"><SelectValue /></SelectTrigger> value={filters.status}
onValueChange={(v) =>
updateFilter("status", v as InventoryItemStatus | "all")
}
>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Toate</SelectItem> <SelectItem value="all">Toate</SelectItem>
{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map((s) => ( {(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map(
<SelectItem key={s} value={s}>{STATUS_LABELS[s]}</SelectItem> (s) => (
))} <SelectItem key={s} value={s}>
{STATUS_LABELS[s]}
</SelectItem>
),
)}
</SelectContent> </SelectContent>
</Select> </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ă <Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button> </Button>
</div> </div>
{loading ? ( {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>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Niciun echipament găsit.</p> <p className="py-8 text-center text-sm text-muted-foreground">
Niciun echipament găsit.
</p>
) : ( ) : (
<div className="overflow-x-auto rounded-lg border"> <div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead><tr className="border-b bg-muted/40"> <thead>
<tr className="border-b bg-muted/40">
<th className="px-3 py-2 text-left font-medium">Nume</th> <th className="px-3 py-2 text-left font-medium">Nume</th>
<th className="px-3 py-2 text-left font-medium">Tip</th> <th className="px-3 py-2 text-left font-medium">Tip</th>
<th className="px-3 py-2 text-left font-medium">Vendor/Model</th> <th className="px-3 py-2 text-left font-medium">
Vendor/Model
</th>
<th className="px-3 py-2 text-left font-medium">S/N</th> <th className="px-3 py-2 text-left font-medium">S/N</th>
<th className="px-3 py-2 text-left font-medium">IP</th> <th className="px-3 py-2 text-left font-medium">IP</th>
<th className="px-3 py-2 text-left font-medium">Atribuit</th> <th className="px-3 py-2 text-left font-medium">
Atribuit
</th>
<th className="px-3 py-2 text-left font-medium">Locație</th> <th className="px-3 py-2 text-left font-medium">Locație</th>
<th className="px-3 py-2 text-left font-medium">Status</th> <th className="px-3 py-2 text-left font-medium">Status</th>
<th className="px-3 py-2 text-right font-medium">Acțiuni</th> <th className="px-3 py-2 text-right font-medium">
</tr></thead> Acțiuni
</th>
</tr>
</thead>
<tbody> <tbody>
{items.map((item) => ( {items.map((item) => (
<tr key={item.id} className="border-b hover:bg-muted/20 transition-colors"> <tr
key={item.id}
className="border-b hover:bg-muted/20 transition-colors"
>
<td className="px-3 py-2 font-medium">{item.name}</td> <td className="px-3 py-2 font-medium">{item.name}</td>
<td className="px-3 py-2"><Badge variant="outline">{TYPE_LABELS[item.type]}</Badge></td> <td className="px-3 py-2">
<Badge variant="outline">
{TYPE_LABELS[item.type]}
</Badge>
</td>
<td className="px-3 py-2 text-xs"> <td className="px-3 py-2 text-xs">
{item.vendor && <span>{item.vendor}</span>} {item.vendor && <span>{item.vendor}</span>}
{item.vendor && item.model && <span className="text-muted-foreground"> / </span>} {item.vendor && item.model && (
{item.model && <span className="text-muted-foreground">{item.model}</span>} <span className="text-muted-foreground"> / </span>
)}
{item.model && (
<span className="text-muted-foreground">
{item.model}
</span>
)}
</td>
<td className="px-3 py-2 font-mono text-xs">
{item.serialNumber}
</td>
<td className="px-3 py-2 font-mono text-xs">
{item.ipAddress}
</td> </td>
<td className="px-3 py-2 font-mono text-xs">{item.serialNumber}</td>
<td className="px-3 py-2 font-mono text-xs">{item.ipAddress}</td>
<td className="px-3 py-2">{item.assignedTo}</td> <td className="px-3 py-2">{item.assignedTo}</td>
<td className="px-3 py-2 text-xs">{item.rackLocation || item.location}</td> <td className="px-3 py-2 text-xs">
<td className="px-3 py-2"><Badge variant="secondary">{STATUS_LABELS[item.status]}</Badge></td> {item.rackLocation || item.location}
</td>
<td className="px-3 py-2">
<Badge variant="secondary">
{STATUS_LABELS[item.status]}
</Badge>
</td>
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingItem(item); setViewMode('edit'); }}> <Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
setEditingItem(item);
setViewMode("edit");
}}
>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => setDeletingId(item.id)}> <Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => setDeletingId(item.id)}
>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -140,27 +282,48 @@ export function ItInventoryModule() {
</> </>
)} )}
{(viewMode === 'add' || viewMode === 'edit') && ( {(viewMode === "add" || viewMode === "edit") && (
<Card> <Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare echipament' : 'Echipament nou'}</CardTitle></CardHeader> <CardHeader>
<CardTitle>
{viewMode === "edit" ? "Editare echipament" : "Echipament nou"}
</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<InventoryForm <InventoryForm
initial={editingItem ?? undefined} initial={editingItem ?? undefined}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onCancel={() => { setViewMode('list'); setEditingItem(null); }} onCancel={() => {
setViewMode("list");
setEditingItem(null);
}}
/> />
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Delete confirmation */} {/* Delete confirmation */}
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}> <Dialog
open={deletingId !== null}
onOpenChange={(open) => {
if (!open) setDeletingId(null);
}}
>
<DialogContent> <DialogContent>
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader> <DialogHeader>
<p className="text-sm">Ești sigur vrei ștergi acest echipament? Acțiunea este ireversibilă.</p> <DialogTitle>Confirmare ștergere</DialogTitle>
</DialogHeader>
<p className="text-sm">
Ești sigur vrei ștergi acest echipament? Acțiunea este
ireversibilă.
</p>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeletingId(null)}>Anulează</Button> <Button variant="outline" onClick={() => setDeletingId(null)}>
<Button variant="destructive" onClick={handleDeleteConfirm}>Șterge</Button> Anulează
</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>
Șterge
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -168,60 +331,214 @@ export function ItInventoryModule() {
); );
} }
function InventoryForm({ initial, onSubmit, onCancel }: { function InventoryForm({
initial,
onSubmit,
onCancel,
}: {
initial?: InventoryItem; initial?: InventoryItem;
onSubmit: (data: Omit<InventoryItem, 'id' | 'createdAt' | 'updatedAt'>) => void; onSubmit: (
data: Omit<InventoryItem, "id" | "createdAt" | "updatedAt">,
) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const [name, setName] = useState(initial?.name ?? ''); const { allContacts } = useContacts();
const [type, setType] = useState<InventoryItemType>(initial?.type ?? 'laptop');
const [serialNumber, setSerialNumber] = useState(initial?.serialNumber ?? ''); const [name, setName] = useState(initial?.name ?? "");
const [assignedTo, setAssignedTo] = useState(initial?.assignedTo ?? ''); const [type, setType] = useState<InventoryItemType>(
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage'); initial?.type ?? "laptop",
const [location, setLocation] = useState(initial?.location ?? ''); );
const [purchaseDate, setPurchaseDate] = useState(initial?.purchaseDate ?? ''); const [serialNumber, setSerialNumber] = useState(initial?.serialNumber ?? "");
const [status, setStatus] = useState<InventoryItemStatus>(initial?.status ?? 'active'); const [assignedTo, setAssignedTo] = useState(initial?.assignedTo ?? "");
const [ipAddress, setIpAddress] = useState(initial?.ipAddress ?? ''); const [assignedToContactId, setAssignedToContactId] = useState(
const [macAddress, setMacAddress] = useState(initial?.macAddress ?? ''); initial?.assignedToContactId ?? "",
const [warrantyExpiry, setWarrantyExpiry] = useState(initial?.warrantyExpiry ?? ''); );
const [purchaseCost, setPurchaseCost] = useState(initial?.purchaseCost ?? ''); const [assignedToFocused, setAssignedToFocused] = useState(false);
const [rackLocation, setRackLocation] = useState(initial?.rackLocation ?? ''); const [company, setCompany] = useState<CompanyId>(
const [vendor, setVendor] = useState(initial?.vendor ?? ''); initial?.company ?? "beletage",
const [model, setModel] = useState(initial?.model ?? ''); );
const [notes, setNotes] = useState(initial?.notes ?? ''); const [location, setLocation] = useState(initial?.location ?? "");
const [purchaseDate, setPurchaseDate] = useState(initial?.purchaseDate ?? "");
const [status, setStatus] = useState<InventoryItemStatus>(
initial?.status ?? "active",
);
const [ipAddress, setIpAddress] = useState(initial?.ipAddress ?? "");
const [macAddress, setMacAddress] = useState(initial?.macAddress ?? "");
const [warrantyExpiry, setWarrantyExpiry] = useState(
initial?.warrantyExpiry ?? "",
);
const [purchaseCost, setPurchaseCost] = useState(initial?.purchaseCost ?? "");
const [rackLocation, setRackLocation] = useState(initial?.rackLocation ?? "");
const [vendor, setVendor] = useState(initial?.vendor ?? "");
const [model, setModel] = useState(initial?.model ?? "");
const [notes, setNotes] = useState(initial?.notes ?? "");
// Contact suggestions for assignedTo autocomplete
const assignedToSuggestions = useMemo(() => {
if (!assignedTo || assignedTo.length < 2) return [];
const q = assignedTo.toLowerCase();
return allContacts
.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.company.toLowerCase().includes(q),
)
.slice(0, 5);
}, [allContacts, assignedTo]);
return ( return (
<form onSubmit={(e) => { <form
onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
onSubmit({ onSubmit({
name, type, serialNumber, assignedTo, company, location, purchaseDate, status, name,
ipAddress, macAddress, warrantyExpiry, purchaseCost, rackLocation, vendor, model, type,
notes, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all', serialNumber,
assignedTo,
assignedToContactId,
company,
location,
purchaseDate,
status,
ipAddress,
macAddress,
warrantyExpiry,
purchaseCost,
rackLocation,
vendor,
model,
notes,
tags: initial?.tags ?? [],
visibility: initial?.visibility ?? "all",
}); });
}} className="space-y-4"> }}
className="space-y-4"
>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div><Label>Nume echipament *</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div> <div>
<div><Label>Tip</Label> <Label>Nume echipament *</Label>
<Select value={type} onValueChange={(v) => setType(v as InventoryItemType)}> <Input
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> value={name}
<SelectContent>{(Object.keys(TYPE_LABELS) as InventoryItemType[]).map((t) => (<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>))}</SelectContent> onChange={(e) => setName(e.target.value)}
className="mt-1"
required
/>
</div>
<div>
<Label>Tip</Label>
<Select
value={type}
onValueChange={(v) => setType(v as InventoryItemType)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(TYPE_LABELS) as InventoryItemType[]).map((t) => (
<SelectItem key={t} value={t}>
{TYPE_LABELS[t]}
</SelectItem>
))}
</SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div><Label>Vendor</Label><Input value={vendor} onChange={(e) => setVendor(e.target.value)} className="mt-1" placeholder="Dell, HP, Lenovo..." /></div> <div>
<div><Label>Model</Label><Input value={model} onChange={(e) => setModel(e.target.value)} className="mt-1" /></div> <Label>Vendor</Label>
<div><Label>Număr serie</Label><Input value={serialNumber} onChange={(e) => setSerialNumber(e.target.value)} className="mt-1" /></div> <Input
value={vendor}
onChange={(e) => setVendor(e.target.value)}
className="mt-1"
placeholder="Dell, HP, Lenovo..."
/>
</div>
<div>
<Label>Model</Label>
<Input
value={model}
onChange={(e) => setModel(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Număr serie</Label>
<Input
value={serialNumber}
onChange={(e) => setSerialNumber(e.target.value)}
className="mt-1"
/>
</div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div><Label>Adresă IP</Label><Input value={ipAddress} onChange={(e) => setIpAddress(e.target.value)} className="mt-1" placeholder="192.168.1.x" /></div> <div>
<div><Label>Adresă MAC</Label><Input value={macAddress} onChange={(e) => setMacAddress(e.target.value)} className="mt-1" placeholder="AA:BB:CC:DD:EE:FF" /></div> <Label>Adresă IP</Label>
<div><Label>Atribuit</Label><Input value={assignedTo} onChange={(e) => setAssignedTo(e.target.value)} className="mt-1" /></div> <Input
value={ipAddress}
onChange={(e) => setIpAddress(e.target.value)}
className="mt-1"
placeholder="192.168.1.x"
/>
</div>
<div>
<Label>Adresă MAC</Label>
<Input
value={macAddress}
onChange={(e) => setMacAddress(e.target.value)}
className="mt-1"
placeholder="AA:BB:CC:DD:EE:FF"
/>
</div>
<div className="relative">
<Label>Atribuit</Label>
<Input
value={assignedTo}
onChange={(e) => {
setAssignedTo(e.target.value);
setAssignedToContactId("");
}}
onFocus={() => setAssignedToFocused(true)}
onBlur={() => setTimeout(() => setAssignedToFocused(false), 200)}
className="mt-1"
placeholder="Caută după nume..."
/>
{assignedToFocused && assignedToSuggestions.length > 0 && (
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-1 shadow-md">
{assignedToSuggestions.map((c) => (
<button
key={c.id}
type="button"
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
onMouseDown={() => {
setAssignedTo(
c.company ? `${c.name} (${c.company})` : c.name,
);
setAssignedToContactId(c.id);
setAssignedToFocused(false);
}}
>
<span className="font-medium">{c.name}</span>
{c.company && (
<span className="ml-1 text-muted-foreground text-xs">
{c.company}
</span>
)}
</button>
))}
</div>
)}
</div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-4">
<div><Label>Companie</Label> <div>
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}> <Label>Companie</Label>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> <Select
value={company}
onValueChange={(v) => setCompany(v as CompanyId)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="beletage">Beletage</SelectItem> <SelectItem value="beletage">Beletage</SelectItem>
<SelectItem value="urban-switch">Urban Switch</SelectItem> <SelectItem value="urban-switch">Urban Switch</SelectItem>
@@ -230,24 +547,86 @@ function InventoryForm({ initial, onSubmit, onCancel }: {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div><Label>Locație / Cameră</Label><Input value={location} onChange={(e) => setLocation(e.target.value)} className="mt-1" /></div> <div>
<div><Label>Rack / Poziție</Label><Input value={rackLocation} onChange={(e) => setRackLocation(e.target.value)} className="mt-1" /></div> <Label>Locație / Cameră</Label>
<div><Label>Status</Label> <Input
<Select value={status} onValueChange={(v) => setStatus(v as InventoryItemStatus)}> value={location}
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> onChange={(e) => setLocation(e.target.value)}
<SelectContent>{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map((s) => (<SelectItem key={s} value={s}>{STATUS_LABELS[s]}</SelectItem>))}</SelectContent> className="mt-1"
/>
</div>
<div>
<Label>Rack / Poziție</Label>
<Input
value={rackLocation}
onChange={(e) => setRackLocation(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Status</Label>
<Select
value={status}
onValueChange={(v) => setStatus(v as InventoryItemStatus)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map(
(s) => (
<SelectItem key={s} value={s}>
{STATUS_LABELS[s]}
</SelectItem>
),
)}
</SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div><Label>Data achiziție</Label><Input type="date" value={purchaseDate} onChange={(e) => setPurchaseDate(e.target.value)} className="mt-1" /></div> <div>
<div><Label>Cost achiziție (RON)</Label><Input type="number" value={purchaseCost} onChange={(e) => setPurchaseCost(e.target.value)} className="mt-1" /></div> <Label>Data achiziție</Label>
<div><Label>Expirare garanție</Label><Input type="date" value={warrantyExpiry} onChange={(e) => setWarrantyExpiry(e.target.value)} className="mt-1" /></div> <Input
type="date"
value={purchaseDate}
onChange={(e) => setPurchaseDate(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Cost achiziție (RON)</Label>
<Input
type="number"
value={purchaseCost}
onChange={(e) => setPurchaseCost(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Expirare garanție</Label>
<Input
type="date"
value={warrantyExpiry}
onChange={(e) => setWarrantyExpiry(e.target.value)}
className="mt-1"
/>
</div>
</div>
<div>
<Label>Note</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="mt-1"
/>
</div> </div>
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button> <Button type="button" variant="outline" onClick={onCancel}>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button> Anulează
</Button>
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
</div> </div>
</form> </form>
); );

View File

@@ -1,22 +1,22 @@
import type { Visibility } from '@/core/module-registry/types'; import type { Visibility } from "@/core/module-registry/types";
import type { CompanyId } from '@/core/auth/types'; import type { CompanyId } from "@/core/auth/types";
export type InventoryItemType = export type InventoryItemType =
| 'laptop' | "laptop"
| 'desktop' | "desktop"
| 'monitor' | "monitor"
| 'printer' | "printer"
| 'phone' | "phone"
| 'tablet' | "tablet"
| 'network' | "network"
| 'peripheral' | "peripheral"
| 'other'; | "other";
export type InventoryItemStatus = export type InventoryItemStatus =
| 'active' | "active"
| 'in-repair' | "in-repair"
| 'storage' | "storage"
| 'decommissioned'; | "decommissioned";
export interface InventoryItem { export interface InventoryItem {
id: string; id: string;
@@ -24,6 +24,7 @@ export interface InventoryItem {
type: InventoryItemType; type: InventoryItemType;
serialNumber: string; serialNumber: string;
assignedTo: string; assignedTo: string;
assignedToContactId?: string;
company: CompanyId; company: CompanyId;
location: string; location: string;
purchaseDate: string; purchaseDate: string;

View File

@@ -1,13 +1,35 @@
'use client'; "use client";
import { useState } from 'react'; import { useState, useRef } from "react";
import { Copy, Check, Hash, Type, Percent, Ruler } from 'lucide-react'; import {
import { Button } from '@/shared/components/ui/button'; Copy,
import { Input } from '@/shared/components/ui/input'; Check,
import { Label } from '@/shared/components/ui/label'; Hash,
import { Textarea } from '@/shared/components/ui/textarea'; Type,
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card'; Percent,
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/components/ui/tabs'; 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 }) { function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -16,17 +38,29 @@ function CopyButton({ text }: { text: string }) {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1500); setTimeout(() => setCopied(false), 1500);
} catch { /* silent */ } } catch {
/* silent */
}
}; };
return ( return (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopy} disabled={!text}> <Button
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />} 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> </Button>
); );
} }
function TextCaseConverter() { function TextCaseConverter() {
const [input, setInput] = useState(''); const [input, setInput] = useState("");
const upper = input.toUpperCase(); const upper = input.toUpperCase();
const lower = input.toLowerCase(); const lower = input.toLowerCase();
const title = input.replace(/\b\w/g, (c) => c.toUpperCase()); const title = input.replace(/\b\w/g, (c) => c.toUpperCase());
@@ -34,15 +68,26 @@ function TextCaseConverter() {
return ( return (
<div className="space-y-3"> <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: "UPPERCASE", value: upper },
{ label: 'lowercase', value: lower }, { label: "lowercase", value: lower },
{ label: 'Title Case', value: title }, { label: "Title Case", value: title },
{ label: 'Sentence case', value: sentence }, { label: "Sentence case", value: sentence },
].map(({ label, value }) => ( ].map(({ label, value }) => (
<div key={label} className="flex items-center gap-2"> <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> <span className="w-24 text-xs text-muted-foreground">{label}</span>
<CopyButton text={value} /> <CopyButton text={value} />
</div> </div>
@@ -52,73 +97,148 @@ function TextCaseConverter() {
} }
function CharacterCounter() { function CharacterCounter() {
const [input, setInput] = useState(''); const [input, setInput] = useState("");
const chars = input.length; 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 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 ( return (
<div className="space-y-3"> <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"> <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>
<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> <CardContent className="p-3">
<Card><CardContent className="p-3"><p className="text-xs text-muted-foreground">Cuvinte</p><p className="text-xl font-bold">{words}</p></CardContent></Card> <p className="text-xs text-muted-foreground">Caractere</p>
<Card><CardContent className="p-3"><p className="text-xs text-muted-foreground">Linii</p><p className="text-xl font-bold">{lines}</p></CardContent></Card> <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>
</div> </div>
); );
} }
function PercentageCalculator() { function PercentageCalculator() {
const [value, setValue] = useState(''); const [value, setValue] = useState("");
const [total, setTotal] = useState(''); const [total, setTotal] = useState("");
const [percent, setPercent] = useState(''); const [percent, setPercent] = useState("");
const v = parseFloat(value); const v = parseFloat(value);
const t = parseFloat(total); const t = parseFloat(total);
const p = parseFloat(percent); const p = parseFloat(percent);
const pctOfTotal = !isNaN(v) && !isNaN(t) && t !== 0 ? ((v / t) * 100).toFixed(2) : '—'; const pctOfTotal =
const valFromPct = !isNaN(p) && !isNaN(t) ? ((p / 100) * t).toFixed(2) : '—'; !isNaN(v) && !isNaN(t) && t !== 0 ? ((v / t) * 100).toFixed(2) : "—";
const valFromPct = !isNaN(p) && !isNaN(t) ? ((p / 100) * t).toFixed(2) : "—";
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3"> <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>
<div><Label>Total</Label><Input type="number" value={total} onChange={(e) => setTotal(e.target.value)} className="mt-1" /></div> <Label>Valoare</Label>
<div><Label>Procent</Label><Input type="number" value={percent} onChange={(e) => setPercent(e.target.value)} className="mt-1" /></div> <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>
<div className="space-y-2 rounded-md border bg-muted/30 p-3 text-sm"> <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>
<p><strong>{percent || '?'}%</strong> din <strong>{total || '?'}</strong> = <strong>{valFromPct}</strong></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>
</div> </div>
); );
} }
function AreaConverter() { function AreaConverter() {
const [mp, setMp] = useState(''); const [mp, setMp] = useState("");
const v = parseFloat(mp); const v = parseFloat(mp);
const conversions = !isNaN(v) ? [ const conversions = !isNaN(v)
{ label: 'mp (m²)', value: v.toFixed(2) }, ? [
{ label: 'ari (100 m²)', value: (v / 100).toFixed(4) }, { label: "mp (m²)", value: v.toFixed(2) },
{ label: 'hectare (10.000 m²)', value: (v / 10000).toFixed(6) }, { label: "ari (100 m²)", value: (v / 100).toFixed(4) },
{ label: 'km²', value: (v / 1000000).toFixed(8) }, { label: "hectare (10.000 m²)", value: (v / 10000).toFixed(6) },
{ label: 'sq ft', value: (v * 10.7639).toFixed(2) }, { label: "km²", value: (v / 1000000).toFixed(8) },
] : []; { label: "sq ft", value: (v * 10.7639).toFixed(2) },
]
: [];
return ( return (
<div className="space-y-3"> <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 && ( {conversions.length > 0 && (
<div className="space-y-1.5"> <div className="space-y-1.5">
{conversions.map(({ label, value: val }) => ( {conversions.map(({ label, value: val }) => (
<div key={label} className="flex items-center gap-2"> <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> <code className="flex-1 rounded border bg-muted/30 px-2 py-1 text-xs">
<span className="w-36 text-xs text-muted-foreground">{label}</span> {val}
</code>
<span className="w-36 text-xs text-muted-foreground">
{label}
</span>
<CopyButton text={val} /> <CopyButton text={val} />
</div> </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() { export function MiniUtilitiesModule() {
return ( return (
<Tabs defaultValue="text-case" className="space-y-4"> <Tabs defaultValue="text-case" className="space-y-4">
<TabsList className="flex-wrap"> <TabsList className="flex-wrap">
<TabsTrigger value="text-case"><Type className="mr-1 h-3.5 w-3.5" /> Transformare text</TabsTrigger> <TabsTrigger value="text-case">
<TabsTrigger value="char-count"><Hash className="mr-1 h-3.5 w-3.5" /> Numărare caractere</TabsTrigger> <Type className="mr-1 h-3.5 w-3.5" /> Transformare text
<TabsTrigger value="percentage"><Percent className="mr-1 h-3.5 w-3.5" /> Procente</TabsTrigger> </TabsTrigger>
<TabsTrigger value="area"><Ruler className="mr-1 h-3.5 w-3.5" /> Convertor suprafețe</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> </TabsList>
<TabsContent value="text-case"> <TabsContent value="text-case">
<Card><CardHeader><CardTitle className="text-base">Transformare text</CardTitle></CardHeader> <Card>
<CardContent><TextCaseConverter /></CardContent></Card> <CardHeader>
<CardTitle className="text-base">Transformare text</CardTitle>
</CardHeader>
<CardContent>
<TextCaseConverter />
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="char-count"> <TabsContent value="char-count">
<Card><CardHeader><CardTitle className="text-base">Numărare caractere</CardTitle></CardHeader> <Card>
<CardContent><CharacterCounter /></CardContent></Card> <CardHeader>
<CardTitle className="text-base">Numărare caractere</CardTitle>
</CardHeader>
<CardContent>
<CharacterCounter />
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="percentage"> <TabsContent value="percentage">
<Card><CardHeader><CardTitle className="text-base">Calculator procente</CardTitle></CardHeader> <Card>
<CardContent><PercentageCalculator /></CardContent></Card> <CardHeader>
<CardTitle className="text-base">Calculator procente</CardTitle>
</CardHeader>
<CardContent>
<PercentageCalculator />
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="area"> <TabsContent value="area">
<Card><CardHeader><CardTitle className="text-base">Convertor suprafețe</CardTitle></CardHeader> <Card>
<CardContent><AreaConverter /></CardContent></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> </TabsContent>
</Tabs> </Tabs>
); );

View File

@@ -1,37 +1,109 @@
'use client'; "use client";
import { useState } from 'react'; import { useState } from "react";
import { import {
Plus, Pencil, Trash2, Search, Eye, EyeOff, Copy, ExternalLink, Plus,
KeyRound, X, Pencil,
} from 'lucide-react'; Trash2,
import { Button } from '@/shared/components/ui/button'; Search,
import { Input } from '@/shared/components/ui/input'; Eye,
import { Label } from '@/shared/components/ui/label'; EyeOff,
import { Textarea } from '@/shared/components/ui/textarea'; Copy,
import { Badge } from '@/shared/components/ui/badge'; ExternalLink,
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card'; KeyRound,
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'; X,
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog'; } from "lucide-react";
import { Switch } from '@/shared/components/ui/switch'; import { Button } from "@/shared/components/ui/button";
import type { VaultEntry, VaultEntryCategory, CustomField } from '../types'; import { Input } from "@/shared/components/ui/input";
import { useVault } from '../hooks/use-vault'; 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 { 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";
const CATEGORY_LABELS: Record<VaultEntryCategory, string> = { const CATEGORY_LABELS: Record<VaultEntryCategory, string> = {
web: 'Web', email: 'Email', server: 'Server', database: 'Bază de date', api: 'API', other: 'Altele', web: "Web",
email: "Email",
server: "Server",
database: "Bază de date",
api: "API",
other: "Altele",
}; };
type ViewMode = 'list' | 'add' | 'edit'; 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 */ /** Generate a random password */
function generatePassword(length: number, options: { upper: boolean; lower: boolean; digits: boolean; symbols: boolean }): string { function generatePassword(
let chars = ''; length: number,
if (options.upper) chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; options: {
if (options.lower) chars += 'abcdefghijklmnopqrstuvwxyz'; upper: boolean;
if (options.digits) chars += '0123456789'; lower: boolean;
if (options.symbols) chars += '!@#$%^&*()-_=+[]{}|;:,.<>?'; digits: boolean;
if (!chars) chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; symbols: boolean;
let result = ''; },
): string {
let chars = "";
if (options.upper) chars += "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
if (options.lower) chars += "abcdefghijklmnopqrstuvwxyz";
if (options.digits) chars += "0123456789";
if (options.symbols) chars += "!@#$%^&*()-_=+[]{}|;:,.<>?";
if (!chars)
chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let result = "";
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length)); result += chars.charAt(Math.floor(Math.random() * chars.length));
} }
@@ -39,17 +111,29 @@ function generatePassword(length: number, options: { upper: boolean; lower: bool
} }
export function PasswordVaultModule() { export function PasswordVaultModule() {
const { entries, allEntries, loading, filters, updateFilter, addEntry, updateEntry, removeEntry } = useVault(); const {
const [viewMode, setViewMode] = useState<ViewMode>('list'); entries,
allEntries,
loading,
filters,
updateFilter,
addEntry,
updateEntry,
removeEntry,
} = useVault();
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [editingEntry, setEditingEntry] = useState<VaultEntry | null>(null); const [editingEntry, setEditingEntry] = useState<VaultEntry | null>(null);
const [visiblePasswords, setVisiblePasswords] = useState<Set<string>>(new Set()); const [visiblePasswords, setVisiblePasswords] = useState<Set<string>>(
new Set(),
);
const [copiedId, setCopiedId] = useState<string | null>(null); const [copiedId, setCopiedId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const togglePassword = (id: string) => { const togglePassword = (id: string) => {
setVisiblePasswords((prev) => { setVisiblePasswords((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id); if (next.has(id)) next.delete(id);
else next.add(id);
return next; return next;
}); });
}; };
@@ -59,16 +143,20 @@ export function PasswordVaultModule() {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
setCopiedId(id); setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000); setTimeout(() => setCopiedId(null), 2000);
} catch { /* silent */ } } catch {
/* silent */
}
}; };
const handleSubmit = async (data: Omit<VaultEntry, 'id' | 'createdAt' | 'updatedAt'>) => { const handleSubmit = async (
if (viewMode === 'edit' && editingEntry) { data: Omit<VaultEntry, "id" | "createdAt" | "updatedAt">,
) => {
if (viewMode === "edit" && editingEntry) {
await updateEntry(editingEntry.id, data); await updateEntry(editingEntry.id, data);
} else { } else {
await addEntry(data); await addEntry(data);
} }
setViewMode('list'); setViewMode("list");
setEditingEntry(null); setEditingEntry(null);
}; };
@@ -82,42 +170,89 @@ export function PasswordVaultModule() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400"> <div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400">
Atenție: Parolele sunt stocate local (localStorage). Nu sunt criptate. Folosiți un manager de parole dedicat pentru date sensibile. Atenție: Parolele sunt stocate local (localStorage). Nu sunt criptate.
Folosiți un manager de parole dedicat pentru date sensibile.
</div> </div>
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <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">{allEntries.length}</p></CardContent></Card> <Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Web</p><p className="text-2xl font-bold">{allEntries.filter((e) => e.category === 'web').length}</p></CardContent></Card> <CardContent className="p-4">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Server</p><p className="text-2xl font-bold">{allEntries.filter((e) => e.category === 'server').length}</p></CardContent></Card> <p className="text-xs text-muted-foreground">Total</p>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">API</p><p className="text-2xl font-bold">{allEntries.filter((e) => e.category === 'api').length}</p></CardContent></Card> <p className="text-2xl font-bold">{allEntries.length}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Web</p>
<p className="text-2xl font-bold">
{allEntries.filter((e) => e.category === "web").length}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Server</p>
<p className="text-2xl font-bold">
{allEntries.filter((e) => e.category === "server").length}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">API</p>
<p className="text-2xl font-bold">
{allEntries.filter((e) => e.category === "api").length}
</p>
</CardContent>
</Card>
</div> </div>
{viewMode === 'list' && ( {viewMode === "list" && (
<> <>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1"> <div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <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> </div>
<Select value={filters.category} onValueChange={(v) => updateFilter('category', v as VaultEntryCategory | 'all')}> <Select
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger> value={filters.category}
onValueChange={(v) =>
updateFilter("category", v as VaultEntryCategory | "all")
}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Toate</SelectItem> <SelectItem value="all">Toate</SelectItem>
{(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map((c) => ( {(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map(
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem> (c) => (
))} <SelectItem key={c} value={c}>
{CATEGORY_LABELS[c]}
</SelectItem>
),
)}
</SelectContent> </SelectContent>
</Select> </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ă <Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button> </Button>
</div> </div>
{loading ? ( {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>
) : entries.length === 0 ? ( ) : entries.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Nicio intrare găsită.</p> <p className="py-8 text-center text-sm text-muted-foreground">
Nicio intrare găsită.
</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{entries.map((entry) => ( {entries.map((entry) => (
@@ -126,20 +261,44 @@ export function PasswordVaultModule() {
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="font-medium">{entry.label}</p> <p className="font-medium">{entry.label}</p>
<Badge variant="outline" className="text-[10px]">{CATEGORY_LABELS[entry.category]}</Badge> <Badge variant="outline" className="text-[10px]">
{CATEGORY_LABELS[entry.category]}
</Badge>
</div> </div>
<p className="text-xs text-muted-foreground">{entry.username}</p> <p className="text-xs text-muted-foreground">
{entry.username}
</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="text-xs"> <code className="text-xs">
{visiblePasswords.has(entry.id) ? entry.encryptedPassword : '••••••••••'} {visiblePasswords.has(entry.id)
? entry.password
: "••••••••••"}
</code> </code>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => togglePassword(entry.id)}> <Button
{visiblePasswords.has(entry.id) ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />} 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>
<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" /> <Copy className="h-3 w-3" />
</Button> </Button>
{copiedId === entry.id && <span className="text-[10px] text-green-500">Copiat!</span>} {copiedId === entry.id && (
<span className="text-[10px] text-green-500">
Copiat!
</span>
)}
</div> </div>
{entry.url && ( {entry.url && (
<p className="flex items-center gap-1 text-xs text-muted-foreground"> <p className="flex items-center gap-1 text-xs text-muted-foreground">
@@ -149,7 +308,11 @@ export function PasswordVaultModule() {
{entry.customFields && entry.customFields.length > 0 && ( {entry.customFields && entry.customFields.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
{entry.customFields.map((cf, i) => ( {entry.customFields.map((cf, i) => (
<Badge key={i} variant="secondary" className="text-[10px]"> <Badge
key={i}
variant="secondary"
className="text-[10px]"
>
{cf.key}: {cf.value} {cf.key}: {cf.value}
</Badge> </Badge>
))} ))}
@@ -157,10 +320,23 @@ export function PasswordVaultModule() {
)} )}
</div> </div>
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingEntry(entry); setViewMode('edit'); }}> <Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
setEditingEntry(entry);
setViewMode("edit");
}}
>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => setDeletingId(entry.id)}> <Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => setDeletingId(entry.id)}
>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -172,23 +348,48 @@ export function PasswordVaultModule() {
</> </>
)} )}
{(viewMode === 'add' || viewMode === 'edit') && ( {(viewMode === "add" || viewMode === "edit") && (
<Card> <Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare' : 'Intrare nouă'}</CardTitle></CardHeader> <CardHeader>
<CardTitle>
{viewMode === "edit" ? "Editare" : "Intrare nouă"}
</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<VaultForm initial={editingEntry ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingEntry(null); }} /> <VaultForm
initial={editingEntry ?? undefined}
onSubmit={handleSubmit}
onCancel={() => {
setViewMode("list");
setEditingEntry(null);
}}
/>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Delete confirmation */} {/* Delete confirmation */}
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}> <Dialog
open={deletingId !== null}
onOpenChange={(open) => {
if (!open) setDeletingId(null);
}}
>
<DialogContent> <DialogContent>
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader> <DialogHeader>
<p className="text-sm">Ești sigur vrei ștergi această intrare? Acțiunea este ireversibilă.</p> <DialogTitle>Confirmare ștergere</DialogTitle>
</DialogHeader>
<p className="text-sm">
Ești sigur vrei ștergi această intrare? Acțiunea este
ireversibilă.
</p>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeletingId(null)}>Anulează</Button> <Button variant="outline" onClick={() => setDeletingId(null)}>
<Button variant="destructive" onClick={handleDeleteConfirm}>Șterge</Button> Anulează
</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>
Șterge
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -196,18 +397,29 @@ export function PasswordVaultModule() {
); );
} }
function VaultForm({ initial, onSubmit, onCancel }: { function VaultForm({
initial,
onSubmit,
onCancel,
}: {
initial?: VaultEntry; initial?: VaultEntry;
onSubmit: (data: Omit<VaultEntry, 'id' | 'createdAt' | 'updatedAt'>) => void; onSubmit: (data: Omit<VaultEntry, "id" | "createdAt" | "updatedAt">) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const [label, setLabel] = useState(initial?.label ?? ''); const [label, setLabel] = useState(initial?.label ?? "");
const [username, setUsername] = useState(initial?.username ?? ''); 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 [url, setUrl] = useState(initial?.url ?? "");
const [category, setCategory] = useState<VaultEntryCategory>(initial?.category ?? 'web'); const [category, setCategory] = useState<VaultEntryCategory>(
const [notes, setNotes] = useState(initial?.notes ?? ''); initial?.category ?? "web",
const [customFields, setCustomFields] = useState<CustomField[]>(initial?.customFields ?? []); );
const [company, setCompany] = useState<CompanyId>(
initial?.company ?? "beletage",
);
const [notes, setNotes] = useState(initial?.notes ?? "");
const [customFields, setCustomFields] = useState<CustomField[]>(
initial?.customFields ?? [],
);
// Password generator state // Password generator state
const [genLength, setGenLength] = useState(16); const [genLength, setGenLength] = useState(16);
@@ -216,16 +428,33 @@ function VaultForm({ initial, onSubmit, onCancel }: {
const [genDigits, setGenDigits] = useState(true); const [genDigits, setGenDigits] = useState(true);
const [genSymbols, setGenSymbols] = useState(true); const [genSymbols, setGenSymbols] = useState(true);
const strength = getPasswordStrength(password);
const handleGenerate = () => { const handleGenerate = () => {
setPassword(generatePassword(genLength, { upper: genUpper, lower: genLower, digits: genDigits, symbols: genSymbols })); setPassword(
generatePassword(genLength, {
upper: genUpper,
lower: genLower,
digits: genDigits,
symbols: genSymbols,
}),
);
}; };
const addCustomField = () => { const addCustomField = () => {
setCustomFields([...customFields, { key: '', value: '' }]); setCustomFields([...customFields, { key: "", value: "" }]);
}; };
const updateCustomField = (index: number, field: keyof CustomField, value: string) => { const updateCustomField = (
setCustomFields(customFields.map((cf, i) => i === index ? { ...cf, [field]: value } : cf)); index: number,
field: keyof CustomField,
value: string,
) => {
setCustomFields(
customFields.map((cf, i) =>
i === index ? { ...cf, [field]: value } : cf,
),
);
}; };
const removeCustomField = (index: number) => { const removeCustomField = (index: number) => {
@@ -233,61 +462,218 @@ function VaultForm({ initial, onSubmit, onCancel }: {
}; };
return ( return (
<form onSubmit={(e) => { <form
onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
onSubmit({ onSubmit({
label, username, encryptedPassword: password, url, category, notes, label,
username,
password,
url,
category,
company,
notes,
customFields: customFields.filter((cf) => cf.key.trim()), customFields: customFields.filter((cf) => cf.key.trim()),
tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'admin', tags: initial?.tags ?? [],
visibility: initial?.visibility ?? "admin",
}); });
}} className="space-y-4"> }}
className="space-y-4"
>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div><Label>Nume/Etichetă *</Label><Input value={label} onChange={(e) => setLabel(e.target.value)} className="mt-1" required /></div> <div>
<div><Label>Categorie</Label> <Label>Nume/Etichetă *</Label>
<Select value={category} onValueChange={(v) => setCategory(v as VaultEntryCategory)}> <Input
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> value={label}
<SelectContent>{(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map((c) => (<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>))}</SelectContent> onChange={(e) => setLabel(e.target.value)}
className="mt-1"
required
/>
</div>
<div>
<Label>Categorie</Label>
<Select
value={category}
onValueChange={(v) => setCategory(v as VaultEntryCategory)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map(
(c) => (
<SelectItem key={c} value={c}>
{CATEGORY_LABELS[c]}
</SelectItem>
),
)}
</SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <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>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> <div>
<Label>Parolă</Label> <Label>Parolă</Label>
<div className="mt-1 flex gap-1.5"> <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" /> <Input
<Button type="button" variant="outline" size="icon" onClick={handleGenerate} title="Generează parolă"> 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" /> <KeyRound className="h-4 w-4" />
</Button> </Button>
</div> </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>
<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> </div>
{/* Password generator options */} {/* Password generator options */}
<div className="rounded border p-3 space-y-2"> <div className="rounded border p-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground">Generator parolă</p> <p className="text-xs font-medium text-muted-foreground">
Generator parolă
</p>
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="text-xs">Lungime:</Label> <Label className="text-xs">Lungime:</Label>
<Input type="number" value={genLength} onChange={(e) => setGenLength(parseInt(e.target.value, 10) || 8)} className="w-16 text-sm" min={4} max={64} /> <Input
type="number"
value={genLength}
onChange={(e) => setGenLength(parseInt(e.target.value, 10) || 8)}
className="w-16 text-sm"
min={4}
max={64}
/>
</div> </div>
<div className="flex items-center gap-1.5"><Switch checked={genUpper} onCheckedChange={setGenUpper} id="gen-upper" /><Label htmlFor="gen-upper" className="text-xs cursor-pointer">A-Z</Label></div> <div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5"><Switch checked={genLower} onCheckedChange={setGenLower} id="gen-lower" /><Label htmlFor="gen-lower" className="text-xs cursor-pointer">a-z</Label></div> <Switch
<div className="flex items-center gap-1.5"><Switch checked={genDigits} onCheckedChange={setGenDigits} id="gen-digits" /><Label htmlFor="gen-digits" className="text-xs cursor-pointer">0-9</Label></div> checked={genUpper}
<div className="flex items-center gap-1.5"><Switch checked={genSymbols} onCheckedChange={setGenSymbols} id="gen-symbols" /><Label htmlFor="gen-symbols" className="text-xs cursor-pointer">!@#$</Label></div> onCheckedChange={setGenUpper}
<Button type="button" variant="secondary" size="sm" onClick={handleGenerate}> id="gen-upper"
/>
<Label htmlFor="gen-upper" className="text-xs cursor-pointer">
A-Z
</Label>
</div>
<div className="flex items-center gap-1.5">
<Switch
checked={genLower}
onCheckedChange={setGenLower}
id="gen-lower"
/>
<Label htmlFor="gen-lower" className="text-xs cursor-pointer">
a-z
</Label>
</div>
<div className="flex items-center gap-1.5">
<Switch
checked={genDigits}
onCheckedChange={setGenDigits}
id="gen-digits"
/>
<Label htmlFor="gen-digits" className="text-xs cursor-pointer">
0-9
</Label>
</div>
<div className="flex items-center gap-1.5">
<Switch
checked={genSymbols}
onCheckedChange={setGenSymbols}
id="gen-symbols"
/>
<Label htmlFor="gen-symbols" className="text-xs cursor-pointer">
!@#$
</Label>
</div>
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleGenerate}
>
<KeyRound className="mr-1 h-3 w-3" /> Generează <KeyRound className="mr-1 h-3 w-3" /> Generează
</Button> </Button>
</div> </div>
</div> </div>
<div><Label>URL</Label><Input value={url} onChange={(e) => setUrl(e.target.value)} className="mt-1" placeholder="https://..." /></div> <div>
<Label>URL</Label>
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
className="mt-1"
placeholder="https://..."
/>
</div>
{/* Custom fields */} {/* Custom fields */}
<div> <div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label>Câmpuri personalizate</Label> <Label>Câmpuri personalizate</Label>
<Button type="button" variant="outline" size="sm" onClick={addCustomField}> <Button
type="button"
variant="outline"
size="sm"
onClick={addCustomField}
>
<Plus className="mr-1 h-3 w-3" /> Adaugă câmp <Plus className="mr-1 h-3 w-3" /> Adaugă câmp
</Button> </Button>
</div> </div>
@@ -295,9 +681,27 @@ function VaultForm({ initial, onSubmit, onCancel }: {
<div className="mt-2 space-y-1.5"> <div className="mt-2 space-y-1.5">
{customFields.map((cf, i) => ( {customFields.map((cf, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
<Input placeholder="Cheie" value={cf.key} onChange={(e) => updateCustomField(i, 'key', e.target.value)} className="w-[140px] text-sm" /> <Input
<Input placeholder="Valoare" value={cf.value} onChange={(e) => updateCustomField(i, 'value', e.target.value)} className="flex-1 text-sm" /> placeholder="Cheie"
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeCustomField(i)}> value={cf.key}
onChange={(e) => updateCustomField(i, "key", e.target.value)}
className="w-[140px] text-sm"
/>
<Input
placeholder="Valoare"
value={cf.value}
onChange={(e) =>
updateCustomField(i, "value", e.target.value)
}
className="flex-1 text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => removeCustomField(i)}
>
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -306,10 +710,20 @@ function VaultForm({ initial, onSubmit, onCancel }: {
)} )}
</div> </div>
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div> <div>
<Label>Note</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
className="mt-1"
/>
</div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button> <Button type="button" variant="outline" onClick={onCancel}>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button> Anulează
</Button>
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
</div> </div>
</form> </form>
); );

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,76 +1,130 @@
'use client'; "use client";
import { useState, useMemo, useRef } from 'react'; import { useState, useMemo, useRef } from "react";
import { Paperclip, X, Clock, Plus } from 'lucide-react'; import { Paperclip, X, Clock, Plus } from "lucide-react";
import type { CompanyId } from '@/core/auth/types'; import type { CompanyId } from "@/core/auth/types";
import type { RegistryEntry, RegistryDirection, RegistryStatus, DocumentType, RegistryAttachment, TrackedDeadline, DeadlineResolution } from '../types'; import type {
import { Input } from '@/shared/components/ui/input'; RegistryEntry,
import { Label } from '@/shared/components/ui/label'; RegistryDirection,
import { Textarea } from '@/shared/components/ui/textarea'; RegistryStatus,
import { Button } from '@/shared/components/ui/button'; DocumentType,
import { Badge } from '@/shared/components/ui/badge'; RegistryAttachment,
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'; TrackedDeadline,
import { useContacts } from '@/modules/address-book/hooks/use-contacts'; DeadlineResolution,
import { v4 as uuid } from 'uuid'; } from "../types";
import { DeadlineCard } from './deadline-card'; import { Input } from "@/shared/components/ui/input";
import { DeadlineAddDialog } from './deadline-add-dialog'; import { Label } from "@/shared/components/ui/label";
import { DeadlineResolveDialog } from './deadline-resolve-dialog'; import { Textarea } from "@/shared/components/ui/textarea";
import { createTrackedDeadline, resolveDeadline as resolveDeadlineFn } from '../services/deadline-service'; import { Button } from "@/shared/components/ui/button";
import { getDeadlineType } from '../services/deadline-catalog'; import { Badge } from "@/shared/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import { useContacts } from "@/modules/address-book/hooks/use-contacts";
import { v4 as uuid } from "uuid";
import { DeadlineCard } from "./deadline-card";
import { DeadlineAddDialog } from "./deadline-add-dialog";
import { DeadlineResolveDialog } from "./deadline-resolve-dialog";
import {
createTrackedDeadline,
resolveDeadline as resolveDeadlineFn,
} from "../services/deadline-service";
import { getDeadlineType } from "../services/deadline-catalog";
interface RegistryEntryFormProps { interface RegistryEntryFormProps {
initial?: RegistryEntry; initial?: RegistryEntry;
allEntries?: RegistryEntry[]; allEntries?: RegistryEntry[];
onSubmit: (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => void; onSubmit: (
data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
) => void;
onCancel: () => void; onCancel: () => void;
} }
const DOC_TYPE_LABELS: Record<DocumentType, string> = { const DOC_TYPE_LABELS: Record<DocumentType, string> = {
contract: 'Contract', contract: "Contract",
oferta: 'Ofertă', oferta: "Ofertă",
factura: 'Factură', factura: "Factură",
scrisoare: 'Scrisoare', scrisoare: "Scrisoare",
aviz: 'Aviz', aviz: "Aviz",
'nota-de-comanda': 'Notă de comandă', "nota-de-comanda": "Notă de comandă",
raport: 'Raport', raport: "Raport",
cerere: 'Cerere', cerere: "Cerere",
altele: 'Altele', altele: "Altele",
}; };
export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: RegistryEntryFormProps) { export function RegistryEntryForm({
initial,
allEntries,
onSubmit,
onCancel,
}: RegistryEntryFormProps) {
const { allContacts } = useContacts(); const { allContacts } = useContacts();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [direction, setDirection] = useState<RegistryDirection>(initial?.direction ?? 'intrat'); const [direction, setDirection] = useState<RegistryDirection>(
const [documentType, setDocumentType] = useState<DocumentType>(initial?.documentType ?? 'scrisoare'); initial?.direction ?? "intrat",
const [subject, setSubject] = useState(initial?.subject ?? ''); );
const [date, setDate] = useState(initial?.date ?? new Date().toISOString().slice(0, 10)); const [documentType, setDocumentType] = useState<DocumentType>(
const [sender, setSender] = useState(initial?.sender ?? ''); initial?.documentType ?? "scrisoare",
const [senderContactId, setSenderContactId] = useState(initial?.senderContactId ?? ''); );
const [recipient, setRecipient] = useState(initial?.recipient ?? ''); const [subject, setSubject] = useState(initial?.subject ?? "");
const [recipientContactId, setRecipientContactId] = useState(initial?.recipientContactId ?? ''); const [date, setDate] = useState(
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage'); initial?.date ?? new Date().toISOString().slice(0, 10),
const [status, setStatus] = useState<RegistryStatus>(initial?.status ?? 'deschis'); );
const [deadline, setDeadline] = useState(initial?.deadline ?? ''); const [sender, setSender] = useState(initial?.sender ?? "");
const [notes, setNotes] = useState(initial?.notes ?? ''); const [senderContactId, setSenderContactId] = useState(
const [linkedEntryIds, setLinkedEntryIds] = useState<string[]>(initial?.linkedEntryIds ?? []); initial?.senderContactId ?? "",
const [attachments, setAttachments] = useState<RegistryAttachment[]>(initial?.attachments ?? []); );
const [trackedDeadlines, setTrackedDeadlines] = useState<TrackedDeadline[]>(initial?.trackedDeadlines ?? []); const [recipient, setRecipient] = useState(initial?.recipient ?? "");
const [recipientContactId, setRecipientContactId] = useState(
initial?.recipientContactId ?? "",
);
const [company, setCompany] = useState<CompanyId>(
initial?.company ?? "beletage",
);
const [status, setStatus] = useState<RegistryStatus>(
initial?.status ?? "deschis",
);
const [deadline, setDeadline] = useState(initial?.deadline ?? "");
const [notes, setNotes] = useState(initial?.notes ?? "");
const [linkedEntryIds, setLinkedEntryIds] = useState<string[]>(
initial?.linkedEntryIds ?? [],
);
const [attachments, setAttachments] = useState<RegistryAttachment[]>(
initial?.attachments ?? [],
);
const [trackedDeadlines, setTrackedDeadlines] = useState<TrackedDeadline[]>(
initial?.trackedDeadlines ?? [],
);
const [linkedSearch, setLinkedSearch] = useState("");
// ── Deadline dialogs ── // ── Deadline dialogs ──
const [deadlineAddOpen, setDeadlineAddOpen] = useState(false); const [deadlineAddOpen, setDeadlineAddOpen] = useState(false);
const [resolvingDeadline, setResolvingDeadline] = useState<TrackedDeadline | null>(null); const [resolvingDeadline, setResolvingDeadline] =
useState<TrackedDeadline | null>(null);
const handleAddDeadline = (typeId: string, startDate: string, chainParentId?: string) => { const handleAddDeadline = (
typeId: string,
startDate: string,
chainParentId?: string,
) => {
const tracked = createTrackedDeadline(typeId, startDate, chainParentId); const tracked = createTrackedDeadline(typeId, startDate, chainParentId);
if (tracked) setTrackedDeadlines((prev) => [...prev, tracked]); if (tracked) setTrackedDeadlines((prev) => [...prev, tracked]);
}; };
const handleResolveDeadline = (resolution: DeadlineResolution, note: string, chainNext: boolean) => { const handleResolveDeadline = (
resolution: DeadlineResolution,
note: string,
chainNext: boolean,
) => {
if (!resolvingDeadline) return; if (!resolvingDeadline) return;
const resolved = resolveDeadlineFn(resolvingDeadline, resolution, note); const resolved = resolveDeadlineFn(resolvingDeadline, resolution, note);
setTrackedDeadlines((prev) => setTrackedDeadlines((prev) =>
prev.map((d) => (d.id === resolved.id ? resolved : d)) prev.map((d) => (d.id === resolved.id ? resolved : d)),
); );
// Handle chain // Handle chain
@@ -78,7 +132,11 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
const def = getDeadlineType(resolvingDeadline.typeId); const def = getDeadlineType(resolvingDeadline.typeId);
if (def?.chainNextTypeId) { if (def?.chainNextTypeId) {
const resolvedDate = new Date().toISOString().slice(0, 10); const resolvedDate = new Date().toISOString().slice(0, 10);
const chained = createTrackedDeadline(def.chainNextTypeId, resolvedDate, resolvingDeadline.id); const chained = createTrackedDeadline(
def.chainNextTypeId,
resolvedDate,
resolvingDeadline.id,
);
if (chained) setTrackedDeadlines((prev) => [...prev, chained]); if (chained) setTrackedDeadlines((prev) => [...prev, chained]);
} }
} }
@@ -96,13 +154,25 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
const senderSuggestions = useMemo(() => { const senderSuggestions = useMemo(() => {
if (!sender || sender.length < 2) return []; if (!sender || sender.length < 2) return [];
const q = sender.toLowerCase(); const q = sender.toLowerCase();
return allContacts.filter((c) => c.name.toLowerCase().includes(q) || c.company.toLowerCase().includes(q)).slice(0, 5); return allContacts
.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.company.toLowerCase().includes(q),
)
.slice(0, 5);
}, [allContacts, sender]); }, [allContacts, sender]);
const recipientSuggestions = useMemo(() => { const recipientSuggestions = useMemo(() => {
if (!recipient || recipient.length < 2) return []; if (!recipient || recipient.length < 2) return [];
const q = recipient.toLowerCase(); const q = recipient.toLowerCase();
return allContacts.filter((c) => c.name.toLowerCase().includes(q) || c.company.toLowerCase().includes(q)).slice(0, 5); return allContacts
.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.company.toLowerCase().includes(q),
)
.slice(0, 5);
}, [allContacts, recipient]); }, [allContacts, recipient]);
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -126,7 +196,7 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = "";
}; };
const removeAttachment = (id: string) => { const removeAttachment = (id: string) => {
@@ -149,10 +219,11 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
deadline: deadline || undefined, deadline: deadline || undefined,
linkedEntryIds, linkedEntryIds,
attachments, attachments,
trackedDeadlines: trackedDeadlines.length > 0 ? trackedDeadlines : undefined, trackedDeadlines:
trackedDeadlines.length > 0 ? trackedDeadlines : undefined,
notes, notes,
tags: initial?.tags ?? [], tags: initial?.tags ?? [],
visibility: initial?.visibility ?? 'all', visibility: initial?.visibility ?? "all",
}); });
}; };
@@ -162,8 +233,13 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div> <div>
<Label>Direcție</Label> <Label>Direcție</Label>
<Select value={direction} onValueChange={(v) => setDirection(v as RegistryDirection)}> <Select
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> value={direction}
onValueChange={(v) => setDirection(v as RegistryDirection)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="intrat">Intrat</SelectItem> <SelectItem value="intrat">Intrat</SelectItem>
<SelectItem value="iesit">Ieșit</SelectItem> <SelectItem value="iesit">Ieșit</SelectItem>
@@ -172,25 +248,44 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
</div> </div>
<div> <div>
<Label>Tip document</Label> <Label>Tip document</Label>
<Select value={documentType} onValueChange={(v) => setDocumentType(v as DocumentType)}> <Select
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> value={documentType}
onValueChange={(v) => setDocumentType(v as DocumentType)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
{(Object.entries(DOC_TYPE_LABELS) as [DocumentType, string][]).map(([key, label]) => ( {(
<SelectItem key={key} value={key}>{label}</SelectItem> Object.entries(DOC_TYPE_LABELS) as [DocumentType, string][]
).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div> <div>
<Label>Data</Label> <Label>Data</Label>
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} className="mt-1" /> <Input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="mt-1"
/>
</div> </div>
</div> </div>
{/* Subject */} {/* Subject */}
<div> <div>
<Label>Subiect *</Label> <Label>Subiect *</Label>
<Input value={subject} onChange={(e) => setSubject(e.target.value)} className="mt-1" required /> <Input
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="mt-1"
required
/>
</div> </div>
{/* Sender / Recipient with autocomplete */} {/* Sender / Recipient with autocomplete */}
@@ -199,7 +294,10 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
<Label>Expeditor</Label> <Label>Expeditor</Label>
<Input <Input
value={sender} value={sender}
onChange={(e) => { setSender(e.target.value); setSenderContactId(''); }} onChange={(e) => {
setSender(e.target.value);
setSenderContactId("");
}}
onFocus={() => setSenderFocused(true)} onFocus={() => setSenderFocused(true)}
onBlur={() => setTimeout(() => setSenderFocused(false), 200)} onBlur={() => setTimeout(() => setSenderFocused(false), 200)}
className="mt-1" className="mt-1"
@@ -219,7 +317,11 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
}} }}
> >
<span className="font-medium">{c.name}</span> <span className="font-medium">{c.name}</span>
{c.company && <span className="ml-1 text-muted-foreground text-xs">{c.company}</span>} {c.company && (
<span className="ml-1 text-muted-foreground text-xs">
{c.company}
</span>
)}
</button> </button>
))} ))}
</div> </div>
@@ -229,7 +331,10 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
<Label>Destinatar</Label> <Label>Destinatar</Label>
<Input <Input
value={recipient} value={recipient}
onChange={(e) => { setRecipient(e.target.value); setRecipientContactId(''); }} onChange={(e) => {
setRecipient(e.target.value);
setRecipientContactId("");
}}
onFocus={() => setRecipientFocused(true)} onFocus={() => setRecipientFocused(true)}
onBlur={() => setTimeout(() => setRecipientFocused(false), 200)} onBlur={() => setTimeout(() => setRecipientFocused(false), 200)}
className="mt-1" className="mt-1"
@@ -243,13 +348,19 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
type="button" type="button"
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent" className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
onMouseDown={() => { onMouseDown={() => {
setRecipient(c.company ? `${c.name} (${c.company})` : c.name); setRecipient(
c.company ? `${c.name} (${c.company})` : c.name,
);
setRecipientContactId(c.id); setRecipientContactId(c.id);
setRecipientFocused(false); setRecipientFocused(false);
}} }}
> >
<span className="font-medium">{c.name}</span> <span className="font-medium">{c.name}</span>
{c.company && <span className="ml-1 text-muted-foreground text-xs">{c.company}</span>} {c.company && (
<span className="ml-1 text-muted-foreground text-xs">
{c.company}
</span>
)}
</button> </button>
))} ))}
</div> </div>
@@ -261,8 +372,13 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div> <div>
<Label>Companie</Label> <Label>Companie</Label>
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}> <Select
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> value={company}
onValueChange={(v) => setCompany(v as CompanyId)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="beletage">Beletage</SelectItem> <SelectItem value="beletage">Beletage</SelectItem>
<SelectItem value="urban-switch">Urban Switch</SelectItem> <SelectItem value="urban-switch">Urban Switch</SelectItem>
@@ -273,8 +389,13 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
</div> </div>
<div> <div>
<Label>Status</Label> <Label>Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as RegistryStatus)}> <Select
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> value={status}
onValueChange={(v) => setStatus(v as RegistryStatus)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="deschis">Deschis</SelectItem> <SelectItem value="deschis">Deschis</SelectItem>
<SelectItem value="inchis">Închis</SelectItem> <SelectItem value="inchis">Închis</SelectItem>
@@ -283,7 +404,12 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
</div> </div>
<div> <div>
<Label>Termen limită</Label> <Label>Termen limită</Label>
<Input type="date" value={deadline} onChange={(e) => setDeadline(e.target.value)} className="mt-1" /> <Input
type="date"
value={deadline}
onChange={(e) => setDeadline(e.target.value)}
className="mt-1"
/>
</div> </div>
</div> </div>
@@ -291,26 +417,50 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
{allEntries && allEntries.length > 0 && ( {allEntries && allEntries.length > 0 && (
<div> <div>
<Label>Înregistrări legate</Label> <Label>Înregistrări legate</Label>
<Input
className="mt-1.5"
placeholder="Caută după număr, subiect sau expeditor…"
value={linkedSearch}
onChange={(e) => setLinkedSearch(e.target.value)}
/>
<div className="mt-1.5 flex flex-wrap gap-1.5"> <div className="mt-1.5 flex flex-wrap gap-1.5">
{allEntries {allEntries
.filter((e) => e.id !== initial?.id) .filter((e) => {
.slice(0, 20) if (e.id === initial?.id) return false;
if (!linkedSearch.trim()) return true;
const q = linkedSearch.toLowerCase();
return (
e.number.toLowerCase().includes(q) ||
e.subject.toLowerCase().includes(q) ||
(e.sender ?? "").toLowerCase().includes(q)
);
})
.map((e) => ( .map((e) => (
<button <button
key={e.id} key={e.id}
type="button" type="button"
onClick={() => { onClick={() => {
setLinkedEntryIds((prev) => setLinkedEntryIds((prev) =>
prev.includes(e.id) ? prev.filter((id) => id !== e.id) : [...prev, e.id] prev.includes(e.id)
? prev.filter((id) => id !== e.id)
: [...prev, e.id],
); );
}} }}
className={`rounded border px-2 py-0.5 text-xs transition-colors ${ className={`rounded border px-2 py-0.5 text-xs transition-colors ${
linkedEntryIds.includes(e.id) linkedEntryIds.includes(e.id)
? 'border-primary bg-primary/10 text-primary' ? "border-primary bg-primary/10 text-primary"
: 'border-muted-foreground/30 text-muted-foreground hover:border-primary/50' : "border-muted-foreground/30 text-muted-foreground hover:border-primary/50"
}`} }`}
> >
{e.number} {e.number}
{e.subject && (
<span className="ml-1 opacity-60">
·{" "}
{e.subject.length > 30
? e.subject.slice(0, 30) + "…"
: e.subject}
</span>
)}
</button> </button>
))} ))}
</div> </div>
@@ -324,7 +474,12 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
<Clock className="h-3.5 w-3.5" /> <Clock className="h-3.5 w-3.5" />
Termene legale Termene legale
</Label> </Label>
<Button type="button" variant="outline" size="sm" onClick={() => setDeadlineAddOpen(true)}> <Button
type="button"
variant="outline"
size="sm"
onClick={() => setDeadlineAddOpen(true)}
>
<Plus className="mr-1 h-3.5 w-3.5" /> Adaugă termen <Plus className="mr-1 h-3.5 w-3.5" /> Adaugă termen
</Button> </Button>
</div> </div>
@@ -342,7 +497,8 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
)} )}
{trackedDeadlines.length === 0 && ( {trackedDeadlines.length === 0 && (
<p className="mt-2 text-xs text-muted-foreground"> <p className="mt-2 text-xs text-muted-foreground">
Niciun termen legal. Apăsați &quot;Adaugă termen&quot; pentru a urmări un termen. Niciun termen legal. Apăsați &quot;Adaugă termen&quot; pentru a
urmări un termen.
</p> </p>
)} )}
</div> </div>
@@ -357,7 +513,9 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
<DeadlineResolveDialog <DeadlineResolveDialog
open={resolvingDeadline !== null} open={resolvingDeadline !== null}
deadline={resolvingDeadline} deadline={resolvingDeadline}
onOpenChange={(open) => { if (!open) setResolvingDeadline(null); }} onOpenChange={(open) => {
if (!open) setResolvingDeadline(null);
}}
onResolve={handleResolveDeadline} onResolve={handleResolveDeadline}
/> />
@@ -365,7 +523,12 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
<div> <div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label>Atașamente</Label> <Label>Atașamente</Label>
<Button type="button" variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}> <Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
>
<Paperclip className="mr-1 h-3.5 w-3.5" /> Adaugă fișier <Paperclip className="mr-1 h-3.5 w-3.5" /> Adaugă fișier
</Button> </Button>
<input <input
@@ -380,13 +543,20 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
{attachments.length > 0 && ( {attachments.length > 0 && (
<div className="mt-2 space-y-1"> <div className="mt-2 space-y-1">
{attachments.map((att) => ( {attachments.map((att) => (
<div key={att.id} className="flex items-center gap-2 rounded border px-2 py-1 text-sm"> <div
key={att.id}
className="flex items-center gap-2 rounded border px-2 py-1 text-sm"
>
<Paperclip className="h-3 w-3 text-muted-foreground" /> <Paperclip className="h-3 w-3 text-muted-foreground" />
<span className="flex-1 truncate">{att.name}</span> <span className="flex-1 truncate">{att.name}</span>
<Badge variant="outline" className="text-[10px]"> <Badge variant="outline" className="text-[10px]">
{(att.size / 1024).toFixed(0)} KB {(att.size / 1024).toFixed(0)} KB
</Badge> </Badge>
<button type="button" onClick={() => removeAttachment(att.id)} className="text-destructive"> <button
type="button"
onClick={() => removeAttachment(att.id)}
className="text-destructive"
>
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
@@ -398,12 +568,19 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
{/* Notes */} {/* Notes */}
<div> <div>
<Label>Note</Label> <Label>Note</Label>
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={3} className="mt-1" /> <Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="mt-1"
/>
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button> <Button type="button" variant="outline" onClick={onCancel}>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button> Anulează
</Button>
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
</div> </div>
</form> </form>
); );

View File

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

View File

@@ -1,16 +1,19 @@
import type { Tag, TagCategory } from '@/core/tagging/types'; import type { Tag, TagCategory } from "@/core/tagging/types";
import type { CompanyId } from '@/core/auth/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" } */ /** 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+(.+)$/); const match = line.match(/^(\w?\d+)\s+(.+)$/);
if (!match?.[1] || !match[2]) return null; if (!match?.[1] || !match[2]) return null;
const num = match[1]; const num = match[1];
const label = match[2].trim(); const label = match[2].trim();
const padded = num.replace(/^[A-Z]/, '').padStart(3, '0'); const padded = num.replace(/^[A-Z]/, "").padStart(3, "0");
const codePrefix = num.startsWith('L') ? `${prefix}L` : prefix; const codePrefix = num.startsWith("L") ? `${prefix}L` : prefix;
return { code: `${codePrefix}-${padded}`, label }; return { code: `${codePrefix}-${padded}`, label };
} }
@@ -19,168 +22,260 @@ export function getManicTimeSeedTags(): SeedTag[] {
// ── Beletage projects ── // ── Beletage projects ──
const beletageProjects = [ const beletageProjects = [
'000 Farmacie', "000 Farmacie",
'002 Cladire birouri Stratec', "002 Cladire birouri Stratec",
'003 PUZ Bellavista', "003 PUZ Bellavista",
'007 Design Apartament Teodora', "007 Design Apartament Teodora",
'010 Casa Doinei', "010 Casa Doinei",
'016 Duplex Eremia', "016 Duplex Eremia",
'024 Bloc Petofi', "024 Bloc Petofi",
'028 PUZ Borhanci-Sopor', "028 PUZ Borhanci-Sopor",
'033 Mansardare Branului', "033 Mansardare Branului",
'039 Cabinete Stoma Scala', "039 Cabinete Stoma Scala",
'041 Imobil mixt Progresului', "041 Imobil mixt Progresului",
'045 Casa Andrei Muresanu', "045 Casa Andrei Muresanu",
'052 PUZ Carpenului', "052 PUZ Carpenului",
'059 PUZ Nordului', "059 PUZ Nordului",
'064 Casa Salicea', "064 Casa Salicea",
'066 Terasa Gherase', "066 Terasa Gherase",
'070 Bloc Fanatelor', "070 Bloc Fanatelor",
'073 Case Frumoasa', "073 Case Frumoasa",
'074 PUG Cosbuc', "074 PUG Cosbuc",
'076 Casa Copernicus', "076 Casa Copernicus",
'077 PUZ Schimbare destinatie Brancusi', "077 PUZ Schimbare destinatie Brancusi",
'078 Service auto Linistei', "078 Service auto Linistei",
'079 Amenajare drum Servitute Eremia', "079 Amenajare drum Servitute Eremia",
'080 Bloc Tribunul', "080 Bloc Tribunul",
'081 Extindere casa Gherase', "081 Extindere casa Gherase",
'083 Modificari casa Zsigmund 18', "083 Modificari casa Zsigmund 18",
'084 Mansardare Petofi 21', "084 Mansardare Petofi 21",
'085 Container CT Spital Tabacarilor', "085 Container CT Spital Tabacarilor",
'086 Imprejmuire casa sat Gheorgheni', "086 Imprejmuire casa sat Gheorgheni",
'087 Duplex Oasului fn', "087 Duplex Oasului fn",
'089 PUZ A-Liu Sopor', "089 PUZ A-Liu Sopor",
'090 VR MedEvents', "090 VR MedEvents",
'091 Reclama Caparol', "091 Reclama Caparol",
'092 Imobil birouri 13 Septembrie', "092 Imobil birouri 13 Septembrie",
'093 Casa Salistea Noua', "093 Casa Salistea Noua",
'094 PUD Casa Rediu', "094 PUD Casa Rediu",
'095 Duplex Vanatorului', "095 Duplex Vanatorului",
'096 Design apartament Sopor', "096 Design apartament Sopor",
'097 Cabana Gilau', "097 Cabana Gilau",
'101 PUZ Gilau', "101 PUZ Gilau",
'102 PUZ Ghimbav', "102 PUZ Ghimbav",
'103 Piscine Lunca Noua', "103 Piscine Lunca Noua",
'104 PUZ REGHIN', "104 PUZ REGHIN",
'105 CUT&Crust', "105 CUT&Crust",
'106 PUZ Mihai Romanu Nord', "106 PUZ Mihai Romanu Nord",
'108 Reabilitare Bloc Beiusului', "108 Reabilitare Bloc Beiusului",
'109 Case Samboleni', "109 Case Samboleni",
'110 Penny Crasna', "110 Penny Crasna",
'111 Anexa Piscina Borhanci', "111 Anexa Piscina Borhanci",
'112 PUZ Blocuri Bistrita', "112 PUZ Blocuri Bistrita",
'113 PUZ VARATEC-FIRIZA', "113 PUZ VARATEC-FIRIZA",
'114 PUG Husi', "114 PUG Husi",
'115 PUG Josenii Bargaului', "115 PUG Josenii Bargaului",
'116 PUG Monor', "116 PUG Monor",
'117 Schimbare Destinatie Mihai Viteazu 2', "117 Schimbare Destinatie Mihai Viteazu 2",
'120 Anexa Brasov', "120 Anexa Brasov",
'121 Imprejurare imobil Mesterul Manole 9', "121 Imprejurare imobil Mesterul Manole 9",
'122 Fastfood Bashar', "122 Fastfood Bashar",
'123 PUD Rediu 2', "123 PUD Rediu 2",
'127 Casa Socaciu Ciurila', "127 Casa Socaciu Ciurila",
'128 Schimbare de destinatie Danubius', "128 Schimbare de destinatie Danubius",
'129 (re) Casa Sarca-Sorescu', "129 (re) Casa Sarca-Sorescu",
'130 Casa Suta-Wonderland', "130 Casa Suta-Wonderland",
'131 PUD Oasului Hufi', "131 PUD Oasului Hufi",
'132 Reabilitare Camin Cultural Baciu', "132 Reabilitare Camin Cultural Baciu",
'133 PUG Feldru', "133 PUG Feldru",
'134 DALI Blocuri Murfatlar', "134 DALI Blocuri Murfatlar",
'135 Case de vacanta Dianei', "135 Case de vacanta Dianei",
'136 PUG BROSTENI', "136 PUG BROSTENI",
'139 Casa Turda', "139 Casa Turda",
'140 Releveu Bistrita (Morariu)', "140 Releveu Bistrita (Morariu)",
'141 PUZ Janovic Jeno', "141 PUZ Janovic Jeno",
'142 Penny Borhanci', "142 Penny Borhanci",
'143 Pavilion Politie Radauti', "143 Pavilion Politie Radauti",
'149 Duplex Sorescu 31-33', "149 Duplex Sorescu 31-33",
'150 DALI SF Scoala Baciu', "150 DALI SF Scoala Baciu",
'151 Casa Alexandru Bohatiel 17', "151 Casa Alexandru Bohatiel 17",
'152 PUZ Penny Tautii Magheraus', "152 PUZ Penny Tautii Magheraus",
'153 PUG Banita', "153 PUG Banita",
'155 PT Scoala Floresti', "155 PT Scoala Floresti",
'156 Case Sorescu', "156 Case Sorescu",
'157 Gradi-Cresa Baciu', "157 Gradi-Cresa Baciu",
'158 Duplex Sorescu 21-23', "158 Duplex Sorescu 21-23",
'159 Amenajare Spatiu Grenke PBC', "159 Amenajare Spatiu Grenke PBC",
'160 Etajare Primaria Baciu', "160 Etajare Primaria Baciu",
'161 Extindere Ap Baciu', "161 Extindere Ap Baciu",
'164 SD salon Aurel Vlaicu', "164 SD salon Aurel Vlaicu",
'165 Reclama Marasti', "165 Reclama Marasti",
'166 Catei Apahida', "166 Catei Apahida",
'167 Apartament Mircea Zaciu 13-15', "167 Apartament Mircea Zaciu 13-15",
'169 Casa PETRILA 37', "169 Casa PETRILA 37",
'170 Cabana Campeni AB', "170 Cabana Campeni AB",
'171 Camin Apahida', "171 Camin Apahida",
'L089 PUZ TUSA-BOJAN', "L089 PUZ TUSA-BOJAN",
'172 Design casa Iugoslaviei 18', "172 Design casa Iugoslaviei 18",
'173 Reabilitare spitale Sighetu', "173 Reabilitare spitale Sighetu",
'174 StudX UMFST', "174 StudX UMFST",
'176 - 2025 - ReAC Ansamblu rezi Bibescu', "176 - 2025 - ReAC Ansamblu rezi Bibescu",
]; ];
for (const line of beletageProjects) { for (const line of beletageProjects) {
const parsed = parseProjectLine(line, 'B'); const parsed = parseProjectLine(line, "B");
if (parsed) { if (parsed) {
tags.push({ tags.push({
label: parsed.label, label: parsed.label,
category: 'project', category: "project",
scope: 'company', scope: "company",
companyId: 'beletage' as CompanyId, companyId: "beletage" as CompanyId,
projectCode: parsed.code, 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 ── // ── Phase tags ──
const phases = [ const phases = [
'CU', 'Schita', 'Avize', 'PUD', 'AO', 'PUZ', 'PUG', "CU",
'DTAD', 'DTAC', 'PT', 'Detalii de Executie', 'Studii de fundamentare', "Schita",
'Regulament', 'Parte desenata', 'Parte scrisa', "Avize",
'Consultanta client', 'Macheta', 'Consultanta receptie', "PUD",
'Redactare', 'Depunere', 'Ridicare', 'Verificare proiect', "AO",
'Vizita santier', "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) { for (const phase of phases) {
tags.push({ tags.push({
label: phase, label: phase,
category: 'phase', category: "phase",
scope: 'global', scope: "global",
color: '#3b82f6', color: "#3b82f6",
}); });
} }
// ── Activity tags ── // ── Activity tags ──
const activities = [ const activities = [
'Ofertare', 'Configurari', 'Organizare initiala', 'Pregatire Portofoliu', "Ofertare",
'Website', 'Documentare', 'Design grafic', 'Design interior', "Configurari",
'Design exterior', 'Releveu', 'Reclama', 'Master MATDR', "Organizare initiala",
'Pauza de masa', 'Timp personal', 'Concediu', 'Compensare overtime', "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) { for (const activity of activities) {
tags.push({ tags.push({
label: activity, label: activity,
category: 'activity', category: "activity",
scope: 'global', scope: "global",
color: '#8b5cf6', color: "#8b5cf6",
}); });
} }
// ── Document type tags ── // ── Document type tags ──
const docTypes = [ const docTypes = [
'Contract', 'Ofertă', 'Factură', 'Scrisoare', "Contract",
'Aviz', 'Notă de comandă', 'Raport', 'Cerere', 'Altele', "Ofertă",
"Factură",
"Scrisoare",
"Aviz",
"Notă de comandă",
"Raport",
"Cerere",
"Altele",
]; ];
for (const dt of docTypes) { for (const dt of docTypes) {
tags.push({ tags.push({
label: dt, label: dt,
category: 'document-type', category: "document-type",
scope: 'global', scope: "global",
color: '#f59e0b', color: "#f59e0b",
}); });
} }

View File

@@ -1,45 +1,91 @@
'use client'; "use client";
import { useState } from 'react'; import { useRef, useState } from "react";
import { Plus, Pencil, Trash2, Search, FileText, ExternalLink, Copy } from 'lucide-react'; import {
import { Button } from '@/shared/components/ui/button'; Plus,
import { Input } from '@/shared/components/ui/input'; Pencil,
import { Label } from '@/shared/components/ui/label'; Trash2,
import { Textarea } from '@/shared/components/ui/textarea'; Search,
import { Badge } from '@/shared/components/ui/badge'; FileText,
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card'; ExternalLink,
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'; Copy,
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog'; FolderOpen,
import type { CompanyId } from '@/core/auth/types'; Wand2,
import type { WordTemplate, TemplateCategory } from '../types'; Loader2,
import { useTemplates } from '../hooks/use-templates'; } 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 { WordTemplate, TemplateCategory } from "../types";
import { useTemplates } from "../hooks/use-templates";
import {
parsePlaceholdersFromBuffer,
parsePlaceholdersFromUrl,
} from "../services/placeholder-parser";
const CATEGORY_LABELS: Record<TemplateCategory, string> = { const CATEGORY_LABELS: Record<TemplateCategory, string> = {
contract: 'Contract', contract: "Contract",
memoriu: 'Memoriu tehnic', memoriu: "Memoriu tehnic",
oferta: 'Ofertă', oferta: "Ofertă",
raport: 'Raport', raport: "Raport",
cerere: 'Cerere', cerere: "Cerere",
aviz: 'Aviz', aviz: "Aviz",
scrisoare: 'Scrisoare', scrisoare: "Scrisoare",
altele: 'Altele', altele: "Altele",
}; };
type ViewMode = 'list' | 'add' | 'edit'; type ViewMode = "list" | "add" | "edit";
export function WordTemplatesModule() { export function WordTemplatesModule() {
const { templates, allTemplates, loading, filters, updateFilter, addTemplate, updateTemplate, cloneTemplate, removeTemplate } = useTemplates(); const {
const [viewMode, setViewMode] = useState<ViewMode>('list'); templates,
const [editingTemplate, setEditingTemplate] = useState<WordTemplate | null>(null); allTemplates,
loading,
filters,
updateFilter,
addTemplate,
updateTemplate,
cloneTemplate,
removeTemplate,
} = useTemplates();
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [editingTemplate, setEditingTemplate] = useState<WordTemplate | null>(
null,
);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const handleSubmit = async (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => { const handleSubmit = async (
if (viewMode === 'edit' && editingTemplate) { data: Omit<WordTemplate, "id" | "createdAt" | "updatedAt">,
) => {
if (viewMode === "edit" && editingTemplate) {
await updateTemplate(editingTemplate.id, data); await updateTemplate(editingTemplate.id, data);
} else { } else {
await addTemplate(data); await addTemplate(data);
} }
setViewMode('list'); setViewMode("list");
setEditingTemplate(null); setEditingTemplate(null);
}; };
@@ -54,30 +100,80 @@ export function WordTemplatesModule() {
<div className="space-y-6"> <div className="space-y-6">
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <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 șabloane</p><p className="text-2xl font-bold">{allTemplates.length}</p></CardContent></Card> <Card>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Beletage</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'beletage').length}</p></CardContent></Card> <CardContent className="p-4">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Urban Switch</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'urban-switch').length}</p></CardContent></Card> <p className="text-xs text-muted-foreground">Total șabloane</p>
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Studii de Teren</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'studii-de-teren').length}</p></CardContent></Card> <p className="text-2xl font-bold">{allTemplates.length}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Beletage</p>
<p className="text-2xl font-bold">
{allTemplates.filter((t) => t.company === "beletage").length}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Urban Switch</p>
<p className="text-2xl font-bold">
{allTemplates.filter((t) => t.company === "urban-switch").length}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Studii de Teren</p>
<p className="text-2xl font-bold">
{
allTemplates.filter((t) => t.company === "studii-de-teren")
.length
}
</p>
</CardContent>
</Card>
</div> </div>
{viewMode === 'list' && ( {viewMode === "list" && (
<> <>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1"> <div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Caută șablon..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" /> <Input
placeholder="Caută șablon..."
value={filters.search}
onChange={(e) => updateFilter("search", e.target.value)}
className="pl-9"
/>
</div> </div>
<Select value={filters.category} onValueChange={(v) => updateFilter('category', v as TemplateCategory | 'all')}> <Select
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger> value={filters.category}
onValueChange={(v) =>
updateFilter("category", v as TemplateCategory | "all")
}
>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Toate categoriile</SelectItem> <SelectItem value="all">Toate categoriile</SelectItem>
{(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => ( {(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map(
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem> (c) => (
))} <SelectItem key={c} value={c}>
{CATEGORY_LABELS[c]}
</SelectItem>
),
)}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={filters.company} onValueChange={(v) => updateFilter('company', v)}> <Select
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger> value={filters.company}
onValueChange={(v) => updateFilter("company", v)}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Toate companiile</SelectItem> <SelectItem value="all">Toate companiile</SelectItem>
<SelectItem value="beletage">Beletage</SelectItem> <SelectItem value="beletage">Beletage</SelectItem>
@@ -86,28 +182,51 @@ export function WordTemplatesModule() {
<SelectItem value="group">Grup</SelectItem> <SelectItem value="group">Grup</SelectItem>
</SelectContent> </SelectContent>
</Select> </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ă <Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button> </Button>
</div> </div>
{loading ? ( {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>
) : templates.length === 0 ? ( ) : templates.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Niciun șablon găsit. Adaugă primul șablon Word.</p> <p className="py-8 text-center text-sm text-muted-foreground">
Niciun șablon găsit. Adaugă primul șablon Word.
</p>
) : ( ) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{templates.map((tpl) => ( {templates.map((tpl) => (
<Card key={tpl.id} className="group relative"> <Card key={tpl.id} className="group relative">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <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="Clonează" onClick={() => cloneTemplate(tpl.id)}> <Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Clonează"
onClick={() => cloneTemplate(tpl.id)}
>
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingTemplate(tpl); setViewMode('edit'); }}> <Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
setEditingTemplate(tpl);
setViewMode("edit");
}}
>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => setDeletingId(tpl.id)}> <Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => setDeletingId(tpl.id)}
>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -117,22 +236,42 @@ export function WordTemplatesModule() {
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium">{tpl.name}</p> <p className="font-medium">{tpl.name}</p>
{tpl.description && <p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">{tpl.description}</p>} {tpl.description && (
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">
{tpl.description}
</p>
)}
<div className="mt-1.5 flex flex-wrap gap-1"> <div className="mt-1.5 flex flex-wrap gap-1">
<Badge variant="outline" className="text-[10px]">{CATEGORY_LABELS[tpl.category]}</Badge> <Badge variant="outline" className="text-[10px]">
<Badge variant="secondary" className="text-[10px]">v{tpl.version}</Badge> {CATEGORY_LABELS[tpl.category]}
{tpl.clonedFrom && <Badge variant="secondary" className="text-[10px]">Clonă</Badge>} </Badge>
<Badge variant="secondary" className="text-[10px]">
v{tpl.version}
</Badge>
{tpl.clonedFrom && (
<Badge variant="secondary" className="text-[10px]">
Clonă
</Badge>
)}
</div> </div>
{/* Placeholders display */} {/* Placeholders display */}
{(tpl.placeholders ?? []).length > 0 && ( {(tpl.placeholders ?? []).length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1"> <div className="mt-1.5 flex flex-wrap gap-1">
{(tpl.placeholders ?? []).map((p) => ( {(tpl.placeholders ?? []).map((p) => (
<span key={p} className="rounded bg-muted px-1 py-0.5 font-mono text-[10px] text-muted-foreground">{`{{${p}}}`}</span> <span
key={p}
className="rounded bg-muted px-1 py-0.5 font-mono text-[10px] text-muted-foreground"
>{`{{${p}}}`}</span>
))} ))}
</div> </div>
)} )}
{tpl.fileUrl && ( {tpl.fileUrl && (
<a href={tpl.fileUrl} target="_blank" rel="noopener noreferrer" className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"> <a
href={tpl.fileUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
<ExternalLink className="h-3 w-3" /> Deschide fișier <ExternalLink className="h-3 w-3" /> Deschide fișier
</a> </a>
)} )}
@@ -146,23 +285,48 @@ export function WordTemplatesModule() {
</> </>
)} )}
{(viewMode === 'add' || viewMode === 'edit') && ( {(viewMode === "add" || viewMode === "edit") && (
<Card> <Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare șablon' : 'Șablon nou'}</CardTitle></CardHeader> <CardHeader>
<CardTitle>
{viewMode === "edit" ? "Editare șablon" : "Șablon nou"}
</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<TemplateForm initial={editingTemplate ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingTemplate(null); }} /> <TemplateForm
initial={editingTemplate ?? undefined}
onSubmit={handleSubmit}
onCancel={() => {
setViewMode("list");
setEditingTemplate(null);
}}
/>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Delete confirmation */} {/* Delete confirmation */}
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}> <Dialog
open={deletingId !== null}
onOpenChange={(open) => {
if (!open) setDeletingId(null);
}}
>
<DialogContent> <DialogContent>
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader> <DialogHeader>
<p className="text-sm">Ești sigur vrei ștergi acest șablon? Acțiunea este ireversibilă.</p> <DialogTitle>Confirmare ștergere</DialogTitle>
</DialogHeader>
<p className="text-sm">
Ești sigur vrei ștergi acest șablon? Acțiunea este
ireversibilă.
</p>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeletingId(null)}>Anulează</Button> <Button variant="outline" onClick={() => setDeletingId(null)}>
<Button variant="destructive" onClick={handleDeleteConfirm}>Șterge</Button> Anulează
</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>
Șterge
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -170,50 +334,151 @@ export function WordTemplatesModule() {
); );
} }
function TemplateForm({ initial, onSubmit, onCancel }: { function TemplateForm({
initial,
onSubmit,
onCancel,
}: {
initial?: WordTemplate; initial?: WordTemplate;
onSubmit: (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => void; onSubmit: (
data: Omit<WordTemplate, "id" | "createdAt" | "updatedAt">,
) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const [name, setName] = useState(initial?.name ?? ''); const [name, setName] = useState(initial?.name ?? "");
const [description, setDescription] = useState(initial?.description ?? ''); const [description, setDescription] = useState(initial?.description ?? "");
const [category, setCategory] = useState<TemplateCategory>(initial?.category ?? 'contract'); const [category, setCategory] = useState<TemplateCategory>(
const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? ''); initial?.category ?? "contract",
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage'); );
const [version, setVersion] = useState(initial?.version ?? '1.0.0'); const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? "");
const [placeholdersText, setPlaceholdersText] = useState((initial?.placeholders ?? []).join(', ')); const [company, setCompany] = useState<CompanyId>(
initial?.company ?? "beletage",
);
const [version, setVersion] = useState(initial?.version ?? "1.0.0");
const [placeholdersText, setPlaceholdersText] = useState(
(initial?.placeholders ?? []).join(", "),
);
const [parsing, setParsing] = useState(false);
const [parseError, setParseError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const applyPlaceholders = (found: string[]) => {
if (found.length === 0) {
setParseError(
"Nu s-au găsit placeholder-e de forma {{VARIABILA}} în fișier.",
);
return;
}
setPlaceholdersText(found.join(", "));
setParseError(null);
};
const handleFileDetect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setParsing(true);
setParseError(null);
try {
const buffer = await file.arrayBuffer();
const found = await parsePlaceholdersFromBuffer(buffer);
applyPlaceholders(found);
} catch (err) {
setParseError(
`Eroare la parsare: ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
setParsing(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const handleUrlDetect = async () => {
if (!fileUrl) return;
setParsing(true);
setParseError(null);
try {
const found = await parsePlaceholdersFromUrl(fileUrl);
applyPlaceholders(found);
} catch (err) {
setParseError(
`Nu s-a putut accesa URL-ul (CORS sau rețea): ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
setParsing(false);
}
};
return ( return (
<form onSubmit={(e) => { <form
onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
const placeholders = placeholdersText const placeholders = placeholdersText
.split(',') .split(",")
.map((p) => p.trim()) .map((p) => p.trim())
.filter((p) => p.length > 0); .filter((p) => p.length > 0);
onSubmit({ onSubmit({
name, description, category, fileUrl, company, version, placeholders, name,
description,
category,
fileUrl,
company,
version,
placeholders,
clonedFrom: initial?.clonedFrom, clonedFrom: initial?.clonedFrom,
tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all', tags: initial?.tags ?? [],
visibility: initial?.visibility ?? "all",
}); });
}} className="space-y-4"> }}
className="space-y-4"
>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div><Label>Nume șablon *</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div> <div>
<div><Label>Categorie</Label> <Label>Nume șablon *</Label>
<Select value={category} onValueChange={(v) => setCategory(v as TemplateCategory)}> <Input
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1"
required
/>
</div>
<div>
<Label>Categorie</Label>
<Select
value={category}
onValueChange={(v) => setCategory(v as TemplateCategory)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
{(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => ( {(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => (
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem> <SelectItem key={c} value={c}>
{CATEGORY_LABELS[c]}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
<div><Label>Descriere</Label><Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} className="mt-1" /></div> <div>
<Label>Descriere</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="mt-1"
/>
</div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div><Label>Companie</Label> <div>
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}> <Label>Companie</Label>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> <Select
value={company}
onValueChange={(v) => setCompany(v as CompanyId)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="beletage">Beletage</SelectItem> <SelectItem value="beletage">Beletage</SelectItem>
<SelectItem value="urban-switch">Urban Switch</SelectItem> <SelectItem value="urban-switch">Urban Switch</SelectItem>
@@ -222,17 +487,94 @@ function TemplateForm({ initial, onSubmit, onCancel }: {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div><Label>Versiune</Label><Input value={version} onChange={(e) => setVersion(e.target.value)} className="mt-1" /></div> <div>
<div><Label>URL fișier</Label><Input value={fileUrl} onChange={(e) => setFileUrl(e.target.value)} className="mt-1" placeholder="https://..." /></div> <Label>Versiune</Label>
<Input
value={version}
onChange={(e) => setVersion(e.target.value)}
className="mt-1"
/>
</div> </div>
<div> <div>
<Label>Placeholder-e (separate prin virgulă)</Label> <Label>URL fișier</Label>
<Input value={placeholdersText} onChange={(e) => setPlaceholdersText(e.target.value)} className="mt-1" placeholder="NUME_BENEFICIAR, DATA_CONTRACT, NR_PROIECT..." /> <div className="mt-1 flex gap-1.5">
<p className="mt-1 text-xs text-muted-foreground">Variabilele din șablon, de forma {'{{VARIABILA}}'}, separate prin virgulă.</p> <Input
value={fileUrl}
onChange={(e) => setFileUrl(e.target.value)}
className="flex-1"
placeholder="https://..."
/>
<Button
type="button"
variant="outline"
size="icon"
title="Detectează placeholder-e din URL"
disabled={!fileUrl || parsing}
onClick={handleUrlDetect}
>
{parsing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wand2 className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
<div>
<div className="flex items-center justify-between">
<Label>Placeholder-e detectate</Label>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
sau detectează automat:
</span>
<input
ref={fileInputRef}
type="file"
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
className="hidden"
onChange={handleFileDetect}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={parsing}
onClick={() => fileInputRef.current?.click()}
>
{parsing ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />{" "}
Parsare...
</>
) : (
<>
<FolderOpen className="mr-1.5 h-3.5 w-3.5" /> Alege fișier
.docx
</>
)}
</Button>
</div>
</div>
<Input
value={placeholdersText}
onChange={(e) => setPlaceholdersText(e.target.value)}
className="mt-1"
placeholder="NUME_BENEFICIAR, DATA_CONTRACT, NR_PROIECT..."
/>
<p className="mt-1 text-xs text-muted-foreground">
Variabilele din șablon de forma {"{{VARIABILA}}"} detectate automat
sau introduse manual, separate prin virgulă.
</p>
{parseError && (
<p className="mt-1 text-xs text-destructive">{parseError}</p>
)}
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button> <Button type="button" variant="outline" onClick={onCancel}>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button> Anulează
</Button>
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
</div> </div>
</form> </form>
); );

View File

@@ -0,0 +1,53 @@
import JSZip from "jszip";
/**
* Parse a .docx ArrayBuffer and extract all {{placeholder}} patterns.
* A .docx is a ZIP; we scan all word/*.xml files for patterns.
*/
export async function parsePlaceholdersFromBuffer(
buffer: ArrayBuffer,
): Promise<string[]> {
const zip = await JSZip.loadAsync(buffer);
// Collect all word/ XML files (document, headers, footers, etc.)
const xmlFileNames = Object.keys(zip.files).filter(
(name) => name.startsWith("word/") && name.endsWith(".xml"),
);
const xmlContents = await Promise.all(
xmlFileNames.map((name) => {
const file = zip.files[name];
return file ? file.async("string") : Promise.resolve("");
}),
);
const combined = xmlContents.join("\n");
// Strip XML tags — placeholders may be split across <w:t> runs, so we
// also need to find patterns that cross tag boundaries. Strategy:
// 1) Try matching in raw XML first (most placeholders appear intact)
// 2) Then strip tags and try again to catch split-run cases
const rawMatches = [...combined.matchAll(/\{\{([^{}]+?)\}\}/g)].map((m) =>
(m[1] ?? "").trim(),
);
const strippedText = combined.replace(/<[^>]+>/g, "");
const strippedMatches = [...strippedText.matchAll(/\{\{([^{}]+?)\}\}/g)].map(
(m) => (m[1] ?? "").trim(),
);
const all = [...rawMatches, ...strippedMatches];
return [...new Set(all)].filter((p) => p.length > 0 && p.length < 80);
}
/**
* Fetch a URL and parse placeholders from the .docx binary.
* May fail if CORS blocks the fetch.
*/
export async function parsePlaceholdersFromUrl(url: string): Promise<string[]> {
const response = await fetch(url);
if (!response.ok)
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const buffer = await response.arrayBuffer();
return parsePlaceholdersFromBuffer(buffer);
}

View File

@@ -1,18 +1,25 @@
'use client'; "use client";
import { useXmlConfig } from '../hooks/use-xml-config'; import { useXmlConfig } from "../hooks/use-xml-config";
import { XmlSettings } from './xml-settings'; import { XmlSettings } from "./xml-settings";
import { CategoryManager } from './category-manager'; import { CategoryManager } from "./category-manager";
import { XmlPreview } from './xml-preview'; import { XmlPreview } from "./xml-preview";
import { Separator } from '@/shared/components/ui/separator'; import { Separator } from "@/shared/components/ui/separator";
import { Button } from '@/shared/components/ui/button'; import { Button } from "@/shared/components/ui/button";
import { RotateCcw } from 'lucide-react'; import { RotateCcw } from "lucide-react";
export function WordXmlModule() { export function WordXmlModule() {
const { const {
config, setMode, setBaseNamespace, setComputeMetrics, config,
setCurrentCategory, updateCategoryFields, addCategory, setMode,
removeCategory, resetCategoryToPreset, clearCategoryFields, resetAll, setBaseNamespace,
setCurrentCategory,
updateCategoryFields,
addCategory,
removeCategory,
resetCategoryToPreset,
clearCategoryFields,
resetAll,
} = useXmlConfig(); } = useXmlConfig();
return ( return (
@@ -21,10 +28,8 @@ export function WordXmlModule() {
<XmlSettings <XmlSettings
baseNamespace={config.baseNamespace} baseNamespace={config.baseNamespace}
mode={config.mode} mode={config.mode}
computeMetrics={config.computeMetrics}
onSetBaseNamespace={setBaseNamespace} onSetBaseNamespace={setBaseNamespace}
onSetMode={setMode} onSetMode={setMode}
onSetComputeMetrics={setComputeMetrics}
/> />
<Separator /> <Separator />

View File

@@ -1,28 +1,37 @@
'use client'; "use client";
import { useMemo, useState } from 'react'; import { useMemo, useState } from "react";
import { Copy, Download, FileArchive } from 'lucide-react'; import { Copy, Download, FileArchive } from "lucide-react";
import { Button } from '@/shared/components/ui/button'; import { Button } from "@/shared/components/ui/button";
import type { XmlGeneratorConfig } from '../types'; import type { XmlGeneratorConfig } from "../types";
import { generateAllCategories, downloadXmlFile, downloadZipAll } from '../services/xml-generator'; import {
generateAllCategories,
downloadXmlFile,
downloadZipAll,
} from "../services/xml-generator";
interface XmlPreviewProps { interface XmlPreviewProps {
config: XmlGeneratorConfig; config: XmlGeneratorConfig;
} }
export function XmlPreview({ config }: XmlPreviewProps) { export function XmlPreview({ config }: XmlPreviewProps) {
const [copied, setCopied] = useState<'xml' | 'xpath' | null>(null); const [copied, setCopied] = useState<"xml" | "xpath" | null>(null);
const allOutputs = useMemo( const allOutputs = useMemo(
() => generateAllCategories(config.categories, config.baseNamespace, config.mode, config.computeMetrics), () =>
[config.categories, config.baseNamespace, config.mode, config.computeMetrics], generateAllCategories(
config.categories,
config.baseNamespace,
config.mode,
),
[config.categories, config.baseNamespace, config.mode],
); );
const current = allOutputs[config.currentCategory]; const current = allOutputs[config.currentCategory];
const xml = current?.xml || ''; const xml = current?.xml || "";
const xpaths = current?.xpaths || ''; const xpaths = current?.xpaths || "";
const handleCopy = async (text: string, type: 'xml' | 'xpath') => { const handleCopy = async (text: string, type: "xml" | "xpath") => {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
setCopied(type); setCopied(type);
@@ -32,9 +41,9 @@ export function XmlPreview({ config }: XmlPreviewProps) {
} }
}; };
const safeCatName = (config.currentCategory || 'unknown') const safeCatName = (config.currentCategory || "unknown")
.replace(/\s+/g, '_') .replace(/\s+/g, "_")
.replace(/[^A-Za-z0-9_.-]/g, ''); .replace(/[^A-Za-z0-9_.-]/g, "");
const handleDownloadCurrent = () => { const handleDownloadCurrent = () => {
if (!xml) return; if (!xml) return;
@@ -42,7 +51,7 @@ export function XmlPreview({ config }: XmlPreviewProps) {
}; };
const handleDownloadZip = async () => { const handleDownloadZip = async () => {
await downloadZipAll(config.categories, config.baseNamespace, config.mode, config.computeMetrics); await downloadZipAll(config.categories, config.baseNamespace, config.mode);
}; };
return ( return (
@@ -50,7 +59,12 @@ export function XmlPreview({ config }: XmlPreviewProps) {
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold">Preview & Export</h2> <h2 className="text-lg font-semibold">Preview & Export</h2>
<div className="ml-auto flex gap-2"> <div className="ml-auto flex gap-2">
<Button variant="outline" size="sm" onClick={handleDownloadCurrent} disabled={!xml}> <Button
variant="outline"
size="sm"
onClick={handleDownloadCurrent}
disabled={!xml}
>
<Download className="mr-1 h-4 w-4" /> XML curent <Download className="mr-1 h-4 w-4" /> XML curent
</Button> </Button>
<Button size="sm" onClick={handleDownloadZip}> <Button size="sm" onClick={handleDownloadZip}>
@@ -63,28 +77,45 @@ export function XmlPreview({ config }: XmlPreviewProps) {
{/* XML preview */} {/* XML preview */}
<div> <div>
<div className="mb-1.5 flex items-center justify-between"> <div className="mb-1.5 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">XML {config.currentCategory}</span> <span className="text-xs font-medium text-muted-foreground">
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => handleCopy(xml, 'xml')} disabled={!xml}> XML {config.currentCategory}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={() => handleCopy(xml, "xml")}
disabled={!xml}
>
<Copy className="mr-1 h-3 w-3" /> <Copy className="mr-1 h-3 w-3" />
{copied === 'xml' ? 'Copiat!' : 'Copiază'} {copied === "xml" ? "Copiat!" : "Copiază"}
</Button> </Button>
</div> </div>
<pre className="max-h-80 overflow-auto rounded-lg border bg-muted/30 p-3 text-xs"> <pre className="max-h-80 overflow-auto rounded-lg border bg-muted/30 p-3 text-xs">
{xml || '<!-- Niciun XML generat. Adaugă câmpuri în categoria curentă. -->'} {xml ||
"<!-- Niciun XML generat. Adaugă câmpuri în categoria curentă. -->"}
</pre> </pre>
</div> </div>
{/* XPath preview */} {/* XPath preview */}
<div> <div>
<div className="mb-1.5 flex items-center justify-between"> <div className="mb-1.5 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">XPaths {config.currentCategory}</span> <span className="text-xs font-medium text-muted-foreground">
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => handleCopy(xpaths, 'xpath')} disabled={!xpaths}> XPaths {config.currentCategory}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={() => handleCopy(xpaths, "xpath")}
disabled={!xpaths}
>
<Copy className="mr-1 h-3 w-3" /> <Copy className="mr-1 h-3 w-3" />
{copied === 'xpath' ? 'Copiat!' : 'Copiază'} {copied === "xpath" ? "Copiat!" : "Copiază"}
</Button> </Button>
</div> </div>
<pre className="max-h-80 overflow-auto rounded-lg border bg-muted/30 p-3 text-xs"> <pre className="max-h-80 overflow-auto rounded-lg border bg-muted/30 p-3 text-xs">
{xpaths || ''} {xpaths || ""}
</pre> </pre>
</div> </div>
</div> </div>

View File

@@ -1,23 +1,22 @@
'use client'; "use client";
import type { XmlGeneratorMode } from '../types'; import type { XmlGeneratorMode } from "../types";
import { Input } from '@/shared/components/ui/input'; import { Input } from "@/shared/components/ui/input";
import { Label } from '@/shared/components/ui/label'; import { Label } from "@/shared/components/ui/label";
import { Switch } from '@/shared/components/ui/switch'; import { cn } from "@/shared/lib/utils";
import { cn } from '@/shared/lib/utils';
interface XmlSettingsProps { interface XmlSettingsProps {
baseNamespace: string; baseNamespace: string;
mode: XmlGeneratorMode; mode: XmlGeneratorMode;
computeMetrics: boolean;
onSetBaseNamespace: (ns: string) => void; onSetBaseNamespace: (ns: string) => void;
onSetMode: (mode: XmlGeneratorMode) => void; onSetMode: (mode: XmlGeneratorMode) => void;
onSetComputeMetrics: (v: boolean) => void;
} }
export function XmlSettings({ export function XmlSettings({
baseNamespace, mode, computeMetrics, baseNamespace,
onSetBaseNamespace, onSetMode, onSetComputeMetrics, mode,
onSetBaseNamespace,
onSetMode,
}: XmlSettingsProps) { }: XmlSettingsProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -38,31 +37,28 @@ export function XmlSettings({
<div> <div>
<Label className="mb-1.5 block">Mod generare</Label> <Label className="mb-1.5 block">Mod generare</Label>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
{(['simple', 'advanced'] as XmlGeneratorMode[]).map((m) => ( {(["simple", "advanced"] as XmlGeneratorMode[]).map((m) => (
<button <button
key={m} key={m}
type="button" type="button"
onClick={() => onSetMode(m)} onClick={() => onSetMode(m)}
className={cn( className={cn(
'rounded-full border px-3 py-1 text-xs font-medium transition-colors', "rounded-full border px-3 py-1 text-xs font-medium transition-colors",
mode === m mode === m
? 'border-primary bg-primary text-primary-foreground' ? "border-primary bg-primary text-primary-foreground"
: 'border-border hover:bg-accent' : "border-border hover:bg-accent",
)} )}
> >
{m === 'simple' ? 'Simple' : 'Advanced'} {m === "simple" ? "Simple" : "Advanced"}
</button> </button>
))} ))}
</div> </div>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{mode === 'simple' ? 'Doar câmpurile definite.' : '+ Short / Upper / Lower / Initials / First.'} {mode === "simple"
? "Doar câmpurile definite."
: "+ Short / Upper / Lower / Initials / First."}
</p> </p>
</div> </div>
<div className="ml-auto flex items-center gap-2">
<Switch checked={computeMetrics} onCheckedChange={onSetComputeMetrics} id="xml-metrics" />
<Label htmlFor="xml-metrics" className="cursor-pointer text-sm">POT / CUT automat</Label>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,20 +1,19 @@
'use client'; "use client";
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo } from "react";
import type { XmlGeneratorConfig, XmlGeneratorMode } from '../types'; import type { XmlGeneratorConfig, XmlGeneratorMode } from "../types";
import { DEFAULT_PRESETS } from '../services/category-presets'; import { DEFAULT_PRESETS } from "../services/category-presets";
function createDefaultConfig(): XmlGeneratorConfig { function createDefaultConfig(): XmlGeneratorConfig {
const categories: Record<string, { name: string; fieldsText: string }> = {}; const categories: Record<string, { name: string; fieldsText: string }> = {};
for (const [name, fields] of Object.entries(DEFAULT_PRESETS)) { for (const [name, fields] of Object.entries(DEFAULT_PRESETS)) {
categories[name] = { name, fieldsText: fields.join('\n') }; categories[name] = { name, fieldsText: fields.join("\n") };
} }
return { return {
baseNamespace: 'http://schemas.beletage.ro/contract', baseNamespace: "http://schemas.beletage.ro/contract",
mode: 'advanced', mode: "advanced",
computeMetrics: true,
categories, categories,
currentCategory: 'Beneficiar', currentCategory: "Beneficiar",
}; };
} }
@@ -29,15 +28,12 @@ export function useXmlConfig() {
setConfig((prev) => ({ ...prev, baseNamespace })); setConfig((prev) => ({ ...prev, baseNamespace }));
}, []); }, []);
const setComputeMetrics = useCallback((computeMetrics: boolean) => {
setConfig((prev) => ({ ...prev, computeMetrics }));
}, []);
const setCurrentCategory = useCallback((name: string) => { const setCurrentCategory = useCallback((name: string) => {
setConfig((prev) => ({ ...prev, currentCategory: name })); setConfig((prev) => ({ ...prev, currentCategory: name }));
}, []); }, []);
const updateCategoryFields = useCallback((categoryName: string, fieldsText: string) => { const updateCategoryFields = useCallback(
(categoryName: string, fieldsText: string) => {
setConfig((prev) => { setConfig((prev) => {
const existing = prev.categories[categoryName]; const existing = prev.categories[categoryName];
if (!existing) return prev; if (!existing) return prev;
@@ -49,14 +45,16 @@ export function useXmlConfig() {
}, },
}; };
}); });
}, []); },
[],
);
const addCategory = useCallback((name: string) => { const addCategory = useCallback((name: string) => {
setConfig((prev) => { setConfig((prev) => {
if (prev.categories[name]) return prev; if (prev.categories[name]) return prev;
return { return {
...prev, ...prev,
categories: { ...prev.categories, [name]: { name, fieldsText: '' } }, categories: { ...prev.categories, [name]: { name, fieldsText: "" } },
currentCategory: name, currentCategory: name,
}; };
}); });
@@ -70,7 +68,9 @@ export function useXmlConfig() {
return { return {
...prev, ...prev,
categories: next, categories: next,
currentCategory: keys.includes(prev.currentCategory) ? prev.currentCategory : keys[0] || '', currentCategory: keys.includes(prev.currentCategory)
? prev.currentCategory
: keys[0] || "",
}; };
}); });
}, []); }, []);
@@ -82,7 +82,7 @@ export function useXmlConfig() {
...prev, ...prev,
categories: { categories: {
...prev.categories, ...prev.categories,
[name]: { name, fieldsText: preset.join('\n') }, [name]: { name, fieldsText: preset.join("\n") },
}, },
})); }));
}, []); }, []);
@@ -95,7 +95,7 @@ export function useXmlConfig() {
...prev, ...prev,
categories: { categories: {
...prev.categories, ...prev.categories,
[name]: { name: existing.name, fieldsText: '' }, [name]: { name: existing.name, fieldsText: "" },
}, },
}; };
}); });
@@ -109,11 +109,11 @@ export function useXmlConfig() {
setConfig(loaded); setConfig(loaded);
}, []); }, []);
return useMemo(() => ({ return useMemo(
() => ({
config, config,
setMode, setMode,
setBaseNamespace, setBaseNamespace,
setComputeMetrics,
setCurrentCategory, setCurrentCategory,
updateCategoryFields, updateCategoryFields,
addCategory, addCategory,
@@ -122,7 +122,19 @@ export function useXmlConfig() {
clearCategoryFields, clearCategoryFields,
resetAll, resetAll,
loadConfig, loadConfig,
}), [config, setMode, setBaseNamespace, setComputeMetrics, setCurrentCategory, }),
updateCategoryFields, addCategory, removeCategory, resetCategoryToPreset, [
clearCategoryFields, resetAll, loadConfig]); config,
setMode,
setBaseNamespace,
setCurrentCategory,
updateCategoryFields,
addCategory,
removeCategory,
resetCategoryToPreset,
clearCategoryFields,
resetAll,
loadConfig,
],
);
} }

View File

@@ -1,21 +1,21 @@
import type { XmlGeneratorMode, XmlCategory, GeneratedOutput } from '../types'; import type { XmlGeneratorMode, XmlCategory, GeneratedOutput } from "../types";
function sanitizeName(name: string): string | null { function sanitizeName(name: string): string | null {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) return null; if (!trimmed) return null;
let n = trimmed.replace(/\s+/g, '_').replace(/[^A-Za-z0-9_.-]/g, ''); let n = trimmed.replace(/\s+/g, "_").replace(/[^A-Za-z0-9_.-]/g, "");
if (!/^[A-Za-z_]/.test(n)) n = '_' + n; if (!/^[A-Za-z_]/.test(n)) n = "_" + n;
return n || null; return n || null;
} }
function getCategoryNamespace(baseNs: string, category: string): string { function getCategoryNamespace(baseNs: string, category: string): string {
const safeCat = sanitizeName(category) || category; const safeCat = sanitizeName(category) || category;
return baseNs.replace(/\/+$/, '') + '/' + safeCat; return baseNs.replace(/\/+$/, "") + "/" + safeCat;
} }
function getCategoryRoot(category: string): string { function getCategoryRoot(category: string): string {
const safeCat = sanitizeName(category) || category; const safeCat = sanitizeName(category) || category;
return safeCat + 'Data'; return safeCat + "Data";
} }
interface FieldEntry { interface FieldEntry {
@@ -29,14 +29,13 @@ export function generateCategoryXml(
catData: XmlCategory, catData: XmlCategory,
baseNamespace: string, baseNamespace: string,
mode: XmlGeneratorMode, mode: XmlGeneratorMode,
computeMetrics: boolean,
): GeneratedOutput { ): GeneratedOutput {
const raw = catData.fieldsText const raw = catData.fieldsText
.split(/\r?\n/) .split(/\r?\n/)
.map((l) => l.trim()) .map((l) => l.trim())
.filter((l) => l.length > 0); .filter((l) => l.length > 0);
if (raw.length === 0) return { xml: '', xpaths: '' }; if (raw.length === 0) return { xml: "", xpaths: "" };
const ns = getCategoryNamespace(baseNamespace, category); const ns = getCategoryNamespace(baseNamespace, category);
const root = getCategoryRoot(category); const root = getCategoryRoot(category);
@@ -51,19 +50,19 @@ export function generateCategoryXml(
let baseName = base; let baseName = base;
let idx = 2; let idx = 2;
while (usedNames.has(baseName)) { while (usedNames.has(baseName)) {
baseName = base + '_' + idx; baseName = base + "_" + idx;
idx++; idx++;
} }
usedNames.add(baseName); usedNames.add(baseName);
const variants = [baseName]; const variants = [baseName];
if (mode === 'advanced') { if (mode === "advanced") {
const suffixes = ['Short', 'Upper', 'Lower', 'Initials', 'First']; const suffixes = ["Short", "Upper", "Lower", "Initials", "First"];
for (const suffix of suffixes) { for (const suffix of suffixes) {
let vn = baseName + suffix; let vn = baseName + suffix;
let k = 2; let k = 2;
while (usedNames.has(vn)) { while (usedNames.has(vn)) {
vn = baseName + suffix + '_' + k; vn = baseName + suffix + "_" + k;
k++; k++;
} }
usedNames.add(vn); usedNames.add(vn);
@@ -74,24 +73,7 @@ export function generateCategoryXml(
fields.push({ label, baseName, variants }); fields.push({ label, baseName, variants });
} }
// Auto-add POT/CUT for Suprafete category const allFields = fields;
const extraMetricFields: FieldEntry[] = [];
if (computeMetrics && category.toLowerCase().includes('suprafete')) {
const hasTeren = fields.some((f) => f.baseName.toLowerCase().includes('suprafatateren'));
const hasLaSol = fields.some((f) => f.baseName.toLowerCase().includes('suprafataconstruitalasol'));
const hasDesf = fields.some((f) => f.baseName.toLowerCase().includes('suprafatadesfasurata'));
if (hasTeren && hasLaSol && !usedNames.has('POT')) {
usedNames.add('POT');
extraMetricFields.push({ label: 'Procent Ocupare Teren', baseName: 'POT', variants: ['POT'] });
}
if (hasTeren && hasDesf && !usedNames.has('CUT')) {
usedNames.add('CUT');
extraMetricFields.push({ label: 'Coeficient Utilizare Teren', baseName: 'CUT', variants: ['CUT'] });
}
}
const allFields = fields.concat(extraMetricFields);
// Build XML // Build XML
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n'; let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
@@ -110,18 +92,8 @@ export function generateCategoryXml(
for (const v of f.variants) { for (const v of f.variants) {
xp += `/${root}/${v}\n`; xp += `/${root}/${v}\n`;
} }
xp += '\n'; xp += "\n";
} }
if (extraMetricFields.length > 0) {
xp += '# Metrici auto (POT / CUT)\n';
for (const f of extraMetricFields) {
for (const v of f.variants) {
xp += `/${root}/${v}\n`;
}
}
xp += '\n';
}
return { xml, xpaths: xp }; return { xml, xpaths: xp };
} }
@@ -129,20 +101,19 @@ export function generateAllCategories(
categories: Record<string, XmlCategory>, categories: Record<string, XmlCategory>,
baseNamespace: string, baseNamespace: string,
mode: XmlGeneratorMode, mode: XmlGeneratorMode,
computeMetrics: boolean,
): Record<string, GeneratedOutput> { ): Record<string, GeneratedOutput> {
const results: Record<string, GeneratedOutput> = {}; const results: Record<string, GeneratedOutput> = {};
for (const cat of Object.keys(categories)) { for (const cat of Object.keys(categories)) {
const catData = categories[cat]; const catData = categories[cat];
if (!catData) continue; if (!catData) continue;
results[cat] = generateCategoryXml(cat, catData, baseNamespace, mode, computeMetrics); results[cat] = generateCategoryXml(cat, catData, baseNamespace, mode);
} }
return results; return results;
} }
export function downloadXmlFile(xml: string, filename: string): void { export function downloadXmlFile(xml: string, filename: string): void {
const blob = new Blob([xml], { type: 'application/xml' }); const blob = new Blob([xml], { type: "application/xml" });
const a = document.createElement('a'); const a = document.createElement("a");
a.href = URL.createObjectURL(blob); a.href = URL.createObjectURL(blob);
a.download = filename; a.download = filename;
document.body.appendChild(a); document.body.appendChild(a);
@@ -155,13 +126,12 @@ export async function downloadZipAll(
categories: Record<string, XmlCategory>, categories: Record<string, XmlCategory>,
baseNamespace: string, baseNamespace: string,
mode: XmlGeneratorMode, mode: XmlGeneratorMode,
computeMetrics: boolean,
): Promise<void> { ): Promise<void> {
const JSZip = (await import('jszip')).default; const JSZip = (await import("jszip")).default;
const results = generateAllCategories(categories, baseNamespace, mode, computeMetrics); const results = generateAllCategories(categories, baseNamespace, mode);
const zip = new JSZip(); const zip = new JSZip();
const folder = zip.folder('customXmlParts')!; const folder = zip.folder("customXmlParts")!;
let hasAny = false; let hasAny = false;
for (const cat of Object.keys(results)) { for (const cat of Object.keys(results)) {
@@ -175,10 +145,10 @@ export async function downloadZipAll(
if (!hasAny) return; if (!hasAny) return;
const content = await zip.generateAsync({ type: 'blob' }); const content = await zip.generateAsync({ type: "blob" });
const a = document.createElement('a'); const a = document.createElement("a");
a.href = URL.createObjectURL(content); a.href = URL.createObjectURL(content);
a.download = 'beletage_custom_xml_parts.zip'; a.download = "beletage_custom_xml_parts.zip";
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);

View File

@@ -1,4 +1,4 @@
export type XmlGeneratorMode = 'simple' | 'advanced'; export type XmlGeneratorMode = "simple" | "advanced";
export interface XmlCategory { export interface XmlCategory {
name: string; name: string;
@@ -8,7 +8,6 @@ export interface XmlCategory {
export interface XmlGeneratorConfig { export interface XmlGeneratorConfig {
baseNamespace: string; baseNamespace: string;
mode: XmlGeneratorMode; mode: XmlGeneratorMode;
computeMetrics: boolean;
categories: Record<string, XmlCategory>; categories: Record<string, XmlCategory>;
currentCategory: string; currentCategory: string;
} }

View File

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