Compare commits

..

34 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
Marius Tarau
501de5161e docs: make SESSION-GUIDE.md device-agnostic with Gitea URLs
All prompts now use repo URL instead of local paths.
Works from any device: Mac, PC, phone, any AI tool.
Two access points: internal (10.10.10.166:3002) and external (git.beletage.ro).
Added tool-specific notes for Claude Code, ChatGPT Codex, Copilot, Cursor, Antigravity, phone.
Raw file URLs for AI tools that can fetch web content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 19:34:49 +02:00
Marius Tarau
9904804097 docs: update SESSION-GUIDE.md with full file paths and repo URLs in all prompts
All prompts now include:
- Gitea repo URL
- Local file paths (/Users/mariustarau/Development/ArchiTools/...)
- Production URL (http://10.10.10.166:3000)
- git pull + npm install as first step
- 6 prompt variants: new session, resume, specific task, bug fix, code review, new module

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 19:21:34 +02:00
Marius Tarau
f6fd5f58e3 docs: add SESSION-GUIDE.md and SESSION-LOG.md for AI session handoffs
SESSION-GUIDE.md: start/resume prompts, git workflow, file update rules
SESSION-LOG.md: session history log (newest first)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 19:11:06 +02:00
Marius Tarau
b1df15bb42 docs: rewrite ROADMAP.md with complete xlsx gap analysis + multi-model recommendations
- 9 phases, 35+ tasks covering all xlsx gaps and future features
- Module 14 (Hot Desk) added as new module task
- All gaps from app_modules_overview.xlsx tracked per module
- Model recommendations: Claude (Opus/Sonnet 4.6/Haiku), OpenAI (GPT-5.3-Codex/5.2/4o-mini), Google (Gemini 3 Pro/Flash/2.5 Flash)
- Step-by-step workflow: AI implements → builds → pushes → user approves
- Quick picker by time budget (15min / 1hr / full session)
- Infrastructure credentials checklist
- Updated CLAUDE.md model table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:06:18 +02:00
Marius Tarau
d6a5852e54 docs: add ROADMAP.md with detailed future task plan
7 phases, 23 tasks with model recommendations, complexity ratings,
file references, infrastructure requirements, and dependency chains.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:40:14 +02:00
Marius Tarau
bb01268bcb feat(registratura): add legal deadline tracking system (Termene Legale)
Full deadline tracking engine for Romanian construction permitting:
- 16 deadline types across 5 categories (Avize, Completări, Analiză, Autorizare, Publicitate)
- Working days vs calendar days with Romanian public holidays (Orthodox Easter via Meeus)
- Backward deadlines (AC extension: 45 working days BEFORE expiry)
- Chain deadlines (resolving one prompts adding the next)
- Tacit approval auto-detection (overdue + applicable type)
- Tabbed UI: Registru + Termene legale dashboard with stats/filters/table
- Inline deadline cards in entry form with add/resolve/remove
- Clock icon + count badge on registry table for entries with deadlines

Also adds CLAUDE.md with full project context for AI assistant handoff.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:27:34 +02:00
Marius Tarau
f0b878cf00 fix: guard against undefined fields when loading old localStorage data
Old entries in localStorage lack new fields (department, role, contactPersons,
linkedEntryIds, attachments, versions, placeholders, ipAddress, vendor, model).
Add null-coalescing guards to prevent client-side crashes on property access.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 06:49:08 +02:00
65 changed files with 11685 additions and 1892 deletions

266
CLAUDE.md Normal file
View File

@@ -0,0 +1,266 @@
# ArchiTools — Project Context for AI Assistants
> This file provides all context needed for Claude Code, Sonnet, or any AI model to work on this project from scratch.
---
## Quick Start
```bash
npm install
npm run dev # http://localhost:3000
npx next build # verify zero errors before pushing
git push origin main # auto-deploys via Portainer webhook
```
---
## Project Overview
**ArchiTools** is a modular internal web dashboard for an architecture/engineering office group of 3 companies:
- **Beletage** (architecture)
- **Urban Switch** (urbanism)
- **Studii de Teren** (geotechnics)
It runs on an on-premise Ubuntu server at `10.10.10.166`, containerized with Docker, managed via Portainer, served by Nginx Proxy Manager.
### Stack
| Layer | Technology |
|---|---|
| Framework | Next.js 16.x, App Router, TypeScript (strict) |
| Styling | Tailwind CSS v4, shadcn/ui |
| State | localStorage (via StorageService abstraction) |
| Deploy | Docker multi-stage, Portainer, Nginx Proxy Manager |
| Repo | Gitea at `http://10.10.10.166:3002/gitadmin/ArchiTools` |
| Language | Code in **English**, UI in **Romanian** |
### Architecture Principles
- **Module platform, not monolith** — each module isolated with own types/services/hooks/components
- **Feature flags** gate module loading (disabled = zero bundle cost)
- **Storage abstraction**: `StorageService` interface with adapters (localStorage default, designed for future DB/MinIO)
- **Cross-module tagging system** as shared service
- **Auth stub** designed for future Authentik SSO integration
- **All entities** include `visibility` / `createdBy` fields from day one
---
## Repository Structure
```
src/
├── app/ # Routing only (thin wrappers)
│ ├── (modules)/ # Module route pages
│ └── layout.tsx # App shell
├── core/ # Platform services
│ ├── module-registry/ # Module registration + types
│ ├── feature-flags/ # Flag evaluation + env override
│ ├── storage/ # StorageService + adapters
│ │ └── adapters/ # localStorage adapter (+ future DB/MinIO)
│ ├── tagging/ # Cross-module tag service
│ ├── i18n/ # Romanian translations
│ ├── theme/ # Light/dark theme
│ └── auth/ # Auth types + stub (future Authentik)
├── modules/ # Module business logic
│ ├── <module-name>/
│ │ ├── components/ # Module UI components
│ │ ├── hooks/ # Module-specific hooks
│ │ ├── services/ # Module business logic
│ │ ├── types.ts # Module types
│ │ ├── config.ts # Module metadata
│ │ └── index.ts # Public exports
│ └── ...
├── shared/ # Shared UI
│ ├── components/
│ │ ├── ui/ # shadcn/ui primitives
│ │ ├── layout/ # Sidebar, Header
│ │ └── common/ # Reusable app components
│ ├── hooks/ # Shared hooks
│ └── lib/ # Utils (cn, etc.)
├── config/ # Global config
│ ├── modules.ts # Module registry entries
│ ├── flags.ts # Default feature flags
│ ├── navigation.ts # Sidebar nav structure
│ └── companies.ts # Company definitions
docs/ # 16 internal technical docs
legacy/ # Original HTML tools for reference
```
---
## Implemented Modules (13/13 — zero placeholders)
| # | Module | Route | Key Features |
|---|---|---|---|
| 1 | **Dashboard** | `/` | Stats cards, module grid, external tools by category |
| 2 | **Email Signature** | `/email-signature` | Multi-company branding, live preview, zoom/copy/download |
| 3 | **Word XML Generator** | `/word-xml` | Category-based XML gen, simple/advanced mode, ZIP export |
| 4 | **Registratura** | `/registratura` | CRUD registry, stats, filters, **legal deadline tracking** |
| 5 | **Tag Manager** | `/tag-manager` | CRUD tags, category/scope/color, grouped display |
| 6 | **IT Inventory** | `/it-inventory` | Equipment tracking, type/status/company filters |
| 7 | **Address Book** | `/address-book` | CRUD contacts, card grid, search/type filter |
| 8 | **Password Vault** | `/password-vault` | CRUD credentials, show/hide/copy, category filter |
| 9 | **Mini Utilities** | `/mini-utilities` | Text case, char counter, percentage calc, area converter |
| 10 | **Prompt Generator** | `/prompt-generator` | Template-driven prompt builder, 4 builtin templates |
| 11 | **Digital Signatures** | `/digital-signatures` | CRUD signature/stamp/initials assets |
| 12 | **Word Templates** | `/word-templates` | Template library, 8 categories, version tracking |
| 13 | **AI Chat** | `/ai-chat` | Session-based chat UI, demo mode (no API keys yet) |
### Registratura — Legal Deadline Tracking (Termene Legale)
The Registratura module includes a full legal deadline tracking engine for Romanian construction permitting:
- **16 deadline types** across 5 categories (Avize, Completări, Analiză, Autorizare, Publicitate)
- **Working days vs calendar days** with Romanian public holiday support (including Orthodox Easter via Meeus algorithm)
- **Backward deadlines** (e.g., AC extension: 45 working days BEFORE expiry)
- **Chain deadlines** (resolving one prompts adding the next)
- **Tacit approval** (auto-detected when overdue + applicable type)
- **Tabbed UI**: "Registru" tab (existing registry) + "Termene legale" tab (deadline dashboard)
Key files:
- `services/working-days.ts` — Romanian holidays, `addWorkingDays()`, `isWorkingDay()`
- `services/deadline-catalog.ts` — 16 `DeadlineTypeDef` entries
- `services/deadline-service.ts``createTrackedDeadline()`, `resolveDeadline()`, `aggregateDeadlines()`
- `components/deadline-dashboard.tsx` — Stats + filters + table
- `components/deadline-add-dialog.tsx` — 3-step wizard (category → type → date preview)
---
## Infrastructure
### Server: `10.10.10.166` (Ubuntu)
| Service | Port | Purpose |
|---|---|---|
| **ArchiTools** | 3000 | This app |
| **Gitea** | 3002 | Git hosting (`gitadmin/ArchiTools`) |
| **Portainer** | 9000 | Docker management, auto-deploy on push |
| **Nginx Proxy Manager** | 81 (admin) | Reverse proxy + SSL termination |
| **Uptime Kuma** | 3001 | Service monitoring |
| **MinIO** | 9003 | Object storage (future) |
| **N8N** | 5678 | Workflow automation (future) |
| **Stirling PDF** | 8087 | PDF tools |
| **IT-Tools** | 8085 | Developer utilities |
| **FileBrowser** | 8086 | File management |
| **Netdata** | 19999 | System monitoring |
| **Dozzle** | 9999 | Docker log viewer |
| **CrowdSec** | 8088 | Security |
| **Authentik** | 9100 | SSO (future) |
### Deployment Pipeline
```
git push origin main
→ Gitea webhook fires
→ Portainer auto-redeploys stack
→ Docker multi-stage build (~1-2 min)
→ Container starts on :3000
→ Nginx Proxy Manager routes traffic
```
### Docker
- `Dockerfile`: 3-stage build (deps → builder → runner), `node:20-alpine`, non-root user
- `docker-compose.yml`: single service, port 3000, watchtower label
- `output: 'standalone'` in `next.config.ts` is **required**
---
## Development Rules
### TypeScript Strict Mode Gotchas
- `array.split()[0]` returns `string | undefined` — use `.slice(0, 10)` instead
- `Record<string, T>[key]` returns `T | undefined` — always guard with null check
- Spread of possibly-undefined objects: `{ ...obj[key], field }` — check existence first
- lucide-react Icons: cast through `unknown``React.ComponentType<{ className?: string }>`
### Conventions
- **Code**: English
- **UI text**: Romanian
- **Components**: functional, `'use client'` directive where needed
- **State**: localStorage via `useStorage('module-name')` hook
- **IDs**: `uuid v4`
- **Dates**: ISO strings (`YYYY-MM-DD` for display, full ISO for timestamps)
- **No emojis** in code or UI unless explicitly requested
### Module Development Pattern
Every module follows:
```
src/modules/<name>/
├── components/ # React components
├── hooks/ # Custom hooks (use-<name>.ts)
├── services/ # Business logic (pure functions)
├── types.ts # TypeScript interfaces
├── config.ts # ModuleConfig metadata
└── index.ts # Public exports
```
### Before Pushing
1. `npx next build` — must pass with zero errors
2. Test the feature manually on `localhost:3000`
3. Commit with descriptive message
4. `git push origin main` — Portainer auto-deploys
---
## Company IDs
| ID | Name | Prefix |
|---|---|---|
| `beletage` | Beletage | B |
| `urban-switch` | Urban Switch | US |
| `studii-de-teren` | Studii de Teren | SDT |
| `group` | Grup | G |
---
## Future Integrations (not yet implemented)
| Feature | Status | Notes |
|---|---|---|
| **Authentik SSO** | Auth stub exists | `src/core/auth/` has types + provider shell |
| **MinIO storage** | Adapter pattern ready | Switch `NEXT_PUBLIC_STORAGE_ADAPTER` to `minio` |
| **API backend** | Adapter pattern ready | Switch to `api` adapter when backend exists |
| **AI Chat API** | UI complete, demo mode | No API keys yet; supports Claude/GPT/Ollama |
| **N8N automations** | Webhook URL configured | For notifications, backups, workflows |
---
## Model Recommendations
| Task Type | Claude | OpenAI | Google | Notes |
|---|---|---|---|---|
| **Bug fixes, config** | Haiku 4.5 | GPT-4o-mini | Gemini 2.5 Flash | Fast, cheap |
| **Features, tests, UI** | **Sonnet 4.6** | GPT-5.2 | Gemini 3 Flash | Best value — Opus-class quality at Sonnet price |
| **New modules, architecture** | Opus 4.6 | GPT-5.3-Codex | Gemini 3 Pro | Complex multi-file, business logic |
**Default: Sonnet 4.6** for most work. See `ROADMAP.md` for per-task recommendations.
### Session Handoff Tips
- Read this `CLAUDE.md` first — it has all context
- Read `ROADMAP.md` for the complete task list with dependencies
- Check `docs/` for deep dives on specific systems
- Check `src/modules/<name>/types.ts` before modifying any module
- Always run `npx next build` before committing
- Push to `main` → Portainer auto-deploys via Gitea webhook
- The 16 docs in `docs/` total ~10,600 lines — search them for architecture questions
---
## Documentation Index
| Doc | Path | Content |
|---|---|---|
| System Architecture | `docs/architecture/SYSTEM-ARCHITECTURE.md` | Overall architecture, module platform design |
| Module System | `docs/architecture/MODULE-SYSTEM.md` | Module registry, lifecycle, config format |
| Feature Flags | `docs/architecture/FEATURE-FLAGS.md` | Flag system, env overrides |
| Storage Layer | `docs/architecture/STORAGE-LAYER.md` | StorageService interface, adapters |
| Tagging System | `docs/architecture/TAGGING-SYSTEM.md` | Cross-module tags |
| Security & Roles | `docs/architecture/SECURITY-AND-ROLES.md` | Visibility, auth, roles |
| Module Dev Guide | `docs/guides/MODULE-DEVELOPMENT.md` | How to create a new module |
| HTML Integration | `docs/guides/HTML-TOOL-INTEGRATION.md` | Legacy tool migration |
| UI Design System | `docs/guides/UI-DESIGN-SYSTEM.md` | Design tokens, component patterns |
| Docker Deployment | `docs/guides/DOCKER-DEPLOYMENT.md` | Full Docker/Portainer/Nginx guide |
| Coding Standards | `docs/guides/CODING-STANDARDS.md` | TS strict, naming, patterns |
| Testing Strategy | `docs/guides/TESTING-STRATEGY.md` | Testing approach |
| Configuration | `docs/guides/CONFIGURATION.md` | Env vars, flags, companies |
| Data Model | `docs/DATA-MODEL.md` | All entity schemas |
| Repo Structure | `docs/REPO-STRUCTURE.md` | Directory layout |
| Prompt Generator | `docs/modules/PROMPT-GENERATOR.md` | Prompt module deep dive |

581
ROADMAP.md Normal file
View File

@@ -0,0 +1,581 @@
# ArchiTools — Complete Roadmap
> Step-by-step implementation plan. Every task from `app_modules_overview.xlsx` is tracked here.
> Modules are implemented in dependency order, tested by the AI, then submitted for user approval.
---
## Workflow
1. AI picks the next task from this file (top to bottom)
2. AI implements it, runs `npx next build` (zero errors required)
3. AI commits and pushes to `main` (Portainer auto-deploys)
4. AI notifies you: "Module X / Task Y is ready for review"
5. You test on `http://10.10.10.166:3000` and approve or request changes
6. AI moves to the next task
---
## AI Model Recommendations
| Tag | Claude | OpenAI | Google | Best For |
| ------------ | ---------- | ------------- | ---------------- | ---------------------------------------------------------------------- |
| `[HEAVY]` | Opus 4.6 | GPT-5.3-Codex | Gemini 3 Pro | Complex multi-file features, business logic, architecture, new modules |
| `[STANDARD]` | Sonnet 4.6 | GPT-5.2 | Gemini 3 Flash | Refactoring, moderate features, UI work, tests, documentation |
| `[LIGHT]` | Haiku 4.5 | GPT-4o-mini | Gemini 2.5 Flash | Quick fixes, small edits, config changes, build debugging |
**Default recommendation: Sonnet 4.6** — it matches Opus-class performance at Sonnet pricing ($3/$15 per M tokens). Use Opus only for tasks marked `[HEAVY]`. Use Haiku for tasks marked `[LIGHT]`.
---
## Current Module Status vs. XLSX Spec
| # | Module | Core Done | Gaps Remaining | New Features Needed |
| --- | ------------------ | ----------- | -------------------------------------------------------------------------------- | ------------------------------------------- |
| 1 | Registratura | YES | Linked-entry selector capped at 20 | Workflow automation, email integration, OCR |
| 2 | Email Signature | YES | US/SDT logo files may be missing from `/public/logos/`; US/SDT no address toggle | AD sync, branding packs |
| 3 | Word XML | YES | POT/CUT toggle exists (spec says remove) | Schema validator, visual mapper |
| 4 | Digital Signatures | YES | No file upload (URL only); tags not editable in form | Permission layers, document insertion |
| 5 | Password Vault | YES | Unencrypted storage; no strength meter; no company scope | Hardware key, rotation reminders |
| 6 | IT Inventory | YES | assignedTo not linked to contacts; no maintenance log | Network scan import |
| 7 | Address Book | YES | No vCard export; no reverse Registratura lookup | Email sync, deduplication |
| 8 | Prompt Generator | YES | Missing architecture viz templates (sketch→render, photorealism) | Prompt scoring |
| 9 | Word Templates | YES | No clause library; placeholders manual only; no Word generation | Diff compare, document generator |
| 10 | Tag Manager | YES | No US/SDT project seeds; no mandatory-category enforcement | Server tag sync, smart suggestions |
| 11 | Mini Utilities | PARTIAL | Missing: U→R value, AI artifact cleaner, MDLPA validator, PDF reducer, OCR | More converters |
| 12 | Dashboard | BASIC | No activity feed, no notifications, no KPI panels | Custom dashboards per role |
| 13 | AI Chat | DEMO ONLY | No API integration, no key config, no streaming | Conversation templates |
| 14 | Hot Desk | NOT STARTED | Entire module missing | — |
---
## PHASE 1 — Module Gap Fixes (close all xlsx gaps)
> Fix existing modules to match the xlsx spec. Ordered by impact and dependency.
### 1.01 ✅ `[LIGHT]` Verify Email Signature Logo Files (2026-02-18)
**What:** Check if `/public/logos/logo-us-dark.svg`, `logo-us-light.svg`, `logo-sdt-dark.svg`, `logo-sdt-light.svg` exist. If not, create placeholder SVGs or obtain real logos from the user.
**Files:** `public/logos/`
**Why first:** Broken images are the most visible bug.
**User action needed:** Provide actual logo files for Urban Switch and Studii de Teren if placeholders won't do.
**Status:** All logo files exist with valid SVG content. No action needed.
---
### 1.02 ✅ `[STANDARD]` Email Signature — Address Toggle for US/SDT (2026-02-18)
**What:** Urban Switch and Studii de Teren are hardcoded to Str. Unirii address. Add address toggle (like Beletage has) if these companies use different addresses.
**Files:** `src/modules/email-signature/components/signature-configurator.tsx`, `src/modules/email-signature/services/company-branding.ts`
**User action needed:** Confirm addresses for Urban Switch and Studii de Teren.
**Status:** Address toggle UI added for US and SDT companies. Currently configured with Str. Unirii address for both. User can update addresses in company-branding.ts when confirmed.
---
### 1.03 ✅ `[STANDARD]` Prompt Generator — Architecture Visualization Templates (2026-02-18)
**What:** Add 6+ new builtin templates per xlsx spec:
1. Architectural rendering prompt (basic massing to detailed)
2. Sketch → professional render prompt
3. Visualization refinement prompt (photorealism fine-tuning)
4. Technical compliance checking prompt
5. Legal/formal review prompt (extend existing)
6. Contract text cleanup prompt
7. GIS / survey interpretation prompt
8. BIM coordination prompt
9. Report rewriting prompt
10. Structured technical Q&A prompt
**Files to modify:** `src/modules/prompt-generator/services/builtin-templates.ts`
**Files to create:** Additional template definitions (can be in same file or split)
**Status:** 10 new templates added (total 14): arch-render-massing, sketch-to-render, photorealism-refinement, tech-compliance, legal-formal-review, contract-cleanup, gis-survey-interpretation, bim-coordination, report-rewrite, structured-qa.
---
### 1.04 ✅ `[STANDARD]` Tag Manager — US/SDT Project Seeds + Mandatory Categories (2026-02-18)
**What:**
1. Add Urban Switch and Studii de Teren project numbering to seed data (US-001, SDT-001 format)
2. Enforce mandatory 1st category (project) and 2nd category (phase) when creating tags — show validation error if missing
3. Import the full tag structure from `legacy/manicprojects/current manic time Tags.txt` in proper 1st→5th category hierarchy
**Files to modify:**
- `src/modules/tag-manager/services/seed-data.ts` — Add US/SDT projects
- `src/modules/tag-manager/components/tag-create-form.tsx` — Add mandatory validation
**Status:** US (10 projects, US-001→US-010, color #345476) and SDT (10 projects, SDT-001→SDT-010, color #0182A1) seed data added. Mandatory validation enforces project code + company scope for project tags. Validation errors shown inline. Legacy ManicTime import already covered all Beletage projects + phases + activities + doc types.
---
### ✅ 1.05 `[STANDARD]` Mini Utilities — Add Missing Tools
**What:** Add the 5 missing tools from xlsx:
1. **U-value → R-value converter** (R = 1/U, with material thickness input)
2. **AI artifact cleaner** (strip markdown formatting, fix encoding, remove prompt artifacts from pasted text)
3. **MDLPA date locale validator** (validate Romanian administrative dates against legal calendar)
4. **PDF reducer** (compress PDF via Stirling PDF API at http://10.10.10.166:8087, or client-side canvas compression for images)
5. **Quick OCR** (paste image → extract text; use Tesseract.js client-side or Stirling PDF OCR endpoint)
**Files to modify:** `src/modules/mini-utilities/components/mini-utilities-module.tsx`
**Dependencies:** `tesseract.js` (for OCR), possibly Stirling PDF API calls
**Status:** ✅ Done. All 5 tools implemented: U→R calculator (with Rsi/Rse/λ), AI artifact cleaner (markdown strip + encoding fix + typography normalise), MDLPA iframe embed, PDF reducer via Stirling PDF API with level selector, OCR via ocr.z.ai iframe. Build passes, pushed to main.
---
### ✅ 1.06 `[STANDARD]` Digital Signatures — File Upload + Tag Editing
**What:**
1. Add drag-and-drop / file picker for uploading signature/stamp images (convert to base64 on upload, like Registratura attachments)
2. Add tag input field to the asset form (tags field exists in type but form doesn't render it)
**Files to modify:**
- `src/modules/digital-signatures/components/` — asset form component
**Status:** ✅ Done. Drag-and-drop/click file picker cu preview base64 în `AssetForm` și `AddVersionForm`. Tags chip input (Enter/virgulă pentru adăugare, Backspace/X pentru ștergere). Build ok, pushat.
---
### ✅ 1.07 `[LIGHT]` Password Vault — Company Scope + Strength Meter
**What:**
1. Add `company` field to credential type and form (scope passwords to a company)
2. Add password strength indicator (visual bar: weak/medium/strong based on length + character diversity)
3. Rename `encryptedPassword``password` in the type (it's not encrypted, the name is misleading)
**Files to modify:**
- `src/modules/password-vault/types.ts`
- `src/modules/password-vault/components/` — form and list components
**Status:** ✅ Done. Renamed `encryptedPassword``password` in type. Added `company: CompanyId` field. Form now has company selector (Beletage/Urban Switch/Studii/Grup). Password strength meter with 4 levels (slabă/medie/puternică/foarte puternică) based on length + character diversity (upper/lower/digit/symbol). Meter updates live. Build ok, pushat.
### ✅ 1.08 `[LIGHT]` IT Inventory — Link assignedTo to Address Book
**What:** Change `assignedTo` from free text to an autocomplete that links to Address Book contacts (same pattern as Registratura sender/recipient).
**Files to modify:**
- `src/modules/it-inventory/components/` — equipment form
- `src/modules/it-inventory/types.ts` — Add `assignedToContactId?: string`
**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
**What:**
1. Add "Export vCard" button per contact (generate `.vcf` file download)
2. Add a section showing Registratura entries where this contact appears as sender or recipient
**Files to modify:**
- `src/modules/address-book/components/` — contact card/detail view
**Files to create:**
- `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
**What:** When a template file URL points to a `.docx`, parse it client-side to extract `{{placeholder}}` patterns and auto-populate the `placeholders[]` field. Use JSZip (already installed) to read the docx XML.
**Files to modify:**
- `src/modules/word-templates/components/` — template form
**Files to create:**
- `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
**What:**
1. Add an activity feed showing recent actions across modules (last 20 creates/updates/deletes from localStorage timestamps)
2. Add KPI cards: entries this week, deadlines this week, overdue count, contacts added this month
3. Wire the `DashboardWidget` type that already exists in `types.ts`
**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:** 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.
**Commit:** `cd4b0de`
**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 ✅
---
## PHASE 2 — New Module: Hot Desk Management
> Module 14 from xlsx. Entirely new.
### 2.01 `[HEAVY]` Hot Desk Module — Full Implementation
**What:** Build Module 14 from scratch per xlsx spec:
- 4 desks in a shared room
- Users reserve desks 1 week ahead
- Calendar view showing desk availability per day
- Reserve/cancel actions
- History of past reservations
- Visual room layout showing which desks are booked
**Module structure:**
```
src/modules/hot-desk/
├── components/
│ ├── hot-desk-module.tsx # Main view with calendar + room layout
│ ├── desk-calendar.tsx # Week view with 4 desk columns
│ ├── desk-room-layout.tsx # Visual 4-desk room diagram
│ └── reservation-dialog.tsx # Book/cancel dialog
├── hooks/
│ └── use-reservations.ts # CRUD + conflict detection
├── services/
│ └── reservation-service.ts # Business logic, overlap check
├── types.ts # DeskReservation, DeskId
├── config.ts # Module metadata
└── index.ts
```
**Files to also create/modify:**
- `src/app/(modules)/hot-desk/page.tsx` — Route
- `src/config/modules.ts` — Register module
- `src/config/navigation.ts` — Add sidebar entry
- `src/config/flags.ts` — Add feature flag
**User approval required** before moving to Phase 3.
---
## PHASE 3 — Quality & Testing
> Foundation work: tests, CI, docs, data safety.
### 3.01 `[STANDARD]` Install Testing Framework (Vitest)
**What:** Install and configure Vitest with React Testing Library.
```bash
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @vitest/coverage-v8
```
**Files to create:** `vitest.config.ts`, `src/test-setup.ts`
**Files to modify:** `package.json` (add test scripts)
---
### 3.02 `[STANDARD]` Unit Tests — Critical Services
**What:** Write tests for the most critical business logic:
1. `working-days.test.ts` — Orthodox Easter 2024-2030, addWorkingDays, backward deadlines
2. `deadline-service.test.ts` — Due date computation, tacit approval, chain resolution
3. `registry-service.test.ts` — Number generation, overdue calculation
4. `local-storage.test.ts` — CRUD, namespace isolation
5. `feature-flags.test.ts` — Defaults, env overrides
**Coverage target:** 90%+ for services.
---
### 3.03 `[STANDARD]` Data Export/Import for All Modules
**What:** Create a shared utility for backing up localStorage data:
1. Per-module JSON export (download file)
2. Per-module JSON import (upload + merge)
3. Full backup: export ALL modules as single JSON
4. Add export/import buttons to each module's main view
**Files to create:** `src/shared/hooks/use-data-export.ts`, `src/shared/components/common/data-export-button.tsx`
---
### 3.04 `[LIGHT]` Update Stale Documentation
**What:** Update docs to reflect current state:
- `docs/architecture/SYSTEM-ARCHITECTURE.md` — Change modules from "Planned" to "Implemented"
- `docs/DATA-MODEL.md` — Add TrackedDeadline, Hot Desk schemas
- `docs/REPO-STRUCTURE.md` — Add new files
---
### 3.05 `[LIGHT]` Wire External Tool URLs to Env Vars
**What:** `src/config/external-tools.ts` has hardcoded IPs. Wire to `process.env.NEXT_PUBLIC_*_URL` with fallback.
---
## PHASE 4 — AI Chat Integration
> Make Module 13 functional.
### 4.01 `[HEAVY]` AI Chat — Real API Integration
**What:** Replace demo mode with actual AI provider calls:
- Create `/api/ai/chat` server-side route (API keys never exposed to browser)
- Provider abstraction: Anthropic Claude, OpenAI GPT, Ollama (local)
- Response streaming via ReadableStream
- Model selector in the UI
- Token usage display
**Env vars:**
```
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...
OLLAMA_BASE_URL=http://10.10.10.166:11434
AI_DEFAULT_PROVIDER=anthropic
AI_DEFAULT_MODEL=claude-sonnet-4-6-20261001
```
**User action needed:** Provide API keys when ready.
---
### 4.02 `[STANDARD]` AI Chat — Domain-Specific System Prompts
**What:** Architecture office-focused conversation modes:
- Romanian construction law assistant
- Architectural visualization prompt crafter
- Technical specification writer
- Urban planning regulation lookup
- Document drafting assistant
- Normative compliance checker
---
### 4.03 `[LIGHT]` Enable AI Chat Feature Flag
**What:** Set `module.ai-chat` enabled in `flags.ts` + production `.env`.
---
## PHASE 5 — Authentication (Authentik SSO)
> Real users, real permissions. Requires server admin access.
### 5.01 `[HEAVY]` Authentik OIDC Integration
**What:** Replace stub user with real Authentik SSO.
- NextAuth.js / Auth.js route handler
- OIDC token → user profile resolution
- Cookie-based session
- `useAuth()` returns real user
**Server setup required:**
1. Create OAuth2 app in Authentik (http://10.10.10.166:9100)
2. Set redirect URI: `http://10.10.10.166:3000/api/auth/callback/authentik`
3. Set env vars: `AUTHENTIK_URL`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET`, `NEXTAUTH_SECRET`
**User action needed:** Authentik admin credentials.
---
### 5.02 `[STANDARD]` Module-Level Access Control
**What:** Implement `canAccessModule()` with role-based rules. FeatureGate checks flag + permission.
**Depends on:** 5.01
---
### 5.03 `[STANDARD]` Data Visibility Enforcement
**What:** Filter storage results by `visibility` and `createdBy` fields (already stored on every entity, never enforced).
**Depends on:** 5.01
---
### 5.04 `[LIGHT]` Audit Logging
**What:** Log create/update/delete actions with user ID + timestamp. Console initially, later storage/N8N.
**Depends on:** 5.01
---
## PHASE 6 — Storage Migration (localStorage → Database)
> Multi-user shared data. Requires PostgreSQL + infrastructure changes.
### 6.01 `[HEAVY]` PostgreSQL + Prisma Setup
**What:** Add PostgreSQL container, create Prisma schema for all entities, run migrations.
**Infrastructure:** New `postgres` service in `docker-compose.yml`.
---
### 6.02 `[HEAVY]` API Storage Adapter
**What:** Create `ApiStorageAdapter` implementing `StorageService`. Use Next.js API routes + Prisma.
**Depends on:** 6.01
---
### 6.03 `[STANDARD]` Data Migration Tool
**What:** One-time export from localStorage → import to PostgreSQL. Preserve IDs and timestamps.
**Depends on:** 6.02
---
### 6.04 `[HEAVY]` MinIO File Storage
**What:** Create `MinioAdapter` for file uploads. Migrate base64 attachments to MinIO objects.
**MinIO already running** at http://10.10.10.166:9003.
**User action needed:** MinIO access key + secret key.
---
## PHASE 7 — Advanced Features
> Cross-cutting features that enhance the entire platform.
### 7.01 `[HEAVY]` Project Entity & Cross-Module Linking
**What:** New module: Projects. Central entity linking Registratura entries, Tags, Contacts, Templates.
**Reference:** `docs/DATA-MODEL.md` lines 566-582.
---
### 7.02 `[STANDARD]` Global Search (Cmd+K)
**What:** Search across all modules. Each module registers a search provider. Header bar integration.
---
### 7.03 `[STANDARD]` Notification System
**What:** Bell icon in header. Deadline alerts, overdue warnings, tacit approval triggers.
---
### 7.04 `[STANDARD]` Registratura — Print/PDF Export
**What:** Export registry as formatted PDF. Options: full registry, single entry, deadline summary.
---
### 7.05 `[STANDARD]` Word Templates — Clause Library + Document Generator
**What:** In-app clause composition, template preview, simple Word generation from templates.
---
### 7.06 `[STANDARD]` N8N Webhook Integration
**What:** Fire webhooks on events (new entry, deadline approaching, status change). N8N at http://10.10.10.166:5678.
---
### 7.07 `[STANDARD]` Mobile Responsiveness Audit
**What:** Test all modules on 375px/768px. Fix overflowing tables, forms, sidebar.
---
## PHASE 8 — Security & External Access
### 8.01 `[HEAVY]` Guest/External Access Role
**What:** Read-only guest role, time-limited share links. Depends on Authentik (Phase 5).
---
### 8.02 `[STANDARD]` CrowdSec Integration
**What:** IP banning for brute force. CrowdSec at http://10.10.10.166:8088.
---
### 8.03 `[LIGHT]` SSL/TLS via Let's Encrypt
**What:** When public domain ready, configure in Nginx Proxy Manager.
---
## PHASE 9 — CI/CD
### 9.01 `[STANDARD]` Gitea Actions CI Pipeline
**What:** `.gitea/workflows/ci.yml` — lint, typecheck, test, build on push.
**Check first:** Is Gitea Actions runner installed on server?
---
### 9.02 `[STANDARD]` E2E Tests (Playwright)
**What:** End-to-end tests for critical flows: navigation, Registratura CRUD, email signature, tag management.
---
## Infrastructure Credentials Needed
| Service | What | When Needed |
| ------------------------ | --------------------------------------- | ------------------- |
| **US/SDT Logos** | SVG/PNG logo files | Phase 1 (task 1.01) |
| **US/SDT Addresses** | Office addresses for email signature | Phase 1 (task 1.02) |
| **Anthropic API Key** | `sk-ant-...` from console.anthropic.com | Phase 4 (task 4.01) |
| **OpenAI API Key** | `sk-...` from platform.openai.com | Phase 4 (task 4.01) |
| **Authentik Admin** | Login to create OAuth app at :9100 | Phase 5 (task 5.01) |
| **MinIO Credentials** | Access key + secret key for :9003 | Phase 6 (task 6.04) |
| **PostgreSQL** | New container + password | Phase 6 (task 6.01) |
| **Gitea Actions Runner** | Registration token from Gitea admin | Phase 9 (task 9.01) |
---
## Quick Picker
**15 min tasks** `[LIGHT]`:
- 1.01 — Check logo files
- ~~1.07 — Password vault company + strength~~ ✅
- 1.08 — IT inventory contact link
- 1.12 — Registry linked-entry limit
- 1.13 — Remove POT/CUT auto-calc
- 3.04 — Update stale docs
- 3.05 — Wire env var URLs
**1 hour tasks** `[STANDARD]`:
- 1.03 — Prompt generator templates
- 1.04 — Tag manager seeds + mandatory
- ~~1.05 — Mini utilities new tools~~ ✅
- ~~1.06 — Digital signatures upload~~ ✅
- 1.09 — Address book vCard + reverse lookup
- 1.11 — Dashboard activity feed + KPIs
- 3.01 + 3.02 — Tests setup + core tests
**Full session tasks** `[HEAVY]`:
- 2.01 — Hot Desk module (new)
- 4.01 — AI Chat API integration
- 5.01 — Authentik SSO
- 6.01 + 6.02 — PostgreSQL + API adapter
- 7.01 — Project entity module

254
SESSION-GUIDE.md Normal file
View File

@@ -0,0 +1,254 @@
# ArchiTools — Session Guide
> Device-agnostic prompts for any AI tool, from anywhere.
---
## Repository URLs
| Access | Git Clone URL | Web UI |
|---|---|---|
| **Internal (office)** | `http://10.10.10.166:3002/gitadmin/ArchiTools.git` | http://10.10.10.166:3002/gitadmin/ArchiTools |
| **External (internet)** | `https://git.beletage.ro/gitadmin/ArchiTools.git` | https://git.beletage.ro/gitadmin/ArchiTools |
### Raw File URLs (for AI tools that can fetch URLs)
Replace `{GITEA}` with whichever base works for you:
- Internal: `http://10.10.10.166:3002`
- External: `https://git.beletage.ro`
| File | URL |
|---|---|
| CLAUDE.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/CLAUDE.md` |
| ROADMAP.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/ROADMAP.md` |
| SESSION-LOG.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/SESSION-LOG.md` |
| SESSION-GUIDE.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/SESSION-GUIDE.md` |
**Production app:** http://10.10.10.166:3000
---
## PROMPT 1: New Session (any device, any AI)
```
I'm working on ArchiTools, an internal web dashboard for an architecture office.
Repository: https://git.beletage.ro/gitadmin/ArchiTools.git (branch: main)
Before doing anything, read these 4 files from the repo:
- CLAUDE.md (project context, stack, architecture, conventions)
- ROADMAP.md (complete task list — tasks with ✅ are done, pick the next one without ✅)
- SESSION-LOG.md (what previous sessions did)
- SESSION-GUIDE.md (workflow rules)
Setup: clone the repo (or pull latest if already cloned), run npm install.
Then pick the next uncompleted task from ROADMAP.md (work top to bottom, skip ✅ tasks).
Rules:
- Run `npx next build` before every commit — zero errors required
- Push to main when done — Portainer auto-deploys to production
- Update ROADMAP.md: mark completed tasks with ✅ and the date
- Update SESSION-LOG.md: add an entry at the TOP describing what you did
- Commit and push these log updates too
- Notify me when a task is ready for my review
- Do NOT move to the next task until I approve the current one
- Code in English, UI text in Romanian
```
---
## PROMPT 2: Resume Previous Work
```
I'm continuing work on ArchiTools.
Repository: https://git.beletage.ro/gitadmin/ArchiTools.git (branch: main)
Read these files from the repo:
- CLAUDE.md (project context)
- ROADMAP.md (find the next task without ✅)
- SESSION-LOG.md (what was done in previous sessions)
Pull latest, npm install, then continue with the next uncompleted task.
Same rules: build must pass, push when done, update ROADMAP.md (✅ + date)
and SESSION-LOG.md, notify me for review before moving to next task.
```
---
## PROMPT 3: Specific Task
```
I'm working on ArchiTools.
Repository: https://git.beletage.ro/gitadmin/ArchiTools.git (branch: main)
Read CLAUDE.md and ROADMAP.md from the repo first.
Pull latest, npm install.
Work on task [NUMBER] ([TASK NAME]) from ROADMAP.md.
Implement it, run `npx next build` (must pass), push to main,
update ROADMAP.md (✅ + date) and SESSION-LOG.md, then notify me.
```
---
## PROMPT 4: Bug Fix
```
I'm working on ArchiTools.
Repository: https://git.beletage.ro/gitadmin/ArchiTools.git (branch: main)
Read CLAUDE.md from the repo first. Pull latest.
Bug: [DESCRIBE THE BUG]
Fix it, run `npx next build`, push to main.
Don't change anything unrelated. Update SESSION-LOG.md.
```
---
## PROMPT 5: Code Review (no changes)
```
I'm working on ArchiTools.
Repository: https://git.beletage.ro/gitadmin/ArchiTools.git (branch: main)
Read CLAUDE.md from the repo. Pull latest.
Review the last 3 commits. Check for:
- TypeScript strict mode issues
- Missing null guards
- UI text that should be in Romanian
- Unused imports or dead code
- Security issues
Don't change anything, just report findings.
```
---
## PROMPT 6: New Module from Scratch
```
I'm working on ArchiTools.
Repository: https://git.beletage.ro/gitadmin/ArchiTools.git (branch: main)
Read CLAUDE.md and ROADMAP.md from the repo first.
Pull latest, npm install.
Build [MODULE NAME] as described in task [NUMBER] of ROADMAP.md.
Use the module at src/modules/registratura/ as reference for the pattern:
types.ts, config.ts, services/, hooks/, components/, index.ts
Also create: route page, update modules.ts, navigation.ts, flags.ts.
Run `npx next build`, push to main, update ROADMAP.md + SESSION-LOG.md, notify me.
```
---
## Tool-Specific Notes
### Claude Code (CLI)
Works natively. Clone/pull, read files, edit, build, push — all built in.
### ChatGPT Codex
Give it the repo URL. It can clone via git, read files, and push.
Use the external URL: `https://git.beletage.ro/gitadmin/ArchiTools.git`
### VS Code + Copilot / Cursor / Windsurf
Clone the repo locally first, then open in the IDE. The AI agent reads files from disk.
```bash
git clone https://git.beletage.ro/gitadmin/ArchiTools.git
cd ArchiTools && npm install && code .
```
### Google Antigravity
Give it the repo URL. It can clone and work autonomously.
Use: `https://git.beletage.ro/gitadmin/ArchiTools.git`
### Phone (ChatGPT app, Claude app)
Can't run code directly, but can read files via raw URLs and give guidance:
```
Read these URLs and help me plan the next task:
https://git.beletage.ro/gitadmin/ArchiTools/raw/branch/main/CLAUDE.md
https://git.beletage.ro/gitadmin/ArchiTools/raw/branch/main/ROADMAP.md
https://git.beletage.ro/gitadmin/ArchiTools/raw/branch/main/SESSION-LOG.md
```
---
## Git Workflow
### First time (any device)
```bash
git clone https://git.beletage.ro/gitadmin/ArchiTools.git
cd ArchiTools
npm install
npm run dev
```
### Session start (pull latest)
```bash
git pull origin main
npm install
```
### Session end (push)
```bash
npx next build
git add <specific-files>
git commit -m "feat(module): description
Co-Authored-By: <model-name>"
git push origin main
```
---
## Files to Update After Every Session
### 1. `ROADMAP.md` — Mark done tasks
```markdown
### 1.01 ✅ (2026-02-18) `[LIGHT]` Verify Email Signature Logo Files
```
### 2. `SESSION-LOG.md` — Add entry at the TOP
```markdown
## Session — 2026-02-18 (Sonnet 4.6)
### Completed
- 1.01: Verified logo files
- 1.02: Added address toggle
### In Progress
- 1.03: Prompt templates — 4 of 10 done
### Blockers
- Need logo files from user
### Notes
- Build passes, commit abc1234
---
```
### 3. `CLAUDE.md` — Only if architecture changes
### 4. Commit + push all log updates before ending the session
---
## Handoff Rules
1. **Always pull before starting** — another session may have pushed
2. **Always push before ending** — don't leave uncommitted work
3. **Update SESSION-LOG.md** — the next AI has zero memory
4. **Mark ROADMAP.md** — ✅ with date is the source of truth
5. **Don't skip tasks** — ordered by dependency
6. **Ask user if blocked** — don't guess on design decisions
7. **One task at a time** — implement, build, push, get approval, then next

221
SESSION-LOG.md Normal file
View File

@@ -0,0 +1,221 @@
# ArchiTools — Session Log
> Newest sessions first. Each AI session appends an entry at the top.
---
## 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)
### Completed
- **Registratura Legal Deadline Tracking** — Full implementation:
- 9 new files: working-days.ts (Romanian holidays + Orthodox Easter), deadline-catalog.ts (16 deadline types), deadline-service.ts, use-deadline-filters.ts, deadline-card.tsx, deadline-add-dialog.tsx, deadline-resolve-dialog.tsx, deadline-table.tsx, deadline-dashboard.tsx
- 6 modified files: types.ts, use-registry.ts, registratura-module.tsx (tabbed), registry-entry-form.tsx (inline deadlines), registry-table.tsx (clock badge), index.ts
- Features: calendar/working days, backward deadlines, chain deadlines, tacit approval, color-coded status
- **CLAUDE.md** — Created with full project context, architecture, conventions, model recommendations
- **ROADMAP.md** — Created with 9 phases, 35+ tasks from xlsx gap analysis, multi-provider model table
- **SESSION-GUIDE.md** — Created with start/resume prompts, git workflow, file update rules
### Commits
- `bb01268` feat(registratura): add legal deadline tracking system (Termene Legale)
- `d6a5852` docs: add ROADMAP.md with detailed future task plan
- `b1df15b` docs: rewrite ROADMAP.md with complete xlsx gap analysis + multi-model recommendations
- (this session) docs: add SESSION-GUIDE.md + SESSION-LOG.md
### Notes
- Build passes with zero errors
- Dev server on localhost:3000 shows tabs correctly
- Production at 10.10.10.166:3000 requires Portainer redeploy after push
- The `app_modules_overview.xlsx` is in the repo root but not committed (it's a reference file)
- No tasks from ROADMAP.md Phase 1+ have been started yet — next session should begin with task 1.01
### Completed
- **Registratura Legal Deadline Tracking** — Full implementation:
- 9 new files: working-days.ts (Romanian holidays + Orthodox Easter), deadline-catalog.ts (16 deadline types), deadline-service.ts, use-deadline-filters.ts, deadline-card.tsx, deadline-add-dialog.tsx, deadline-resolve-dialog.tsx, deadline-table.tsx, deadline-dashboard.tsx
- 6 modified files: types.ts, use-registry.ts, registratura-module.tsx (tabbed), registry-entry-form.tsx (inline deadlines), registry-table.tsx (clock badge), index.ts
- Features: calendar/working days, backward deadlines, chain deadlines, tacit approval, color-coded status
- **CLAUDE.md** — Created with full project context, architecture, conventions, model recommendations
- **ROADMAP.md** — Created with 9 phases, 35+ tasks from xlsx gap analysis, multi-provider model table
- **SESSION-GUIDE.md** — Created with start/resume prompts, git workflow, file update rules
### Commits
- `bb01268` feat(registratura): add legal deadline tracking system (Termene Legale)
- `d6a5852` docs: add ROADMAP.md with detailed future task plan
- `b1df15b` docs: rewrite ROADMAP.md with complete xlsx gap analysis + multi-model recommendations
- (this session) docs: add SESSION-GUIDE.md + SESSION-LOG.md
### Notes
- Build passes with zero errors
- Dev server on localhost:3000 shows tabs correctly
- Production at 10.10.10.166:3000 requires Portainer redeploy after push
- 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
---

133
package-lock.json generated
View File

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

View File

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

View File

@@ -0,0 +1,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 * as Icons from 'lucide-react';
import { getAllModules } from '@/core/module-registry';
import { useFeatureFlag } from '@/core/feature-flags';
import { useI18n } from '@/core/i18n';
import { EXTERNAL_TOOLS } from '@/config/external-tools';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/shared/components/ui/card';
import { Badge } from '@/shared/components/ui/badge';
import Link from "next/link";
import * as Icons from "lucide-react";
import { getAllModules } from "@/core/module-registry";
import { useFeatureFlag } from "@/core/feature-flags";
import { useI18n } from "@/core/i18n";
import { EXTERNAL_TOOLS } from "@/config/external-tools";
import {
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 }) {
const pascalName = name.replace(/(^|-)([a-z])/g, (_, _p, c: string) => c.toUpperCase());
const IconComponent = (Icons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[pascalName];
function DynamicIcon({
name,
className,
}: {
name: string;
className?: string;
}) {
const pascalName = name.replace(/(^|-)([a-z])/g, (_, _p, c: string) =>
c.toUpperCase(),
);
const IconComponent = (
Icons as unknown as Record<
string,
React.ComponentType<{ className?: string }>
>
)[pascalName];
if (!IconComponent) return <Icons.Circle className={className} />;
return <IconComponent className={className} />;
}
function 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);
if (!enabled) return null;
@@ -29,7 +60,9 @@ function ModuleCard({ module }: { module: { id: string; name: string; descriptio
</div>
<div>
<CardTitle className="text-base">{module.name}</CardTitle>
<CardDescription className="text-sm">{module.description}</CardDescription>
<CardDescription className="text-sm">
{module.description}
</CardDescription>
</div>
</CardHeader>
</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> = {
dev: 'Dezvoltare',
tools: 'Instrumente',
monitoring: 'Monitorizare',
security: 'Securitate',
dev: "Dezvoltare",
tools: "Instrumente",
monitoring: "Monitorizare",
security: "Securitate",
};
export default function DashboardPage() {
const { t } = useI18n();
const modules = getAllModules();
const { activity, kpis } = useDashboardData();
const toolCategories = Object.keys(CATEGORY_LABELS).filter(
(cat) => EXTERNAL_TOOLS.some((tool) => tool.category === cat)
const toolCategories = Object.keys(CATEGORY_LABELS).filter((cat) =>
EXTERNAL_TOOLS.some((tool) => tool.category === cat),
);
return (
<div className="mx-auto max-w-6xl space-y-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('dashboard.welcome')}</h1>
<p className="mt-1 text-muted-foreground">{t('dashboard.subtitle')}</p>
<h1 className="text-3xl font-bold tracking-tight">
{t("dashboard.welcome")}
</h1>
<p className="mt-1 text-muted-foreground">{t("dashboard.subtitle")}</p>
</div>
{/* Quick stats */}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{/* KPI panels */}
<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>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Module active</p>
<p className="text-2xl font-bold">{modules.length}</p>
<p className="text-xs text-muted-foreground">
Registratură intrări săptămâna aceasta
</p>
<p className="text-2xl font-bold">{kpis.registraturaThisWeek}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Companii</p>
<p className="text-2xl font-bold">3</p>
<p className="text-xs text-muted-foreground">Dosare deschise</p>
<p className="text-2xl font-bold">{kpis.registraturaOpen}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Instrumente externe</p>
<p className="text-2xl font-bold">{EXTERNAL_TOOLS.length}</p>
<p className="text-xs text-muted-foreground">
Termene legale săptămâna aceasta
</p>
<p className="text-2xl font-bold">{kpis.deadlinesThisWeek}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Stocare</p>
<p className="text-2xl font-bold">localStorage</p>
<p className="text-xs text-muted-foreground">Termene depășite</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>
</Card>
</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 */}
<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">
{modules.map((m) => (
<ModuleCard key={m.id} module={m} />
@@ -103,27 +243,44 @@ export default function DashboardPage() {
<div className="space-y-4">
{toolCategories.map((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">
{EXTERNAL_TOOLS.filter((tool) => tool.category === cat).map((tool) => {
{EXTERNAL_TOOLS.filter((tool) => tool.category === cat).map(
(tool) => {
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">
<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>
<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>
</CardHeader>
</Card>
);
if (!tool.url) return cardContent;
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}
</a>
);
})}
},
)}
</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 {
id: CompanyId;
@@ -16,48 +16,48 @@ export interface Company {
export const COMPANIES: Record<CompanyId, Company> = {
beletage: {
id: 'beletage',
name: 'Beletage SRL',
shortName: 'Beletage',
cui: '',
color: '#22B5AB',
address: 'str. Unirii, nr. 3, ap. 26',
city: 'Cluj-Napoca',
id: "beletage",
name: "Beletage SRL",
shortName: "Beletage",
cui: "",
color: "#22B5AB",
address: "str. Unirii, nr. 3, ap. 26",
city: "Cluj-Napoca",
},
'urban-switch': {
id: 'urban-switch',
name: 'Urban Switch SRL',
shortName: 'Urban Switch',
cui: '',
color: '#6366f1',
address: '',
city: 'Cluj-Napoca',
"urban-switch": {
id: "urban-switch",
name: "Urban Switch SRL",
shortName: "Urban Switch",
cui: "",
color: "#6366f1",
address: "",
city: "Cluj-Napoca",
logo: {
light: '/logos/logo-us-light.svg',
dark: '/logos/logo-us-dark.svg',
light: "/logos/logo-us-light.svg",
dark: "/logos/logo-us-light.svg",
},
},
'studii-de-teren': {
id: 'studii-de-teren',
name: 'Studii de Teren SRL',
shortName: 'Studii de Teren',
cui: '',
color: '#f59e0b',
address: '',
city: 'Cluj-Napoca',
"studii-de-teren": {
id: "studii-de-teren",
name: "Studii de Teren SRL",
shortName: "Studii de Teren",
cui: "",
color: "#f59e0b",
address: "",
city: "Cluj-Napoca",
logo: {
light: '/logos/logo-sdt-dark.svg',
dark: '/logos/logo-sdt-light.svg',
light: "/logos/logo-sdt-light.svg",
dark: "/logos/logo-sdt-light.svg",
},
},
group: {
id: 'group',
name: 'Grup Companii',
shortName: 'Grup',
cui: '',
color: '#64748b',
address: '',
city: 'Cluj-Napoca',
id: "group",
name: "Grup Companii",
shortName: "Grup",
cui: "",
color: "#64748b",
address: "",
city: "Cluj-Napoca",
},
};

View File

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

View File

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

View File

@@ -1,109 +1,113 @@
import type { Labels } from '../types';
import type { Labels } from "../types";
export const ro: Labels = {
common: {
save: 'Salvează',
cancel: 'Anulează',
delete: 'Șterge',
edit: 'Editează',
create: 'Creează',
search: 'Caută',
filter: 'Filtrează',
export: 'Exportă',
import: 'Importă',
copy: 'Copiază',
close: 'Închide',
confirm: 'Confirmă',
back: 'Înapoi',
next: 'Următorul',
loading: 'Se încarcă...',
noResults: 'Niciun rezultat',
error: 'Eroare',
success: 'Succes',
actions: 'Acțiuni',
settings: 'Setări',
all: 'Toate',
yes: 'Da',
no: 'Nu',
save: "Salvează",
cancel: "Anulează",
delete: "Șterge",
edit: "Editează",
create: "Creează",
search: "Caută",
filter: "Filtrează",
export: "Exportă",
import: "Importă",
copy: "Copiază",
close: "Închide",
confirm: "Confirmă",
back: "Înapoi",
next: "Următorul",
loading: "Se încarcă...",
noResults: "Niciun rezultat",
error: "Eroare",
success: "Succes",
actions: "Acțiuni",
settings: "Setări",
all: "Toate",
yes: "Da",
no: "Nu",
},
nav: {
dashboard: 'Panou principal',
operations: 'Operațiuni',
generators: 'Generatoare',
management: 'Management',
tools: 'Instrumente',
ai: 'AI & Automatizări',
externalTools: 'Instrumente externe',
dashboard: "Panou principal",
operations: "Operațiuni",
generators: "Generatoare",
management: "Management",
tools: "Instrumente",
ai: "AI & Automatizări",
externalTools: "Instrumente externe",
},
dashboard: {
title: 'Panou principal',
welcome: 'Bine ai venit în ArchiTools',
subtitle: 'Platforma internă de instrumente pentru birou',
quickActions: 'Acțiuni rapide',
recentActivity: 'Activitate recentă',
modules: 'Module',
infrastructure: 'Infrastructură',
title: "Panou principal",
welcome: "Bine ai venit în ArchiTools",
subtitle: "Platforma internă de instrumente pentru birou",
quickActions: "Acțiuni rapide",
recentActivity: "Activitate recentă",
modules: "Module",
infrastructure: "Infrastructură",
},
registratura: {
title: 'Registratură',
description: 'Registru de corespondență multi-firmă',
newEntry: 'Înregistrare nouă',
entries: 'Înregistrări',
incoming: 'Intrare',
outgoing: 'Ieșire',
internal: 'Intern',
title: "Registratură",
description: "Registru de corespondență multi-firmă",
newEntry: "Înregistrare nouă",
entries: "Înregistrări",
incoming: "Intrare",
outgoing: "Ieșire",
internal: "Intern",
},
'email-signature': {
title: 'Generator Semnătură Email',
description: 'Configurator semnătură email pentru companii',
preview: 'Previzualizare',
downloadHtml: 'Descarcă HTML',
"email-signature": {
title: "Generator Semnătură Email",
description: "Configurator semnătură email pentru companii",
preview: "Previzualizare",
downloadHtml: "Descarcă HTML",
},
'word-xml': {
title: 'Generator XML Word',
description: 'Generator Custom XML Parts pentru Word',
generate: 'Generează XML',
downloadXml: 'Descarcă XML',
downloadZip: 'Descarcă ZIP',
"word-xml": {
title: "Generator XML Word",
description: "Generator Custom XML Parts pentru Word",
generate: "Generează XML",
downloadXml: "Descarcă XML",
downloadZip: "Descarcă ZIP",
},
'prompt-generator': {
title: 'Generator Prompturi',
description: 'Constructor de prompturi structurate pentru AI',
templates: 'Șabloane',
compose: 'Compune',
history: 'Istoric',
preview: 'Previzualizare',
"prompt-generator": {
title: "Generator Prompturi",
description: "Constructor de prompturi structurate pentru AI",
templates: "Șabloane",
compose: "Compune",
history: "Istoric",
preview: "Previzualizare",
},
'digital-signatures': {
title: 'Semnături și Ștampile',
description: 'Bibliotecă semnături digitale și ștampile scanate',
"digital-signatures": {
title: "Semnături și Ștampile",
description: "Bibliotecă semnături digitale și ștampile scanate",
},
'password-vault': {
title: 'Seif Parole',
description: 'Depozit intern de credențiale partajate',
"password-vault": {
title: "Seif Parole",
description: "Depozit intern de credențiale partajate",
},
'it-inventory': {
title: 'Inventar IT',
description: 'Evidența echipamentelor și dispozitivelor',
"it-inventory": {
title: "Inventar IT",
description: "Evidența echipamentelor și dispozitivelor",
},
'address-book': {
title: 'Contacte',
description: 'Clienți, furnizori, instituții',
"address-book": {
title: "Contacte",
description: "Clienți, furnizori, instituții",
},
'word-templates': {
title: 'Șabloane Word',
description: 'Bibliotecă contracte, oferte, rapoarte',
"word-templates": {
title: "Șabloane Word",
description: "Bibliotecă contracte, oferte, rapoarte",
},
'tag-manager': {
title: 'Manager Etichete',
description: 'Administrare etichete proiecte și categorii',
"tag-manager": {
title: "Manager Etichete",
description: "Administrare etichete proiecte și categorii",
},
'mini-utilities': {
title: 'Utilitare',
description: 'Calculatoare tehnice și instrumente text',
"mini-utilities": {
title: "Utilitare",
description: "Calculatoare tehnice și instrumente text",
},
'ai-chat': {
title: 'Chat AI',
description: 'Interfață asistent AI',
"ai-chat": {
title: "Chat 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 {
Plus, Pencil, Trash2, Search, Mail, Phone, MapPin,
Globe, Building2, UserPlus, 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';
Plus,
Pencil,
Trash2,
Search,
Mail,
Phone,
MapPin,
Globe,
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 {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/shared/components/ui/select';
import type { AddressContact, ContactType, ContactPerson } from '../types';
import { useContacts } from '../hooks/use-contacts';
import { useTags } from '@/core/tagging';
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,
} 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> = {
client: 'Client',
supplier: 'Furnizor',
institution: 'Instituție',
collaborator: 'Colaborator',
internal: 'Intern',
client: "Client",
supplier: "Furnizor",
institution: "Instituție",
collaborator: "Colaborator",
internal: "Intern",
};
type ViewMode = 'list' | 'add' | 'edit';
type ViewMode = "list" | "add" | "edit";
export function AddressBookModule() {
const { contacts, allContacts, loading, filters, updateFilter, addContact, updateContact, removeContact } = useContacts();
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [editingContact, setEditingContact] = useState<AddressContact | null>(null);
const {
contacts,
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'>) => {
if (viewMode === 'edit' && editingContact) {
const handleSubmit = async (
data: Omit<AddressContact, "id" | "createdAt" | "updatedAt">,
) => {
if (viewMode === "edit" && editingContact) {
await updateContact(editingContact.id, data);
} else {
await addContact(data);
}
setViewMode('list');
setViewMode("list");
setEditingContact(null);
};
@@ -47,48 +91,81 @@ export function AddressBookModule() {
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total</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) => (
<Card key={type}><CardContent className="p-4">
<p className="text-xs text-muted-foreground">{TYPE_LABELS[type]}</p>
<p className="text-2xl font-bold">{allContacts.filter((c) => c.type === type).length}</p>
</CardContent></Card>
<Card key={type}>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">
{TYPE_LABELS[type]}
</p>
<p className="text-2xl font-bold">
{allContacts.filter((c) => c.type === type).length}
</p>
</CardContent>
</Card>
))}
</div>
{viewMode === 'list' && (
{viewMode === "list" && (
<>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Caută 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>
<Select value={filters.type} onValueChange={(v) => updateFilter('type', v as ContactType | 'all')}>
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
<Select
value={filters.type}
onValueChange={(v) =>
updateFilter("type", v as ContactType | "all")
}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate tipurile</SelectItem>
{(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>
</Select>
<Button onClick={() => setViewMode('add')} className="shrink-0">
<Button onClick={() => setViewMode("add")} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
<p className="py-8 text-center text-sm text-muted-foreground">
Se încarcă...
</p>
) : 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">
{contacts.map((contact) => (
<ContactCard
key={contact.id}
contact={contact}
onEdit={() => { setEditingContact(contact); setViewMode('edit'); }}
onEdit={() => {
setEditingContact(contact);
setViewMode("edit");
}}
onDelete={() => removeContact(contact.id)}
onViewDetail={() => setViewingContact(contact)}
/>
))}
</div>
@@ -96,37 +173,89 @@ export function AddressBookModule() {
</>
)}
{(viewMode === 'add' || viewMode === 'edit') && (
{(viewMode === "add" || viewMode === "edit") && (
<Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare contact' : 'Contact nou'}</CardTitle></CardHeader>
<CardHeader>
<CardTitle>
{viewMode === "edit" ? "Editare contact" : "Contact nou"}
</CardTitle>
</CardHeader>
<CardContent>
<ContactForm
initial={editingContact ?? undefined}
onSubmit={handleSubmit}
onCancel={() => { setViewMode('list'); setEditingContact(null); }}
onCancel={() => {
setViewMode("list");
setEditingContact(null);
}}
/>
</CardContent>
</Card>
)}
{/* Contact Detail Dialog */}
<ContactDetailDialog
contact={viewingContact}
onClose={() => setViewingContact(null)}
onEdit={(c) => {
setViewingContact(null);
setEditingContact(c);
setViewMode("edit");
}}
/>
</div>
);
}
// ── Contact Card ──
function ContactCard({ contact, onEdit, onDelete }: {
function ContactCard({
contact,
onEdit,
onDelete,
onViewDetail,
}: {
contact: AddressContact;
onEdit: () => void;
onDelete: () => void;
onViewDetail: () => void;
}) {
return (
<Card className="group relative">
<CardContent className="p-4">
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-7 w-7" 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" />
</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" />
</Button>
</div>
@@ -134,54 +263,71 @@ function ContactCard({ contact, onEdit, onDelete }: {
<div>
<p className="font-medium">{contact.name}</p>
<div className="flex flex-wrap items-center gap-1.5">
{contact.company && <p className="text-xs text-muted-foreground">{contact.company}</p>}
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[contact.type]}</Badge>
{contact.company && (
<p className="text-xs text-muted-foreground">
{contact.company}
</p>
)}
<Badge variant="outline" className="text-[10px]">
{TYPE_LABELS[contact.type]}
</Badge>
{contact.department && (
<Badge variant="secondary" className="text-[10px]">{contact.department}</Badge>
<Badge variant="secondary" className="text-[10px]">
{contact.department}
</Badge>
)}
</div>
{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>
{contact.email && (
<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>
)}
{contact.email2 && (
<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>
)}
{contact.phone && (
<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>
)}
{contact.phone2 && (
<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>
)}
{contact.address && (
<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>
)}
{contact.website && (
<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>
)}
{contact.contactPersons.length > 0 && (
{(contact.contactPersons ?? []).length > 0 && (
<div className="mt-1 border-t pt-1">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1">
Persoane de contact ({contact.contactPersons.length})
</p>
{contact.contactPersons.slice(0, 2).map((cp, i) => (
<p key={i} className="text-xs text-muted-foreground">
{cp.name}{cp.role ? `${cp.role}` : ''}
{cp.name}
{cp.role ? `${cp.role}` : ""}
</p>
))}
{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 ──
function ContactForm({ initial, onSubmit, onCancel }: {
function ContactForm({
initial,
onSubmit,
onCancel,
}: {
initial?: AddressContact;
onSubmit: (data: Omit<AddressContact, 'id' | 'createdAt' | 'updatedAt'>) => void;
onSubmit: (
data: Omit<AddressContact, "id" | "createdAt" | "updatedAt">,
) => void;
onCancel: () => void;
}) {
const { tags: projectTags } = useTags('project');
const [name, setName] = useState(initial?.name ?? '');
const [company, setCompany] = useState(initial?.company ?? '');
const [type, setType] = useState<ContactType>(initial?.type ?? 'client');
const [email, setEmail] = useState(initial?.email ?? '');
const [email2, setEmail2] = useState(initial?.email2 ?? '');
const [phone, setPhone] = useState(initial?.phone ?? '');
const [phone2, setPhone2] = useState(initial?.phone2 ?? '');
const [address, setAddress] = useState(initial?.address ?? '');
const [department, setDepartment] = useState(initial?.department ?? '');
const [role, setRole] = useState(initial?.role ?? '');
const [website, setWebsite] = useState(initial?.website ?? '');
const [notes, setNotes] = useState(initial?.notes ?? '');
const [projectIds, setProjectIds] = useState<string[]>(initial?.projectIds ?? []);
const { tags: projectTags } = useTags("project");
const [name, setName] = useState(initial?.name ?? "");
const [company, setCompany] = useState(initial?.company ?? "");
const [type, setType] = useState<ContactType>(initial?.type ?? "client");
const [email, setEmail] = useState(initial?.email ?? "");
const [email2, setEmail2] = useState(initial?.email2 ?? "");
const [phone, setPhone] = useState(initial?.phone ?? "");
const [phone2, setPhone2] = useState(initial?.phone2 ?? "");
const [address, setAddress] = useState(initial?.address ?? "");
const [department, setDepartment] = useState(initial?.department ?? "");
const [role, setRole] = useState(initial?.role ?? "");
const [website, setWebsite] = useState(initial?.website ?? "");
const [notes, setNotes] = useState(initial?.notes ?? "");
const [projectIds, setProjectIds] = useState<string[]>(
initial?.projectIds ?? [],
);
const [contactPersons, setContactPersons] = useState<ContactPerson[]>(
initial?.contactPersons ?? []
initial?.contactPersons ?? [],
);
const addContactPerson = () => {
setContactPersons([...contactPersons, { name: '', role: '', email: '', phone: '' }]);
setContactPersons([
...contactPersons,
{ name: "", role: "", email: "", phone: "" },
]);
};
const updateContactPerson = (index: number, field: keyof ContactPerson, value: string) => {
setContactPersons(contactPersons.map((cp, i) =>
i === index ? { ...cp, [field]: value } : cp
));
const updateContactPerson = (
index: number,
field: keyof ContactPerson,
value: string,
) => {
setContactPersons(
contactPersons.map((cp, i) =>
i === index ? { ...cp, [field]: value } : cp,
),
);
};
const removeContactPerson = (index: number) => {
@@ -238,7 +623,9 @@ function ContactForm({ initial, onSubmit, onCancel }: {
const toggleProject = (projectId: string) => {
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) => {
e.preventDefault();
onSubmit({
name, company, type, email, email2, phone, phone2,
address, department, role, website, notes,
name,
company,
type,
email,
email2,
phone,
phone2,
address,
department,
role,
website,
notes,
projectIds,
contactPersons: contactPersons.filter((cp) => cp.name.trim()),
tags: initial?.tags ?? [],
visibility: initial?.visibility ?? 'all',
visibility: initial?.visibility ?? "all",
});
}}
className="space-y-4"
>
{/* Row 1: Name + Company + Type */}
<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><Label>Companie/Organizație</Label><Input value={company} onChange={(e) => setCompany(e.target.value)} className="mt-1" /></div>
<div><Label>Tip</Label>
<div>
<Label>Nume *</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)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(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>
</Select>
@@ -275,25 +692,87 @@ function ContactForm({ initial, onSubmit, onCancel }: {
{/* Row 2: Department + Role + Website */}
<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><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>
<Label>Departament</Label>
<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>
{/* Row 3: Emails + Phones */}
<div className="grid gap-4 sm:grid-cols-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><Label>Email secundar</Label><Input type="email" value={email2} onChange={(e) => setEmail2(e.target.value)} className="mt-1" /></div>
<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 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><Label>Telefon secundar</Label><Input type="tel" value={phone2} onChange={(e) => setPhone2(e.target.value)} className="mt-1" /></div>
<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>
{/* 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 */}
{projectTags.length > 0 && (
@@ -307,11 +786,12 @@ function ContactForm({ initial, onSubmit, onCancel }: {
onClick={() => toggleProject(pt.id)}
className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${
projectIds.includes(pt.id)
? 'border-primary bg-primary/10 text-primary'
: 'border-muted-foreground/30 text-muted-foreground hover:border-primary/50'
? "border-primary bg-primary/10 text-primary"
: "border-muted-foreground/30 text-muted-foreground hover:border-primary/50"
}`}
>
{pt.projectCode ? `${pt.projectCode} ` : ''}{pt.label}
{pt.projectCode ? `${pt.projectCode} ` : ""}
{pt.label}
</button>
))}
</div>
@@ -322,19 +802,61 @@ function ContactForm({ initial, onSubmit, onCancel }: {
<div>
<div className="flex items-center justify-between">
<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ă
</Button>
</div>
{contactPersons.length > 0 && (
<div className="mt-2 space-y-2">
{contactPersons.map((cp, i) => (
<div key={i} className="flex flex-wrap items-start gap-2 rounded border p-2">
<Input 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)}>
<div
key={i}
className="flex flex-wrap items-start gap-2 rounded border p-2"
>
<Input
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" />
</Button>
</div>
@@ -344,11 +866,21 @@ function ContactForm({ initial, onSubmit, onCancel }: {
</div>
{/* 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">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
<Button type="button" variant="outline" onClick={onCancel}>
Anulează
</Button>
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
</div>
</form>
);

View File

@@ -76,8 +76,8 @@ export function useContacts() {
c.company.toLowerCase().includes(q) ||
c.email.toLowerCase().includes(q) ||
c.phone.includes(q) ||
c.department.toLowerCase().includes(q) ||
c.role.toLowerCase().includes(q)
(c.department ?? '').toLowerCase().includes(q) ||
(c.role ?? '').toLowerCase().includes(q)
);
}
return true;

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

View File

@@ -60,7 +60,7 @@ export function useSignatures() {
const existing = assets.find((a) => a.id === assetId);
if (!existing) return;
const version: AssetVersion = { id: uuid(), imageUrl, notes, createdAt: new Date().toISOString() };
const updatedVersions = [...existing.versions, version];
const updatedVersions = [...(existing.versions ?? []), version];
await updateAsset(assetId, { imageUrl, versions: updatedVersions });
}, [assets, updateAsset]);

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 { Plus, Pencil, Trash2, Search } 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 { InventoryItem, InventoryItemType, InventoryItemStatus } from '../types';
import { useInventory } from '../hooks/use-inventory';
import { useState, useMemo } from "react";
import { Plus, Pencil, Trash2, Search } 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 {
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> = {
laptop: 'Laptop', desktop: 'Desktop', monitor: 'Monitor', printer: 'Imprimantă',
phone: 'Telefon', tablet: 'Tabletă', network: 'Rețea', peripheral: 'Periferic', other: 'Altele',
laptop: "Laptop",
desktop: "Desktop",
monitor: "Monitor",
printer: "Imprimantă",
phone: "Telefon",
tablet: "Tabletă",
network: "Rețea",
peripheral: "Periferic",
other: "Altele",
};
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() {
const { items, allItems, loading, filters, updateFilter, addItem, updateItem, removeItem } = useInventory();
const [viewMode, setViewMode] = useState<ViewMode>('list');
const {
items,
allItems,
loading,
filters,
updateFilter,
addItem,
updateItem,
removeItem,
} = useInventory();
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const handleSubmit = async (data: Omit<InventoryItem, 'id' | 'createdAt' | 'updatedAt'>) => {
if (viewMode === 'edit' && editingItem) {
const handleSubmit = async (
data: Omit<InventoryItem, "id" | "createdAt" | "updatedAt">,
) => {
if (viewMode === "edit" && editingItem) {
await updateItem(editingItem.id, data);
} else {
await addItem(data);
}
setViewMode('list');
setViewMode("list");
setEditingItem(null);
};
@@ -52,81 +95,180 @@ export function ItInventoryModule() {
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total</p><p className="text-2xl font-bold">{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>
<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>
<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>
{viewMode === 'list' && (
{viewMode === "list" && (
<>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Caută..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
<Input
placeholder="Caută..."
value={filters.search}
onChange={(e) => updateFilter("search", e.target.value)}
className="pl-9"
/>
</div>
<Select value={filters.type} onValueChange={(v) => updateFilter('type', v as InventoryItemType | 'all')}>
<SelectTrigger className="w-[140px]"><SelectValue /></SelectTrigger>
<Select
value={filters.type}
onValueChange={(v) =>
updateFilter("type", v as InventoryItemType | "all")
}
>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate tipurile</SelectItem>
{(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>
</Select>
<Select value={filters.status} onValueChange={(v) => updateFilter('status', v as InventoryItemStatus | 'all')}>
<SelectTrigger className="w-[140px]"><SelectValue /></SelectTrigger>
<Select
value={filters.status}
onValueChange={(v) =>
updateFilter("status", v as InventoryItemStatus | "all")
}
>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate</SelectItem>
{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map((s) => (
<SelectItem key={s} value={s}>{STATUS_LABELS[s]}</SelectItem>
))}
{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map(
(s) => (
<SelectItem key={s} value={s}>
{STATUS_LABELS[s]}
</SelectItem>
),
)}
</SelectContent>
</Select>
<Button onClick={() => setViewMode('add')} className="shrink-0">
<Button onClick={() => setViewMode("add")} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
<p className="py-8 text-center text-sm text-muted-foreground">
Se încarcă...
</p>
) : 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">
<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">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">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">Status</th>
<th className="px-3 py-2 text-right font-medium">Acțiuni</th>
</tr></thead>
<th className="px-3 py-2 text-right font-medium">
Acțiuni
</th>
</tr>
</thead>
<tbody>
{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"><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">
{item.vendor && <span>{item.vendor}</span>}
{item.vendor && item.model && <span className="text-muted-foreground"> / </span>}
{item.model && <span className="text-muted-foreground">{item.model}</span>}
{item.vendor && item.model && (
<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 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 text-xs">{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-xs">
{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">
<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" />
</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" />
</Button>
</div>
@@ -140,27 +282,48 @@ export function ItInventoryModule() {
</>
)}
{(viewMode === 'add' || viewMode === 'edit') && (
{(viewMode === "add" || viewMode === "edit") && (
<Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare echipament' : 'Echipament nou'}</CardTitle></CardHeader>
<CardHeader>
<CardTitle>
{viewMode === "edit" ? "Editare echipament" : "Echipament nou"}
</CardTitle>
</CardHeader>
<CardContent>
<InventoryForm
initial={editingItem ?? undefined}
onSubmit={handleSubmit}
onCancel={() => { setViewMode('list'); setEditingItem(null); }}
onCancel={() => {
setViewMode("list");
setEditingItem(null);
}}
/>
</CardContent>
</Card>
)}
{/* Delete confirmation */}
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}>
<Dialog
open={deletingId !== null}
onOpenChange={(open) => {
if (!open) setDeletingId(null);
}}
>
<DialogContent>
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader>
<p className="text-sm">Ești sigur vrei ștergi acest echipament? Acțiunea este ireversibilă.</p>
<DialogHeader>
<DialogTitle>Confirmare ștergere</DialogTitle>
</DialogHeader>
<p className="text-sm">
Ești sigur vrei ștergi acest echipament? Acțiunea este
ireversibilă.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingId(null)}>Anulează</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>Șterge</Button>
<Button variant="outline" onClick={() => setDeletingId(null)}>
Anulează
</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>
Șterge
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -168,60 +331,214 @@ export function ItInventoryModule() {
);
}
function InventoryForm({ initial, onSubmit, onCancel }: {
function InventoryForm({
initial,
onSubmit,
onCancel,
}: {
initial?: InventoryItem;
onSubmit: (data: Omit<InventoryItem, 'id' | 'createdAt' | 'updatedAt'>) => void;
onSubmit: (
data: Omit<InventoryItem, "id" | "createdAt" | "updatedAt">,
) => void;
onCancel: () => void;
}) {
const [name, setName] = useState(initial?.name ?? '');
const [type, setType] = useState<InventoryItemType>(initial?.type ?? 'laptop');
const [serialNumber, setSerialNumber] = useState(initial?.serialNumber ?? '');
const [assignedTo, setAssignedTo] = useState(initial?.assignedTo ?? '');
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
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 ?? '');
const { allContacts } = useContacts();
const [name, setName] = useState(initial?.name ?? "");
const [type, setType] = useState<InventoryItemType>(
initial?.type ?? "laptop",
);
const [serialNumber, setSerialNumber] = useState(initial?.serialNumber ?? "");
const [assignedTo, setAssignedTo] = useState(initial?.assignedTo ?? "");
const [assignedToContactId, setAssignedToContactId] = useState(
initial?.assignedToContactId ?? "",
);
const [assignedToFocused, setAssignedToFocused] = useState(false);
const [company, setCompany] = useState<CompanyId>(
initial?.company ?? "beletage",
);
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 (
<form onSubmit={(e) => {
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit({
name, type, serialNumber, assignedTo, company, location, purchaseDate, status,
ipAddress, macAddress, warrantyExpiry, purchaseCost, rackLocation, vendor, model,
notes, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all',
name,
type,
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><Label>Nume echipament *</Label><Input value={name} 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>
<div>
<Label>Nume echipament *</Label>
<Input
value={name}
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>
</div>
</div>
<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><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>
<Label>Vendor</Label>
<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 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><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><Label>Atribuit</Label><Input value={assignedTo} onChange={(e) => setAssignedTo(e.target.value)} className="mt-1" /></div>
<div>
<Label>Adresă IP</Label>
<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 className="grid gap-4 sm:grid-cols-4">
<div><Label>Companie</Label>
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<div>
<Label>Companie</Label>
<Select
value={company}
onValueChange={(v) => setCompany(v as CompanyId)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="beletage">Beletage</SelectItem>
<SelectItem value="urban-switch">Urban Switch</SelectItem>
@@ -230,24 +547,86 @@ function InventoryForm({ initial, onSubmit, onCancel }: {
</SelectContent>
</Select>
</div>
<div><Label>Locație / Cameră</Label><Input value={location} onChange={(e) => setLocation(e.target.value)} 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>
<div>
<Label>Locație / Cameră</Label>
<Input
value={location}
onChange={(e) => setLocation(e.target.value)}
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>
</div>
</div>
<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><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>
<Label>Data achiziție</Label>
<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><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">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
<Button type="button" variant="outline" onClick={onCancel}>
Anulează
</Button>
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
</div>
</form>
);

View File

@@ -79,9 +79,9 @@ export function useInventory() {
item.name.toLowerCase().includes(q) ||
item.serialNumber.toLowerCase().includes(q) ||
item.assignedTo.toLowerCase().includes(q) ||
item.ipAddress.toLowerCase().includes(q) ||
item.vendor.toLowerCase().includes(q) ||
item.model.toLowerCase().includes(q)
(item.ipAddress ?? '').toLowerCase().includes(q) ||
(item.vendor ?? '').toLowerCase().includes(q) ||
(item.model ?? '').toLowerCase().includes(q)
);
}
return true;

View File

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

View File

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

View File

@@ -1,37 +1,109 @@
'use client';
"use client";
import { useState } from 'react';
import { useState } from "react";
import {
Plus, Pencil, Trash2, Search, Eye, EyeOff, Copy, ExternalLink,
KeyRound, 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 { Switch } from '@/shared/components/ui/switch';
import type { VaultEntry, VaultEntryCategory, CustomField } from '../types';
import { useVault } from '../hooks/use-vault';
Plus,
Pencil,
Trash2,
Search,
Eye,
EyeOff,
Copy,
ExternalLink,
KeyRound,
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 { 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> = {
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 */
function generatePassword(length: number, options: { upper: boolean; lower: boolean; digits: boolean; symbols: boolean }): 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 = '';
function generatePassword(
length: number,
options: {
upper: boolean;
lower: boolean;
digits: boolean;
symbols: boolean;
},
): 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++) {
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() {
const { entries, allEntries, loading, filters, updateFilter, addEntry, updateEntry, removeEntry } = useVault();
const [viewMode, setViewMode] = useState<ViewMode>('list');
const {
entries,
allEntries,
loading,
filters,
updateFilter,
addEntry,
updateEntry,
removeEntry,
} = useVault();
const [viewMode, setViewMode] = useState<ViewMode>("list");
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 [deletingId, setDeletingId] = useState<string | null>(null);
const togglePassword = (id: string) => {
setVisiblePasswords((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;
});
};
@@ -59,16 +143,20 @@ export function PasswordVaultModule() {
await navigator.clipboard.writeText(text);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
} catch { /* silent */ }
} catch {
/* silent */
}
};
const handleSubmit = async (data: Omit<VaultEntry, 'id' | 'createdAt' | 'updatedAt'>) => {
if (viewMode === 'edit' && editingEntry) {
const handleSubmit = async (
data: Omit<VaultEntry, "id" | "createdAt" | "updatedAt">,
) => {
if (viewMode === "edit" && editingEntry) {
await updateEntry(editingEntry.id, data);
} else {
await addEntry(data);
}
setViewMode('list');
setViewMode("list");
setEditingEntry(null);
};
@@ -82,42 +170,89 @@ export function PasswordVaultModule() {
return (
<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">
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>
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total</p><p className="text-2xl font-bold">{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>
<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>
<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>
{viewMode === 'list' && (
{viewMode === "list" && (
<>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Caută..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
<Input
placeholder="Caută..."
value={filters.search}
onChange={(e) => updateFilter("search", e.target.value)}
className="pl-9"
/>
</div>
<Select value={filters.category} onValueChange={(v) => updateFilter('category', v as VaultEntryCategory | 'all')}>
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
<Select
value={filters.category}
onValueChange={(v) =>
updateFilter("category", v as VaultEntryCategory | "all")
}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate</SelectItem>
{(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map((c) => (
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>
))}
{(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map(
(c) => (
<SelectItem key={c} value={c}>
{CATEGORY_LABELS[c]}
</SelectItem>
),
)}
</SelectContent>
</Select>
<Button onClick={() => setViewMode('add')} className="shrink-0">
<Button onClick={() => setViewMode("add")} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
<p className="py-8 text-center text-sm text-muted-foreground">
Se încarcă...
</p>
) : 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">
{entries.map((entry) => (
@@ -126,20 +261,44 @@ export function PasswordVaultModule() {
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2">
<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>
<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">
<code className="text-xs">
{visiblePasswords.has(entry.id) ? entry.encryptedPassword : '••••••••••'}
{visiblePasswords.has(entry.id)
? entry.password
: "••••••••••"}
</code>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => togglePassword(entry.id)}>
{visiblePasswords.has(entry.id) ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => togglePassword(entry.id)}
>
{visiblePasswords.has(entry.id) ? (
<EyeOff className="h-3 w-3" />
) : (
<Eye className="h-3 w-3" />
)}
</Button>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => handleCopy(entry.encryptedPassword, entry.id)}>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => handleCopy(entry.password, entry.id)}
>
<Copy className="h-3 w-3" />
</Button>
{copiedId === entry.id && <span className="text-[10px] text-green-500">Copiat!</span>}
{copiedId === entry.id && (
<span className="text-[10px] text-green-500">
Copiat!
</span>
)}
</div>
{entry.url && (
<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 && (
<div className="mt-1 flex flex-wrap gap-1">
{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}
</Badge>
))}
@@ -157,10 +320,23 @@ export function PasswordVaultModule() {
)}
</div>
<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" />
</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" />
</Button>
</div>
@@ -172,23 +348,48 @@ export function PasswordVaultModule() {
</>
)}
{(viewMode === 'add' || viewMode === 'edit') && (
{(viewMode === "add" || viewMode === "edit") && (
<Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare' : 'Intrare nouă'}</CardTitle></CardHeader>
<CardHeader>
<CardTitle>
{viewMode === "edit" ? "Editare" : "Intrare nouă"}
</CardTitle>
</CardHeader>
<CardContent>
<VaultForm initial={editingEntry ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingEntry(null); }} />
<VaultForm
initial={editingEntry ?? undefined}
onSubmit={handleSubmit}
onCancel={() => {
setViewMode("list");
setEditingEntry(null);
}}
/>
</CardContent>
</Card>
)}
{/* Delete confirmation */}
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}>
<Dialog
open={deletingId !== null}
onOpenChange={(open) => {
if (!open) setDeletingId(null);
}}
>
<DialogContent>
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader>
<p className="text-sm">Ești sigur vrei ștergi această intrare? Acțiunea este ireversibilă.</p>
<DialogHeader>
<DialogTitle>Confirmare ștergere</DialogTitle>
</DialogHeader>
<p className="text-sm">
Ești sigur vrei ștergi această intrare? Acțiunea este
ireversibilă.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingId(null)}>Anulează</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>Șterge</Button>
<Button variant="outline" onClick={() => setDeletingId(null)}>
Anulează
</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>
Șterge
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -196,18 +397,29 @@ export function PasswordVaultModule() {
);
}
function VaultForm({ initial, onSubmit, onCancel }: {
function VaultForm({
initial,
onSubmit,
onCancel,
}: {
initial?: VaultEntry;
onSubmit: (data: Omit<VaultEntry, 'id' | 'createdAt' | 'updatedAt'>) => void;
onSubmit: (data: Omit<VaultEntry, "id" | "createdAt" | "updatedAt">) => void;
onCancel: () => void;
}) {
const [label, setLabel] = useState(initial?.label ?? '');
const [username, setUsername] = useState(initial?.username ?? '');
const [password, setPassword] = useState(initial?.encryptedPassword ?? '');
const [url, setUrl] = useState(initial?.url ?? '');
const [category, setCategory] = useState<VaultEntryCategory>(initial?.category ?? 'web');
const [notes, setNotes] = useState(initial?.notes ?? '');
const [customFields, setCustomFields] = useState<CustomField[]>(initial?.customFields ?? []);
const [label, setLabel] = useState(initial?.label ?? "");
const [username, setUsername] = useState(initial?.username ?? "");
const [password, setPassword] = useState(initial?.password ?? "");
const [url, setUrl] = useState(initial?.url ?? "");
const [category, setCategory] = useState<VaultEntryCategory>(
initial?.category ?? "web",
);
const [company, setCompany] = useState<CompanyId>(
initial?.company ?? "beletage",
);
const [notes, setNotes] = useState(initial?.notes ?? "");
const [customFields, setCustomFields] = useState<CustomField[]>(
initial?.customFields ?? [],
);
// Password generator state
const [genLength, setGenLength] = useState(16);
@@ -216,16 +428,33 @@ function VaultForm({ initial, onSubmit, onCancel }: {
const [genDigits, setGenDigits] = useState(true);
const [genSymbols, setGenSymbols] = useState(true);
const strength = getPasswordStrength(password);
const handleGenerate = () => {
setPassword(generatePassword(genLength, { upper: genUpper, lower: genLower, digits: genDigits, symbols: genSymbols }));
setPassword(
generatePassword(genLength, {
upper: genUpper,
lower: genLower,
digits: genDigits,
symbols: genSymbols,
}),
);
};
const addCustomField = () => {
setCustomFields([...customFields, { key: '', value: '' }]);
setCustomFields([...customFields, { key: "", value: "" }]);
};
const updateCustomField = (index: number, field: keyof CustomField, value: string) => {
setCustomFields(customFields.map((cf, i) => i === index ? { ...cf, [field]: value } : cf));
const updateCustomField = (
index: number,
field: keyof CustomField,
value: string,
) => {
setCustomFields(
customFields.map((cf, i) =>
i === index ? { ...cf, [field]: value } : cf,
),
);
};
const removeCustomField = (index: number) => {
@@ -233,61 +462,218 @@ function VaultForm({ initial, onSubmit, onCancel }: {
};
return (
<form onSubmit={(e) => {
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit({
label, username, encryptedPassword: password, url, category, notes,
label,
username,
password,
url,
category,
company,
notes,
customFields: customFields.filter((cf) => cf.key.trim()),
tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'admin',
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><Label>Nume/Etichetă *</Label><Input value={label} 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>
<div>
<Label>Nume/Etichetă *</Label>
<Input
value={label}
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>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div><Label>Utilizator</Label><Input value={username} onChange={(e) => setUsername(e.target.value)} className="mt-1" /></div>
<div>
<Label>Companie</Label>
<Select
value={company}
onValueChange={(v) => setCompany(v as CompanyId)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(COMPANY_LABELS) as CompanyId[]).map((c) => (
<SelectItem key={c} value={c}>
{COMPANY_LABELS[c]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Utilizator</Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1"
/>
</div>
</div>
<div>
<Label>Parolă</Label>
<div className="mt-1 flex gap-1.5">
<Input type="text" value={password} onChange={(e) => setPassword(e.target.value)} className="flex-1 font-mono text-sm" />
<Button type="button" variant="outline" size="icon" onClick={handleGenerate} title="Generează parolă">
<Input
type="text"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="flex-1 font-mono text-sm"
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleGenerate}
title="Generează parolă"
>
<KeyRound className="h-4 w-4" />
</Button>
</div>
{password && (
<div className="mt-2 space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Forță:</span>
<span
className={
strength.level === 3
? "text-emerald-600 font-medium"
: strength.level === 2
? "text-green-600 font-medium"
: strength.level === 1
? "text-yellow-600 font-medium"
: "text-red-600 font-medium"
}
>
{strength.label}
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={`h-full ${strength.color} transition-all`}
style={{ width: `${(strength.level + 1) * 25}%` }}
/>
</div>
</div>
)}
</div>
{/* Password generator options */}
<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 items-center gap-2">
<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 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"><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}>
<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">
<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ă
</Button>
</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 */}
<div>
<div className="flex items-center justify-between">
<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
</Button>
</div>
@@ -295,9 +681,27 @@ function VaultForm({ initial, onSubmit, onCancel }: {
<div className="mt-2 space-y-1.5">
{customFields.map((cf, i) => (
<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 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)}>
<Input
placeholder="Cheie"
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" />
</Button>
</div>
@@ -306,10 +710,20 @@ function VaultForm({ initial, onSubmit, onCancel }: {
)}
</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">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
<Button type="button" variant="outline" onClick={onCancel}>
Anulează
</Button>
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
</div>
</form>
);

View File

@@ -1,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 =
| 'web'
| 'email'
| 'server'
| 'database'
| 'api'
| 'other';
| "web"
| "email"
| "server"
| "database"
| "api"
| "other";
/** Custom key-value field */
export interface CustomField {
@@ -18,9 +19,10 @@ export interface VaultEntry {
id: string;
label: string;
username: string;
encryptedPassword: string;
password: string;
url: string;
category: VaultEntryCategory;
company: CompanyId;
/** Custom key-value fields */
customFields: CustomField[];
notes: string;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,187 @@
'use client';
import { useState, useMemo } 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 { Badge } from '@/shared/components/ui/badge';
import { DEADLINE_CATALOG, CATEGORY_LABELS } from '../services/deadline-catalog';
import { computeDueDate } from '../services/working-days';
import type { DeadlineCategory, DeadlineTypeDef } from '../types';
interface DeadlineAddDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
entryDate: string;
onAdd: (typeId: string, startDate: string) => void;
}
type Step = 'category' | 'type' | 'date';
const CATEGORIES: DeadlineCategory[] = ['avize', 'completari', 'analiza', 'autorizare', 'publicitate'];
export function DeadlineAddDialog({ open, onOpenChange, entryDate, onAdd }: DeadlineAddDialogProps) {
const [step, setStep] = useState<Step>('category');
const [selectedCategory, setSelectedCategory] = useState<DeadlineCategory | null>(null);
const [selectedType, setSelectedType] = useState<DeadlineTypeDef | null>(null);
const [startDate, setStartDate] = useState(entryDate);
const typesForCategory = useMemo(() => {
if (!selectedCategory) return [];
return DEADLINE_CATALOG.filter((d) => d.category === selectedCategory);
}, [selectedCategory]);
const dueDatePreview = useMemo(() => {
if (!selectedType || !startDate) return null;
const start = new Date(startDate);
start.setHours(0, 0, 0, 0);
const due = computeDueDate(start, selectedType.days, selectedType.dayType, selectedType.isBackwardDeadline);
return due.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
}, [selectedType, startDate]);
const handleClose = () => {
setStep('category');
setSelectedCategory(null);
setSelectedType(null);
setStartDate(entryDate);
onOpenChange(false);
};
const handleCategorySelect = (cat: DeadlineCategory) => {
setSelectedCategory(cat);
setStep('type');
};
const handleTypeSelect = (typ: DeadlineTypeDef) => {
setSelectedType(typ);
if (!typ.requiresCustomStartDate) {
setStartDate(entryDate);
}
setStep('date');
};
const handleBack = () => {
if (step === 'type') {
setStep('category');
setSelectedCategory(null);
} else if (step === 'date') {
setStep('type');
setSelectedType(null);
}
};
const handleConfirm = () => {
if (!selectedType || !startDate) return;
onAdd(selectedType.id, startDate);
handleClose();
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) handleClose(); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{step === 'category' && 'Adaugă termen legal — Categorie'}
{step === 'type' && `Adaugă termen legal — ${selectedCategory ? CATEGORY_LABELS[selectedCategory] : ''}`}
{step === 'date' && `Adaugă termen legal — ${selectedType?.label ?? ''}`}
</DialogTitle>
</DialogHeader>
{step === 'category' && (
<div className="grid gap-2 py-2">
{CATEGORIES.map((cat) => (
<button
key={cat}
type="button"
className="flex items-center justify-between rounded-lg border p-3 text-left transition-colors hover:bg-accent"
onClick={() => handleCategorySelect(cat)}
>
<span className="font-medium text-sm">{CATEGORY_LABELS[cat]}</span>
<Badge variant="outline" className="text-xs">
{DEADLINE_CATALOG.filter((d) => d.category === cat).length}
</Badge>
</button>
))}
</div>
)}
{step === 'type' && (
<div className="grid gap-2 py-2">
{typesForCategory.map((typ) => (
<button
key={typ.id}
type="button"
className="rounded-lg border p-3 text-left transition-colors hover:bg-accent"
onClick={() => handleTypeSelect(typ)}
>
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{typ.label}</span>
<Badge variant="outline" className="text-[10px]">
{typ.days} {typ.dayType === 'working' ? 'zile lucr.' : 'zile cal.'}
</Badge>
{typ.tacitApprovalApplicable && (
<Badge variant="outline" className="text-[10px] text-blue-600">tacit</Badge>
)}
{typ.isBackwardDeadline && (
<Badge variant="outline" className="text-[10px] text-orange-600">înapoi</Badge>
)}
</div>
<p className="text-xs text-muted-foreground mt-1">{typ.description}</p>
</button>
))}
</div>
)}
{step === 'date' && selectedType && (
<div className="space-y-4 py-2">
<div>
<Label>{selectedType.startDateLabel}</Label>
{selectedType.startDateHint && (
<p className="text-xs text-muted-foreground mt-0.5">{selectedType.startDateHint}</p>
)}
<Input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="mt-1"
/>
</div>
{dueDatePreview && (
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-xs text-muted-foreground">
{selectedType.isBackwardDeadline ? 'Termen limită depunere' : 'Termen limită calculat'}
</p>
<p className="text-lg font-bold">{dueDatePreview}</p>
<p className="text-xs text-muted-foreground mt-1">
{selectedType.days} {selectedType.dayType === 'working' ? 'zile lucrătoare' : 'zile calendaristice'}
{selectedType.isBackwardDeadline ? ' ÎNAINTE' : ' de la data start'}
</p>
{selectedType.legalReference && (
<p className="text-xs text-muted-foreground mt-1 italic">Ref: {selectedType.legalReference}</p>
)}
</div>
)}
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
{step !== 'category' && (
<Button type="button" variant="outline" onClick={handleBack}>Înapoi</Button>
)}
{step === 'category' && (
<Button type="button" variant="outline" onClick={handleClose}>Anulează</Button>
)}
{step === 'date' && (
<Button type="button" onClick={handleConfirm} disabled={!startDate}>
Adaugă termen
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import { Clock, CheckCircle2, X } from 'lucide-react';
import { Badge } from '@/shared/components/ui/badge';
import { Button } from '@/shared/components/ui/button';
import type { TrackedDeadline } from '../types';
import { getDeadlineType } from '../services/deadline-catalog';
import { getDeadlineDisplayStatus } from '../services/deadline-service';
import { cn } from '@/shared/lib/utils';
interface DeadlineCardProps {
deadline: TrackedDeadline;
onResolve: (deadline: TrackedDeadline) => void;
onRemove: (deadlineId: string) => void;
}
const VARIANT_CLASSES: Record<string, string> = {
green: 'border-green-500/30 bg-green-50 dark:bg-green-950/20',
yellow: 'border-yellow-500/30 bg-yellow-50 dark:bg-yellow-950/20',
red: 'border-red-500/30 bg-red-50 dark:bg-red-950/20',
blue: 'border-blue-500/30 bg-blue-50 dark:bg-blue-950/20',
gray: 'border-muted bg-muted/30',
};
const BADGE_CLASSES: Record<string, string> = {
green: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
yellow: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
red: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
blue: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
gray: 'bg-muted text-muted-foreground',
};
export function DeadlineCard({ deadline, onResolve, onRemove }: DeadlineCardProps) {
const def = getDeadlineType(deadline.typeId);
const status = getDeadlineDisplayStatus(deadline);
return (
<div className={cn('flex items-center gap-3 rounded-lg border p-3', VARIANT_CLASSES[status.variant] ?? '')}>
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{def?.label ?? deadline.typeId}</span>
<Badge className={cn('text-[10px] border-0', BADGE_CLASSES[status.variant] ?? '')}>
{status.label}
{status.daysRemaining !== null && status.variant !== 'blue' && (
<span className="ml-1">
({status.daysRemaining < 0 ? `${Math.abs(status.daysRemaining)}z depășit` : `${status.daysRemaining}z`})
</span>
)}
</Badge>
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{def?.isBackwardDeadline ? 'Termen limită' : 'Start'}: {formatDate(deadline.startDate)}
{' → '}
{def?.isBackwardDeadline ? 'Depunere până la' : 'Termen'}: {formatDate(deadline.dueDate)}
{def?.dayType === 'working' && <span className="ml-1">(zile lucrătoare)</span>}
</div>
</div>
<div className="flex gap-1 shrink-0">
{deadline.resolution === 'pending' && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-green-600"
onClick={() => onResolve(deadline)}
title="Rezolvă"
>
<CheckCircle2 className="h-3.5 w-3.5" />
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => onRemove(deadline.id)}
title="Șterge"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch {
return iso;
}
}

View File

@@ -0,0 +1,160 @@
'use client';
import { useState, useMemo } from 'react';
import { Card, CardContent } from '@/shared/components/ui/card';
import { Badge } from '@/shared/components/ui/badge';
import { Label } from '@/shared/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import { Button } from '@/shared/components/ui/button';
import type { RegistryEntry, TrackedDeadline, DeadlineResolution, DeadlineCategory } from '../types';
import { aggregateDeadlines } from '../services/deadline-service';
import { CATEGORY_LABELS, getDeadlineType } from '../services/deadline-catalog';
import { useDeadlineFilters } from '../hooks/use-deadline-filters';
import { DeadlineTable } from './deadline-table';
import { DeadlineResolveDialog } from './deadline-resolve-dialog';
interface DeadlineDashboardProps {
entries: RegistryEntry[];
onResolveDeadline: (entryId: string, deadlineId: string, resolution: DeadlineResolution, note: string, chainNext: boolean) => void;
onAddChainedDeadline: (entryId: string, typeId: string, startDate: string, parentId: string) => void;
}
const RESOLUTION_LABELS: Record<string, string> = {
pending: 'În așteptare',
completed: 'Finalizat',
'aprobat-tacit': 'Aprobat tacit',
respins: 'Respins',
anulat: 'Anulat',
};
export function DeadlineDashboard({ entries, onResolveDeadline, onAddChainedDeadline }: DeadlineDashboardProps) {
const { filters, updateFilter } = useDeadlineFilters();
const [resolvingEntry, setResolvingEntry] = useState<string | null>(null);
const [resolvingDeadline, setResolvingDeadline] = useState<TrackedDeadline | null>(null);
const stats = useMemo(() => aggregateDeadlines(entries), [entries]);
const filteredRows = useMemo(() => {
return stats.all.filter((row) => {
if (filters.category !== 'all') {
const def = getDeadlineType(row.deadline.typeId);
if (def && def.category !== filters.category) return false;
}
if (filters.resolution !== 'all') {
// Map tacit display status to actual resolution filter
if (filters.resolution === 'pending') {
if (row.deadline.resolution !== 'pending') return false;
} else if (row.deadline.resolution !== filters.resolution) {
return false;
}
}
if (filters.urgentOnly) {
if (row.status.variant !== 'yellow' && row.status.variant !== 'red') return false;
}
return true;
});
}, [stats.all, filters]);
const handleResolveClick = (entryId: string, deadline: TrackedDeadline) => {
setResolvingEntry(entryId);
setResolvingDeadline(deadline);
};
const handleResolve = (resolution: DeadlineResolution, note: string, chainNext: boolean) => {
if (!resolvingEntry || !resolvingDeadline) return;
onResolveDeadline(resolvingEntry, resolvingDeadline.id, resolution, note, chainNext);
// Handle chain creation
if (chainNext) {
const def = getDeadlineType(resolvingDeadline.typeId);
if (def?.chainNextTypeId) {
const resolvedDate = new Date().toISOString().slice(0, 10);
onAddChainedDeadline(resolvingEntry, def.chainNextTypeId, resolvedDate, resolvingDeadline.id);
}
}
setResolvingEntry(null);
setResolvingDeadline(null);
};
return (
<div className="space-y-4">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Active" value={stats.active} />
<StatCard label="Urgente" value={stats.urgent} variant={stats.urgent > 0 ? 'destructive' : undefined} />
<StatCard label="Depășit termen" value={stats.overdue} variant={stats.overdue > 0 ? 'destructive' : undefined} />
<StatCard label="Aprobat tacit" value={stats.tacit} variant={stats.tacit > 0 ? 'blue' : undefined} />
</div>
{/* Filters */}
<div className="flex flex-wrap items-end gap-3">
<div>
<Label className="text-xs">Categorie</Label>
<Select value={filters.category} onValueChange={(v) => updateFilter('category', v as DeadlineCategory | 'all')}>
<SelectTrigger className="mt-1 w-[160px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate</SelectItem>
{(Object.entries(CATEGORY_LABELS) as [DeadlineCategory, string][]).map(([key, label]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Status</Label>
<Select value={filters.resolution} onValueChange={(v) => updateFilter('resolution', v as DeadlineResolution | 'all')}>
<SelectTrigger className="mt-1 w-[160px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate</SelectItem>
{Object.entries(RESOLUTION_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant={filters.urgentOnly ? 'default' : 'outline'}
size="sm"
onClick={() => updateFilter('urgentOnly', !filters.urgentOnly)}
>
Doar urgente
</Button>
</div>
{/* Table */}
<DeadlineTable rows={filteredRows} onResolve={handleResolveClick} />
<p className="text-xs text-muted-foreground">
{filteredRows.length} din {stats.all.length} termene afișate
</p>
<DeadlineResolveDialog
open={resolvingDeadline !== null}
deadline={resolvingDeadline}
onOpenChange={(open) => {
if (!open) {
setResolvingEntry(null);
setResolvingDeadline(null);
}
}}
onResolve={handleResolve}
/>
</div>
);
}
function StatCard({ label, value, variant }: { label: string; value: number; variant?: 'destructive' | 'blue' }) {
return (
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">{label}</p>
<p className={`text-2xl font-bold ${
variant === 'destructive' && value > 0 ? 'text-destructive' : ''
}${variant === 'blue' && value > 0 ? 'text-blue-600' : ''}`}>
{value}
</p>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,100 @@
'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 { Label } from '@/shared/components/ui/label';
import { Textarea } from '@/shared/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import type { TrackedDeadline, DeadlineResolution } from '../types';
import { getDeadlineType } from '../services/deadline-catalog';
interface DeadlineResolveDialogProps {
open: boolean;
deadline: TrackedDeadline | null;
onOpenChange: (open: boolean) => void;
onResolve: (resolution: DeadlineResolution, note: string, chainNext: boolean) => void;
}
const RESOLUTION_OPTIONS: Array<{ value: DeadlineResolution; label: string }> = [
{ value: 'completed', label: 'Finalizat' },
{ value: 'aprobat-tacit', label: 'Aprobat tacit' },
{ value: 'respins', label: 'Respins' },
{ value: 'anulat', label: 'Anulat' },
];
export function DeadlineResolveDialog({ open, deadline, onOpenChange, onResolve }: DeadlineResolveDialogProps) {
const [resolution, setResolution] = useState<DeadlineResolution>('completed');
const [note, setNote] = useState('');
if (!deadline) return null;
const def = getDeadlineType(deadline.typeId);
const hasChain = def?.chainNextTypeId && (resolution === 'completed' || resolution === 'aprobat-tacit');
const handleResolve = () => {
onResolve(resolution, note, !!hasChain);
setResolution('completed');
setNote('');
onOpenChange(false);
};
const handleClose = () => {
setResolution('completed');
setNote('');
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) handleClose(); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Rezolvă {def?.label ?? deadline.typeId}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div>
<Label>Rezoluție</Label>
<Select value={resolution} onValueChange={(v) => setResolution(v as DeadlineResolution)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{RESOLUTION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Notă (opțional)</Label>
<Textarea
value={note}
onChange={(e) => setNote(e.target.value)}
rows={2}
className="mt-1"
placeholder="Detalii rezoluție..."
/>
</div>
{hasChain && def?.chainNextActionLabel && (
<div className="rounded-lg border border-blue-500/30 bg-blue-50 dark:bg-blue-950/20 p-3">
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">
Termen înlănțuit disponibil
</p>
<p className="text-xs text-blue-700 dark:text-blue-300 mt-1">
La rezolvare veți fi întrebat dacă doriți: {def.chainNextActionLabel}
</p>
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>Anulează</Button>
<Button type="button" onClick={handleResolve}>Rezolvă</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,127 @@
'use client';
import { CheckCircle2 } from 'lucide-react';
import { Badge } from '@/shared/components/ui/badge';
import { Button } from '@/shared/components/ui/button';
import type { TrackedDeadline, RegistryEntry } from '../types';
import type { DeadlineDisplayStatus } from '../services/deadline-service';
import { getDeadlineType, CATEGORY_LABELS } from '../services/deadline-catalog';
import { cn } from '@/shared/lib/utils';
interface DeadlineRow {
deadline: TrackedDeadline;
entry: RegistryEntry;
status: DeadlineDisplayStatus;
}
interface DeadlineTableProps {
rows: DeadlineRow[];
onResolve: (entryId: string, deadline: TrackedDeadline) => void;
}
const BADGE_CLASSES: Record<string, string> = {
green: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 border-0',
yellow: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 border-0',
red: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 border-0',
blue: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 border-0',
gray: 'bg-muted text-muted-foreground border-0',
};
const ROW_CLASSES: Record<string, string> = {
red: 'bg-destructive/5',
yellow: 'bg-yellow-50/50 dark:bg-yellow-950/10',
blue: 'bg-blue-50/50 dark:bg-blue-950/10',
};
export function DeadlineTable({ rows, onResolve }: DeadlineTableProps) {
if (rows.length === 0) {
return (
<p className="py-8 text-center text-sm text-muted-foreground">
Niciun termen legal urmărit.
</p>
);
}
return (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
<th className="px-3 py-2 text-left font-medium">Nr. înreg.</th>
<th className="px-3 py-2 text-left font-medium">Categorie</th>
<th className="px-3 py-2 text-left font-medium">Tip termen</th>
<th className="px-3 py-2 text-left font-medium">Data start</th>
<th className="px-3 py-2 text-left font-medium">Termen limită</th>
<th className="px-3 py-2 text-left font-medium">Zile</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>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const def = getDeadlineType(row.deadline.typeId);
return (
<tr
key={row.deadline.id}
className={cn(
'border-b transition-colors hover:bg-muted/20',
ROW_CLASSES[row.status.variant] ?? '',
)}
>
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">{row.entry.number}</td>
<td className="px-3 py-2 text-xs">
{def ? CATEGORY_LABELS[def.category] : '—'}
</td>
<td className="px-3 py-2 text-xs">
<span className="font-medium">{def?.label ?? row.deadline.typeId}</span>
{def?.dayType === 'working' && (
<span className="ml-1 text-muted-foreground">(lucr.)</span>
)}
</td>
<td className="px-3 py-2 text-xs whitespace-nowrap">{formatDate(row.deadline.startDate)}</td>
<td className="px-3 py-2 text-xs whitespace-nowrap font-medium">{formatDate(row.deadline.dueDate)}</td>
<td className="px-3 py-2 text-xs whitespace-nowrap">
{row.status.daysRemaining !== null ? (
<span className={cn(row.status.daysRemaining < 0 && 'text-destructive font-medium')}>
{row.status.daysRemaining < 0
? `${Math.abs(row.status.daysRemaining)}z depășit`
: `${row.status.daysRemaining}z`}
</span>
) : (
'—'
)}
</td>
<td className="px-3 py-2">
<Badge className={cn('text-[10px]', BADGE_CLASSES[row.status.variant] ?? '')}>
{row.status.label}
</Badge>
</td>
<td className="px-3 py-2 text-right">
{row.deadline.resolution === 'pending' && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-green-600"
onClick={() => onResolve(row.entry.id, row.deadline)}
title="Rezolvă"
>
<CheckCircle2 className="h-3.5 w-3.5" />
</Button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch {
return iso;
}
}

View File

@@ -1,10 +1,11 @@
'use client';
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { Plus } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
import { Badge } from '@/shared/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/components/ui/tabs';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/shared/components/ui/dialog';
@@ -12,8 +13,10 @@ import { useRegistry } from '../hooks/use-registry';
import { RegistryFilters } from './registry-filters';
import { RegistryTable } from './registry-table';
import { RegistryEntryForm } from './registry-entry-form';
import { DeadlineDashboard } from './deadline-dashboard';
import { getOverdueDays } from '../services/registry-service';
import type { RegistryEntry } from '../types';
import { aggregateDeadlines } from '../services/deadline-service';
import type { RegistryEntry, DeadlineResolution } from '../types';
type ViewMode = 'list' | 'add' | 'edit';
@@ -21,6 +24,7 @@ export function RegistraturaModule() {
const {
entries, allEntries, loading, filters, updateFilter,
addEntry, updateEntry, removeEntry, closeEntry,
addDeadline, resolveDeadline, removeDeadline,
} = useRegistry();
const [viewMode, setViewMode] = useState<ViewMode>('list');
@@ -50,7 +54,7 @@ export function RegistraturaModule() {
const handleCloseRequest = (id: string) => {
const entry = allEntries.find((e) => e.id === id);
if (entry && entry.linkedEntryIds.length > 0) {
if (entry && (entry.linkedEntryIds ?? []).length > 0) {
setClosingId(id);
} else {
closeEntry(id, false);
@@ -69,6 +73,21 @@ export function RegistraturaModule() {
setEditingEntry(null);
};
// ── Dashboard deadline resolve/chain handlers ──
const handleDashboardResolve = async (
entryId: string,
deadlineId: string,
resolution: DeadlineResolution,
note: string,
_chainNext: boolean,
) => {
await resolveDeadline(entryId, deadlineId, resolution, note);
};
const handleAddChainedDeadline = async (entryId: string, typeId: string, startDate: string, parentId: string) => {
await addDeadline(entryId, typeId, startDate, parentId);
};
// Stats
const total = allEntries.length;
const open = allEntries.filter((e) => e.status === 'deschis').length;
@@ -79,9 +98,26 @@ export function RegistraturaModule() {
}).length;
const intrat = allEntries.filter((e) => e.direction === 'intrat').length;
const deadlineStats = useMemo(() => aggregateDeadlines(allEntries), [allEntries]);
const urgentDeadlines = deadlineStats.urgent + deadlineStats.overdue;
const closingEntry = closingId ? allEntries.find((e) => e.id === closingId) : null;
return (
<Tabs defaultValue="registru">
<TabsList>
<TabsTrigger value="registru">Registru</TabsTrigger>
<TabsTrigger value="termene">
Termene legale
{urgentDeadlines > 0 && (
<Badge variant="destructive" className="ml-1.5 text-[10px] px-1.5 py-0">
{urgentDeadlines}
</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="registru">
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
@@ -158,7 +194,7 @@ export function RegistraturaModule() {
</DialogHeader>
<div className="py-2">
<p className="text-sm">
Această înregistrare are {closingEntry?.linkedEntryIds.length ?? 0} înregistrări legate.
Această înregistrare are {closingEntry?.linkedEntryIds?.length ?? 0} înregistrări legate.
Vrei le închizi și pe acestea?
</p>
</div>
@@ -174,6 +210,16 @@ export function RegistraturaModule() {
</DialogContent>
</Dialog>
</div>
</TabsContent>
<TabsContent value="termene">
<DeadlineDashboard
entries={allEntries}
onResolveDeadline={handleDashboardResolve}
onAddChainedDeadline={handleAddChainedDeadline}
/>
</TabsContent>
</Tabs>
);
}

View File

@@ -1,55 +1,151 @@
'use client';
"use client";
import { useState, useMemo, useRef } from 'react';
import { Paperclip, X } from 'lucide-react';
import type { CompanyId } from '@/core/auth/types';
import type { RegistryEntry, RegistryDirection, RegistryStatus, DocumentType, RegistryAttachment } from '../types';
import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label';
import { Textarea } from '@/shared/components/ui/textarea';
import { Button } from '@/shared/components/ui/button';
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 { useState, useMemo, useRef } from "react";
import { Paperclip, X, Clock, Plus } from "lucide-react";
import type { CompanyId } from "@/core/auth/types";
import type {
RegistryEntry,
RegistryDirection,
RegistryStatus,
DocumentType,
RegistryAttachment,
TrackedDeadline,
DeadlineResolution,
} from "../types";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Textarea } from "@/shared/components/ui/textarea";
import { Button } from "@/shared/components/ui/button";
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 {
initial?: RegistryEntry;
allEntries?: RegistryEntry[];
onSubmit: (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => void;
onSubmit: (
data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
) => void;
onCancel: () => void;
}
const DOC_TYPE_LABELS: Record<DocumentType, string> = {
contract: 'Contract',
oferta: 'Ofertă',
factura: 'Factură',
scrisoare: 'Scrisoare',
aviz: 'Aviz',
'nota-de-comanda': 'Notă de comandă',
raport: 'Raport',
cerere: 'Cerere',
altele: 'Altele',
contract: "Contract",
oferta: "Ofertă",
factura: "Factură",
scrisoare: "Scrisoare",
aviz: "Aviz",
"nota-de-comanda": "Notă de comandă",
raport: "Raport",
cerere: "Cerere",
altele: "Altele",
};
export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: RegistryEntryFormProps) {
export function RegistryEntryForm({
initial,
allEntries,
onSubmit,
onCancel,
}: RegistryEntryFormProps) {
const { allContacts } = useContacts();
const fileInputRef = useRef<HTMLInputElement>(null);
const [direction, setDirection] = useState<RegistryDirection>(initial?.direction ?? 'intrat');
const [documentType, setDocumentType] = useState<DocumentType>(initial?.documentType ?? 'scrisoare');
const [subject, setSubject] = useState(initial?.subject ?? '');
const [date, setDate] = useState(initial?.date ?? new Date().toISOString().slice(0, 10));
const [sender, setSender] = useState(initial?.sender ?? '');
const [senderContactId, setSenderContactId] = useState(initial?.senderContactId ?? '');
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 [direction, setDirection] = useState<RegistryDirection>(
initial?.direction ?? "intrat",
);
const [documentType, setDocumentType] = useState<DocumentType>(
initial?.documentType ?? "scrisoare",
);
const [subject, setSubject] = useState(initial?.subject ?? "");
const [date, setDate] = useState(
initial?.date ?? new Date().toISOString().slice(0, 10),
);
const [sender, setSender] = useState(initial?.sender ?? "");
const [senderContactId, setSenderContactId] = useState(
initial?.senderContactId ?? "",
);
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 ──
const [deadlineAddOpen, setDeadlineAddOpen] = useState(false);
const [resolvingDeadline, setResolvingDeadline] =
useState<TrackedDeadline | null>(null);
const handleAddDeadline = (
typeId: string,
startDate: string,
chainParentId?: string,
) => {
const tracked = createTrackedDeadline(typeId, startDate, chainParentId);
if (tracked) setTrackedDeadlines((prev) => [...prev, tracked]);
};
const handleResolveDeadline = (
resolution: DeadlineResolution,
note: string,
chainNext: boolean,
) => {
if (!resolvingDeadline) return;
const resolved = resolveDeadlineFn(resolvingDeadline, resolution, note);
setTrackedDeadlines((prev) =>
prev.map((d) => (d.id === resolved.id ? resolved : d)),
);
// Handle chain
if (chainNext) {
const def = getDeadlineType(resolvingDeadline.typeId);
if (def?.chainNextTypeId) {
const resolvedDate = new Date().toISOString().slice(0, 10);
const chained = createTrackedDeadline(
def.chainNextTypeId,
resolvedDate,
resolvingDeadline.id,
);
if (chained) setTrackedDeadlines((prev) => [...prev, chained]);
}
}
setResolvingDeadline(null);
};
const handleRemoveDeadline = (deadlineId: string) => {
setTrackedDeadlines((prev) => prev.filter((d) => d.id !== deadlineId));
};
// ── Sender/Recipient autocomplete suggestions ──
const [senderFocused, setSenderFocused] = useState(false);
@@ -58,13 +154,25 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
const senderSuggestions = useMemo(() => {
if (!sender || sender.length < 2) return [];
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]);
const recipientSuggestions = useMemo(() => {
if (!recipient || recipient.length < 2) return [];
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]);
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -88,7 +196,7 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
};
reader.readAsDataURL(file);
}
if (fileInputRef.current) fileInputRef.current.value = '';
if (fileInputRef.current) fileInputRef.current.value = "";
};
const removeAttachment = (id: string) => {
@@ -111,9 +219,11 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
deadline: deadline || undefined,
linkedEntryIds,
attachments,
trackedDeadlines:
trackedDeadlines.length > 0 ? trackedDeadlines : undefined,
notes,
tags: initial?.tags ?? [],
visibility: initial?.visibility ?? 'all',
visibility: initial?.visibility ?? "all",
});
};
@@ -123,8 +233,13 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
<div className="grid gap-4 sm:grid-cols-3">
<div>
<Label>Direcție</Label>
<Select value={direction} onValueChange={(v) => setDirection(v as RegistryDirection)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<Select
value={direction}
onValueChange={(v) => setDirection(v as RegistryDirection)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="intrat">Intrat</SelectItem>
<SelectItem value="iesit">Ieșit</SelectItem>
@@ -133,25 +248,44 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
</div>
<div>
<Label>Tip document</Label>
<Select value={documentType} onValueChange={(v) => setDocumentType(v as DocumentType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<Select
value={documentType}
onValueChange={(v) => setDocumentType(v as DocumentType)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<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>
</Select>
</div>
<div>
<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>
{/* Subject */}
<div>
<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>
{/* Sender / Recipient with autocomplete */}
@@ -160,7 +294,10 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
<Label>Expeditor</Label>
<Input
value={sender}
onChange={(e) => { setSender(e.target.value); setSenderContactId(''); }}
onChange={(e) => {
setSender(e.target.value);
setSenderContactId("");
}}
onFocus={() => setSenderFocused(true)}
onBlur={() => setTimeout(() => setSenderFocused(false), 200)}
className="mt-1"
@@ -180,7 +317,11 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
}}
>
<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>
))}
</div>
@@ -190,7 +331,10 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
<Label>Destinatar</Label>
<Input
value={recipient}
onChange={(e) => { setRecipient(e.target.value); setRecipientContactId(''); }}
onChange={(e) => {
setRecipient(e.target.value);
setRecipientContactId("");
}}
onFocus={() => setRecipientFocused(true)}
onBlur={() => setTimeout(() => setRecipientFocused(false), 200)}
className="mt-1"
@@ -204,13 +348,19 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
type="button"
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
onMouseDown={() => {
setRecipient(c.company ? `${c.name} (${c.company})` : c.name);
setRecipient(
c.company ? `${c.name} (${c.company})` : c.name,
);
setRecipientContactId(c.id);
setRecipientFocused(false);
}}
>
<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>
))}
</div>
@@ -222,8 +372,13 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
<div className="grid gap-4 sm:grid-cols-3">
<div>
<Label>Companie</Label>
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<Select
value={company}
onValueChange={(v) => setCompany(v as CompanyId)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="beletage">Beletage</SelectItem>
<SelectItem value="urban-switch">Urban Switch</SelectItem>
@@ -234,8 +389,13 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
</div>
<div>
<Label>Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as RegistryStatus)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<Select
value={status}
onValueChange={(v) => setStatus(v as RegistryStatus)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deschis">Deschis</SelectItem>
<SelectItem value="inchis">Închis</SelectItem>
@@ -244,7 +404,12 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
</div>
<div>
<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>
@@ -252,37 +417,118 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
{allEntries && allEntries.length > 0 && (
<div>
<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">
{allEntries
.filter((e) => e.id !== initial?.id)
.slice(0, 20)
.filter((e) => {
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) => (
<button
key={e.id}
type="button"
onClick={() => {
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 ${
linkedEntryIds.includes(e.id)
? 'border-primary bg-primary/10 text-primary'
: 'border-muted-foreground/30 text-muted-foreground hover:border-primary/50'
? "border-primary bg-primary/10 text-primary"
: "border-muted-foreground/30 text-muted-foreground hover:border-primary/50"
}`}
>
{e.number}
{e.subject && (
<span className="ml-1 opacity-60">
·{" "}
{e.subject.length > 30
? e.subject.slice(0, 30) + "…"
: e.subject}
</span>
)}
</button>
))}
</div>
</div>
)}
{/* Tracked Deadlines */}
<div>
<div className="flex items-center justify-between">
<Label className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
Termene legale
</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setDeadlineAddOpen(true)}
>
<Plus className="mr-1 h-3.5 w-3.5" /> Adaugă termen
</Button>
</div>
{trackedDeadlines.length > 0 && (
<div className="mt-2 space-y-2">
{trackedDeadlines.map((dl) => (
<DeadlineCard
key={dl.id}
deadline={dl}
onResolve={setResolvingDeadline}
onRemove={handleRemoveDeadline}
/>
))}
</div>
)}
{trackedDeadlines.length === 0 && (
<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.
</p>
)}
</div>
<DeadlineAddDialog
open={deadlineAddOpen}
onOpenChange={setDeadlineAddOpen}
entryDate={date}
onAdd={handleAddDeadline}
/>
<DeadlineResolveDialog
open={resolvingDeadline !== null}
deadline={resolvingDeadline}
onOpenChange={(open) => {
if (!open) setResolvingDeadline(null);
}}
onResolve={handleResolveDeadline}
/>
{/* Attachments */}
<div>
<div className="flex items-center justify-between">
<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
</Button>
<input
@@ -297,13 +543,20 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
{attachments.length > 0 && (
<div className="mt-2 space-y-1">
{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" />
<span className="flex-1 truncate">{att.name}</span>
<Badge variant="outline" className="text-[10px]">
{(att.size / 1024).toFixed(0)} KB
</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" />
</button>
</div>
@@ -315,12 +568,19 @@ export function RegistryEntryForm({ initial, allEntries, onSubmit, onCancel }: R
{/* Notes */}
<div>
<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 className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
<Button type="button" variant="outline" onClick={onCancel}>
Anulează
</Button>
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
</div>
</form>
);

View File

@@ -1,6 +1,6 @@
'use client';
import { Pencil, Trash2, CheckCircle2, Link2 } from 'lucide-react';
import { Pencil, Trash2, CheckCircle2, Link2, Clock } from 'lucide-react';
import { Button } from '@/shared/components/ui/button';
import { Badge } from '@/shared/components/ui/badge';
import type { RegistryEntry, DocumentType } from '../types';
@@ -69,7 +69,7 @@ export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: R
</thead>
<tbody>
{entries.map((entry) => {
const overdueDays = entry.status === 'deschis' ? getOverdueDays(entry.deadline) : null;
const overdueDays = (entry.status === 'deschis' || !entry.status) ? getOverdueDays(entry.deadline) : null;
const isOverdue = overdueDays !== null && overdueDays > 0;
return (
<tr
@@ -86,20 +86,26 @@ export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: R
variant={entry.direction === 'intrat' ? 'default' : 'secondary'}
className="text-xs"
>
{DIRECTION_LABELS[entry.direction]}
{DIRECTION_LABELS[entry.direction] ?? entry.direction ?? '—'}
</Badge>
</td>
<td className="px-3 py-2 text-xs">{DOC_TYPE_LABELS[entry.documentType]}</td>
<td className="px-3 py-2 text-xs">{DOC_TYPE_LABELS[entry.documentType] ?? entry.documentType ?? '—'}</td>
<td className="px-3 py-2 max-w-[200px] truncate">
{entry.subject}
{entry.linkedEntryIds.length > 0 && (
{(entry.linkedEntryIds ?? []).length > 0 && (
<Link2 className="ml-1 inline h-3 w-3 text-muted-foreground" />
)}
{entry.attachments.length > 0 && (
{(entry.attachments ?? []).length > 0 && (
<Badge variant="outline" className="ml-1 text-[10px] px-1">
{entry.attachments.length} fișiere
</Badge>
)}
{(entry.trackedDeadlines ?? []).length > 0 && (
<Badge variant="outline" className="ml-1 text-[10px] px-1">
<Clock className="mr-0.5 inline h-2.5 w-2.5" />
{(entry.trackedDeadlines ?? []).length}
</Badge>
)}
</td>
<td className="px-3 py-2 max-w-[130px] truncate">{entry.sender}</td>
<td className="px-3 py-2 max-w-[130px] truncate">{entry.recipient}</td>

View File

@@ -0,0 +1,28 @@
'use client';
import { useState, useCallback } from 'react';
import type { DeadlineCategory, DeadlineResolution } from '../types';
export interface DeadlineFilters {
category: DeadlineCategory | 'all';
resolution: DeadlineResolution | 'all';
urgentOnly: boolean;
}
export function useDeadlineFilters() {
const [filters, setFilters] = useState<DeadlineFilters>({
category: 'all',
resolution: 'all',
urgentOnly: false,
});
const updateFilter = useCallback(<K extends keyof DeadlineFilters>(key: K, value: DeadlineFilters[K]) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
const resetFilters = useCallback(() => {
setFilters({ category: 'all', resolution: 'all', urgentOnly: false });
}, []);
return { filters, updateFilter, resetFilters };
}

View File

@@ -3,8 +3,10 @@
import { useState, useEffect, useCallback } from 'react';
import { useStorage } from '@/core/storage';
import { v4 as uuid } from 'uuid';
import type { RegistryEntry, RegistryDirection, RegistryStatus, DocumentType } from '../types';
import type { RegistryEntry, RegistryDirection, RegistryStatus, DocumentType, TrackedDeadline, DeadlineResolution } from '../types';
import { getAllEntries, saveEntry, deleteEntry, generateRegistryNumber } from '../services/registry-service';
import { createTrackedDeadline, resolveDeadline as resolveDeadlineFn } from '../services/deadline-service';
import { getDeadlineType } from '../services/deadline-catalog';
export interface RegistryFilters {
search: string;
@@ -76,8 +78,9 @@ export function useRegistry() {
const entry = entries.find((e) => e.id === id);
if (!entry) return;
await updateEntry(id, { status: 'inchis' });
if (closeLinked && entry.linkedEntryIds.length > 0) {
for (const linkedId of entry.linkedEntryIds) {
const linked = entry.linkedEntryIds ?? [];
if (closeLinked && linked.length > 0) {
for (const linkedId of linked) {
const linked = entries.find((e) => e.id === linkedId);
if (linked && linked.status !== 'inchis') {
const updatedLinked: RegistryEntry = {
@@ -96,6 +99,71 @@ export function useRegistry() {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
// ── Deadline operations ──
const addDeadline = useCallback(async (entryId: string, typeId: string, startDate: string, chainParentId?: string) => {
const entry = entries.find((e) => e.id === entryId);
if (!entry) return null;
const tracked = createTrackedDeadline(typeId, startDate, chainParentId);
if (!tracked) return null;
const existing = entry.trackedDeadlines ?? [];
const updated: RegistryEntry = {
...entry,
trackedDeadlines: [...existing, tracked],
updatedAt: new Date().toISOString(),
};
await saveEntry(storage, updated);
await refresh();
return tracked;
}, [entries, storage, refresh]);
const resolveDeadline = useCallback(async (
entryId: string,
deadlineId: string,
resolution: DeadlineResolution,
note?: string,
): Promise<TrackedDeadline | null> => {
const entry = entries.find((e) => e.id === entryId);
if (!entry) return null;
const deadlines = entry.trackedDeadlines ?? [];
const idx = deadlines.findIndex((d) => d.id === deadlineId);
if (idx === -1) return null;
const dl = deadlines[idx];
if (!dl) return null;
const resolved = resolveDeadlineFn(dl, resolution, note);
const updatedDeadlines = [...deadlines];
updatedDeadlines[idx] = resolved;
const updated: RegistryEntry = {
...entry,
trackedDeadlines: updatedDeadlines,
updatedAt: new Date().toISOString(),
};
await saveEntry(storage, updated);
// If the resolved deadline has a chain, automatically check for the next type
const def = getDeadlineType(dl.typeId);
await refresh();
if (def?.chainNextTypeId && (resolution === 'completed' || resolution === 'aprobat-tacit')) {
return resolved;
}
return resolved;
}, [entries, storage, refresh]);
const removeDeadline = useCallback(async (entryId: string, deadlineId: string) => {
const entry = entries.find((e) => e.id === entryId);
if (!entry) return;
const deadlines = entry.trackedDeadlines ?? [];
const updated: RegistryEntry = {
...entry,
trackedDeadlines: deadlines.filter((d) => d.id !== deadlineId),
updatedAt: new Date().toISOString(),
};
await saveEntry(storage, updated);
await refresh();
}, [entries, storage, refresh]);
const filteredEntries = entries.filter((entry) => {
if (filters.direction !== 'all' && entry.direction !== filters.direction) return false;
if (filters.status !== 'all' && entry.status !== filters.status) return false;
@@ -123,6 +191,9 @@ export function useRegistry() {
updateEntry,
removeEntry,
closeEntry,
addDeadline,
resolveDeadline,
removeDeadline,
refresh,
};
}

View File

@@ -1,3 +1,7 @@
export { registraturaConfig } from './config';
export { RegistraturaModule } from './components/registratura-module';
export type { RegistryEntry, RegistryDirection, RegistryStatus, DocumentType } from './types';
export type {
RegistryEntry, RegistryDirection, RegistryStatus, DocumentType,
DeadlineDayType, DeadlineResolution, DeadlineCategory,
DeadlineTypeDef, TrackedDeadline,
} from './types';

View File

@@ -0,0 +1,220 @@
import type { DeadlineTypeDef, DeadlineCategory } from '../types';
export const DEADLINE_CATALOG: DeadlineTypeDef[] = [
// ── Avize ──
{
id: 'cerere-cu',
label: 'Cerere CU',
description: 'Termen de emitere a Certificatului de Urbanism de la data depunerii cererii.',
days: 15,
dayType: 'calendar',
startDateLabel: 'Data depunerii',
requiresCustomStartDate: false,
tacitApprovalApplicable: true,
category: 'avize',
legalReference: 'Legea 50/1991, art. 6¹',
},
{
id: 'avize-normale',
label: 'Cerere Avize normale',
description: 'Termen de emitere a avizelor de la data depunerii cererii.',
days: 15,
dayType: 'calendar',
startDateLabel: 'Data depunerii',
requiresCustomStartDate: false,
tacitApprovalApplicable: true,
category: 'avize',
},
{
id: 'aviz-cultura',
label: 'Aviz Cultură',
description: 'Termen de emitere a avizului Ministerului Culturii de la data comisiei.',
days: 30,
dayType: 'calendar',
startDateLabel: 'Data comisie',
requiresCustomStartDate: true,
startDateHint: 'Data ședinței comisiei de specialitate',
tacitApprovalApplicable: true,
category: 'avize',
},
{
id: 'aviz-mediu',
label: 'Aviz Mediu',
description: 'Termen de emitere a avizului de mediu de la finalizarea procedurilor.',
days: 15,
dayType: 'calendar',
startDateLabel: 'Data finalizare proceduri',
requiresCustomStartDate: true,
startDateHint: 'Data finalizării procedurii de evaluare de mediu',
tacitApprovalApplicable: true,
category: 'avize',
},
{
id: 'aviz-aeronautica',
label: 'Aviz Aeronautică',
description: 'Termen de emitere a avizului de la Autoritatea Aeronautică.',
days: 30,
dayType: 'calendar',
startDateLabel: 'Data depunerii',
requiresCustomStartDate: false,
tacitApprovalApplicable: true,
category: 'avize',
},
// ── Completări ──
{
id: 'completare-beneficiar',
label: 'Completare — termen beneficiar',
description: 'Termen acordat beneficiarului pentru completarea documentației.',
days: 60,
dayType: 'calendar',
startDateLabel: 'Data notificării',
requiresCustomStartDate: false,
tacitApprovalApplicable: false,
chainNextTypeId: 'completare-emitere',
chainNextActionLabel: 'Adaugă termen emitere (15 zile)',
category: 'completari',
},
{
id: 'completare-emitere',
label: 'Completare — termen emitere',
description: 'Termen de emitere după depunerea completărilor.',
days: 15,
dayType: 'calendar',
startDateLabel: 'Data depunere completări',
requiresCustomStartDate: true,
startDateHint: 'Data la care beneficiarul a depus completările',
tacitApprovalApplicable: true,
category: 'completari',
},
// ── Analiză ──
{
id: 'ctatu-analiza',
label: 'Analiză CTATU',
description: 'Termen de analiză în Comisia Tehnică de Amenajare a Teritoriului și Urbanism.',
days: 30,
dayType: 'calendar',
startDateLabel: 'Data depunerii',
requiresCustomStartDate: false,
tacitApprovalApplicable: false,
category: 'analiza',
},
{
id: 'consiliu-promovare',
label: 'Promovare Consiliu Local',
description: 'Termen de promovare în ședința Consiliului Local.',
days: 30,
dayType: 'calendar',
startDateLabel: 'Data depunerii',
requiresCustomStartDate: false,
tacitApprovalApplicable: false,
category: 'analiza',
},
{
id: 'consiliu-vot',
label: 'Vot Consiliu Local',
description: 'Termen de vot în Consiliu Local de la finalizarea dezbaterii publice.',
days: 45,
dayType: 'calendar',
startDateLabel: 'Data finalizare dezbatere',
requiresCustomStartDate: true,
startDateHint: 'Data finalizării dezbaterii publice',
tacitApprovalApplicable: false,
category: 'analiza',
},
// ── Autorizare ──
{
id: 'verificare-ac',
label: 'Verificare AC',
description: 'Termen de verificare a documentației pentru Autorizația de Construire.',
days: 5,
dayType: 'working',
startDateLabel: 'Data depunerii',
requiresCustomStartDate: false,
tacitApprovalApplicable: false,
category: 'autorizare',
},
{
id: 'prelungire-ac',
label: 'Cerere prelungire AC',
description: 'Cererea de prelungire trebuie depusă cu minim 45 zile lucrătoare ÎNAINTE de expirarea AC.',
days: 45,
dayType: 'working',
startDateLabel: 'Data expirare AC',
requiresCustomStartDate: true,
startDateHint: 'Data de expirare a Autorizației de Construire',
tacitApprovalApplicable: false,
category: 'autorizare',
isBackwardDeadline: true,
},
{
id: 'prelungire-ac-comunicare',
label: 'Comunicare decizie prelungire',
description: 'Termen de comunicare a deciziei privind prelungirea AC.',
days: 15,
dayType: 'working',
startDateLabel: 'Data depunere cerere',
requiresCustomStartDate: true,
startDateHint: 'Data depunerii cererii de prelungire',
tacitApprovalApplicable: false,
category: 'autorizare',
},
// ── Publicitate ──
{
id: 'publicitate-ac',
label: 'Publicitate AC',
description: 'Termen de publicitate a Autorizației de Construire.',
days: 30,
dayType: 'calendar',
startDateLabel: 'Data emitere AC',
requiresCustomStartDate: true,
startDateHint: 'Data emiterii Autorizației de Construire',
tacitApprovalApplicable: false,
category: 'publicitate',
},
{
id: 'plangere-prealabila',
label: 'Plângere prealabilă',
description: 'Termen de depunere a plângerii prealabile.',
days: 30,
dayType: 'calendar',
startDateLabel: 'Data ultimă publicitate',
requiresCustomStartDate: true,
startDateHint: 'Data ultimei publicități / aduceri la cunoștință',
tacitApprovalApplicable: false,
chainNextTypeId: 'contestare-instanta',
chainNextActionLabel: 'Adaugă termen contestare instanță (60 zile)',
category: 'publicitate',
},
{
id: 'contestare-instanta',
label: 'Contestare în instanță',
description: 'Termen de contestare în instanța de contencios administrativ.',
days: 60,
dayType: 'calendar',
startDateLabel: 'Data răspuns plângere',
requiresCustomStartDate: true,
startDateHint: 'Data primirii răspunsului la plângerea prealabilă',
tacitApprovalApplicable: false,
category: 'publicitate',
},
];
export const CATEGORY_LABELS: Record<DeadlineCategory, string> = {
avize: 'Avize',
completari: 'Completări',
analiza: 'Analiză',
autorizare: 'Autorizare',
publicitate: 'Publicitate',
};
export function getDeadlineType(typeId: string): DeadlineTypeDef | undefined {
return DEADLINE_CATALOG.find((d) => d.id === typeId);
}
export function getDeadlinesByCategory(category: DeadlineCategory): DeadlineTypeDef[] {
return DEADLINE_CATALOG.filter((d) => d.category === category);
}

View File

@@ -0,0 +1,146 @@
import { v4 as uuid } from 'uuid';
import type { TrackedDeadline, DeadlineResolution, RegistryEntry } from '../types';
import { getDeadlineType } from './deadline-catalog';
import { computeDueDate } from './working-days';
export interface DeadlineDisplayStatus {
label: string;
variant: 'green' | 'yellow' | 'red' | 'blue' | 'gray';
daysRemaining: number | null;
}
/**
* Create a new tracked deadline from a type definition + start date.
*/
export function createTrackedDeadline(
typeId: string,
startDate: string,
chainParentId?: string,
): TrackedDeadline | null {
const def = getDeadlineType(typeId);
if (!def) return null;
const start = new Date(startDate);
start.setHours(0, 0, 0, 0);
const due = computeDueDate(start, def.days, def.dayType, def.isBackwardDeadline);
return {
id: uuid(),
typeId,
startDate,
dueDate: formatDate(due),
resolution: 'pending',
chainParentId,
createdAt: new Date().toISOString(),
};
}
/**
* Resolve a deadline with a given resolution.
*/
export function resolveDeadline(
deadline: TrackedDeadline,
resolution: DeadlineResolution,
note?: string,
): TrackedDeadline {
return {
...deadline,
resolution,
resolvedDate: new Date().toISOString(),
resolutionNote: note,
};
}
/**
* Get the display status for a tracked deadline — color coding + label.
*/
export function getDeadlineDisplayStatus(deadline: TrackedDeadline): DeadlineDisplayStatus {
const def = getDeadlineType(deadline.typeId);
// Already resolved
if (deadline.resolution !== 'pending') {
if (deadline.resolution === 'aprobat-tacit') {
return { label: 'Aprobat tacit', variant: 'blue', daysRemaining: null };
}
if (deadline.resolution === 'respins') {
return { label: 'Respins', variant: 'gray', daysRemaining: null };
}
if (deadline.resolution === 'anulat') {
return { label: 'Anulat', variant: 'gray', daysRemaining: null };
}
return { label: 'Finalizat', variant: 'gray', daysRemaining: null };
}
// Pending — compute days remaining
const now = new Date();
now.setHours(0, 0, 0, 0);
const due = new Date(deadline.dueDate);
due.setHours(0, 0, 0, 0);
const diff = due.getTime() - now.getTime();
const daysRemaining = Math.ceil(diff / (1000 * 60 * 60 * 24));
// Overdue + tacit applicable → tacit approval
if (daysRemaining < 0 && def?.tacitApprovalApplicable) {
return { label: 'Aprobat tacit', variant: 'blue', daysRemaining };
}
if (daysRemaining < 0) {
return { label: 'Depășit termen', variant: 'red', daysRemaining };
}
if (daysRemaining <= 5) {
return { label: 'Urgent', variant: 'yellow', daysRemaining };
}
return { label: 'În termen', variant: 'green', daysRemaining };
}
/**
* Aggregate deadline stats across all entries.
*/
export function aggregateDeadlines(entries: RegistryEntry[]): {
active: number;
urgent: number;
overdue: number;
tacit: number;
all: Array<{ deadline: TrackedDeadline; entry: RegistryEntry; status: DeadlineDisplayStatus }>;
} {
let active = 0;
let urgent = 0;
let overdue = 0;
let tacit = 0;
const all: Array<{ deadline: TrackedDeadline; entry: RegistryEntry; status: DeadlineDisplayStatus }> = [];
for (const entry of entries) {
for (const dl of entry.trackedDeadlines ?? []) {
const status = getDeadlineDisplayStatus(dl);
all.push({ deadline: dl, entry, status });
if (dl.resolution === 'pending') {
active++;
if (status.variant === 'yellow') urgent++;
if (status.variant === 'red') overdue++;
if (status.variant === 'blue') tacit++;
} else if (dl.resolution === 'aprobat-tacit') {
tacit++;
}
}
}
// Sort: overdue first, then by due date ascending
all.sort((a, b) => {
const aP = a.deadline.resolution === 'pending' ? 0 : 1;
const bP = b.deadline.resolution === 'pending' ? 0 : 1;
if (aP !== bP) return aP - bP;
return a.deadline.dueDate.localeCompare(b.deadline.dueDate);
});
return { active, urgent, overdue, tacit, all };
}
function formatDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}

View File

@@ -0,0 +1,146 @@
/**
* Romanian working-day arithmetic.
*
* Fixed public holidays + Orthodox Easter-derived moveable feasts.
* Uses the Meeus algorithm for Orthodox Easter computation.
*/
// ── Fixed Romanian public holidays (month is 0-indexed) ──
interface FixedHoliday {
month: number;
day: number;
}
const FIXED_HOLIDAYS: FixedHoliday[] = [
{ month: 0, day: 1 }, // Anul Nou
{ month: 0, day: 2 }, // Anul Nou (zi 2)
{ month: 0, day: 24 }, // Ziua Unirii
{ month: 4, day: 1 }, // Ziua Muncii
{ month: 5, day: 1 }, // Ziua Copilului
{ month: 7, day: 15 }, // Adormirea Maicii Domnului
{ month: 10, day: 30 }, // Sfântul Andrei
{ month: 11, day: 1 }, // Ziua Națională
{ month: 11, day: 25 }, // Crăciunul
{ month: 11, day: 26 }, // Crăciunul (zi 2)
];
// ── Orthodox Easter via Meeus algorithm ──
function orthodoxEaster(year: number): Date {
const a = year % 4;
const b = year % 7;
const c = year % 19;
const d = (19 * c + 15) % 30;
const e = (2 * a + 4 * b - d + 34) % 7;
const month = Math.floor((d + e + 114) / 31); // 3 = March, 4 = April
const day = ((d + e + 114) % 31) + 1;
// Julian date — convert to Gregorian by adding 13 days (valid 19002099)
const julian = new Date(year, month - 1, day);
julian.setDate(julian.getDate() + 13);
return julian;
}
function getMovableHolidays(year: number): Date[] {
const easter = orthodoxEaster(year);
const goodFriday = new Date(easter);
goodFriday.setDate(easter.getDate() - 2);
const easterMonday = new Date(easter);
easterMonday.setDate(easter.getDate() + 1);
const rusaliiSunday = new Date(easter);
rusaliiSunday.setDate(easter.getDate() + 49);
const rusaliiMonday = new Date(easter);
rusaliiMonday.setDate(easter.getDate() + 50);
return [goodFriday, easter, easterMonday, rusaliiSunday, rusaliiMonday];
}
// ── Holiday cache per year ──
const holidayCache = new Map<number, Set<string>>();
function toKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function getHolidaySet(year: number): Set<string> {
const cached = holidayCache.get(year);
if (cached) return cached;
const set = new Set<string>();
for (const h of FIXED_HOLIDAYS) {
set.add(toKey(new Date(year, h.month, h.day)));
}
for (const d of getMovableHolidays(year)) {
set.add(toKey(d));
}
holidayCache.set(year, set);
return set;
}
// ── Public API ──
export function isPublicHoliday(date: Date): boolean {
return getHolidaySet(date.getFullYear()).has(toKey(date));
}
export function isWeekend(date: Date): boolean {
const day = date.getDay();
return day === 0 || day === 6;
}
export function isWorkingDay(date: Date): boolean {
return !isWeekend(date) && !isPublicHoliday(date);
}
/**
* Add calendar days (simply skips no days).
* Supports negative values.
*/
export function addCalendarDays(start: Date, days: number): Date {
const result = new Date(start);
result.setDate(result.getDate() + days);
return result;
}
/**
* Add working days, skipping weekends and Romanian public holidays.
* Supports negative values (for backward deadlines).
*/
export function addWorkingDays(start: Date, days: number): Date {
const result = new Date(start);
const direction = days >= 0 ? 1 : -1;
let remaining = Math.abs(days);
while (remaining > 0) {
result.setDate(result.getDate() + direction);
if (isWorkingDay(result)) {
remaining--;
}
}
return result;
}
/**
* Compute the due date for a deadline definition.
*/
export function computeDueDate(
startDate: Date,
days: number,
dayType: 'calendar' | 'working',
isBackward?: boolean,
): Date {
const effectiveDays = isBackward ? -days : days;
return dayType === 'working'
? addWorkingDays(startDate, effectiveDays)
: addCalendarDays(startDate, effectiveDays);
}

View File

@@ -30,6 +30,44 @@ export interface RegistryAttachment {
addedAt: string;
}
// ── Deadline tracking types ──
export type DeadlineDayType = 'calendar' | 'working';
export type DeadlineResolution = 'pending' | 'completed' | 'aprobat-tacit' | 'respins' | 'anulat';
export type DeadlineCategory = 'avize' | 'completari' | 'analiza' | 'autorizare' | 'publicitate';
export interface DeadlineTypeDef {
id: string;
label: string;
description: string;
days: number;
dayType: DeadlineDayType;
startDateLabel: string;
requiresCustomStartDate: boolean;
startDateHint?: string;
tacitApprovalApplicable: boolean;
tacitApprovalExcludable?: boolean;
chainNextTypeId?: string;
chainNextActionLabel?: string;
legalReference?: string;
category: DeadlineCategory;
isBackwardDeadline?: boolean;
}
export interface TrackedDeadline {
id: string;
typeId: string;
startDate: string;
dueDate: string;
resolution: DeadlineResolution;
resolvedDate?: string;
resolutionNote?: string;
chainParentId?: string;
createdAt: string;
}
export interface RegistryEntry {
id: string;
/** Company-specific number: B-0001/2026, US-0001/2026, SDT-0001/2026 */
@@ -52,6 +90,8 @@ export interface RegistryEntry {
linkedEntryIds: string[];
/** File attachments */
attachments: RegistryAttachment[];
/** Tracked legal deadlines */
trackedDeadlines?: TrackedDeadline[];
tags: string[];
notes: string;
visibility: Visibility;

View File

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

View File

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

View File

@@ -1,45 +1,91 @@
'use client';
"use client";
import { useState } from 'react';
import { Plus, Pencil, Trash2, Search, FileText, ExternalLink, Copy } 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 { useRef, useState } from "react";
import {
Plus,
Pencil,
Trash2,
Search,
FileText,
ExternalLink,
Copy,
FolderOpen,
Wand2,
Loader2,
} 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> = {
contract: 'Contract',
memoriu: 'Memoriu tehnic',
oferta: 'Ofertă',
raport: 'Raport',
cerere: 'Cerere',
aviz: 'Aviz',
scrisoare: 'Scrisoare',
altele: 'Altele',
contract: "Contract",
memoriu: "Memoriu tehnic",
oferta: "Ofertă",
raport: "Raport",
cerere: "Cerere",
aviz: "Aviz",
scrisoare: "Scrisoare",
altele: "Altele",
};
type ViewMode = 'list' | 'add' | 'edit';
type ViewMode = "list" | "add" | "edit";
export function WordTemplatesModule() {
const { templates, allTemplates, loading, filters, updateFilter, addTemplate, updateTemplate, cloneTemplate, removeTemplate } = useTemplates();
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [editingTemplate, setEditingTemplate] = useState<WordTemplate | null>(null);
const {
templates,
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 handleSubmit = async (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => {
if (viewMode === 'edit' && editingTemplate) {
const handleSubmit = async (
data: Omit<WordTemplate, "id" | "createdAt" | "updatedAt">,
) => {
if (viewMode === "edit" && editingTemplate) {
await updateTemplate(editingTemplate.id, data);
} else {
await addTemplate(data);
}
setViewMode('list');
setViewMode("list");
setEditingTemplate(null);
};
@@ -54,30 +100,80 @@ export function WordTemplatesModule() {
<div className="space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total șabloane</p><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>
<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>
<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>
{viewMode === 'list' && (
{viewMode === "list" && (
<>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input placeholder="Caută ș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>
<Select value={filters.category} onValueChange={(v) => updateFilter('category', v as TemplateCategory | 'all')}>
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
<Select
value={filters.category}
onValueChange={(v) =>
updateFilter("category", v as TemplateCategory | "all")
}
>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate categoriile</SelectItem>
{(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map((c) => (
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>
))}
{(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map(
(c) => (
<SelectItem key={c} value={c}>
{CATEGORY_LABELS[c]}
</SelectItem>
),
)}
</SelectContent>
</Select>
<Select value={filters.company} onValueChange={(v) => updateFilter('company', v)}>
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
<Select
value={filters.company}
onValueChange={(v) => updateFilter("company", v)}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate companiile</SelectItem>
<SelectItem value="beletage">Beletage</SelectItem>
@@ -86,28 +182,51 @@ export function WordTemplatesModule() {
<SelectItem value="group">Grup</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => setViewMode('add')} className="shrink-0">
<Button onClick={() => setViewMode("add")} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button>
</div>
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
<p className="py-8 text-center text-sm text-muted-foreground">
Se încarcă...
</p>
) : 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">
{templates.map((tpl) => (
<Card key={tpl.id} className="group relative">
<CardContent className="p-4">
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button variant="ghost" size="icon" className="h-7 w-7" title="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" />
</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" />
</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" />
</Button>
</div>
@@ -117,22 +236,42 @@ export function WordTemplatesModule() {
</div>
<div className="min-w-0">
<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">
<Badge variant="outline" className="text-[10px]">{CATEGORY_LABELS[tpl.category]}</Badge>
<Badge variant="secondary" className="text-[10px]">v{tpl.version}</Badge>
{tpl.clonedFrom && <Badge variant="secondary" className="text-[10px]">Clonă</Badge>}
<Badge variant="outline" className="text-[10px]">
{CATEGORY_LABELS[tpl.category]}
</Badge>
<Badge variant="secondary" className="text-[10px]">
v{tpl.version}
</Badge>
{tpl.clonedFrom && (
<Badge variant="secondary" className="text-[10px]">
Clonă
</Badge>
)}
</div>
{/* Placeholders display */}
{tpl.placeholders.length > 0 && (
{(tpl.placeholders ?? []).length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{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>
{(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>
))}
</div>
)}
{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
</a>
)}
@@ -146,23 +285,48 @@ export function WordTemplatesModule() {
</>
)}
{(viewMode === 'add' || viewMode === 'edit') && (
{(viewMode === "add" || viewMode === "edit") && (
<Card>
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare șablon' : 'Șablon nou'}</CardTitle></CardHeader>
<CardHeader>
<CardTitle>
{viewMode === "edit" ? "Editare șablon" : "Șablon nou"}
</CardTitle>
</CardHeader>
<CardContent>
<TemplateForm initial={editingTemplate ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingTemplate(null); }} />
<TemplateForm
initial={editingTemplate ?? undefined}
onSubmit={handleSubmit}
onCancel={() => {
setViewMode("list");
setEditingTemplate(null);
}}
/>
</CardContent>
</Card>
)}
{/* Delete confirmation */}
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}>
<Dialog
open={deletingId !== null}
onOpenChange={(open) => {
if (!open) setDeletingId(null);
}}
>
<DialogContent>
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader>
<p className="text-sm">Ești sigur vrei ștergi acest șablon? Acțiunea este ireversibilă.</p>
<DialogHeader>
<DialogTitle>Confirmare ștergere</DialogTitle>
</DialogHeader>
<p className="text-sm">
Ești sigur vrei ștergi acest șablon? Acțiunea este
ireversibilă.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingId(null)}>Anulează</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>Șterge</Button>
<Button variant="outline" onClick={() => setDeletingId(null)}>
Anulează
</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>
Șterge
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -170,50 +334,151 @@ export function WordTemplatesModule() {
);
}
function TemplateForm({ initial, onSubmit, onCancel }: {
function TemplateForm({
initial,
onSubmit,
onCancel,
}: {
initial?: WordTemplate;
onSubmit: (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => void;
onSubmit: (
data: Omit<WordTemplate, "id" | "createdAt" | "updatedAt">,
) => void;
onCancel: () => void;
}) {
const [name, setName] = useState(initial?.name ?? '');
const [description, setDescription] = useState(initial?.description ?? '');
const [category, setCategory] = useState<TemplateCategory>(initial?.category ?? 'contract');
const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? '');
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 [name, setName] = useState(initial?.name ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
const [category, setCategory] = useState<TemplateCategory>(
initial?.category ?? "contract",
);
const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? "");
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 (
<form onSubmit={(e) => {
<form
onSubmit={(e) => {
e.preventDefault();
const placeholders = placeholdersText
.split(',')
.split(",")
.map((p) => p.trim())
.filter((p) => p.length > 0);
onSubmit({
name, description, category, fileUrl, company, version, placeholders,
name,
description,
category,
fileUrl,
company,
version,
placeholders,
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><Label>Nume șablon *</Label><Input 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>
<div>
<Label>Nume șablon *</Label>
<Input
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>
{(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>
</Select>
</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><Label>Companie</Label>
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<div>
<Label>Companie</Label>
<Select
value={company}
onValueChange={(v) => setCompany(v as CompanyId)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="beletage">Beletage</SelectItem>
<SelectItem value="urban-switch">Urban Switch</SelectItem>
@@ -222,17 +487,94 @@ function TemplateForm({ initial, onSubmit, onCancel }: {
</SelectContent>
</Select>
</div>
<div><Label>Versiune</Label><Input value={version} onChange={(e) => setVersion(e.target.value)} className="mt-1" /></div>
<div><Label>URL fișier</Label><Input value={fileUrl} onChange={(e) => setFileUrl(e.target.value)} className="mt-1" placeholder="https://..." /></div>
<div>
<Label>Versiune</Label>
<Input
value={version}
onChange={(e) => setVersion(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Placeholder-e (separate prin virgulă)</Label>
<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}}'}, separate prin virgulă.</p>
<Label>URL fișier</Label>
<div className="mt-1 flex gap-1.5">
<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 className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
<Button type="button" variant="outline" onClick={onCancel}>
Anulează
</Button>
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
</div>
</form>
);

View File

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

View File

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

View File

@@ -1,23 +1,22 @@
'use client';
"use client";
import type { XmlGeneratorMode } from '../types';
import { Input } from '@/shared/components/ui/input';
import { Label } from '@/shared/components/ui/label';
import { Switch } from '@/shared/components/ui/switch';
import { cn } from '@/shared/lib/utils';
import type { XmlGeneratorMode } from "../types";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { cn } from "@/shared/lib/utils";
interface XmlSettingsProps {
baseNamespace: string;
mode: XmlGeneratorMode;
computeMetrics: boolean;
onSetBaseNamespace: (ns: string) => void;
onSetMode: (mode: XmlGeneratorMode) => void;
onSetComputeMetrics: (v: boolean) => void;
}
export function XmlSettings({
baseNamespace, mode, computeMetrics,
onSetBaseNamespace, onSetMode, onSetComputeMetrics,
baseNamespace,
mode,
onSetBaseNamespace,
onSetMode,
}: XmlSettingsProps) {
return (
<div className="space-y-4">
@@ -38,31 +37,28 @@ export function XmlSettings({
<div>
<Label className="mb-1.5 block">Mod generare</Label>
<div className="flex gap-1.5">
{(['simple', 'advanced'] as XmlGeneratorMode[]).map((m) => (
{(["simple", "advanced"] as XmlGeneratorMode[]).map((m) => (
<button
key={m}
type="button"
onClick={() => onSetMode(m)}
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
? 'border-primary bg-primary text-primary-foreground'
: 'border-border hover:bg-accent'
? "border-primary bg-primary text-primary-foreground"
: "border-border hover:bg-accent",
)}
>
{m === 'simple' ? 'Simple' : 'Advanced'}
{m === "simple" ? "Simple" : "Advanced"}
</button>
))}
</div>
<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>
</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>
);

View File

@@ -1,20 +1,19 @@
'use client';
"use client";
import { useState, useCallback, useMemo } from 'react';
import type { XmlGeneratorConfig, XmlGeneratorMode } from '../types';
import { DEFAULT_PRESETS } from '../services/category-presets';
import { useState, useCallback, useMemo } from "react";
import type { XmlGeneratorConfig, XmlGeneratorMode } from "../types";
import { DEFAULT_PRESETS } from "../services/category-presets";
function createDefaultConfig(): XmlGeneratorConfig {
const categories: Record<string, { name: string; fieldsText: string }> = {};
for (const [name, fields] of Object.entries(DEFAULT_PRESETS)) {
categories[name] = { name, fieldsText: fields.join('\n') };
categories[name] = { name, fieldsText: fields.join("\n") };
}
return {
baseNamespace: 'http://schemas.beletage.ro/contract',
mode: 'advanced',
computeMetrics: true,
baseNamespace: "http://schemas.beletage.ro/contract",
mode: "advanced",
categories,
currentCategory: 'Beneficiar',
currentCategory: "Beneficiar",
};
}
@@ -29,15 +28,12 @@ export function useXmlConfig() {
setConfig((prev) => ({ ...prev, baseNamespace }));
}, []);
const setComputeMetrics = useCallback((computeMetrics: boolean) => {
setConfig((prev) => ({ ...prev, computeMetrics }));
}, []);
const setCurrentCategory = useCallback((name: string) => {
setConfig((prev) => ({ ...prev, currentCategory: name }));
}, []);
const updateCategoryFields = useCallback((categoryName: string, fieldsText: string) => {
const updateCategoryFields = useCallback(
(categoryName: string, fieldsText: string) => {
setConfig((prev) => {
const existing = prev.categories[categoryName];
if (!existing) return prev;
@@ -49,14 +45,16 @@ export function useXmlConfig() {
},
};
});
}, []);
},
[],
);
const addCategory = useCallback((name: string) => {
setConfig((prev) => {
if (prev.categories[name]) return prev;
return {
...prev,
categories: { ...prev.categories, [name]: { name, fieldsText: '' } },
categories: { ...prev.categories, [name]: { name, fieldsText: "" } },
currentCategory: name,
};
});
@@ -70,7 +68,9 @@ export function useXmlConfig() {
return {
...prev,
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,
categories: {
...prev.categories,
[name]: { name, fieldsText: preset.join('\n') },
[name]: { name, fieldsText: preset.join("\n") },
},
}));
}, []);
@@ -95,7 +95,7 @@ export function useXmlConfig() {
...prev,
categories: {
...prev.categories,
[name]: { name: existing.name, fieldsText: '' },
[name]: { name: existing.name, fieldsText: "" },
},
};
});
@@ -109,11 +109,11 @@ export function useXmlConfig() {
setConfig(loaded);
}, []);
return useMemo(() => ({
return useMemo(
() => ({
config,
setMode,
setBaseNamespace,
setComputeMetrics,
setCurrentCategory,
updateCategoryFields,
addCategory,
@@ -122,7 +122,19 @@ export function useXmlConfig() {
clearCategoryFields,
resetAll,
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 {
const trimmed = name.trim();
if (!trimmed) return null;
let n = trimmed.replace(/\s+/g, '_').replace(/[^A-Za-z0-9_.-]/g, '');
if (!/^[A-Za-z_]/.test(n)) n = '_' + n;
let n = trimmed.replace(/\s+/g, "_").replace(/[^A-Za-z0-9_.-]/g, "");
if (!/^[A-Za-z_]/.test(n)) n = "_" + n;
return n || null;
}
function getCategoryNamespace(baseNs: string, category: string): string {
const safeCat = sanitizeName(category) || category;
return baseNs.replace(/\/+$/, '') + '/' + safeCat;
return baseNs.replace(/\/+$/, "") + "/" + safeCat;
}
function getCategoryRoot(category: string): string {
const safeCat = sanitizeName(category) || category;
return safeCat + 'Data';
return safeCat + "Data";
}
interface FieldEntry {
@@ -29,14 +29,13 @@ export function generateCategoryXml(
catData: XmlCategory,
baseNamespace: string,
mode: XmlGeneratorMode,
computeMetrics: boolean,
): GeneratedOutput {
const raw = catData.fieldsText
.split(/\r?\n/)
.map((l) => l.trim())
.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 root = getCategoryRoot(category);
@@ -51,19 +50,19 @@ export function generateCategoryXml(
let baseName = base;
let idx = 2;
while (usedNames.has(baseName)) {
baseName = base + '_' + idx;
baseName = base + "_" + idx;
idx++;
}
usedNames.add(baseName);
const variants = [baseName];
if (mode === 'advanced') {
const suffixes = ['Short', 'Upper', 'Lower', 'Initials', 'First'];
if (mode === "advanced") {
const suffixes = ["Short", "Upper", "Lower", "Initials", "First"];
for (const suffix of suffixes) {
let vn = baseName + suffix;
let k = 2;
while (usedNames.has(vn)) {
vn = baseName + suffix + '_' + k;
vn = baseName + suffix + "_" + k;
k++;
}
usedNames.add(vn);
@@ -74,24 +73,7 @@ export function generateCategoryXml(
fields.push({ label, baseName, variants });
}
// Auto-add POT/CUT for Suprafete category
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);
const allFields = fields;
// Build XML
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
@@ -110,18 +92,8 @@ export function generateCategoryXml(
for (const v of f.variants) {
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 };
}
@@ -129,20 +101,19 @@ export function generateAllCategories(
categories: Record<string, XmlCategory>,
baseNamespace: string,
mode: XmlGeneratorMode,
computeMetrics: boolean,
): Record<string, GeneratedOutput> {
const results: Record<string, GeneratedOutput> = {};
for (const cat of Object.keys(categories)) {
const catData = categories[cat];
if (!catData) continue;
results[cat] = generateCategoryXml(cat, catData, baseNamespace, mode, computeMetrics);
results[cat] = generateCategoryXml(cat, catData, baseNamespace, mode);
}
return results;
}
export function downloadXmlFile(xml: string, filename: string): void {
const blob = new Blob([xml], { type: 'application/xml' });
const a = document.createElement('a');
const blob = new Blob([xml], { type: "application/xml" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
@@ -155,13 +126,12 @@ export async function downloadZipAll(
categories: Record<string, XmlCategory>,
baseNamespace: string,
mode: XmlGeneratorMode,
computeMetrics: boolean,
): Promise<void> {
const JSZip = (await import('jszip')).default;
const results = generateAllCategories(categories, baseNamespace, mode, computeMetrics);
const JSZip = (await import("jszip")).default;
const results = generateAllCategories(categories, baseNamespace, mode);
const zip = new JSZip();
const folder = zip.folder('customXmlParts')!;
const folder = zip.folder("customXmlParts")!;
let hasAny = false;
for (const cat of Object.keys(results)) {
@@ -175,10 +145,10 @@ export async function downloadZipAll(
if (!hasAny) return;
const content = await zip.generateAsync({ type: 'blob' });
const a = document.createElement('a');
const content = await zip.generateAsync({ type: "blob" });
const a = document.createElement("a");
a.href = URL.createObjectURL(content);
a.download = 'beletage_custom_xml_parts.zip';
a.download = "beletage_custom_xml_parts.zip";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);

View File

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

View File

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