Compare commits
42 Commits
cb5e01b189
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b1ba589f0 | ||
|
|
6cb655a79f | ||
|
|
eaca24aa58 | ||
|
|
cd4b0de1e9 | ||
|
|
1f2af98f51 | ||
|
|
713a66bcd9 | ||
|
|
67fd88813a | ||
|
|
da33dc9b81 | ||
|
|
35305e4389 | ||
|
|
a49dbb2ced | ||
|
|
b96b004baf | ||
|
|
8b0ad5c2d7 | ||
|
|
4502a01aa1 | ||
|
|
c940fab4e9 | ||
|
|
d89db0fa3b | ||
|
|
8a2c5fa298 | ||
|
|
0fe53a566b | ||
|
|
41036db659 | ||
|
|
3154eb7f4a | ||
|
|
124887bee6 | ||
|
|
4bc5832458 | ||
|
|
7a5206e771 | ||
|
|
81cfdd6aa8 | ||
|
|
b8b9c7cf97 | ||
|
|
42260a17a4 | ||
|
|
5330ea536b | ||
|
|
1db61d87f4 | ||
|
|
501de5161e | ||
|
|
9904804097 | ||
|
|
f6fd5f58e3 | ||
|
|
b1df15bb42 | ||
|
|
d6a5852e54 | ||
|
|
bb01268bcb | ||
|
|
f0b878cf00 | ||
|
|
2c9f0bc6b7 | ||
|
|
455d95a8c6 | ||
|
|
c3abbf1c4b | ||
|
|
f7e6cbbc65 | ||
|
|
93cf6feae2 | ||
|
|
98eda56035 | ||
|
|
84d9db4515 | ||
|
|
f555258dcb |
266
CLAUDE.md
Normal file
266
CLAUDE.md
Normal 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
581
ROADMAP.md
Normal 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 Word’s 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
254
SESSION-GUIDE.md
Normal 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
221
SESSION-LOG.md
Normal 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 Word’s 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
133
package-lock.json
generated
@@ -18,6 +18,7 @@
|
|||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"tailwind-merge": "^3.4.1",
|
"tailwind-merge": "^3.4.1",
|
||||||
|
"tesseract.js": "^7.0.0",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -101,6 +102,7 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -660,6 +662,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -1855,6 +1858,7 @@
|
|||||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^14.21.3 || >=16"
|
"node": "^14.21.3 || >=16"
|
||||||
},
|
},
|
||||||
@@ -3901,6 +3905,7 @@
|
|||||||
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
|
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -3911,6 +3916,7 @@
|
|||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -3921,6 +3927,7 @@
|
|||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -3991,6 +3998,7 @@
|
|||||||
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
|
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.56.0",
|
"@typescript-eslint/scope-manager": "8.56.0",
|
||||||
"@typescript-eslint/types": "8.56.0",
|
"@typescript-eslint/types": "8.56.0",
|
||||||
@@ -4517,6 +4525,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -4913,6 +4922,12 @@
|
|||||||
"baseline-browser-mapping": "dist/cli.js"
|
"baseline-browser-mapping": "dist/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bmp-js": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "2.2.2",
|
"version": "2.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||||
@@ -4982,6 +4997,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -6017,6 +6033,7 @@
|
|||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -6202,6 +6219,7 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -6515,6 +6533,7 @@
|
|||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -7244,6 +7263,7 @@
|
|||||||
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
|
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
@@ -7310,6 +7330,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/idb-keyval": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -7920,6 +7946,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-url": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-weakmap": {
|
"node_modules/is-weakmap": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
||||||
@@ -9236,6 +9268,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/opencollective-postinstall": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"opencollective-postinstall": "index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -9789,6 +9830,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -9798,6 +9840,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -9942,6 +9985,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/regenerator-runtime": {
|
||||||
|
"version": "0.13.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||||
|
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/regexp.prototype.flags": {
|
"node_modules/regexp.prototype.flags": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||||
@@ -10976,6 +11025,50 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tesseract.js": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"bmp-js": "^0.1.0",
|
||||||
|
"idb-keyval": "^6.2.0",
|
||||||
|
"is-url": "^1.2.4",
|
||||||
|
"node-fetch": "^2.6.9",
|
||||||
|
"opencollective-postinstall": "^2.0.3",
|
||||||
|
"regenerator-runtime": "^0.13.3",
|
||||||
|
"tesseract.js-core": "^7.0.0",
|
||||||
|
"wasm-feature-detect": "^1.8.0",
|
||||||
|
"zlibjs": "^0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tesseract.js-core": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/tesseract.js/node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tiny-invariant": {
|
"node_modules/tiny-invariant": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
@@ -11034,6 +11127,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -11097,6 +11191,12 @@
|
|||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
||||||
@@ -11291,6 +11391,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -11559,6 +11660,12 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wasm-feature-detect": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/web-streams-polyfill": {
|
"node_modules/web-streams-polyfill": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||||
@@ -11569,6 +11676,22 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -11898,12 +12021,22 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/zlibjs": {
|
||||||
|
"version": "0.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
|
||||||
|
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.3.6",
|
"version": "4.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"tailwind-merge": "^3.4.1",
|
"tailwind-merge": "^3.4.1",
|
||||||
|
"tesseract.js": "^7.0.0",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
31
src/app/(modules)/hot-desk/page.tsx
Normal file
31
src/app/(modules)/hot-desk/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/app/api/compress-pdf/route.ts
Normal file
43
src/app/api/compress-pdf/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const STIRLING_PDF_URL =
|
||||||
|
process.env.STIRLING_PDF_URL ?? "http://10.10.10.166:8087";
|
||||||
|
const STIRLING_PDF_API_KEY =
|
||||||
|
process.env.STIRLING_PDF_API_KEY ?? "cd829f62-6eef-43eb-a64d-c91af727b53a";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const formData = await req.formData();
|
||||||
|
|
||||||
|
const res = await fetch(`${STIRLING_PDF_URL}/api/v1/misc/compress-pdf`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "X-API-KEY": STIRLING_PDF_API_KEY },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => res.statusText);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Stirling PDF error: ${res.status} — ${text}` },
|
||||||
|
{ status: res.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const buffer = Buffer.from(await blob.arrayBuffer());
|
||||||
|
|
||||||
|
return new NextResponse(buffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/pdf",
|
||||||
|
"Content-Disposition": 'attachment; filename="compressed.pdf"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Nu s-a putut contacta Stirling PDF: ${message}` },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
237
src/app/page.tsx
237
src/app/page.tsx
@@ -1,22 +1,53 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
import * as Icons from 'lucide-react';
|
import * as Icons from "lucide-react";
|
||||||
import { getAllModules } from '@/core/module-registry';
|
import { getAllModules } from "@/core/module-registry";
|
||||||
import { useFeatureFlag } from '@/core/feature-flags';
|
import { useFeatureFlag } from "@/core/feature-flags";
|
||||||
import { useI18n } from '@/core/i18n';
|
import { useI18n } from "@/core/i18n";
|
||||||
import { EXTERNAL_TOOLS } from '@/config/external-tools';
|
import { EXTERNAL_TOOLS } from "@/config/external-tools";
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/shared/components/ui/card';
|
import {
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
} from "@/shared/components/ui/card";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import { useDashboardData } from "@/modules/dashboard/hooks/use-dashboard-data";
|
||||||
|
|
||||||
function DynamicIcon({ name, className }: { name: string; className?: string }) {
|
function DynamicIcon({
|
||||||
const pascalName = name.replace(/(^|-)([a-z])/g, (_, _p, c: string) => c.toUpperCase());
|
name,
|
||||||
const IconComponent = (Icons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[pascalName];
|
className,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const pascalName = name.replace(/(^|-)([a-z])/g, (_, _p, c: string) =>
|
||||||
|
c.toUpperCase(),
|
||||||
|
);
|
||||||
|
const IconComponent = (
|
||||||
|
Icons as unknown as Record<
|
||||||
|
string,
|
||||||
|
React.ComponentType<{ className?: string }>
|
||||||
|
>
|
||||||
|
)[pascalName];
|
||||||
if (!IconComponent) return <Icons.Circle className={className} />;
|
if (!IconComponent) return <Icons.Circle className={className} />;
|
||||||
return <IconComponent className={className} />;
|
return <IconComponent className={className} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModuleCard({ module }: { module: { id: string; name: string; description: string; icon: string; route: string; featureFlag: string } }) {
|
function ModuleCard({
|
||||||
|
module,
|
||||||
|
}: {
|
||||||
|
module: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
route: string;
|
||||||
|
featureFlag: string;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
const enabled = useFeatureFlag(module.featureFlag);
|
const enabled = useFeatureFlag(module.featureFlag);
|
||||||
if (!enabled) return null;
|
if (!enabled) return null;
|
||||||
|
|
||||||
@@ -29,7 +60,9 @@ function ModuleCard({ module }: { module: { id: string; name: string; descriptio
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">{module.name}</CardTitle>
|
<CardTitle className="text-base">{module.name}</CardTitle>
|
||||||
<CardDescription className="text-sm">{module.description}</CardDescription>
|
<CardDescription className="text-sm">
|
||||||
|
{module.description}
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -37,59 +70,166 @@ function ModuleCard({ module }: { module: { id: string; name: string; descriptio
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RELATIVE_LABELS: Intl.RelativeTimeFormatUnit[] = [
|
||||||
|
"year",
|
||||||
|
"month",
|
||||||
|
"week",
|
||||||
|
"day",
|
||||||
|
"hour",
|
||||||
|
"minute",
|
||||||
|
"second",
|
||||||
|
];
|
||||||
|
const RELATIVE_MS = [
|
||||||
|
31536000000, 2592000000, 604800000, 86400000, 3600000, 60000, 1000,
|
||||||
|
];
|
||||||
|
|
||||||
|
function relativeTime(isoString: string): string {
|
||||||
|
const diff = new Date(isoString).getTime() - Date.now();
|
||||||
|
const rtf = new Intl.RelativeTimeFormat("ro", { numeric: "auto" });
|
||||||
|
for (let i = 0; i < RELATIVE_MS.length; i++) {
|
||||||
|
const ms = RELATIVE_MS[i];
|
||||||
|
if (ms === undefined) continue;
|
||||||
|
const absMs = RELATIVE_LABELS[i];
|
||||||
|
if (absMs === undefined) continue;
|
||||||
|
if (Math.abs(diff) >= ms || i === RELATIVE_MS.length - 1) {
|
||||||
|
return rtf.format(Math.round(diff / ms), absMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "acum";
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODULE_ICONS: Record<string, string> = {
|
||||||
|
registratura: "BookOpen",
|
||||||
|
"address-book": "Users",
|
||||||
|
"it-inventory": "Monitor",
|
||||||
|
"password-vault": "KeyRound",
|
||||||
|
"digital-signatures": "PenLine",
|
||||||
|
"word-templates": "FileText",
|
||||||
|
"tag-manager": "Tag",
|
||||||
|
"prompt-generator": "Wand2",
|
||||||
|
};
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<string, string> = {
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
dev: 'Dezvoltare',
|
dev: "Dezvoltare",
|
||||||
tools: 'Instrumente',
|
tools: "Instrumente",
|
||||||
monitoring: 'Monitorizare',
|
monitoring: "Monitorizare",
|
||||||
security: 'Securitate',
|
security: "Securitate",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const modules = getAllModules();
|
const modules = getAllModules();
|
||||||
|
const { activity, kpis } = useDashboardData();
|
||||||
|
|
||||||
const toolCategories = Object.keys(CATEGORY_LABELS).filter(
|
const toolCategories = Object.keys(CATEGORY_LABELS).filter((cat) =>
|
||||||
(cat) => EXTERNAL_TOOLS.some((tool) => tool.category === cat)
|
EXTERNAL_TOOLS.some((tool) => tool.category === cat),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl space-y-8">
|
<div className="mx-auto max-w-6xl space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{t('dashboard.welcome')}</h1>
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
<p className="mt-1 text-muted-foreground">{t('dashboard.subtitle')}</p>
|
{t("dashboard.welcome")}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">{t("dashboard.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick stats */}
|
{/* KPI panels */}
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div>
|
||||||
|
<h2 className="mb-3 text-lg font-semibold">Indicatori cheie</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-xs text-muted-foreground">Module active</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
<p className="text-2xl font-bold">{modules.length}</p>
|
Registratură — intrări săptămâna aceasta
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">{kpis.registraturaThisWeek}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-xs text-muted-foreground">Companii</p>
|
<p className="text-xs text-muted-foreground">Dosare deschise</p>
|
||||||
<p className="text-2xl font-bold">3</p>
|
<p className="text-2xl font-bold">{kpis.registraturaOpen}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-xs text-muted-foreground">Instrumente externe</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
<p className="text-2xl font-bold">{EXTERNAL_TOOLS.length}</p>
|
Termene legale săptămâna aceasta
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">{kpis.deadlinesThisWeek}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-xs text-muted-foreground">Stocare</p>
|
<p className="text-xs text-muted-foreground">Termene depășite</p>
|
||||||
<p className="text-2xl font-bold">localStorage</p>
|
<p
|
||||||
|
className={`text-2xl font-bold ${kpis.overdueDeadlines > 0 ? "text-destructive" : ""}`}
|
||||||
|
>
|
||||||
|
{kpis.overdueDeadlines}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Contacte noi luna aceasta
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">{kpis.contactsThisMonth}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Echipamente IT active
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">{kpis.inventoryActive}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity feed */}
|
||||||
|
{activity.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 text-lg font-semibold">Activitate recentă</h2>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="divide-y p-0">
|
||||||
|
{activity.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-center gap-3 px-4 py-2.5"
|
||||||
|
>
|
||||||
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||||
|
<DynamicIcon
|
||||||
|
name={MODULE_ICONS[item.namespace] ?? "Circle"}
|
||||||
|
className="h-3.5 w-3.5 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm">
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
<span className="ml-1 text-muted-foreground text-xs">
|
||||||
|
{item.action}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{item.moduleLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||||
|
{relativeTime(item.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Modules grid */}
|
{/* Modules grid */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="mb-4 text-lg font-semibold">{t('dashboard.modules')}</h2>
|
<h2 className="mb-4 text-lg font-semibold">{t("dashboard.modules")}</h2>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{modules.map((m) => (
|
{modules.map((m) => (
|
||||||
<ModuleCard key={m.id} module={m} />
|
<ModuleCard key={m.id} module={m} />
|
||||||
@@ -103,27 +243,44 @@ export default function DashboardPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{toolCategories.map((cat) => (
|
{toolCategories.map((cat) => (
|
||||||
<div key={cat}>
|
<div key={cat}>
|
||||||
<Badge variant="outline" className="mb-2">{CATEGORY_LABELS[cat]}</Badge>
|
<Badge variant="outline" className="mb-2">
|
||||||
|
{CATEGORY_LABELS[cat]}
|
||||||
|
</Badge>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{EXTERNAL_TOOLS.filter((tool) => tool.category === cat).map((tool) => {
|
{EXTERNAL_TOOLS.filter((tool) => tool.category === cat).map(
|
||||||
|
(tool) => {
|
||||||
const cardContent = (
|
const cardContent = (
|
||||||
<Card key={tool.id} className="transition-colors hover:bg-accent/30">
|
<Card
|
||||||
|
key={tool.id}
|
||||||
|
className="transition-colors hover:bg-accent/30"
|
||||||
|
>
|
||||||
<CardHeader className="flex flex-row items-center gap-3 space-y-0 p-4">
|
<CardHeader className="flex flex-row items-center gap-3 space-y-0 p-4">
|
||||||
<DynamicIcon name={tool.icon} className="h-4 w-4 text-muted-foreground" />
|
<DynamicIcon
|
||||||
|
name={tool.icon}
|
||||||
|
className="h-4 w-4 text-muted-foreground"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">{tool.name}</p>
|
<p className="text-sm font-medium">{tool.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{tool.description}</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{tool.description}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
if (!tool.url) return cardContent;
|
if (!tool.url) return cardContent;
|
||||||
return (
|
return (
|
||||||
<a key={tool.id} href={tool.url} target="_blank" rel="noopener noreferrer">
|
<a
|
||||||
|
key={tool.id}
|
||||||
|
href={tool.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
{cardContent}
|
{cardContent}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
})}
|
},
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CompanyId } from '@/core/auth/types';
|
import type { CompanyId } from "@/core/auth/types";
|
||||||
|
|
||||||
export interface Company {
|
export interface Company {
|
||||||
id: CompanyId;
|
id: CompanyId;
|
||||||
@@ -16,48 +16,48 @@ export interface Company {
|
|||||||
|
|
||||||
export const COMPANIES: Record<CompanyId, Company> = {
|
export const COMPANIES: Record<CompanyId, Company> = {
|
||||||
beletage: {
|
beletage: {
|
||||||
id: 'beletage',
|
id: "beletage",
|
||||||
name: 'Beletage SRL',
|
name: "Beletage SRL",
|
||||||
shortName: 'Beletage',
|
shortName: "Beletage",
|
||||||
cui: '',
|
cui: "",
|
||||||
color: '#22B5AB',
|
color: "#22B5AB",
|
||||||
address: 'str. Unirii, nr. 3, ap. 26',
|
address: "str. Unirii, nr. 3, ap. 26",
|
||||||
city: 'Cluj-Napoca',
|
city: "Cluj-Napoca",
|
||||||
},
|
},
|
||||||
'urban-switch': {
|
"urban-switch": {
|
||||||
id: 'urban-switch',
|
id: "urban-switch",
|
||||||
name: 'Urban Switch SRL',
|
name: "Urban Switch SRL",
|
||||||
shortName: 'Urban Switch',
|
shortName: "Urban Switch",
|
||||||
cui: '',
|
cui: "",
|
||||||
color: '#6366f1',
|
color: "#6366f1",
|
||||||
address: '',
|
address: "",
|
||||||
city: 'Cluj-Napoca',
|
city: "Cluj-Napoca",
|
||||||
logo: {
|
logo: {
|
||||||
light: '/logos/logo-us-light.svg',
|
light: "/logos/logo-us-light.svg",
|
||||||
dark: '/logos/logo-us-dark.svg',
|
dark: "/logos/logo-us-light.svg",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'studii-de-teren': {
|
"studii-de-teren": {
|
||||||
id: 'studii-de-teren',
|
id: "studii-de-teren",
|
||||||
name: 'Studii de Teren SRL',
|
name: "Studii de Teren SRL",
|
||||||
shortName: 'Studii de Teren',
|
shortName: "Studii de Teren",
|
||||||
cui: '',
|
cui: "",
|
||||||
color: '#f59e0b',
|
color: "#f59e0b",
|
||||||
address: '',
|
address: "",
|
||||||
city: 'Cluj-Napoca',
|
city: "Cluj-Napoca",
|
||||||
logo: {
|
logo: {
|
||||||
light: '/logos/logo-sdt-dark.svg',
|
light: "/logos/logo-sdt-light.svg",
|
||||||
dark: '/logos/logo-sdt-light.svg',
|
dark: "/logos/logo-sdt-light.svg",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
group: {
|
group: {
|
||||||
id: 'group',
|
id: "group",
|
||||||
name: 'Grup Companii',
|
name: "Grup Companii",
|
||||||
shortName: 'Grup',
|
shortName: "Grup",
|
||||||
cui: '',
|
cui: "",
|
||||||
color: '#64748b',
|
color: "#64748b",
|
||||||
address: '',
|
address: "",
|
||||||
city: 'Cluj-Napoca',
|
city: "Cluj-Napoca",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,119 +1,127 @@
|
|||||||
import type { FeatureFlag } from '@/core/feature-flags/types';
|
import type { FeatureFlag } from "@/core/feature-flags/types";
|
||||||
|
|
||||||
export const DEFAULT_FLAGS: FeatureFlag[] = [
|
export const DEFAULT_FLAGS: FeatureFlag[] = [
|
||||||
// Module flags
|
// Module flags
|
||||||
{
|
{
|
||||||
key: 'module.registratura',
|
key: "module.registratura",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
label: 'Registratură',
|
label: "Registratură",
|
||||||
description: 'Registru de corespondență multi-firmă',
|
description: "Registru de corespondență multi-firmă",
|
||||||
category: 'module',
|
category: "module",
|
||||||
overridable: true,
|
overridable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'module.email-signature',
|
key: "module.email-signature",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
label: 'Generator Semnătură Email',
|
label: "Generator Semnătură Email",
|
||||||
description: 'Configurator semnătură email',
|
description: "Configurator semnătură email",
|
||||||
category: 'module',
|
category: "module",
|
||||||
overridable: true,
|
overridable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'module.word-xml',
|
key: "module.word-xml",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
label: 'Generator XML Word',
|
label: "Generator XML Word",
|
||||||
description: 'Generator Custom XML Parts pentru Word',
|
description: "Generator Custom XML Parts pentru Word",
|
||||||
category: 'module',
|
category: "module",
|
||||||
overridable: true,
|
overridable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'module.prompt-generator',
|
key: "module.prompt-generator",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
label: 'Generator Prompturi',
|
label: "Generator Prompturi",
|
||||||
description: 'Constructor de prompturi structurate',
|
description: "Constructor de prompturi structurate",
|
||||||
category: 'module',
|
category: "module",
|
||||||
overridable: true,
|
overridable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'module.digital-signatures',
|
key: "module.digital-signatures",
|
||||||
enabled: false,
|
|
||||||
label: 'Semnături și Ștampile',
|
|
||||||
description: 'Bibliotecă semnături digitale',
|
|
||||||
category: 'module',
|
|
||||||
overridable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'module.password-vault',
|
|
||||||
enabled: false,
|
|
||||||
label: 'Seif Parole',
|
|
||||||
description: 'Depozit intern de credențiale',
|
|
||||||
category: 'module',
|
|
||||||
overridable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'module.it-inventory',
|
|
||||||
enabled: false,
|
|
||||||
label: 'Inventar IT',
|
|
||||||
description: 'Evidența echipamentelor',
|
|
||||||
category: 'module',
|
|
||||||
overridable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'module.address-book',
|
|
||||||
enabled: false,
|
|
||||||
label: 'Contacte',
|
|
||||||
description: 'Clienți, furnizori, instituții',
|
|
||||||
category: 'module',
|
|
||||||
overridable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'module.word-templates',
|
|
||||||
enabled: false,
|
|
||||||
label: 'Șabloane Word',
|
|
||||||
description: 'Bibliotecă contracte și rapoarte',
|
|
||||||
category: 'module',
|
|
||||||
overridable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'module.tag-manager',
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
label: 'Manager Etichete',
|
label: "Semnături și Ștampile",
|
||||||
description: 'Administrare etichete',
|
description: "Bibliotecă semnături digitale",
|
||||||
category: 'module',
|
category: "module",
|
||||||
overridable: true,
|
overridable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'module.mini-utilities',
|
key: "module.password-vault",
|
||||||
enabled: false,
|
enabled: true,
|
||||||
label: 'Utilitare',
|
label: "Seif Parole",
|
||||||
description: 'Calculatoare și instrumente text',
|
description: "Depozit intern de credențiale",
|
||||||
category: 'module',
|
category: "module",
|
||||||
overridable: true,
|
overridable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'module.ai-chat',
|
key: "module.it-inventory",
|
||||||
|
enabled: true,
|
||||||
|
label: "Inventar IT",
|
||||||
|
description: "Evidența echipamentelor",
|
||||||
|
category: "module",
|
||||||
|
overridable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "module.address-book",
|
||||||
|
enabled: true,
|
||||||
|
label: "Contacte",
|
||||||
|
description: "Clienți, furnizori, instituții",
|
||||||
|
category: "module",
|
||||||
|
overridable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "module.word-templates",
|
||||||
|
enabled: true,
|
||||||
|
label: "Șabloane Word",
|
||||||
|
description: "Bibliotecă contracte și rapoarte",
|
||||||
|
category: "module",
|
||||||
|
overridable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "module.tag-manager",
|
||||||
|
enabled: true,
|
||||||
|
label: "Manager Etichete",
|
||||||
|
description: "Administrare etichete",
|
||||||
|
category: "module",
|
||||||
|
overridable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "module.mini-utilities",
|
||||||
|
enabled: true,
|
||||||
|
label: "Utilitare",
|
||||||
|
description: "Calculatoare și instrumente text",
|
||||||
|
category: "module",
|
||||||
|
overridable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "module.ai-chat",
|
||||||
enabled: false,
|
enabled: false,
|
||||||
label: 'Chat AI',
|
label: "Chat AI",
|
||||||
description: 'Interfață asistent AI',
|
description: "Interfață asistent AI",
|
||||||
category: 'module',
|
category: "module",
|
||||||
|
overridable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "module.hot-desk",
|
||||||
|
enabled: true,
|
||||||
|
label: "Birouri Partajate",
|
||||||
|
description: "Rezervare birouri în camera partajată",
|
||||||
|
category: "module",
|
||||||
overridable: true,
|
overridable: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
// System flags
|
// System flags
|
||||||
{
|
{
|
||||||
key: 'system.dark-mode',
|
key: "system.dark-mode",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
label: 'Mod întunecat',
|
label: "Mod întunecat",
|
||||||
description: 'Activează tema întunecată',
|
description: "Activează tema întunecată",
|
||||||
category: 'system',
|
category: "system",
|
||||||
overridable: true,
|
overridable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'system.external-links',
|
key: "system.external-links",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
label: 'Linkuri externe',
|
label: "Linkuri externe",
|
||||||
description: 'Afișează linkuri instrumente externe în navigare',
|
description: "Afișează linkuri instrumente externe în navigare",
|
||||||
category: 'system',
|
category: "system",
|
||||||
overridable: true,
|
overridable: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import type { ModuleConfig } from '@/core/module-registry/types';
|
import type { ModuleConfig } from "@/core/module-registry/types";
|
||||||
import { registerModules } from '@/core/module-registry';
|
import { registerModules } from "@/core/module-registry";
|
||||||
|
|
||||||
import { registraturaConfig } from '@/modules/registratura/config';
|
import { registraturaConfig } from "@/modules/registratura/config";
|
||||||
import { emailSignatureConfig } from '@/modules/email-signature/config';
|
import { emailSignatureConfig } from "@/modules/email-signature/config";
|
||||||
import { wordXmlConfig } from '@/modules/word-xml/config';
|
import { wordXmlConfig } from "@/modules/word-xml/config";
|
||||||
import { promptGeneratorConfig } from '@/modules/prompt-generator/config';
|
import { promptGeneratorConfig } from "@/modules/prompt-generator/config";
|
||||||
import { digitalSignaturesConfig } from '@/modules/digital-signatures/config';
|
import { digitalSignaturesConfig } from "@/modules/digital-signatures/config";
|
||||||
import { passwordVaultConfig } from '@/modules/password-vault/config';
|
import { passwordVaultConfig } from "@/modules/password-vault/config";
|
||||||
import { itInventoryConfig } from '@/modules/it-inventory/config';
|
import { itInventoryConfig } from "@/modules/it-inventory/config";
|
||||||
import { addressBookConfig } from '@/modules/address-book/config';
|
import { addressBookConfig } from "@/modules/address-book/config";
|
||||||
import { wordTemplatesConfig } from '@/modules/word-templates/config';
|
import { wordTemplatesConfig } from "@/modules/word-templates/config";
|
||||||
import { tagManagerConfig } from '@/modules/tag-manager/config';
|
import { tagManagerConfig } from "@/modules/tag-manager/config";
|
||||||
import { miniUtilitiesConfig } from '@/modules/mini-utilities/config';
|
import { miniUtilitiesConfig } from "@/modules/mini-utilities/config";
|
||||||
import { aiChatConfig } from '@/modules/ai-chat/config';
|
import { aiChatConfig } from "@/modules/ai-chat/config";
|
||||||
|
import { hotDeskConfig } from "@/modules/hot-desk/config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toate configurările modulelor ArchiTools, ordonate după navOrder.
|
* Toate configurările modulelor ArchiTools, ordonate după navOrder.
|
||||||
@@ -27,6 +28,7 @@ export const MODULE_CONFIGS: ModuleConfig[] = [
|
|||||||
digitalSignaturesConfig, // navOrder: 30 | management
|
digitalSignaturesConfig, // navOrder: 30 | management
|
||||||
itInventoryConfig, // navOrder: 31 | management
|
itInventoryConfig, // navOrder: 31 | management
|
||||||
addressBookConfig, // navOrder: 32 | management
|
addressBookConfig, // navOrder: 32 | management
|
||||||
|
hotDeskConfig, // navOrder: 33 | management
|
||||||
tagManagerConfig, // navOrder: 40 | tools
|
tagManagerConfig, // navOrder: 40 | tools
|
||||||
miniUtilitiesConfig, // navOrder: 41 | tools
|
miniUtilitiesConfig, // navOrder: 41 | tools
|
||||||
promptGeneratorConfig, // navOrder: 50 | ai
|
promptGeneratorConfig, // navOrder: 50 | ai
|
||||||
|
|||||||
@@ -1,109 +1,113 @@
|
|||||||
import type { Labels } from '../types';
|
import type { Labels } from "../types";
|
||||||
|
|
||||||
export const ro: Labels = {
|
export const ro: Labels = {
|
||||||
common: {
|
common: {
|
||||||
save: 'Salvează',
|
save: "Salvează",
|
||||||
cancel: 'Anulează',
|
cancel: "Anulează",
|
||||||
delete: 'Șterge',
|
delete: "Șterge",
|
||||||
edit: 'Editează',
|
edit: "Editează",
|
||||||
create: 'Creează',
|
create: "Creează",
|
||||||
search: 'Caută',
|
search: "Caută",
|
||||||
filter: 'Filtrează',
|
filter: "Filtrează",
|
||||||
export: 'Exportă',
|
export: "Exportă",
|
||||||
import: 'Importă',
|
import: "Importă",
|
||||||
copy: 'Copiază',
|
copy: "Copiază",
|
||||||
close: 'Închide',
|
close: "Închide",
|
||||||
confirm: 'Confirmă',
|
confirm: "Confirmă",
|
||||||
back: 'Înapoi',
|
back: "Înapoi",
|
||||||
next: 'Următorul',
|
next: "Următorul",
|
||||||
loading: 'Se încarcă...',
|
loading: "Se încarcă...",
|
||||||
noResults: 'Niciun rezultat',
|
noResults: "Niciun rezultat",
|
||||||
error: 'Eroare',
|
error: "Eroare",
|
||||||
success: 'Succes',
|
success: "Succes",
|
||||||
actions: 'Acțiuni',
|
actions: "Acțiuni",
|
||||||
settings: 'Setări',
|
settings: "Setări",
|
||||||
all: 'Toate',
|
all: "Toate",
|
||||||
yes: 'Da',
|
yes: "Da",
|
||||||
no: 'Nu',
|
no: "Nu",
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
dashboard: 'Panou principal',
|
dashboard: "Panou principal",
|
||||||
operations: 'Operațiuni',
|
operations: "Operațiuni",
|
||||||
generators: 'Generatoare',
|
generators: "Generatoare",
|
||||||
management: 'Management',
|
management: "Management",
|
||||||
tools: 'Instrumente',
|
tools: "Instrumente",
|
||||||
ai: 'AI & Automatizări',
|
ai: "AI & Automatizări",
|
||||||
externalTools: 'Instrumente externe',
|
externalTools: "Instrumente externe",
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
title: 'Panou principal',
|
title: "Panou principal",
|
||||||
welcome: 'Bine ai venit în ArchiTools',
|
welcome: "Bine ai venit în ArchiTools",
|
||||||
subtitle: 'Platforma internă de instrumente pentru birou',
|
subtitle: "Platforma internă de instrumente pentru birou",
|
||||||
quickActions: 'Acțiuni rapide',
|
quickActions: "Acțiuni rapide",
|
||||||
recentActivity: 'Activitate recentă',
|
recentActivity: "Activitate recentă",
|
||||||
modules: 'Module',
|
modules: "Module",
|
||||||
infrastructure: 'Infrastructură',
|
infrastructure: "Infrastructură",
|
||||||
},
|
},
|
||||||
registratura: {
|
registratura: {
|
||||||
title: 'Registratură',
|
title: "Registratură",
|
||||||
description: 'Registru de corespondență multi-firmă',
|
description: "Registru de corespondență multi-firmă",
|
||||||
newEntry: 'Înregistrare nouă',
|
newEntry: "Înregistrare nouă",
|
||||||
entries: 'Înregistrări',
|
entries: "Înregistrări",
|
||||||
incoming: 'Intrare',
|
incoming: "Intrare",
|
||||||
outgoing: 'Ieșire',
|
outgoing: "Ieșire",
|
||||||
internal: 'Intern',
|
internal: "Intern",
|
||||||
},
|
},
|
||||||
'email-signature': {
|
"email-signature": {
|
||||||
title: 'Generator Semnătură Email',
|
title: "Generator Semnătură Email",
|
||||||
description: 'Configurator semnătură email pentru companii',
|
description: "Configurator semnătură email pentru companii",
|
||||||
preview: 'Previzualizare',
|
preview: "Previzualizare",
|
||||||
downloadHtml: 'Descarcă HTML',
|
downloadHtml: "Descarcă HTML",
|
||||||
},
|
},
|
||||||
'word-xml': {
|
"word-xml": {
|
||||||
title: 'Generator XML Word',
|
title: "Generator XML Word",
|
||||||
description: 'Generator Custom XML Parts pentru Word',
|
description: "Generator Custom XML Parts pentru Word",
|
||||||
generate: 'Generează XML',
|
generate: "Generează XML",
|
||||||
downloadXml: 'Descarcă XML',
|
downloadXml: "Descarcă XML",
|
||||||
downloadZip: 'Descarcă ZIP',
|
downloadZip: "Descarcă ZIP",
|
||||||
},
|
},
|
||||||
'prompt-generator': {
|
"prompt-generator": {
|
||||||
title: 'Generator Prompturi',
|
title: "Generator Prompturi",
|
||||||
description: 'Constructor de prompturi structurate pentru AI',
|
description: "Constructor de prompturi structurate pentru AI",
|
||||||
templates: 'Șabloane',
|
templates: "Șabloane",
|
||||||
compose: 'Compune',
|
compose: "Compune",
|
||||||
history: 'Istoric',
|
history: "Istoric",
|
||||||
preview: 'Previzualizare',
|
preview: "Previzualizare",
|
||||||
},
|
},
|
||||||
'digital-signatures': {
|
"digital-signatures": {
|
||||||
title: 'Semnături și Ștampile',
|
title: "Semnături și Ștampile",
|
||||||
description: 'Bibliotecă semnături digitale și ștampile scanate',
|
description: "Bibliotecă semnături digitale și ștampile scanate",
|
||||||
},
|
},
|
||||||
'password-vault': {
|
"password-vault": {
|
||||||
title: 'Seif Parole',
|
title: "Seif Parole",
|
||||||
description: 'Depozit intern de credențiale partajate',
|
description: "Depozit intern de credențiale partajate",
|
||||||
},
|
},
|
||||||
'it-inventory': {
|
"it-inventory": {
|
||||||
title: 'Inventar IT',
|
title: "Inventar IT",
|
||||||
description: 'Evidența echipamentelor și dispozitivelor',
|
description: "Evidența echipamentelor și dispozitivelor",
|
||||||
},
|
},
|
||||||
'address-book': {
|
"address-book": {
|
||||||
title: 'Contacte',
|
title: "Contacte",
|
||||||
description: 'Clienți, furnizori, instituții',
|
description: "Clienți, furnizori, instituții",
|
||||||
},
|
},
|
||||||
'word-templates': {
|
"word-templates": {
|
||||||
title: 'Șabloane Word',
|
title: "Șabloane Word",
|
||||||
description: 'Bibliotecă contracte, oferte, rapoarte',
|
description: "Bibliotecă contracte, oferte, rapoarte",
|
||||||
},
|
},
|
||||||
'tag-manager': {
|
"tag-manager": {
|
||||||
title: 'Manager Etichete',
|
title: "Manager Etichete",
|
||||||
description: 'Administrare etichete proiecte și categorii',
|
description: "Administrare etichete proiecte și categorii",
|
||||||
},
|
},
|
||||||
'mini-utilities': {
|
"mini-utilities": {
|
||||||
title: 'Utilitare',
|
title: "Utilitare",
|
||||||
description: 'Calculatoare tehnice și instrumente text',
|
description: "Calculatoare tehnice și instrumente text",
|
||||||
},
|
},
|
||||||
'ai-chat': {
|
"ai-chat": {
|
||||||
title: 'Chat AI',
|
title: "Chat AI",
|
||||||
description: 'Interfață asistent AI',
|
description: "Interfață asistent AI",
|
||||||
|
},
|
||||||
|
"hot-desk": {
|
||||||
|
title: "Birouri Partajate",
|
||||||
|
description: "Rezervare birouri în camera partajată",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export type { Tag, TagCategory, TagScope } from './types';
|
export type { Tag, TagCategory, TagScope } from './types';
|
||||||
|
export { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from './types';
|
||||||
export { TagService } from './tag-service';
|
export { TagService } from './tag-service';
|
||||||
export { useTags } from './use-tags';
|
export { useTags } from './use-tags';
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export class TagService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getChildren(parentId: string): Promise<Tag[]> {
|
||||||
|
return this.storage.query<Tag>(NAMESPACE, (tag) => tag.parentId === parentId);
|
||||||
|
}
|
||||||
|
|
||||||
async createTag(data: Omit<Tag, 'id' | 'createdAt'>): Promise<Tag> {
|
async createTag(data: Omit<Tag, 'id' | 'createdAt'>): Promise<Tag> {
|
||||||
const tag: Tag = {
|
const tag: Tag = {
|
||||||
...data,
|
...data,
|
||||||
@@ -43,19 +47,41 @@ export class TagService {
|
|||||||
async updateTag(id: string, updates: Partial<Omit<Tag, 'id' | 'createdAt'>>): Promise<Tag | null> {
|
async updateTag(id: string, updates: Partial<Omit<Tag, 'id' | 'createdAt'>>): Promise<Tag | null> {
|
||||||
const existing = await this.storage.get<Tag>(NAMESPACE, id);
|
const existing = await this.storage.get<Tag>(NAMESPACE, id);
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
const updated = { ...existing, ...updates };
|
const updated: Tag = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
||||||
await this.storage.set(NAMESPACE, id, updated);
|
await this.storage.set(NAMESPACE, id, updated);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteTag(id: string): Promise<void> {
|
async deleteTag(id: string): Promise<void> {
|
||||||
|
// Also delete children
|
||||||
|
const children = await this.getChildren(id);
|
||||||
|
for (const child of children) {
|
||||||
|
await this.storage.delete(NAMESPACE, child.id);
|
||||||
|
}
|
||||||
await this.storage.delete(NAMESPACE, id);
|
await this.storage.delete(NAMESPACE, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchTags(query: string): Promise<Tag[]> {
|
async searchTags(query: string): Promise<Tag[]> {
|
||||||
const lower = query.toLowerCase();
|
const lower = query.toLowerCase();
|
||||||
return this.storage.query<Tag>(NAMESPACE, (tag) =>
|
return this.storage.query<Tag>(NAMESPACE, (tag) =>
|
||||||
tag.label.toLowerCase().includes(lower)
|
tag.label.toLowerCase().includes(lower) ||
|
||||||
|
(tag.projectCode?.toLowerCase().includes(lower) ?? false)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bulk import tags (for seed data). Skips tags whose label already exists in same category. */
|
||||||
|
async importTags(tags: Omit<Tag, 'id' | 'createdAt'>[]): Promise<number> {
|
||||||
|
const existing = await this.getAllTags();
|
||||||
|
const existingKeys = new Set(existing.map((t) => `${t.category}::${t.label}`));
|
||||||
|
let imported = 0;
|
||||||
|
for (const data of tags) {
|
||||||
|
const key = `${data.category}::${data.label}`;
|
||||||
|
if (!existingKeys.has(key)) {
|
||||||
|
await this.createTag(data);
|
||||||
|
existingKeys.add(key);
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return imported;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,25 @@ export type TagCategory =
|
|||||||
| 'phase'
|
| 'phase'
|
||||||
| 'activity'
|
| 'activity'
|
||||||
| 'document-type'
|
| 'document-type'
|
||||||
| 'company'
|
|
||||||
| 'priority'
|
|
||||||
| 'status'
|
|
||||||
| 'custom';
|
| 'custom';
|
||||||
|
|
||||||
|
/** Display order for categories — project & phase are mandatory */
|
||||||
|
export const TAG_CATEGORY_ORDER: TagCategory[] = [
|
||||||
|
'project',
|
||||||
|
'phase',
|
||||||
|
'activity',
|
||||||
|
'document-type',
|
||||||
|
'custom',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TAG_CATEGORY_LABELS: Record<TagCategory, string> = {
|
||||||
|
project: 'Proiect',
|
||||||
|
phase: 'Fază',
|
||||||
|
activity: 'Activitate',
|
||||||
|
'document-type': 'Tip document',
|
||||||
|
custom: 'Personalizat',
|
||||||
|
};
|
||||||
|
|
||||||
export type TagScope = 'global' | 'module' | 'company';
|
export type TagScope = 'global' | 'module' | 'company';
|
||||||
|
|
||||||
export interface Tag {
|
export interface Tag {
|
||||||
@@ -21,7 +35,11 @@ export interface Tag {
|
|||||||
scope: TagScope;
|
scope: TagScope;
|
||||||
moduleId?: string;
|
moduleId?: string;
|
||||||
companyId?: CompanyId;
|
companyId?: CompanyId;
|
||||||
|
/** For hierarchy: parent tag id */
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
|
/** For project tags: numbered code e.g. "B-001", "US-024" */
|
||||||
|
projectCode?: string;
|
||||||
metadata?: Record<string, string>;
|
metadata?: Record<string, string>;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ export function useTags(category?: TagCategory) {
|
|||||||
[service, refresh]
|
[service, refresh]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const updateTag = useCallback(
|
||||||
|
async (id: string, updates: Partial<Omit<Tag, 'id' | 'createdAt'>>) => {
|
||||||
|
const tag = await service.updateTag(id, updates);
|
||||||
|
await refresh();
|
||||||
|
return tag;
|
||||||
|
},
|
||||||
|
[service, refresh]
|
||||||
|
);
|
||||||
|
|
||||||
const deleteTag = useCallback(
|
const deleteTag = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
await service.deleteTag(id);
|
await service.deleteTag(id);
|
||||||
@@ -41,5 +50,21 @@ export function useTags(category?: TagCategory) {
|
|||||||
[service, refresh]
|
[service, refresh]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { tags, loading, createTag, deleteTag, refresh };
|
const importTags = useCallback(
|
||||||
|
async (data: Omit<Tag, 'id' | 'createdAt'>[]) => {
|
||||||
|
const count = await service.importTags(data);
|
||||||
|
await refresh();
|
||||||
|
return count;
|
||||||
|
},
|
||||||
|
[service, refresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchTags = useCallback(
|
||||||
|
async (query: string) => {
|
||||||
|
return service.searchTags(query);
|
||||||
|
},
|
||||||
|
[service]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { tags, loading, createTag, updateTag, deleteTag, importTags, searchTags, refresh, service };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,167 +1,886 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { Plus, Pencil, Trash2, Search, Mail, Phone, MapPin } from 'lucide-react';
|
import {
|
||||||
import { Button } from '@/shared/components/ui/button';
|
Plus,
|
||||||
import { Input } from '@/shared/components/ui/input';
|
Pencil,
|
||||||
import { Label } from '@/shared/components/ui/label';
|
Trash2,
|
||||||
import { Textarea } from '@/shared/components/ui/textarea';
|
Search,
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
Mail,
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
Phone,
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
MapPin,
|
||||||
import type { AddressContact, ContactType } from '../types';
|
Globe,
|
||||||
import { useContacts } from '../hooks/use-contacts';
|
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 {
|
||||||
|
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> = {
|
const TYPE_LABELS: Record<ContactType, string> = {
|
||||||
client: 'Client', supplier: 'Furnizor', institution: 'Instituție', collaborator: 'Colaborator',
|
client: "Client",
|
||||||
|
supplier: "Furnizor",
|
||||||
|
institution: "Instituție",
|
||||||
|
collaborator: "Colaborator",
|
||||||
|
internal: "Intern",
|
||||||
};
|
};
|
||||||
|
|
||||||
type ViewMode = 'list' | 'add' | 'edit';
|
type ViewMode = "list" | "add" | "edit";
|
||||||
|
|
||||||
export function AddressBookModule() {
|
export function AddressBookModule() {
|
||||||
const { contacts, allContacts, loading, filters, updateFilter, addContact, updateContact, removeContact } = useContacts();
|
const {
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
contacts,
|
||||||
const [editingContact, setEditingContact] = useState<AddressContact | null>(null);
|
allContacts,
|
||||||
|
loading,
|
||||||
|
filters,
|
||||||
|
updateFilter,
|
||||||
|
addContact,
|
||||||
|
updateContact,
|
||||||
|
removeContact,
|
||||||
|
} = useContacts();
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||||
|
const [editingContact, setEditingContact] = useState<AddressContact | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [viewingContact, setViewingContact] = useState<AddressContact | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = async (data: Omit<AddressContact, 'id' | 'createdAt'>) => {
|
const handleSubmit = async (
|
||||||
if (viewMode === 'edit' && editingContact) {
|
data: Omit<AddressContact, "id" | "createdAt" | "updatedAt">,
|
||||||
|
) => {
|
||||||
|
if (viewMode === "edit" && editingContact) {
|
||||||
await updateContact(editingContact.id, data);
|
await updateContact(editingContact.id, data);
|
||||||
} else {
|
} else {
|
||||||
await addContact(data);
|
await addContact(data);
|
||||||
}
|
}
|
||||||
setViewMode('list');
|
setViewMode("list");
|
||||||
setEditingContact(null);
|
setEditingContact(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<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>
|
||||||
{(Object.keys(TYPE_LABELS) as ContactType[]).slice(0, 3).map((type) => (
|
<CardContent className="p-4">
|
||||||
<Card key={type}><CardContent className="p-4">
|
<p className="text-xs text-muted-foreground">Total</p>
|
||||||
<p className="text-xs text-muted-foreground">{TYPE_LABELS[type]}</p>
|
<p className="text-2xl font-bold">{allContacts.length}</p>
|
||||||
<p className="text-2xl font-bold">{allContacts.filter((c) => c.type === type).length}</p>
|
</CardContent>
|
||||||
</CardContent></Card>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === 'list' && (
|
{viewMode === "list" && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="relative min-w-[200px] flex-1">
|
<div className="relative min-w-[200px] flex-1">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input placeholder="Caută contact..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
|
<Input
|
||||||
|
placeholder="Caută contact..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => updateFilter("search", e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select value={filters.type} onValueChange={(v) => updateFilter('type', v as ContactType | 'all')}>
|
<Select
|
||||||
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
|
value={filters.type}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateFilter("type", v as ContactType | "all")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[150px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Toate tipurile</SelectItem>
|
<SelectItem value="all">Toate tipurile</SelectItem>
|
||||||
{(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => (
|
{(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => (
|
||||||
<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>
|
<SelectItem key={t} value={t}>
|
||||||
|
{TYPE_LABELS[t]}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button onClick={() => setViewMode('add')} className="shrink-0">
|
<Button onClick={() => setViewMode("add")} className="shrink-0">
|
||||||
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Se încarcă...
|
||||||
|
</p>
|
||||||
) : contacts.length === 0 ? (
|
) : contacts.length === 0 ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">Niciun contact găsit.</p>
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Niciun contact găsit.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{contacts.map((contact) => (
|
{contacts.map((contact) => (
|
||||||
<Card key={contact.id} className="group relative">
|
<ContactCard
|
||||||
<CardContent className="p-4">
|
key={contact.id}
|
||||||
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
contact={contact}
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingContact(contact); setViewMode('edit'); }}>
|
onEdit={() => {
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
setEditingContact(contact);
|
||||||
</Button>
|
setViewMode("edit");
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeContact(contact.id)}>
|
}}
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
onDelete={() => removeContact(contact.id)}
|
||||||
</Button>
|
onViewDetail={() => setViewingContact(contact)}
|
||||||
</div>
|
/>
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{contact.name}</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{contact.company && <p className="text-xs text-muted-foreground">{contact.company}</p>}
|
|
||||||
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[contact.type]}</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{contact.email && (
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
||||||
<Mail className="h-3 w-3" /><span>{contact.email}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{contact.phone && (
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
||||||
<Phone className="h-3 w-3" /><span>{contact.phone}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{contact.address && (
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
||||||
<MapPin className="h-3 w-3" /><span className="truncate">{contact.address}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(viewMode === 'add' || viewMode === 'edit') && (
|
{(viewMode === "add" || viewMode === "edit") && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare contact' : 'Contact nou'}</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{viewMode === "edit" ? "Editare contact" : "Contact nou"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ContactForm initial={editingContact ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingContact(null); }} />
|
<ContactForm
|
||||||
|
initial={editingContact ?? undefined}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={() => {
|
||||||
|
setViewMode("list");
|
||||||
|
setEditingContact(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Contact Detail Dialog */}
|
||||||
|
<ContactDetailDialog
|
||||||
|
contact={viewingContact}
|
||||||
|
onClose={() => setViewingContact(null)}
|
||||||
|
onEdit={(c) => {
|
||||||
|
setViewingContact(null);
|
||||||
|
setEditingContact(c);
|
||||||
|
setViewMode("edit");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContactForm({ initial, onSubmit, onCancel }: {
|
// ── Contact Card ──
|
||||||
initial?: AddressContact;
|
|
||||||
onSubmit: (data: Omit<AddressContact, 'id' | 'createdAt'>) => void;
|
function ContactCard({
|
||||||
onCancel: () => void;
|
contact,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onViewDetail,
|
||||||
|
}: {
|
||||||
|
contact: AddressContact;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onViewDetail: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState(initial?.name ?? '');
|
return (
|
||||||
const [company, setCompany] = useState(initial?.company ?? '');
|
<Card className="group relative">
|
||||||
const [type, setType] = useState<ContactType>(initial?.type ?? 'client');
|
<CardContent className="p-4">
|
||||||
const [email, setEmail] = useState(initial?.email ?? '');
|
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
const [phone, setPhone] = useState(initial?.phone ?? '');
|
<Button
|
||||||
const [address, setAddress] = useState(initial?.address ?? '');
|
variant="ghost"
|
||||||
const [notes, setNotes] = useState(initial?.notes ?? '');
|
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}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<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.department && (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{contact.department}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{contact.role && (
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(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}` : ""}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
{contact.contactPersons.length > 2 && (
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
+{contact.contactPersons.length - 2} altele
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
return (
|
||||||
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ name, company, type, email, phone, address, notes, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4">
|
<Dialog
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
open={contact !== null}
|
||||||
<div><Label>Nume</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div>
|
onOpenChange={(open) => {
|
||||||
<div><Label>Companie/Organizație</Label><Input value={company} onChange={(e) => setCompany(e.target.value)} className="mt-1" /></div>
|
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>
|
</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,
|
||||||
|
}: {
|
||||||
|
initial?: AddressContact;
|
||||||
|
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 [contactPersons, setContactPersons] = useState<ContactPerson[]>(
|
||||||
|
initial?.contactPersons ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addContactPerson = () => {
|
||||||
|
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 removeContactPerson = (index: number) => {
|
||||||
|
setContactPersons(contactPersons.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleProject = (projectId: string) => {
|
||||||
|
setProjectIds((prev) =>
|
||||||
|
prev.includes(projectId)
|
||||||
|
? prev.filter((id) => id !== projectId)
|
||||||
|
: [...prev, projectId],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit({
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
{/* Row 1: Name + Company + Type */}
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div><Label>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)}>
|
<Select value={type} onValueChange={(v) => setType(v as ContactType)}>
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="mt-1">
|
||||||
<SelectContent>{(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => (<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>))}</SelectContent>
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(TYPE_LABELS) as ContactType[]).map((t) => (
|
||||||
|
<SelectItem key={t} value={t}>
|
||||||
|
{TYPE_LABELS[t]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div><Label>Email</Label><Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="mt-1" /></div>
|
|
||||||
<div><Label>Telefon</Label><Input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} className="mt-1" /></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div><Label>Adresă</Label><Input value={address} onChange={(e) => setAddress(e.target.value)} className="mt-1" /></div>
|
|
||||||
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div>
|
{/* 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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
<div>
|
||||||
|
<Label>Adresă</Label>
|
||||||
|
<Input
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project links */}
|
||||||
|
{projectTags.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label>Proiecte asociate</Label>
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||||
|
{projectTags.map((pt) => (
|
||||||
|
<button
|
||||||
|
key={pt.id}
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pt.projectCode ? `${pt.projectCode} ` : ""}
|
||||||
|
{pt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contact Persons */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Persoane de contact</Label>
|
||||||
|
<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)}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div>
|
||||||
|
<Label>Note</Label>
|
||||||
|
<Textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
|
Anulează
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ export function useContacts() {
|
|||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
useEffect(() => { refresh(); }, [refresh]);
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
const addContact = useCallback(async (data: Omit<AddressContact, 'id' | 'createdAt'>) => {
|
const addContact = useCallback(async (data: Omit<AddressContact, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||||
const contact: AddressContact = { ...data, id: uuid(), createdAt: new Date().toISOString() };
|
const now = new Date().toISOString();
|
||||||
|
const contact: AddressContact = { ...data, id: uuid(), createdAt: now, updatedAt: now };
|
||||||
await storage.set(`${PREFIX}${contact.id}`, contact);
|
await storage.set(`${PREFIX}${contact.id}`, contact);
|
||||||
await refresh();
|
await refresh();
|
||||||
return contact;
|
return contact;
|
||||||
@@ -46,7 +47,13 @@ export function useContacts() {
|
|||||||
const updateContact = useCallback(async (id: string, updates: Partial<AddressContact>) => {
|
const updateContact = useCallback(async (id: string, updates: Partial<AddressContact>) => {
|
||||||
const existing = contacts.find((c) => c.id === id);
|
const existing = contacts.find((c) => c.id === id);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt };
|
const updated: AddressContact = {
|
||||||
|
...existing,
|
||||||
|
...updates,
|
||||||
|
id: existing.id,
|
||||||
|
createdAt: existing.createdAt,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
await storage.set(`${PREFIX}${id}`, updated);
|
await storage.set(`${PREFIX}${id}`, updated);
|
||||||
await refresh();
|
await refresh();
|
||||||
}, [storage, refresh, contacts]);
|
}, [storage, refresh, contacts]);
|
||||||
@@ -64,7 +71,14 @@ export function useContacts() {
|
|||||||
if (filters.type !== 'all' && c.type !== filters.type) return false;
|
if (filters.type !== 'all' && c.type !== filters.type) return false;
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
const q = filters.search.toLowerCase();
|
const q = filters.search.toLowerCase();
|
||||||
return c.name.toLowerCase().includes(q) || c.company.toLowerCase().includes(q) || c.email.toLowerCase().includes(q) || c.phone.includes(q);
|
return (
|
||||||
|
c.name.toLowerCase().includes(q) ||
|
||||||
|
c.company.toLowerCase().includes(q) ||
|
||||||
|
c.email.toLowerCase().includes(q) ||
|
||||||
|
c.phone.includes(q) ||
|
||||||
|
(c.department ?? '').toLowerCase().includes(q) ||
|
||||||
|
(c.role ?? '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
98
src/modules/address-book/services/vcard-export.ts
Normal file
98
src/modules/address-book/services/vcard-export.ts
Normal 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");
|
||||||
|
}
|
||||||
@@ -1,17 +1,44 @@
|
|||||||
import type { Visibility } from '@/core/module-registry/types';
|
import type { Visibility } from '@/core/module-registry/types';
|
||||||
|
|
||||||
export type ContactType = 'client' | 'supplier' | 'institution' | 'collaborator';
|
export type ContactType = 'client' | 'supplier' | 'institution' | 'collaborator' | 'internal';
|
||||||
|
|
||||||
|
/** A contact person within an organization/entity */
|
||||||
|
export interface ContactPerson {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AddressContact {
|
export interface AddressContact {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Primary name (person or organization) */
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Organization/company name */
|
||||||
company: string;
|
company: string;
|
||||||
type: ContactType;
|
type: ContactType;
|
||||||
|
/** Primary email */
|
||||||
email: string;
|
email: string;
|
||||||
|
/** Secondary email */
|
||||||
|
email2: string;
|
||||||
|
/** Primary phone */
|
||||||
phone: string;
|
phone: string;
|
||||||
|
/** Secondary phone */
|
||||||
|
phone2: string;
|
||||||
address: string;
|
address: string;
|
||||||
|
/** Department within the organization */
|
||||||
|
department: string;
|
||||||
|
/** Role / job title */
|
||||||
|
role: string;
|
||||||
|
/** Website URL */
|
||||||
|
website: string;
|
||||||
|
/** Linked project tag IDs */
|
||||||
|
projectIds: string[];
|
||||||
|
/** Additional contact persons for this entity */
|
||||||
|
contactPersons: ContactPerson[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
notes: string;
|
notes: string;
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
193
src/modules/dashboard/hooks/use-dashboard-data.ts
Normal file
193
src/modules/dashboard/hooks/use-dashboard-data.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -1,92 +1,222 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useRef } from "react";
|
||||||
import { Plus, Pencil, Trash2, Search, PenTool, Stamp, Type } from 'lucide-react';
|
import {
|
||||||
import { Button } from '@/shared/components/ui/button';
|
Plus,
|
||||||
import { Input } from '@/shared/components/ui/input';
|
Pencil,
|
||||||
import { Label } from '@/shared/components/ui/label';
|
Trash2,
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
Search,
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
PenTool,
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
Stamp,
|
||||||
import type { CompanyId } from '@/core/auth/types';
|
Type,
|
||||||
import type { SignatureAsset, SignatureAssetType } from '../types';
|
History,
|
||||||
import { useSignatures } from '../hooks/use-signatures';
|
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> = {
|
const TYPE_LABELS: Record<SignatureAssetType, string> = {
|
||||||
signature: 'Semnătură', stamp: 'Ștampilă', initials: 'Inițiale',
|
signature: "Semnătură",
|
||||||
|
stamp: "Ștampilă",
|
||||||
|
initials: "Inițiale",
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_ICONS: Record<SignatureAssetType, typeof PenTool> = {
|
const TYPE_ICONS: Record<SignatureAssetType, typeof PenTool> = {
|
||||||
signature: PenTool, stamp: Stamp, initials: Type,
|
signature: PenTool,
|
||||||
|
stamp: Stamp,
|
||||||
|
initials: Type,
|
||||||
};
|
};
|
||||||
|
|
||||||
type ViewMode = 'list' | 'add' | 'edit';
|
type ViewMode = "list" | "add" | "edit";
|
||||||
|
|
||||||
export function DigitalSignaturesModule() {
|
export function DigitalSignaturesModule() {
|
||||||
const { assets, allAssets, loading, filters, updateFilter, addAsset, updateAsset, removeAsset } = useSignatures();
|
const {
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
assets,
|
||||||
|
allAssets,
|
||||||
|
loading,
|
||||||
|
filters,
|
||||||
|
updateFilter,
|
||||||
|
addAsset,
|
||||||
|
updateAsset,
|
||||||
|
addVersion,
|
||||||
|
removeAsset,
|
||||||
|
} = useSignatures();
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||||
const [editingAsset, setEditingAsset] = useState<SignatureAsset | null>(null);
|
const [editingAsset, setEditingAsset] = useState<SignatureAsset | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [versionAsset, setVersionAsset] = useState<SignatureAsset | null>(null);
|
||||||
|
|
||||||
const handleSubmit = async (data: Omit<SignatureAsset, 'id' | 'createdAt'>) => {
|
const handleSubmit = async (
|
||||||
if (viewMode === 'edit' && editingAsset) {
|
data: Omit<SignatureAsset, "id" | "createdAt" | "updatedAt">,
|
||||||
|
) => {
|
||||||
|
if (viewMode === "edit" && editingAsset) {
|
||||||
await updateAsset(editingAsset.id, data);
|
await updateAsset(editingAsset.id, data);
|
||||||
} else {
|
} else {
|
||||||
await addAsset(data);
|
await addAsset(data);
|
||||||
}
|
}
|
||||||
setViewMode('list');
|
setViewMode("list");
|
||||||
setEditingAsset(null);
|
setEditingAsset(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (deletingId) {
|
||||||
|
await removeAsset(deletingId);
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddVersion = async (imageUrl: string, notes: string) => {
|
||||||
|
if (versionAsset) {
|
||||||
|
await addVersion(versionAsset.id, imageUrl, notes);
|
||||||
|
setVersionAsset(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isExpiringSoon = (date?: string) => {
|
||||||
|
if (!date) return false;
|
||||||
|
const diff = new Date(date).getTime() - Date.now();
|
||||||
|
return diff > 0 && diff < 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
|
};
|
||||||
|
|
||||||
|
const isExpired = (date?: string) => {
|
||||||
|
if (!date) return false;
|
||||||
|
return new Date(date).getTime() < Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total</p><p className="text-2xl font-bold">{allAssets.length}</p></CardContent></Card>
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Total</p>
|
||||||
|
<p className="text-2xl font-bold">{allAssets.length}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
{(Object.keys(TYPE_LABELS) as SignatureAssetType[]).map((type) => (
|
{(Object.keys(TYPE_LABELS) as SignatureAssetType[]).map((type) => (
|
||||||
<Card key={type}><CardContent className="p-4">
|
<Card key={type}>
|
||||||
<p className="text-xs text-muted-foreground">{TYPE_LABELS[type]}</p>
|
<CardContent className="p-4">
|
||||||
<p className="text-2xl font-bold">{allAssets.filter((a) => a.type === type).length}</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
</CardContent></Card>
|
{TYPE_LABELS[type]}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{allAssets.filter((a) => a.type === type).length}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === 'list' && (
|
{viewMode === "list" && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="relative min-w-[200px] flex-1">
|
<div className="relative min-w-[200px] flex-1">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input placeholder="Caută..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
|
<Input
|
||||||
|
placeholder="Caută..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => updateFilter("search", e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select value={filters.type} onValueChange={(v) => updateFilter('type', v as SignatureAssetType | 'all')}>
|
<Select
|
||||||
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
|
value={filters.type}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateFilter("type", v as SignatureAssetType | "all")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[150px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Toate tipurile</SelectItem>
|
<SelectItem value="all">Toate tipurile</SelectItem>
|
||||||
{(Object.keys(TYPE_LABELS) as SignatureAssetType[]).map((t) => (
|
{(Object.keys(TYPE_LABELS) as SignatureAssetType[]).map((t) => (
|
||||||
<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>
|
<SelectItem key={t} value={t}>
|
||||||
|
{TYPE_LABELS[t]}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button onClick={() => setViewMode('add')} className="shrink-0">
|
<Button onClick={() => setViewMode("add")} className="shrink-0">
|
||||||
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Se încarcă...
|
||||||
|
</p>
|
||||||
) : assets.length === 0 ? (
|
) : assets.length === 0 ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">Niciun element găsit. Adaugă o semnătură, ștampilă sau inițiale.</p>
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Niciun element găsit. Adaugă o semnătură, ștampilă sau inițiale.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{assets.map((asset) => {
|
{assets.map((asset) => {
|
||||||
const Icon = TYPE_ICONS[asset.type];
|
const Icon = TYPE_ICONS[asset.type];
|
||||||
|
const expired = isExpired(asset.expirationDate);
|
||||||
|
const expiringSoon = isExpiringSoon(asset.expirationDate);
|
||||||
return (
|
return (
|
||||||
<Card key={asset.id} className="group relative">
|
<Card
|
||||||
|
key={asset.id}
|
||||||
|
className={`group relative ${expired ? "border-destructive/50" : expiringSoon ? "border-yellow-500/50" : ""}`}
|
||||||
|
>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingAsset(asset); setViewMode('edit'); }}>
|
<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");
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeAsset(asset.id)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
onClick={() => setDeletingId(asset.id)}
|
||||||
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,19 +224,68 @@ export function DigitalSignaturesModule() {
|
|||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg border bg-muted/30">
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg border bg-muted/30">
|
||||||
{asset.imageUrl ? (
|
{asset.imageUrl ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={asset.imageUrl} alt={asset.label} className="max-h-10 max-w-10 object-contain" />
|
<img
|
||||||
|
src={asset.imageUrl}
|
||||||
|
alt={asset.label}
|
||||||
|
className="max-h-10 max-w-10 object-contain"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Icon className="h-6 w-6 text-muted-foreground" />
|
<Icon className="h-6 w-6 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-medium">{asset.label}</p>
|
<p className="font-medium">{asset.label}</p>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
<Badge variant="outline" className="text-[10px]">{TYPE_LABELS[asset.type]}</Badge>
|
<Badge variant="outline" className="text-[10px]">
|
||||||
<span className="text-xs text-muted-foreground">{asset.owner}</span>
|
{TYPE_LABELS[asset.type]}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{asset.owner}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Metadata row */}
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{asset.legalStatus && (
|
||||||
|
<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}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{asset.usageNotes && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -116,36 +295,294 @@ export function DigitalSignaturesModule() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(viewMode === 'add' || viewMode === 'edit') && (
|
{(viewMode === "add" || viewMode === "edit") && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare' : 'Element nou'}</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{viewMode === "edit" ? "Editare" : "Element nou"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<AssetForm initial={editingAsset ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingAsset(null); }} />
|
<AssetForm
|
||||||
|
initial={editingAsset ?? undefined}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={() => {
|
||||||
|
setViewMode("list");
|
||||||
|
setEditingAsset(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<Dialog
|
||||||
|
open={deletingId !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDeletingId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Confirmare ștergere</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm">
|
||||||
|
Ești sigur că vrei să ștergi acest element? Acțiunea este
|
||||||
|
ireversibilă.
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<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);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Versiune nouă — {versionAsset?.label}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<AddVersionForm
|
||||||
|
onSubmit={handleAddVersion}
|
||||||
|
onCancel={() => setVersionAsset(null)}
|
||||||
|
history={versionAsset?.versions ?? []}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssetForm({ initial, onSubmit, onCancel }: {
|
function AddVersionForm({
|
||||||
initial?: SignatureAsset;
|
onSubmit,
|
||||||
onSubmit: (data: Omit<SignatureAsset, 'id' | 'createdAt'>) => void;
|
onCancel,
|
||||||
|
history,
|
||||||
|
}: {
|
||||||
|
onSubmit: (imageUrl: string, notes: string) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
history: Array<{
|
||||||
|
id: string;
|
||||||
|
imageUrl: string;
|
||||||
|
notes: string;
|
||||||
|
createdAt: string;
|
||||||
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const [label, setLabel] = useState(initial?.label ?? '');
|
const [imageUrl, setImageUrl] = useState("");
|
||||||
const [type, setType] = useState<SignatureAssetType>(initial?.type ?? 'signature');
|
const [notes, setNotes] = useState("");
|
||||||
const [imageUrl, setImageUrl] = useState(initial?.imageUrl ?? '');
|
|
||||||
const [owner, setOwner] = useState(initial?.owner ?? '');
|
|
||||||
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ label, type, imageUrl, owner, company, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4">
|
<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>
|
||||||
|
{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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<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..."
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssetForm({
|
||||||
|
initial,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
initial?: SignatureAsset;
|
||||||
|
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 [tags, setTags] = useState<string[]>(initial?.tags ?? []);
|
||||||
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
|
||||||
|
const addTag = (raw: string) => {
|
||||||
|
const t = raw.trim().toLowerCase();
|
||||||
|
if (t && !tags.includes(t)) setTags((prev) => [...prev, t]);
|
||||||
|
setTagInput("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTagKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter" || e.key === ",") {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag(tagInput);
|
||||||
|
}
|
||||||
|
if (e.key === "Backspace" && tagInput === "" && tags.length > 0)
|
||||||
|
setTags((prev) => prev.slice(0, -1));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit({
|
||||||
|
label,
|
||||||
|
type,
|
||||||
|
imageUrl,
|
||||||
|
owner,
|
||||||
|
company,
|
||||||
|
expirationDate: expirationDate || undefined,
|
||||||
|
legalStatus,
|
||||||
|
usageNotes,
|
||||||
|
versions: initial?.versions ?? [],
|
||||||
|
tags,
|
||||||
|
visibility: initial?.visibility ?? "all",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div><Label>Denumire</Label><Input value={label} onChange={(e) => setLabel(e.target.value)} className="mt-1" required /></div>
|
<div>
|
||||||
<div><Label>Tip</Label>
|
<Label>Denumire *</Label>
|
||||||
<Select value={type} onValueChange={(v) => setType(v as SignatureAssetType)}>
|
<Input
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Tip</Label>
|
||||||
|
<Select
|
||||||
|
value={type}
|
||||||
|
onValueChange={(v) => setType(v as SignatureAssetType)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="signature">Semnătură</SelectItem>
|
<SelectItem value="signature">Semnătură</SelectItem>
|
||||||
<SelectItem value="stamp">Ștampilă</SelectItem>
|
<SelectItem value="stamp">Ștampilă</SelectItem>
|
||||||
@@ -155,10 +592,23 @@ function AssetForm({ initial, onSubmit, onCancel }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div><Label>Proprietar</Label><Input value={owner} onChange={(e) => setOwner(e.target.value)} className="mt-1" /></div>
|
<div>
|
||||||
<div><Label>Companie</Label>
|
<Label>Proprietar</Label>
|
||||||
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
|
<Input
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
value={owner}
|
||||||
|
onChange={(e) => setOwner(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Companie</Label>
|
||||||
|
<Select
|
||||||
|
value={company}
|
||||||
|
onValueChange={(v) => setCompany(v as CompanyId)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="beletage">Beletage</SelectItem>
|
<SelectItem value="beletage">Beletage</SelectItem>
|
||||||
<SelectItem value="urban-switch">Urban Switch</SelectItem>
|
<SelectItem value="urban-switch">Urban Switch</SelectItem>
|
||||||
@@ -169,13 +619,77 @@ function AssetForm({ initial, onSubmit, onCancel }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>URL imagine</Label>
|
<Label>Imagine</Label>
|
||||||
<Input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} className="mt-1" placeholder="https://... sau data:image/png;base64,..." />
|
<div className="mt-1">
|
||||||
<p className="mt-1 text-xs text-muted-foreground">URL către imaginea semnăturii/ștampilei. Suportă URL-uri externe sau base64.</p>
|
<ImageUploadField value={imageUrl} onChange={setImageUrl} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div 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>
|
||||||
|
<div>
|
||||||
|
<Label>Etichete</Label>
|
||||||
|
<div className="mt-1 flex min-h-[38px] flex-wrap items-center gap-1.5 rounded-md border bg-background px-2 py-1.5 focus-within:ring-1 focus-within:ring-ring">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="flex items-center gap-0.5 rounded-full border bg-muted px-2 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTags((t) => t.filter((x) => x !== tag))}
|
||||||
|
className="ml-0.5 opacity-60 hover:opacity-100"
|
||||||
|
>
|
||||||
|
<X className="h-2.5 w-2.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
onKeyDown={handleTagKeyDown}
|
||||||
|
onBlur={() => {
|
||||||
|
if (tagInput.trim()) addTag(tagInput);
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
tags.length === 0 ? "Adaugă etichete (Enter sau virgulă)..." : ""
|
||||||
|
}
|
||||||
|
className="min-w-[120px] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
|
Anulează
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useStorage } from '@/core/storage';
|
import { useStorage } from '@/core/storage';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import type { SignatureAsset, SignatureAssetType } from '../types';
|
import type { SignatureAsset, SignatureAssetType, AssetVersion } from '../types';
|
||||||
|
|
||||||
const PREFIX = 'sig:';
|
const PREFIX = 'sig:';
|
||||||
|
|
||||||
@@ -36,8 +36,9 @@ export function useSignatures() {
|
|||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
useEffect(() => { refresh(); }, [refresh]);
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
const addAsset = useCallback(async (data: Omit<SignatureAsset, 'id' | 'createdAt'>) => {
|
const addAsset = useCallback(async (data: Omit<SignatureAsset, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||||
const asset: SignatureAsset = { ...data, id: uuid(), createdAt: new Date().toISOString() };
|
const now = new Date().toISOString();
|
||||||
|
const asset: SignatureAsset = { ...data, id: uuid(), createdAt: now, updatedAt: now };
|
||||||
await storage.set(`${PREFIX}${asset.id}`, asset);
|
await storage.set(`${PREFIX}${asset.id}`, asset);
|
||||||
await refresh();
|
await refresh();
|
||||||
return asset;
|
return asset;
|
||||||
@@ -46,11 +47,23 @@ export function useSignatures() {
|
|||||||
const updateAsset = useCallback(async (id: string, updates: Partial<SignatureAsset>) => {
|
const updateAsset = useCallback(async (id: string, updates: Partial<SignatureAsset>) => {
|
||||||
const existing = assets.find((a) => a.id === id);
|
const existing = assets.find((a) => a.id === id);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt };
|
const updated: SignatureAsset = {
|
||||||
|
...existing, ...updates,
|
||||||
|
id: existing.id, createdAt: existing.createdAt,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
await storage.set(`${PREFIX}${id}`, updated);
|
await storage.set(`${PREFIX}${id}`, updated);
|
||||||
await refresh();
|
await refresh();
|
||||||
}, [storage, refresh, assets]);
|
}, [storage, refresh, assets]);
|
||||||
|
|
||||||
|
const addVersion = useCallback(async (assetId: string, imageUrl: string, notes: string) => {
|
||||||
|
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];
|
||||||
|
await updateAsset(assetId, { imageUrl, versions: updatedVersions });
|
||||||
|
}, [assets, updateAsset]);
|
||||||
|
|
||||||
const removeAsset = useCallback(async (id: string) => {
|
const removeAsset = useCallback(async (id: string) => {
|
||||||
await storage.delete(`${PREFIX}${id}`);
|
await storage.delete(`${PREFIX}${id}`);
|
||||||
await refresh();
|
await refresh();
|
||||||
@@ -69,5 +82,5 @@ export function useSignatures() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { assets: filteredAssets, allAssets: assets, loading, filters, updateFilter, addAsset, updateAsset, removeAsset, refresh };
|
return { assets: filteredAssets, allAssets: assets, loading, filters, updateFilter, addAsset, updateAsset, addVersion, removeAsset, refresh };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export { digitalSignaturesConfig } from './config';
|
export { digitalSignaturesConfig } from './config';
|
||||||
export { DigitalSignaturesModule } from './components/digital-signatures-module';
|
export { DigitalSignaturesModule } from './components/digital-signatures-module';
|
||||||
export type { SignatureAsset, SignatureAssetType } from './types';
|
export type { SignatureAsset, SignatureAssetType, AssetVersion } from './types';
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import type { CompanyId } from '@/core/auth/types';
|
|||||||
|
|
||||||
export type SignatureAssetType = 'signature' | 'stamp' | 'initials';
|
export type SignatureAssetType = 'signature' | 'stamp' | 'initials';
|
||||||
|
|
||||||
|
/** Version history entry */
|
||||||
|
export interface AssetVersion {
|
||||||
|
id: string;
|
||||||
|
imageUrl: string;
|
||||||
|
notes: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SignatureAsset {
|
export interface SignatureAsset {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -10,7 +18,16 @@ export interface SignatureAsset {
|
|||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
owner: string;
|
owner: string;
|
||||||
company: CompanyId;
|
company: CompanyId;
|
||||||
|
/** Expiration date (YYYY-MM-DD) */
|
||||||
|
expirationDate?: string;
|
||||||
|
/** Legal status description */
|
||||||
|
legalStatus: string;
|
||||||
|
/** Usage notes */
|
||||||
|
usageNotes: string;
|
||||||
|
/** Version history */
|
||||||
|
versions: AssetVersion[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { RotateCcw } from 'lucide-react';
|
|||||||
export function EmailSignatureModule() {
|
export function EmailSignatureModule() {
|
||||||
const {
|
const {
|
||||||
config, updateField, updateColor, updateLayout,
|
config, updateField, updateColor, updateLayout,
|
||||||
setVariant, setCompany, resetToDefaults, loadConfig,
|
setVariant, setCompany, setAddress, resetToDefaults, loadConfig,
|
||||||
} = useSignatureConfig();
|
} = useSignatureConfig();
|
||||||
|
|
||||||
const { saved, loading, save, remove } = useSavedSignatures();
|
const { saved, loading, save, remove } = useSavedSignatures();
|
||||||
@@ -28,6 +28,7 @@ export function EmailSignatureModule() {
|
|||||||
onUpdateLayout={updateLayout}
|
onUpdateLayout={updateLayout}
|
||||||
onSetVariant={setVariant}
|
onSetVariant={setVariant}
|
||||||
onSetCompany={setCompany}
|
onSetCompany={setCompany}
|
||||||
|
onSetAddress={setAddress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -49,7 +50,7 @@ export function EmailSignatureModule() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel — preview */}
|
{/* Right panel — preview (scrollable, resizable) */}
|
||||||
<div>
|
<div>
|
||||||
<SignaturePreview config={config} />
|
<SignaturePreview config={config} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,72 +1,249 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import type { CompanyId } from '@/core/auth/types';
|
import type { CompanyId } from "@/core/auth/types";
|
||||||
import type { SignatureConfig, SignatureColors, SignatureLayout, SignatureVariant } from '../types';
|
import type {
|
||||||
import { COMPANY_BRANDING } from '../services/company-branding';
|
SignatureConfig,
|
||||||
import { Input } from '@/shared/components/ui/input';
|
SignatureColors,
|
||||||
import { Label } from '@/shared/components/ui/label';
|
SignatureLayout,
|
||||||
import { Switch } from '@/shared/components/ui/switch';
|
SignatureVariant,
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
} from "../types";
|
||||||
import { Separator } from '@/shared/components/ui/separator';
|
import {
|
||||||
import { cn } from '@/shared/lib/utils';
|
COMPANY_BRANDING,
|
||||||
|
BELETAGE_ADDRESSES,
|
||||||
|
US_ADDRESSES,
|
||||||
|
SDT_ADDRESSES,
|
||||||
|
} from "../services/company-branding";
|
||||||
|
import type { AddressKey } from "../services/company-branding";
|
||||||
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { Label } from "@/shared/components/ui/label";
|
||||||
|
import { Switch } from "@/shared/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select";
|
||||||
|
import { Separator } from "@/shared/components/ui/separator";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
interface SignatureConfiguratorProps {
|
interface SignatureConfiguratorProps {
|
||||||
config: SignatureConfig;
|
config: SignatureConfig;
|
||||||
onUpdateField: <K extends keyof SignatureConfig>(key: K, value: SignatureConfig[K]) => void;
|
onUpdateField: <K extends keyof SignatureConfig>(
|
||||||
|
key: K,
|
||||||
|
value: SignatureConfig[K],
|
||||||
|
) => void;
|
||||||
onUpdateColor: (key: keyof SignatureColors, value: string) => void;
|
onUpdateColor: (key: keyof SignatureColors, value: string) => void;
|
||||||
onUpdateLayout: (key: keyof SignatureLayout, value: number) => void;
|
onUpdateLayout: (key: keyof SignatureLayout, value: number) => void;
|
||||||
onSetVariant: (variant: SignatureVariant) => void;
|
onSetVariant: (variant: SignatureVariant) => void;
|
||||||
onSetCompany: (company: CompanyId) => void;
|
onSetCompany: (company: CompanyId) => void;
|
||||||
|
onSetAddress?: (address: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLOR_PALETTE: Record<string, string> = {
|
/** Color palette per company */
|
||||||
verde: '#22B5AB',
|
const COMPANY_PALETTES: Record<CompanyId, Record<string, string>> = {
|
||||||
griInchis: '#54504F',
|
beletage: {
|
||||||
griDeschis: '#A7A9AA',
|
verde: "#22B5AB",
|
||||||
negru: '#323232',
|
griInchis: "#54504F",
|
||||||
|
griDeschis: "#A7A9AA",
|
||||||
|
negru: "#323232",
|
||||||
|
},
|
||||||
|
"urban-switch": {
|
||||||
|
albastru: "#345476",
|
||||||
|
griInchis: "#2D2D2D",
|
||||||
|
griDeschis: "#6B7280",
|
||||||
|
negru: "#1F2937",
|
||||||
|
},
|
||||||
|
"studii-de-teren": {
|
||||||
|
teal: "#0182A1",
|
||||||
|
bleumarin: "#000D1A",
|
||||||
|
griInchis: "#2D2D2D",
|
||||||
|
griDeschis: "#6B7280",
|
||||||
|
negru: "#1F2937",
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
gri: "#64748b",
|
||||||
|
griInchis: "#334155",
|
||||||
|
griDeschis: "#94a3b8",
|
||||||
|
negru: "#1e293b",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const COLOR_LABELS: Record<keyof SignatureColors, string> = {
|
const COLOR_LABELS: Record<keyof SignatureColors, string> = {
|
||||||
prefix: 'Titulatură',
|
prefix: "Titulatură",
|
||||||
name: 'Nume',
|
name: "Nume",
|
||||||
title: 'Funcție',
|
title: "Funcție",
|
||||||
address: 'Adresă',
|
address: "Adresă",
|
||||||
phone: 'Telefon',
|
phone: "Telefon",
|
||||||
website: 'Website',
|
website: "Website",
|
||||||
motto: 'Motto',
|
motto: "Motto",
|
||||||
};
|
};
|
||||||
|
|
||||||
const LAYOUT_CONTROLS: { key: keyof SignatureLayout; label: string; min: number; max: number }[] = [
|
const LAYOUT_CONTROLS: {
|
||||||
{ key: 'greenLineWidth', label: 'Lungime linie accent', min: 50, max: 300 },
|
key: keyof SignatureLayout;
|
||||||
{ key: 'sectionSpacing', label: 'Spațiere secțiuni', min: 0, max: 30 },
|
label: string;
|
||||||
{ key: 'logoSpacing', label: 'Spațiere logo', min: 0, max: 30 },
|
min: number;
|
||||||
{ key: 'titleSpacing', label: 'Spațiere funcție', min: 0, max: 20 },
|
max: number;
|
||||||
{ key: 'gutterWidth', label: 'Aliniere contact', min: 0, max: 150 },
|
}[] = [
|
||||||
{ key: 'iconTextSpacing', label: 'Spațiu icon-text', min: -10, max: 30 },
|
{ key: "greenLineWidth", label: "Lungime linie accent", min: 50, max: 300 },
|
||||||
{ key: 'iconVerticalOffset', label: 'Aliniere verticală iconițe', min: -10, max: 10 },
|
{ key: "sectionSpacing", label: "Spațiere secțiuni", min: 0, max: 30 },
|
||||||
{ key: 'mottoSpacing', label: 'Spațiere motto', min: 0, max: 20 },
|
{ key: "logoSpacing", label: "Spațiere logo", min: 0, max: 30 },
|
||||||
|
{ key: "titleSpacing", label: "Spațiere funcție", min: 0, max: 20 },
|
||||||
|
{ key: "gutterWidth", label: "Aliniere contact", min: 0, max: 150 },
|
||||||
|
{ key: "iconTextSpacing", label: "Spațiu icon-text", min: -10, max: 30 },
|
||||||
|
{
|
||||||
|
key: "iconVerticalOffset",
|
||||||
|
label: "Aliniere verticală iconițe",
|
||||||
|
min: -10,
|
||||||
|
max: 10,
|
||||||
|
},
|
||||||
|
{ key: "mottoSpacing", label: "Spațiere motto", min: 0, max: 20 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SignatureConfigurator({
|
export function SignatureConfigurator({
|
||||||
config, onUpdateField, onUpdateColor, onUpdateLayout, onSetVariant, onSetCompany,
|
config,
|
||||||
|
onUpdateField,
|
||||||
|
onUpdateColor,
|
||||||
|
onUpdateLayout,
|
||||||
|
onSetVariant,
|
||||||
|
onSetCompany,
|
||||||
|
onSetAddress,
|
||||||
}: SignatureConfiguratorProps) {
|
}: SignatureConfiguratorProps) {
|
||||||
|
const palette = COMPANY_PALETTES[config.company];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Company selector */}
|
{/* Company selector */}
|
||||||
<div>
|
<div>
|
||||||
<Label>Companie</Label>
|
<Label>Companie</Label>
|
||||||
<Select value={config.company} onValueChange={(v) => onSetCompany(v as CompanyId)}>
|
<Select
|
||||||
|
value={config.company}
|
||||||
|
onValueChange={(v) => onSetCompany(v as CompanyId)}
|
||||||
|
>
|
||||||
<SelectTrigger className="mt-1">
|
<SelectTrigger className="mt-1">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{Object.values(COMPANY_BRANDING).map((b) => (
|
{Object.values(COMPANY_BRANDING).map((b) => (
|
||||||
<SelectItem key={b.id} value={b.id}>{b.name}</SelectItem>
|
<SelectItem key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Address selector (for Beletage) */}
|
||||||
|
{config.company === "beletage" && onSetAddress && (
|
||||||
|
<div>
|
||||||
|
<Label>Adresă birou</Label>
|
||||||
|
<Select
|
||||||
|
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 AddressKey;
|
||||||
|
onSetAddress(BELETAGE_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 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>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Personal data */}
|
{/* Personal data */}
|
||||||
@@ -74,19 +251,40 @@ export function SignatureConfigurator({
|
|||||||
<h3 className="text-sm font-semibold">Date personale</h3>
|
<h3 className="text-sm font-semibold">Date personale</h3>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="sig-prefix">Titulatură (prefix)</Label>
|
<Label htmlFor="sig-prefix">Titulatură (prefix)</Label>
|
||||||
<Input id="sig-prefix" value={config.prefix} onChange={(e) => onUpdateField('prefix', e.target.value)} className="mt-1" />
|
<Input
|
||||||
|
id="sig-prefix"
|
||||||
|
value={config.prefix}
|
||||||
|
onChange={(e) => onUpdateField("prefix", e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="sig-name">Nume și Prenume</Label>
|
<Label htmlFor="sig-name">Nume și Prenume</Label>
|
||||||
<Input id="sig-name" value={config.name} onChange={(e) => onUpdateField('name', e.target.value)} className="mt-1" />
|
<Input
|
||||||
|
id="sig-name"
|
||||||
|
value={config.name}
|
||||||
|
onChange={(e) => onUpdateField("name", e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="sig-title">Funcția</Label>
|
<Label htmlFor="sig-title">Funcția</Label>
|
||||||
<Input id="sig-title" value={config.title} onChange={(e) => onUpdateField('title', e.target.value)} className="mt-1" />
|
<Input
|
||||||
|
id="sig-title"
|
||||||
|
value={config.title}
|
||||||
|
onChange={(e) => onUpdateField("title", e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="sig-phone">Telefon (format 07xxxxxxxx)</Label>
|
<Label htmlFor="sig-phone">Telefon (format 07xxxxxxxx)</Label>
|
||||||
<Input id="sig-phone" type="tel" value={config.phone} onChange={(e) => onUpdateField('phone', e.target.value)} className="mt-1" />
|
<Input
|
||||||
|
id="sig-phone"
|
||||||
|
type="tel"
|
||||||
|
value={config.phone}
|
||||||
|
onChange={(e) => onUpdateField("phone", e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -95,48 +293,65 @@ export function SignatureConfigurator({
|
|||||||
{/* Variant */}
|
{/* Variant */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold">Variantă</h3>
|
<h3 className="text-sm font-semibold">Variantă</h3>
|
||||||
<Select value={config.variant} onValueChange={(v) => onSetVariant(v as SignatureVariant)}>
|
<Select
|
||||||
|
value={config.variant}
|
||||||
|
onValueChange={(v) => onSetVariant(v as SignatureVariant)}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="full">Completă (logo + adresă + motto)</SelectItem>
|
<SelectItem value="full">
|
||||||
|
Completă (logo + adresă + motto)
|
||||||
|
</SelectItem>
|
||||||
<SelectItem value="reply">Simplă (fără logo/adresă)</SelectItem>
|
<SelectItem value="reply">Simplă (fără logo/adresă)</SelectItem>
|
||||||
<SelectItem value="minimal">Super-simplă (doar nume/telefon)</SelectItem>
|
<SelectItem value="minimal">
|
||||||
|
Super-simplă (doar nume/telefon)
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch checked={config.useSvg} onCheckedChange={(v) => onUpdateField('useSvg', v)} id="svg-toggle" />
|
<Switch
|
||||||
<Label htmlFor="svg-toggle" className="cursor-pointer text-sm">Imagini SVG (calitate maximă)</Label>
|
checked={config.useSvg}
|
||||||
|
onCheckedChange={(v) => onUpdateField("useSvg", v)}
|
||||||
|
id="svg-toggle"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="svg-toggle" className="cursor-pointer text-sm">
|
||||||
|
Imagini SVG (calitate maximă)
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Colors */}
|
{/* Colors — company-specific palette */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold">Culori text</h3>
|
<h3 className="text-sm font-semibold">Culori text</h3>
|
||||||
{(Object.keys(COLOR_LABELS) as (keyof SignatureColors)[]).map((colorKey) => (
|
{(Object.keys(COLOR_LABELS) as (keyof SignatureColors)[]).map(
|
||||||
|
(colorKey) => (
|
||||||
<div key={colorKey} className="flex items-center justify-between">
|
<div key={colorKey} className="flex items-center justify-between">
|
||||||
<span className="text-sm text-muted-foreground">{COLOR_LABELS[colorKey]}</span>
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{COLOR_LABELS[colorKey]}
|
||||||
|
</span>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
{Object.values(COLOR_PALETTE).map((color) => (
|
{Object.values(palette).map((color) => (
|
||||||
<button
|
<button
|
||||||
key={color}
|
key={color}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onUpdateColor(colorKey, color)}
|
onClick={() => onUpdateColor(colorKey, color)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-6 w-6 rounded-full border-2 transition-all',
|
"h-6 w-6 rounded-full border-2 transition-all",
|
||||||
config.colors[colorKey] === color
|
config.colors[colorKey] === color
|
||||||
? 'border-primary scale-110 ring-2 ring-primary/30'
|
? "border-primary scale-110 ring-2 ring-primary/30"
|
||||||
: 'border-transparent hover:scale-105'
|
: "border-transparent hover:scale-105",
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -148,14 +363,18 @@ export function SignatureConfigurator({
|
|||||||
<div key={key}>
|
<div key={key}>
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<Label>{label}</Label>
|
<Label>{label}</Label>
|
||||||
<span className="text-muted-foreground">{config.layout[key]}px</span>
|
<span className="text-muted-foreground">
|
||||||
|
{config.layout[key]}px
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
value={config.layout[key]}
|
value={config.layout[key]}
|
||||||
onChange={(e) => onUpdateLayout(key, parseInt(e.target.value, 10))}
|
onChange={(e) =>
|
||||||
|
onUpdateLayout(key, parseInt(e.target.value, 10))
|
||||||
|
}
|
||||||
className="mt-1 w-full accent-primary"
|
className="mt-1 w-full accent-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,15 +6,19 @@ import { Button } from '@/shared/components/ui/button';
|
|||||||
import type { SignatureConfig } from '../types';
|
import type { SignatureConfig } from '../types';
|
||||||
import { generateSignatureHtml, downloadSignatureHtml } from '../services/signature-builder';
|
import { generateSignatureHtml, downloadSignatureHtml } from '../services/signature-builder';
|
||||||
|
|
||||||
|
const ZOOM_LEVELS = [0.75, 1, 1.5, 2, 2.5];
|
||||||
|
|
||||||
interface SignaturePreviewProps {
|
interface SignaturePreviewProps {
|
||||||
config: SignatureConfig;
|
config: SignatureConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SignaturePreview({ config }: SignaturePreviewProps) {
|
export function SignaturePreview({ config }: SignaturePreviewProps) {
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoomIndex, setZoomIndex] = useState(1); // start at 100%
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const previewRef = useRef<HTMLDivElement>(null);
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const zoom = ZOOM_LEVELS[zoomIndex] ?? 1;
|
||||||
|
|
||||||
const html = useMemo(() => generateSignatureHtml(config), [config]);
|
const html = useMemo(() => generateSignatureHtml(config), [config]);
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
@@ -32,17 +36,23 @@ export function SignaturePreview({ config }: SignaturePreviewProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleZoom = () => setZoom((z) => (z === 1 ? 2 : 1));
|
const zoomIn = () => setZoomIndex((i) => Math.min(i + 1, ZOOM_LEVELS.length - 1));
|
||||||
|
const zoomOut = () => setZoomIndex((i) => Math.max(i - 1, 0));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold">Previzualizare</h2>
|
<h2 className="text-lg font-semibold">Previzualizare</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={toggleZoom}>
|
<div className="flex items-center rounded-md border">
|
||||||
{zoom === 1 ? <ZoomIn className="mr-1 h-4 w-4" /> : <ZoomOut className="mr-1 h-4 w-4" />}
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-r-none" onClick={zoomOut} disabled={zoomIndex <= 0}>
|
||||||
{zoom === 1 ? '200%' : '100%'}
|
<ZoomOut className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<span className="w-14 text-center text-sm font-medium">{Math.round(zoom * 100)}%</span>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-l-none" onClick={zoomIn} disabled={zoomIndex >= ZOOM_LEVELS.length - 1}>
|
||||||
|
<ZoomIn className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={handleCopy}>
|
<Button variant="outline" size="sm" onClick={handleCopy}>
|
||||||
<Copy className="mr-1 h-4 w-4" />
|
<Copy className="mr-1 h-4 w-4" />
|
||||||
{copied ? 'Copiat!' : 'Copiază HTML'}
|
{copied ? 'Copiat!' : 'Copiază HTML'}
|
||||||
@@ -54,7 +64,7 @@ export function SignaturePreview({ config }: SignaturePreviewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-auto rounded-lg border bg-white p-6">
|
<div className="max-h-[calc(100vh-14rem)] overflow-auto rounded-lg border bg-white p-6">
|
||||||
<div
|
<div
|
||||||
ref={previewRef}
|
ref={previewRef}
|
||||||
style={{ transform: `scale(${zoom})`, transformOrigin: 'top left' }}
|
style={{ transform: `scale(${zoom})`, transformOrigin: 'top left' }}
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ export function useSignatureConfig(initialCompany: CompanyId = 'beletage') {
|
|||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setAddress = useCallback((address: string[]) => {
|
||||||
|
setConfig((prev) => ({ ...prev, addressOverride: address }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const resetToDefaults = useCallback(() => {
|
const resetToDefaults = useCallback(() => {
|
||||||
setConfig(createDefaultConfig(config.company));
|
setConfig(createDefaultConfig(config.company));
|
||||||
}, [config.company]);
|
}, [config.company]);
|
||||||
@@ -83,7 +87,8 @@ export function useSignatureConfig(initialCompany: CompanyId = 'beletage') {
|
|||||||
updateLayout,
|
updateLayout,
|
||||||
setVariant,
|
setVariant,
|
||||||
setCompany,
|
setCompany,
|
||||||
|
setAddress,
|
||||||
resetToDefaults,
|
resetToDefaults,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
}), [config, updateField, updateColor, updateLayout, setVariant, setCompany, resetToDefaults, loadConfig]);
|
}), [config, updateField, updateColor, updateLayout, setVariant, setCompany, setAddress, resetToDefaults, loadConfig]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,110 +1,150 @@
|
|||||||
import type { CompanyId } from '@/core/auth/types';
|
import type { CompanyId } from "@/core/auth/types";
|
||||||
import type { CompanyBranding, SignatureColors } from '../types';
|
import type { CompanyBranding, SignatureColors } from "../types";
|
||||||
|
|
||||||
const BELETAGE_COLORS: SignatureColors = {
|
const BELETAGE_COLORS: SignatureColors = {
|
||||||
prefix: '#54504F',
|
prefix: "#54504F",
|
||||||
name: '#54504F',
|
name: "#54504F",
|
||||||
title: '#A7A9AA',
|
title: "#A7A9AA",
|
||||||
address: '#A7A9AA',
|
address: "#A7A9AA",
|
||||||
phone: '#54504F',
|
phone: "#54504F",
|
||||||
website: '#54504F',
|
website: "#54504F",
|
||||||
motto: '#22B5AB',
|
motto: "#22B5AB",
|
||||||
};
|
};
|
||||||
|
|
||||||
const URBAN_SWITCH_COLORS: SignatureColors = {
|
const URBAN_SWITCH_COLORS: SignatureColors = {
|
||||||
prefix: '#3B3B3B',
|
prefix: "#345476",
|
||||||
name: '#3B3B3B',
|
name: "#345476",
|
||||||
title: '#8B8B8B',
|
title: "#6B7280",
|
||||||
address: '#8B8B8B',
|
address: "#6B7280",
|
||||||
phone: '#3B3B3B',
|
phone: "#345476",
|
||||||
website: '#3B3B3B',
|
website: "#345476",
|
||||||
motto: '#6366f1',
|
motto: "#345476",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STUDII_COLORS: SignatureColors = {
|
const STUDII_COLORS: SignatureColors = {
|
||||||
prefix: '#3B3B3B',
|
prefix: "#000D1A",
|
||||||
name: '#3B3B3B',
|
name: "#000D1A",
|
||||||
title: '#8B8B8B',
|
title: "#6B7280",
|
||||||
address: '#8B8B8B',
|
address: "#6B7280",
|
||||||
phone: '#3B3B3B',
|
phone: "#000D1A",
|
||||||
website: '#3B3B3B',
|
website: "#0182A1",
|
||||||
motto: '#f59e0b',
|
motto: "#0182A1",
|
||||||
|
};
|
||||||
|
|
||||||
|
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: 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> = {
|
export const COMPANY_BRANDING: Record<CompanyId, CompanyBranding> = {
|
||||||
beletage: {
|
beletage: {
|
||||||
id: 'beletage',
|
id: "beletage",
|
||||||
name: 'Beletage SRL',
|
name: "Beletage SRL",
|
||||||
accent: '#22B5AB',
|
accent: "#22B5AB",
|
||||||
logo: {
|
logo: {
|
||||||
png: 'https://beletage.ro/img/Semnatura-Logo.png',
|
png: "https://beletage.ro/img/Semnatura-Logo.png",
|
||||||
svg: 'https://beletage.ro/img/Logo-Beletage.svg',
|
svg: "https://beletage.ro/img/Logo-Beletage.svg",
|
||||||
},
|
},
|
||||||
slashGrey: {
|
slashGrey: {
|
||||||
png: 'https://beletage.ro/img/Grey-slash.png',
|
png: "https://beletage.ro/img/Grey-slash.png",
|
||||||
svg: 'https://beletage.ro/img/Grey-slash.svg',
|
svg: "https://beletage.ro/img/Grey-slash.svg",
|
||||||
},
|
},
|
||||||
slashAccent: {
|
slashAccent: {
|
||||||
png: 'https://beletage.ro/img/Green-slash.png',
|
png: "https://beletage.ro/img/Green-slash.png",
|
||||||
svg: 'https://beletage.ro/img/Green-slash.svg',
|
svg: "https://beletage.ro/img/Green-slash.svg",
|
||||||
},
|
},
|
||||||
address: ['str. Unirii, nr. 3, ap. 26', 'Cluj-Napoca, Cluj 400417', 'România'],
|
logoDimensions: { width: 162, height: 24 },
|
||||||
website: 'www.beletage.ro',
|
address: [...ADDR_CHRISTESCU],
|
||||||
motto: 'we make complex simple',
|
website: "www.beletage.ro",
|
||||||
|
motto: "we make complex simple",
|
||||||
defaultColors: BELETAGE_COLORS,
|
defaultColors: BELETAGE_COLORS,
|
||||||
},
|
},
|
||||||
'urban-switch': {
|
"urban-switch": {
|
||||||
id: 'urban-switch',
|
id: "urban-switch",
|
||||||
name: 'Urban Switch SRL',
|
name: "Urban Switch SRL",
|
||||||
accent: '#6366f1',
|
accent: "#345476",
|
||||||
logo: {
|
logo: {
|
||||||
png: '',
|
png: "/logos/logo-us-light.svg",
|
||||||
svg: '',
|
svg: "/logos/logo-us-light.svg",
|
||||||
},
|
},
|
||||||
slashGrey: {
|
slashGrey: {
|
||||||
png: 'https://beletage.ro/img/Grey-slash.png',
|
png: "https://beletage.ro/img/Grey-slash.png",
|
||||||
svg: 'https://beletage.ro/img/Grey-slash.svg',
|
svg: "https://beletage.ro/img/Grey-slash.svg",
|
||||||
},
|
},
|
||||||
slashAccent: {
|
slashAccent: { png: "", svg: "" },
|
||||||
png: '',
|
logoDimensions: { width: 140, height: 24 },
|
||||||
svg: '',
|
address: [...ADDR_CHRISTESCU],
|
||||||
},
|
website: "www.urbanswitch.ro",
|
||||||
address: ['Cluj-Napoca', 'România'],
|
motto: "shaping urban futures",
|
||||||
website: '',
|
|
||||||
motto: '',
|
|
||||||
defaultColors: URBAN_SWITCH_COLORS,
|
defaultColors: URBAN_SWITCH_COLORS,
|
||||||
},
|
},
|
||||||
'studii-de-teren': {
|
"studii-de-teren": {
|
||||||
id: 'studii-de-teren',
|
id: "studii-de-teren",
|
||||||
name: 'Studii de Teren SRL',
|
name: "Studii de Teren SRL",
|
||||||
accent: '#f59e0b',
|
accent: "#0182A1",
|
||||||
logo: {
|
logo: {
|
||||||
png: '',
|
png: "/logos/logo-sdt-light.svg",
|
||||||
svg: '',
|
svg: "/logos/logo-sdt-light.svg",
|
||||||
},
|
},
|
||||||
slashGrey: {
|
slashGrey: {
|
||||||
png: 'https://beletage.ro/img/Grey-slash.png',
|
png: "https://beletage.ro/img/Grey-slash.png",
|
||||||
svg: 'https://beletage.ro/img/Grey-slash.svg',
|
svg: "https://beletage.ro/img/Grey-slash.svg",
|
||||||
},
|
},
|
||||||
slashAccent: {
|
slashAccent: { png: "", svg: "" },
|
||||||
png: '',
|
logoDimensions: { width: 71, height: 24 },
|
||||||
svg: '',
|
address: [...ADDR_CHRISTESCU],
|
||||||
},
|
website: "www.studiideteren.ro",
|
||||||
address: ['Cluj-Napoca', 'România'],
|
motto: "ground truth, measured right",
|
||||||
website: '',
|
|
||||||
motto: '',
|
|
||||||
defaultColors: STUDII_COLORS,
|
defaultColors: STUDII_COLORS,
|
||||||
},
|
},
|
||||||
group: {
|
group: {
|
||||||
id: 'group',
|
id: "group",
|
||||||
name: 'Grup Companii',
|
name: "Grup Companii",
|
||||||
accent: '#64748b',
|
accent: "#64748b",
|
||||||
logo: { png: '', svg: '' },
|
logo: { png: "", svg: "" },
|
||||||
slashGrey: { png: '', svg: '' },
|
slashGrey: {
|
||||||
slashAccent: { png: '', svg: '' },
|
png: "https://beletage.ro/img/Grey-slash.png",
|
||||||
address: ['Cluj-Napoca', 'România'],
|
svg: "https://beletage.ro/img/Grey-slash.svg",
|
||||||
website: '',
|
},
|
||||||
motto: '',
|
slashAccent: { png: "", svg: "" },
|
||||||
|
address: ["Cluj-Napoca, Cluj", "România"],
|
||||||
|
website: "",
|
||||||
|
motto: "",
|
||||||
defaultColors: BELETAGE_COLORS,
|
defaultColors: BELETAGE_COLORS,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { SignatureConfig, CompanyBranding } from '../types';
|
import type { SignatureConfig, CompanyBranding } from "../types";
|
||||||
import { getBranding } from './company-branding';
|
import { getBranding } from "./company-branding";
|
||||||
|
|
||||||
export function formatPhone(raw: string): { display: string; link: string } {
|
export function formatPhone(raw: string): { display: string; link: string } {
|
||||||
const clean = raw.replace(/\s/g, '');
|
const clean = raw.replace(/\s/g, "");
|
||||||
if (clean.length === 10 && clean.startsWith('07')) {
|
if (clean.length === 10 && clean.startsWith("07")) {
|
||||||
return {
|
return {
|
||||||
display: `+40 ${clean.substring(1, 4)} ${clean.substring(4, 7)} ${clean.substring(7, 10)}`,
|
display: `+40 ${clean.substring(1, 4)} ${clean.substring(4, 7)} ${clean.substring(7, 10)}`,
|
||||||
link: `tel:+40${clean.substring(1)}`,
|
link: `tel:+40${clean.substring(1)}`,
|
||||||
@@ -14,32 +14,50 @@ export function formatPhone(raw: string): { display: string; link: string } {
|
|||||||
|
|
||||||
export function generateSignatureHtml(config: SignatureConfig): string {
|
export function generateSignatureHtml(config: SignatureConfig): string {
|
||||||
const branding = getBranding(config.company);
|
const branding = getBranding(config.company);
|
||||||
|
const address = config.addressOverride ?? branding.address;
|
||||||
const { display: phone, link: phoneLink } = formatPhone(config.phone);
|
const { display: phone, link: phoneLink } = formatPhone(config.phone);
|
||||||
const images = config.useSvg
|
const images = config.useSvg
|
||||||
? { logo: branding.logo.svg, greySlash: branding.slashGrey.svg, accentSlash: branding.slashAccent.svg }
|
? {
|
||||||
: { logo: branding.logo.png, greySlash: branding.slashGrey.png, accentSlash: branding.slashAccent.png };
|
logo: branding.logo.svg,
|
||||||
|
greySlash: branding.slashGrey.svg,
|
||||||
|
accentSlash: branding.slashAccent.svg,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
logo: branding.logo.png,
|
||||||
|
greySlash: branding.slashGrey.png,
|
||||||
|
accentSlash: branding.slashAccent.png,
|
||||||
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
greenLineWidth, gutterWidth, iconTextSpacing, iconVerticalOffset,
|
greenLineWidth,
|
||||||
mottoSpacing, sectionSpacing, titleSpacing, logoSpacing,
|
gutterWidth,
|
||||||
|
iconTextSpacing,
|
||||||
|
iconVerticalOffset,
|
||||||
|
mottoSpacing,
|
||||||
|
sectionSpacing,
|
||||||
|
titleSpacing,
|
||||||
|
logoSpacing,
|
||||||
} = config.layout;
|
} = config.layout;
|
||||||
const colors = config.colors;
|
const colors = config.colors;
|
||||||
|
|
||||||
const isReply = config.variant === 'reply' || config.variant === 'minimal';
|
const isReply = config.variant === "reply" || config.variant === "minimal";
|
||||||
const isMinimal = config.variant === 'minimal';
|
const isMinimal = config.variant === "minimal";
|
||||||
|
|
||||||
const hide = 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;';
|
const logoDim = branding.logoDimensions ?? { width: 162, height: 24 };
|
||||||
const hideTitle = isReply ? hide : '';
|
|
||||||
const hideLogo = isReply ? hide : '';
|
const hide =
|
||||||
const hideBottom = isMinimal ? hide : '';
|
"mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;";
|
||||||
const hidePhoneIcon = isMinimal ? hide : '';
|
const hideTitle = isReply ? hide : "";
|
||||||
|
const hideLogo = isReply ? hide : "";
|
||||||
|
const hideBottom = isMinimal ? hide : "";
|
||||||
|
const hidePhoneIcon = isMinimal ? hide : "";
|
||||||
|
|
||||||
const spacerWidth = Math.max(0, iconTextSpacing);
|
const spacerWidth = Math.max(0, iconTextSpacing);
|
||||||
const textPaddingLeft = Math.max(0, -iconTextSpacing);
|
const textPaddingLeft = Math.max(0, -iconTextSpacing);
|
||||||
|
|
||||||
const prefixHtml = config.prefix
|
const prefixHtml = config.prefix
|
||||||
? `<span style="font-size:13px; color:${colors.prefix};">${esc(config.prefix)} </span>`
|
? `<span style="font-size:13px; color:${colors.prefix};">${esc(config.prefix)} </span>`
|
||||||
: '';
|
: "";
|
||||||
|
|
||||||
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540" style="font-family: Arial, Helvetica, sans-serif; color:#333333; font-size:14px; line-height:18px;">
|
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540" style="font-family: Arial, Helvetica, sans-serif; color:#333333; font-size:14px; line-height:18px;">
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -56,28 +74,32 @@ export function generateSignatureHtml(config: SignatureConfig): string {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr style="${hideLogo}"><td style="padding:${logoSpacing}px 0 ${logoSpacing + 2}px 0;">
|
<tr style="${hideLogo}"><td style="padding:${logoSpacing}px 0 ${logoSpacing + 2}px 0;">
|
||||||
${images.logo ? `<a href="https://${branding.website}" style="text-decoration:none; border:0;">
|
${
|
||||||
<img src="${images.logo}" alt="${esc(branding.name)}" style="display:block; border:0; height:24px; width:162px;" height="24" width="162">
|
images.logo
|
||||||
</a>` : ''}
|
? `<a href="https://${branding.website}" style="text-decoration:none; border:0;">
|
||||||
|
<img src="${images.logo}" alt="${esc(branding.name)}" style="display:block; border:0; height:${logoDim.height}px; width:${logoDim.width}px;" height="${logoDim.height}" width="${logoDim.width}">
|
||||||
|
</a>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
</td></tr>
|
</td></tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding-top:${hideLogo ? '0' : sectionSpacing}px;">
|
<td style="padding-top:${hideLogo ? "0" : sectionSpacing}px;">
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540" style="font-size:13px; line-height:18px;">
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540" style="font-size:13px; line-height:18px;">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr style="${hideLogo}">
|
<tr style="${hideLogo}">
|
||||||
<td width="${gutterWidth}" style="width:${gutterWidth}px; font-size:0; line-height:0;"></td>
|
<td width="${gutterWidth}" style="width:${gutterWidth}px; font-size:0; line-height:0;"></td>
|
||||||
<td width="11" style="width:11px; vertical-align:top; padding-top:${4 + iconVerticalOffset}px;">
|
<td width="11" style="width:11px; vertical-align:top; padding-top:${4 + iconVerticalOffset}px;">
|
||||||
${images.greySlash ? `<img src="${images.greySlash}" alt="" width="11" height="11" style="display:block; border:0;">` : ''}
|
${images.greySlash ? `<img src="${images.greySlash}" alt="" width="11" height="11" style="display:block; border:0;">` : ""}
|
||||||
</td>
|
</td>
|
||||||
<td width="${spacerWidth}" style="width:${spacerWidth}px; font-size:0; line-height:0;"></td>
|
<td width="${spacerWidth}" style="width:${spacerWidth}px; font-size:0; line-height:0;"></td>
|
||||||
<td style="vertical-align:top; padding:0 0 0 ${textPaddingLeft}px;">
|
<td style="vertical-align:top; padding:0 0 0 ${textPaddingLeft}px;">
|
||||||
<span style="color:${colors.address}; text-decoration:none;">${branding.address.join('<br>')}</span>
|
<span style="color:${colors.address}; text-decoration:none;">${address.join("<br>")}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="${gutterWidth}" style="width:${gutterWidth}px; font-size:0; line-height:0;"></td>
|
<td width="${gutterWidth}" style="width:${gutterWidth}px; font-size:0; line-height:0;"></td>
|
||||||
<td width="11" style="width:11px; vertical-align:top; padding-top:${12 + iconVerticalOffset}px; ${hidePhoneIcon}">
|
<td width="11" style="width:11px; vertical-align:top; padding-top:${12 + iconVerticalOffset}px; ${hidePhoneIcon}">
|
||||||
${images.accentSlash ? `<img src="${images.accentSlash}" alt="" width="11" height="7" style="display:block; border:0;">` : ''}
|
${images.accentSlash ? `<img src="${images.accentSlash}" alt="" width="11" height="7" style="display:block; border:0;">` : ""}
|
||||||
</td>
|
</td>
|
||||||
<td width="${isMinimal ? 0 : spacerWidth}" style="width:${isMinimal ? 0 : spacerWidth}px; font-size:0; line-height:0;"></td>
|
<td width="${isMinimal ? 0 : spacerWidth}" style="width:${isMinimal ? 0 : spacerWidth}px; font-size:0; line-height:0;"></td>
|
||||||
<td style="vertical-align:top; padding:8px 0 0 ${isMinimal ? 0 : textPaddingLeft}px;">
|
<td style="vertical-align:top; padding:8px 0 0 ${isMinimal ? 0 : textPaddingLeft}px;">
|
||||||
@@ -88,7 +110,7 @@ export function generateSignatureHtml(config: SignatureConfig): string {
|
|||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
${branding.website ? `<tr style="${hideBottom}"><td style="padding:${sectionSpacing}px 0 ${mottoSpacing}px 0;"><a href="https://${branding.website}" style="color:${colors.website}; text-decoration:none;"><span style="color:${colors.website}; text-decoration:none;">${branding.website}</span></a></td></tr>` : ''}
|
${branding.website ? `<tr style="${hideBottom}"><td style="padding:${sectionSpacing}px 0 ${mottoSpacing}px 0;"><a href="https://${branding.website}" style="color:${colors.website}; text-decoration:none;"><span style="color:${colors.website}; text-decoration:none;">${branding.website}</span></a></td></tr>` : ""}
|
||||||
<tr style="${hideBottom}">
|
<tr style="${hideBottom}">
|
||||||
<td style="padding:0; font-size:0; line-height:0;">
|
<td style="padding:0; font-size:0; line-height:0;">
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540">
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540">
|
||||||
@@ -99,22 +121,22 @@ export function generateSignatureHtml(config: SignatureConfig): string {
|
|||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
${branding.motto ? `<tr style="${hideBottom}"><td style="padding:${mottoSpacing}px 0 0 0;"><span style="font-size:12px; color:${colors.motto}; font-style:italic;">${esc(branding.motto)}</span></td></tr>` : ''}
|
${branding.motto ? `<tr style="${hideBottom}"><td style="padding:${mottoSpacing}px 0 0 0;"><span style="font-size:12px; color:${colors.motto}; font-style:italic;">${esc(branding.motto)}</span></td></tr>` : ""}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>`;
|
</table>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function esc(text: string): string {
|
function esc(text: string): string {
|
||||||
return text
|
return text
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, "&")
|
||||||
.replace(/</g, '<')
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, '>')
|
.replace(/>/g, ">")
|
||||||
.replace(/"/g, '"');
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadSignatureHtml(html: string, filename: string): void {
|
export function downloadSignatureHtml(html: string, filename: string): void {
|
||||||
const blob = new Blob([html], { type: 'text/html' });
|
const blob = new Blob([html], { type: "text/html" });
|
||||||
const a = document.createElement('a');
|
const a = document.createElement("a");
|
||||||
a.href = URL.createObjectURL(blob);
|
a.href = URL.createObjectURL(blob);
|
||||||
a.download = filename;
|
a.download = filename;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { CompanyId } from '@/core/auth/types';
|
import type { CompanyId } from "@/core/auth/types";
|
||||||
|
|
||||||
export type SignatureVariant = 'full' | 'reply' | 'minimal';
|
export type SignatureVariant = "full" | "reply" | "minimal";
|
||||||
|
|
||||||
export interface SignatureColors {
|
export interface SignatureColors {
|
||||||
prefix: string;
|
prefix: string;
|
||||||
@@ -30,6 +30,8 @@ export interface CompanyBranding {
|
|||||||
logo: { png: string; svg: string };
|
logo: { png: string; svg: string };
|
||||||
slashGrey: { png: string; svg: string };
|
slashGrey: { png: string; svg: string };
|
||||||
slashAccent: { png: string; svg: string };
|
slashAccent: { png: string; svg: string };
|
||||||
|
/** Logo dimensions (width × height) for the signature HTML */
|
||||||
|
logoDimensions?: { width: number; height: number };
|
||||||
address: string[];
|
address: string[];
|
||||||
website: string;
|
website: string;
|
||||||
motto: string;
|
motto: string;
|
||||||
@@ -48,6 +50,8 @@ export interface SignatureConfig {
|
|||||||
layout: SignatureLayout;
|
layout: SignatureLayout;
|
||||||
variant: SignatureVariant;
|
variant: SignatureVariant;
|
||||||
useSvg: boolean;
|
useSvg: boolean;
|
||||||
|
/** Override the default company address */
|
||||||
|
addressOverride?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SavedSignature {
|
export interface SavedSignature {
|
||||||
|
|||||||
161
src/modules/hot-desk/components/desk-calendar.tsx
Normal file
161
src/modules/hot-desk/components/desk-calendar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
src/modules/hot-desk/components/desk-room-layout.tsx
Normal file
137
src/modules/hot-desk/components/desk-room-layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
305
src/modules/hot-desk/components/hot-desk-module.tsx
Normal file
305
src/modules/hot-desk/components/hot-desk-module.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
src/modules/hot-desk/components/reservation-dialog.tsx
Normal file
161
src/modules/hot-desk/components/reservation-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/modules/hot-desk/config.ts
Normal file
17
src/modules/hot-desk/config.ts
Normal 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"],
|
||||||
|
};
|
||||||
77
src/modules/hot-desk/hooks/use-reservations.ts
Normal file
77
src/modules/hot-desk/hooks/use-reservations.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
3
src/modules/hot-desk/index.ts
Normal file
3
src/modules/hot-desk/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { hotDeskConfig } from "./config";
|
||||||
|
export { HotDeskModule } from "./components/hot-desk-module";
|
||||||
|
export type { DeskReservation, DeskId } from "./types";
|
||||||
206
src/modules/hot-desk/services/reservation-service.ts
Normal file
206
src/modules/hot-desk/services/reservation-service.ts
Normal 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}`;
|
||||||
|
}
|
||||||
38
src/modules/hot-desk/types.ts
Normal file
38
src/modules/hot-desk/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,117 +1,274 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useMemo } from "react";
|
||||||
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Search } from "lucide-react";
|
||||||
import { Button } from '@/shared/components/ui/button';
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Input } from '@/shared/components/ui/input';
|
import { Input } from "@/shared/components/ui/input";
|
||||||
import { Label } from '@/shared/components/ui/label';
|
import { Label } from "@/shared/components/ui/label";
|
||||||
import { Textarea } from '@/shared/components/ui/textarea';
|
import { Textarea } from "@/shared/components/ui/textarea";
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
import {
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
Card,
|
||||||
import type { CompanyId } from '@/core/auth/types';
|
CardContent,
|
||||||
import type { InventoryItem, InventoryItemType, InventoryItemStatus } from '../types';
|
CardHeader,
|
||||||
import { useInventory } from '../hooks/use-inventory';
|
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> = {
|
const TYPE_LABELS: Record<InventoryItemType, string> = {
|
||||||
laptop: 'Laptop', desktop: 'Desktop', monitor: 'Monitor', printer: 'Imprimantă',
|
laptop: "Laptop",
|
||||||
phone: 'Telefon', tablet: 'Tabletă', network: 'Rețea', peripheral: 'Periferic', other: 'Altele',
|
desktop: "Desktop",
|
||||||
|
monitor: "Monitor",
|
||||||
|
printer: "Imprimantă",
|
||||||
|
phone: "Telefon",
|
||||||
|
tablet: "Tabletă",
|
||||||
|
network: "Rețea",
|
||||||
|
peripheral: "Periferic",
|
||||||
|
other: "Altele",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABELS: Record<InventoryItemStatus, string> = {
|
const STATUS_LABELS: Record<InventoryItemStatus, string> = {
|
||||||
active: 'Activ', 'in-repair': 'În reparație', storage: 'Depozitat', decommissioned: 'Dezafectat',
|
active: "Activ",
|
||||||
|
"in-repair": "În reparație",
|
||||||
|
storage: "Depozitat",
|
||||||
|
decommissioned: "Dezafectat",
|
||||||
};
|
};
|
||||||
|
|
||||||
type ViewMode = 'list' | 'add' | 'edit';
|
type ViewMode = "list" | "add" | "edit";
|
||||||
|
|
||||||
export function ItInventoryModule() {
|
export function ItInventoryModule() {
|
||||||
const { items, allItems, loading, filters, updateFilter, addItem, updateItem, removeItem } = useInventory();
|
const {
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
items,
|
||||||
|
allItems,
|
||||||
|
loading,
|
||||||
|
filters,
|
||||||
|
updateFilter,
|
||||||
|
addItem,
|
||||||
|
updateItem,
|
||||||
|
removeItem,
|
||||||
|
} = useInventory();
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||||
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
|
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleSubmit = async (data: Omit<InventoryItem, 'id' | 'createdAt'>) => {
|
const handleSubmit = async (
|
||||||
if (viewMode === 'edit' && editingItem) {
|
data: Omit<InventoryItem, "id" | "createdAt" | "updatedAt">,
|
||||||
|
) => {
|
||||||
|
if (viewMode === "edit" && editingItem) {
|
||||||
await updateItem(editingItem.id, data);
|
await updateItem(editingItem.id, data);
|
||||||
} else {
|
} else {
|
||||||
await addItem(data);
|
await addItem(data);
|
||||||
}
|
}
|
||||||
setViewMode('list');
|
setViewMode("list");
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (deletingId) {
|
||||||
|
await removeItem(deletingId);
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total</p><p className="text-2xl font-bold">{allItems.length}</p></CardContent></Card>
|
<Card>
|
||||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Active</p><p className="text-2xl font-bold">{allItems.filter((i) => i.status === 'active').length}</p></CardContent></Card>
|
<CardContent className="p-4">
|
||||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">În reparație</p><p className="text-2xl font-bold">{allItems.filter((i) => i.status === 'in-repair').length}</p></CardContent></Card>
|
<p className="text-xs text-muted-foreground">Total</p>
|
||||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Dezafectate</p><p className="text-2xl font-bold">{allItems.filter((i) => i.status === 'decommissioned').length}</p></CardContent></Card>
|
<p className="text-2xl font-bold">{allItems.length}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Active</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{allItems.filter((i) => i.status === "active").length}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">În reparație</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{allItems.filter((i) => i.status === "in-repair").length}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Dezafectate</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{allItems.filter((i) => i.status === "decommissioned").length}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === 'list' && (
|
{viewMode === "list" && (
|
||||||
<>
|
<>
|
||||||
{/* Filters */}
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="relative min-w-[200px] flex-1">
|
<div className="relative min-w-[200px] flex-1">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input placeholder="Caută..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
|
<Input
|
||||||
|
placeholder="Caută..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => updateFilter("search", e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select value={filters.type} onValueChange={(v) => updateFilter('type', v as InventoryItemType | 'all')}>
|
<Select
|
||||||
<SelectTrigger className="w-[140px]"><SelectValue /></SelectTrigger>
|
value={filters.type}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateFilter("type", v as InventoryItemType | "all")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Toate tipurile</SelectItem>
|
<SelectItem value="all">Toate tipurile</SelectItem>
|
||||||
{(Object.keys(TYPE_LABELS) as InventoryItemType[]).map((t) => (
|
{(Object.keys(TYPE_LABELS) as InventoryItemType[]).map((t) => (
|
||||||
<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>
|
<SelectItem key={t} value={t}>
|
||||||
|
{TYPE_LABELS[t]}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={filters.status} onValueChange={(v) => updateFilter('status', v as InventoryItemStatus | 'all')}>
|
<Select
|
||||||
<SelectTrigger className="w-[140px]"><SelectValue /></SelectTrigger>
|
value={filters.status}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateFilter("status", v as InventoryItemStatus | "all")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Toate</SelectItem>
|
<SelectItem value="all">Toate</SelectItem>
|
||||||
{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map((s) => (
|
{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map(
|
||||||
<SelectItem key={s} value={s}>{STATUS_LABELS[s]}</SelectItem>
|
(s) => (
|
||||||
))}
|
<SelectItem key={s} value={s}>
|
||||||
|
{STATUS_LABELS[s]}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button onClick={() => setViewMode('add')} className="shrink-0">
|
<Button onClick={() => setViewMode("add")} className="shrink-0">
|
||||||
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Se încarcă...
|
||||||
|
</p>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">Niciun echipament găsit.</p>
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Niciun echipament găsit.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto rounded-lg border">
|
<div className="overflow-x-auto rounded-lg border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead><tr className="border-b bg-muted/40">
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/40">
|
||||||
<th className="px-3 py-2 text-left font-medium">Nume</th>
|
<th className="px-3 py-2 text-left font-medium">Nume</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Tip</th>
|
<th className="px-3 py-2 text-left font-medium">Tip</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">
|
||||||
|
Vendor/Model
|
||||||
|
</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">S/N</th>
|
<th className="px-3 py-2 text-left font-medium">S/N</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Atribuit</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">Locație</th>
|
<th className="px-3 py-2 text-left font-medium">Locație</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Status</th>
|
<th className="px-3 py-2 text-left font-medium">Status</th>
|
||||||
<th className="px-3 py-2 text-right font-medium">Acțiuni</th>
|
<th className="px-3 py-2 text-right font-medium">
|
||||||
</tr></thead>
|
Acțiuni
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<tr key={item.id} className="border-b hover:bg-muted/20 transition-colors">
|
<tr
|
||||||
|
key={item.id}
|
||||||
|
className="border-b hover:bg-muted/20 transition-colors"
|
||||||
|
>
|
||||||
<td className="px-3 py-2 font-medium">{item.name}</td>
|
<td className="px-3 py-2 font-medium">{item.name}</td>
|
||||||
<td className="px-3 py-2"><Badge variant="outline">{TYPE_LABELS[item.type]}</Badge></td>
|
<td className="px-3 py-2">
|
||||||
<td className="px-3 py-2 font-mono text-xs">{item.serialNumber}</td>
|
<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>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs">
|
||||||
|
{item.serialNumber}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs">
|
||||||
|
{item.ipAddress}
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2">{item.assignedTo}</td>
|
<td className="px-3 py-2">{item.assignedTo}</td>
|
||||||
<td className="px-3 py-2">{item.location}</td>
|
<td className="px-3 py-2 text-xs">
|
||||||
<td className="px-3 py-2"><Badge variant="secondary">{STATUS_LABELS[item.status]}</Badge></td>
|
{item.rackLocation || item.location}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{STATUS_LABELS[item.status]}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingItem(item); setViewMode('edit'); }}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingItem(item);
|
||||||
|
setViewMode("edit");
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeItem(item.id)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
onClick={() => setDeletingId(item.id)}
|
||||||
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,56 +282,263 @@ export function ItInventoryModule() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(viewMode === 'add' || viewMode === 'edit') && (
|
{(viewMode === "add" || viewMode === "edit") && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare echipament' : 'Echipament nou'}</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{viewMode === "edit" ? "Editare echipament" : "Echipament nou"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<InventoryForm
|
<InventoryForm
|
||||||
initial={editingItem ?? undefined}
|
initial={editingItem ?? undefined}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={() => { setViewMode('list'); setEditingItem(null); }}
|
onCancel={() => {
|
||||||
|
setViewMode("list");
|
||||||
|
setEditingItem(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<Dialog
|
||||||
|
open={deletingId !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDeletingId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Confirmare ștergere</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm">
|
||||||
|
Ești sigur că vrei să ștergi acest echipament? Acțiunea este
|
||||||
|
ireversibilă.
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeletingId(null)}>
|
||||||
|
Anulează
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDeleteConfirm}>
|
||||||
|
Șterge
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InventoryForm({ initial, onSubmit, onCancel }: {
|
function InventoryForm({
|
||||||
|
initial,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
initial?: InventoryItem;
|
initial?: InventoryItem;
|
||||||
onSubmit: (data: Omit<InventoryItem, 'id' | 'createdAt'>) => void;
|
onSubmit: (
|
||||||
|
data: Omit<InventoryItem, "id" | "createdAt" | "updatedAt">,
|
||||||
|
) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState(initial?.name ?? '');
|
const { allContacts } = useContacts();
|
||||||
const [type, setType] = useState<InventoryItemType>(initial?.type ?? 'laptop');
|
|
||||||
const [serialNumber, setSerialNumber] = useState(initial?.serialNumber ?? '');
|
const [name, setName] = useState(initial?.name ?? "");
|
||||||
const [assignedTo, setAssignedTo] = useState(initial?.assignedTo ?? '');
|
const [type, setType] = useState<InventoryItemType>(
|
||||||
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
|
initial?.type ?? "laptop",
|
||||||
const [location, setLocation] = useState(initial?.location ?? '');
|
);
|
||||||
const [purchaseDate, setPurchaseDate] = useState(initial?.purchaseDate ?? '');
|
const [serialNumber, setSerialNumber] = useState(initial?.serialNumber ?? "");
|
||||||
const [status, setStatus] = useState<InventoryItemStatus>(initial?.status ?? 'active');
|
const [assignedTo, setAssignedTo] = useState(initial?.assignedTo ?? "");
|
||||||
const [notes, setNotes] = useState(initial?.notes ?? '');
|
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 (
|
return (
|
||||||
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ name, type, serialNumber, assignedTo, company, location, purchaseDate, status, notes, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4">
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit({
|
||||||
|
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"
|
||||||
|
>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div><Label>Nume echipament</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div>
|
<div>
|
||||||
<div><Label>Tip</Label>
|
<Label>Nume echipament *</Label>
|
||||||
<Select value={type} onValueChange={(v) => setType(v as InventoryItemType)}>
|
<Input
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
value={name}
|
||||||
<SelectContent>{(Object.keys(TYPE_LABELS) as InventoryItemType[]).map((t) => (<SelectItem key={t} value={t}>{TYPE_LABELS[t]}</SelectItem>))}</SelectContent>
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Tip</Label>
|
||||||
|
<Select
|
||||||
|
value={type}
|
||||||
|
onValueChange={(v) => setType(v as InventoryItemType)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(TYPE_LABELS) as InventoryItemType[]).map((t) => (
|
||||||
|
<SelectItem key={t} value={t}>
|
||||||
|
{TYPE_LABELS[t]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div><Label>Număr serie</Label><Input value={serialNumber} onChange={(e) => setSerialNumber(e.target.value)} className="mt-1" /></div>
|
<div>
|
||||||
<div><Label>Atribuit</Label><Input value={assignedTo} onChange={(e) => setAssignedTo(e.target.value)} className="mt-1" /></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>
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div><Label>Companie</Label>
|
<div>
|
||||||
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
|
<Label>Adresă IP</Label>
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
<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>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="beletage">Beletage</SelectItem>
|
<SelectItem value="beletage">Beletage</SelectItem>
|
||||||
<SelectItem value="urban-switch">Urban Switch</SelectItem>
|
<SelectItem value="urban-switch">Urban Switch</SelectItem>
|
||||||
@@ -183,21 +547,86 @@ function InventoryForm({ initial, onSubmit, onCancel }: {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div><Label>Locație</Label><Input value={location} onChange={(e) => setLocation(e.target.value)} className="mt-1" /></div>
|
<div>
|
||||||
<div><Label>Data achiziție</Label><Input type="date" value={purchaseDate} onChange={(e) => setPurchaseDate(e.target.value)} className="mt-1" /></div>
|
<Label>Locație / Cameră</Label>
|
||||||
|
<Input
|
||||||
|
value={location}
|
||||||
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div>
|
||||||
<div><Label>Status</Label>
|
<Label>Rack / Poziție</Label>
|
||||||
<Select value={status} onValueChange={(v) => setStatus(v as InventoryItemStatus)}>
|
<Input
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
value={rackLocation}
|
||||||
<SelectContent>{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map((s) => (<SelectItem key={s} value={s}>{STATUS_LABELS[s]}</SelectItem>))}</SelectContent>
|
onChange={(e) => setRackLocation(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Status</Label>
|
||||||
|
<Select
|
||||||
|
value={status}
|
||||||
|
onValueChange={(v) => setStatus(v as InventoryItemStatus)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map(
|
||||||
|
(s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{STATUS_LABELS[s]}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></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>
|
||||||
|
<div>
|
||||||
|
<Label>Note</Label>
|
||||||
|
<Textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
|
Anulează
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ export function useInventory() {
|
|||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
useEffect(() => { refresh(); }, [refresh]);
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
const addItem = useCallback(async (data: Omit<InventoryItem, 'id' | 'createdAt'>) => {
|
const addItem = useCallback(async (data: Omit<InventoryItem, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||||
const item: InventoryItem = { ...data, id: uuid(), createdAt: new Date().toISOString() };
|
const now = new Date().toISOString();
|
||||||
|
const item: InventoryItem = { ...data, id: uuid(), createdAt: now, updatedAt: now };
|
||||||
await storage.set(`${PREFIX}${item.id}`, item);
|
await storage.set(`${PREFIX}${item.id}`, item);
|
||||||
await refresh();
|
await refresh();
|
||||||
return item;
|
return item;
|
||||||
@@ -50,7 +51,11 @@ export function useInventory() {
|
|||||||
const updateItem = useCallback(async (id: string, updates: Partial<InventoryItem>) => {
|
const updateItem = useCallback(async (id: string, updates: Partial<InventoryItem>) => {
|
||||||
const existing = items.find((i) => i.id === id);
|
const existing = items.find((i) => i.id === id);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt };
|
const updated: InventoryItem = {
|
||||||
|
...existing, ...updates,
|
||||||
|
id: existing.id, createdAt: existing.createdAt,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
await storage.set(`${PREFIX}${id}`, updated);
|
await storage.set(`${PREFIX}${id}`, updated);
|
||||||
await refresh();
|
await refresh();
|
||||||
}, [storage, refresh, items]);
|
}, [storage, refresh, items]);
|
||||||
@@ -70,7 +75,14 @@ export function useInventory() {
|
|||||||
if (filters.company !== 'all' && item.company !== filters.company) return false;
|
if (filters.company !== 'all' && item.company !== filters.company) return false;
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
const q = filters.search.toLowerCase();
|
const q = filters.search.toLowerCase();
|
||||||
return item.name.toLowerCase().includes(q) || item.serialNumber.toLowerCase().includes(q) || item.assignedTo.toLowerCase().includes(q);
|
return (
|
||||||
|
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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import type { Visibility } from '@/core/module-registry/types';
|
import type { Visibility } from "@/core/module-registry/types";
|
||||||
import type { CompanyId } from '@/core/auth/types';
|
import type { CompanyId } from "@/core/auth/types";
|
||||||
|
|
||||||
export type InventoryItemType =
|
export type InventoryItemType =
|
||||||
| 'laptop'
|
| "laptop"
|
||||||
| 'desktop'
|
| "desktop"
|
||||||
| 'monitor'
|
| "monitor"
|
||||||
| 'printer'
|
| "printer"
|
||||||
| 'phone'
|
| "phone"
|
||||||
| 'tablet'
|
| "tablet"
|
||||||
| 'network'
|
| "network"
|
||||||
| 'peripheral'
|
| "peripheral"
|
||||||
| 'other';
|
| "other";
|
||||||
|
|
||||||
export type InventoryItemStatus =
|
export type InventoryItemStatus =
|
||||||
| 'active'
|
| "active"
|
||||||
| 'in-repair'
|
| "in-repair"
|
||||||
| 'storage'
|
| "storage"
|
||||||
| 'decommissioned';
|
| "decommissioned";
|
||||||
|
|
||||||
export interface InventoryItem {
|
export interface InventoryItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -24,12 +24,28 @@ export interface InventoryItem {
|
|||||||
type: InventoryItemType;
|
type: InventoryItemType;
|
||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
assignedTo: string;
|
assignedTo: string;
|
||||||
|
assignedToContactId?: string;
|
||||||
company: CompanyId;
|
company: CompanyId;
|
||||||
location: string;
|
location: string;
|
||||||
purchaseDate: string;
|
purchaseDate: string;
|
||||||
status: InventoryItemStatus;
|
status: InventoryItemStatus;
|
||||||
|
/** IP address */
|
||||||
|
ipAddress: string;
|
||||||
|
/** MAC address */
|
||||||
|
macAddress: string;
|
||||||
|
/** Warranty expiry date (YYYY-MM-DD) */
|
||||||
|
warrantyExpiry: string;
|
||||||
|
/** Purchase cost (RON) */
|
||||||
|
purchaseCost: string;
|
||||||
|
/** Room / rack position */
|
||||||
|
rackLocation: string;
|
||||||
|
/** Vendor / manufacturer */
|
||||||
|
vendor: string;
|
||||||
|
/** Model name/number */
|
||||||
|
model: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
notes: string;
|
notes: string;
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,35 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useRef } from "react";
|
||||||
import { Copy, Check, Hash, Type, Percent, Ruler } from 'lucide-react';
|
import {
|
||||||
import { Button } from '@/shared/components/ui/button';
|
Copy,
|
||||||
import { Input } from '@/shared/components/ui/input';
|
Check,
|
||||||
import { Label } from '@/shared/components/ui/label';
|
Hash,
|
||||||
import { Textarea } from '@/shared/components/ui/textarea';
|
Type,
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
Percent,
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/components/ui/tabs';
|
Ruler,
|
||||||
|
Zap,
|
||||||
|
Wand2,
|
||||||
|
Building2,
|
||||||
|
FileDown,
|
||||||
|
ScanText,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { Label } from "@/shared/components/ui/label";
|
||||||
|
import { Textarea } from "@/shared/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card";
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "@/shared/components/ui/tabs";
|
||||||
|
|
||||||
function CopyButton({ text }: { text: string }) {
|
function CopyButton({ text }: { text: string }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@@ -16,17 +38,29 @@ function CopyButton({ text }: { text: string }) {
|
|||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 1500);
|
setTimeout(() => setCopied(false), 1500);
|
||||||
} catch { /* silent */ }
|
} catch {
|
||||||
|
/* silent */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopy} disabled={!text}>
|
<Button
|
||||||
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={!text}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-3.5 w-3.5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TextCaseConverter() {
|
function TextCaseConverter() {
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState("");
|
||||||
const upper = input.toUpperCase();
|
const upper = input.toUpperCase();
|
||||||
const lower = input.toLowerCase();
|
const lower = input.toLowerCase();
|
||||||
const title = input.replace(/\b\w/g, (c) => c.toUpperCase());
|
const title = input.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
@@ -34,15 +68,26 @@ function TextCaseConverter() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div><Label>Text sursă</Label><Textarea value={input} onChange={(e) => setInput(e.target.value)} rows={3} className="mt-1" placeholder="Introdu text..." /></div>
|
<div>
|
||||||
|
<Label>Text sursă</Label>
|
||||||
|
<Textarea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Introdu text..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{[
|
{[
|
||||||
{ label: 'UPPERCASE', value: upper },
|
{ label: "UPPERCASE", value: upper },
|
||||||
{ label: 'lowercase', value: lower },
|
{ label: "lowercase", value: lower },
|
||||||
{ label: 'Title Case', value: title },
|
{ label: "Title Case", value: title },
|
||||||
{ label: 'Sentence case', value: sentence },
|
{ label: "Sentence case", value: sentence },
|
||||||
].map(({ label, value }) => (
|
].map(({ label, value }) => (
|
||||||
<div key={label} className="flex items-center gap-2">
|
<div key={label} className="flex items-center gap-2">
|
||||||
<code className="flex-1 truncate rounded border bg-muted/30 px-2 py-1 text-xs">{value || '—'}</code>
|
<code className="flex-1 truncate rounded border bg-muted/30 px-2 py-1 text-xs">
|
||||||
|
{value || "—"}
|
||||||
|
</code>
|
||||||
<span className="w-24 text-xs text-muted-foreground">{label}</span>
|
<span className="w-24 text-xs text-muted-foreground">{label}</span>
|
||||||
<CopyButton text={value} />
|
<CopyButton text={value} />
|
||||||
</div>
|
</div>
|
||||||
@@ -52,73 +97,148 @@ function TextCaseConverter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CharacterCounter() {
|
function CharacterCounter() {
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState("");
|
||||||
const chars = input.length;
|
const chars = input.length;
|
||||||
const charsNoSpaces = input.replace(/\s/g, '').length;
|
const charsNoSpaces = input.replace(/\s/g, "").length;
|
||||||
const words = input.trim() ? input.trim().split(/\s+/).length : 0;
|
const words = input.trim() ? input.trim().split(/\s+/).length : 0;
|
||||||
const lines = input ? input.split('\n').length : 0;
|
const lines = input ? input.split("\n").length : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div><Label>Text</Label><Textarea value={input} onChange={(e) => setInput(e.target.value)} rows={5} className="mt-1" placeholder="Introdu text..." /></div>
|
<div>
|
||||||
|
<Label>Text</Label>
|
||||||
|
<Textarea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Introdu text..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<Card><CardContent className="p-3"><p className="text-xs text-muted-foreground">Caractere</p><p className="text-xl font-bold">{chars}</p></CardContent></Card>
|
<Card>
|
||||||
<Card><CardContent className="p-3"><p className="text-xs text-muted-foreground">Fără spații</p><p className="text-xl font-bold">{charsNoSpaces}</p></CardContent></Card>
|
<CardContent className="p-3">
|
||||||
<Card><CardContent className="p-3"><p className="text-xs text-muted-foreground">Cuvinte</p><p className="text-xl font-bold">{words}</p></CardContent></Card>
|
<p className="text-xs text-muted-foreground">Caractere</p>
|
||||||
<Card><CardContent className="p-3"><p className="text-xs text-muted-foreground">Linii</p><p className="text-xl font-bold">{lines}</p></CardContent></Card>
|
<p className="text-xl font-bold">{chars}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">Fără spații</p>
|
||||||
|
<p className="text-xl font-bold">{charsNoSpaces}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">Cuvinte</p>
|
||||||
|
<p className="text-xl font-bold">{words}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">Linii</p>
|
||||||
|
<p className="text-xl font-bold">{lines}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PercentageCalculator() {
|
function PercentageCalculator() {
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState("");
|
||||||
const [total, setTotal] = useState('');
|
const [total, setTotal] = useState("");
|
||||||
const [percent, setPercent] = useState('');
|
const [percent, setPercent] = useState("");
|
||||||
|
|
||||||
const v = parseFloat(value);
|
const v = parseFloat(value);
|
||||||
const t = parseFloat(total);
|
const t = parseFloat(total);
|
||||||
const p = parseFloat(percent);
|
const p = parseFloat(percent);
|
||||||
|
|
||||||
const pctOfTotal = !isNaN(v) && !isNaN(t) && t !== 0 ? ((v / t) * 100).toFixed(2) : '—';
|
const pctOfTotal =
|
||||||
const valFromPct = !isNaN(p) && !isNaN(t) ? ((p / 100) * t).toFixed(2) : '—';
|
!isNaN(v) && !isNaN(t) && t !== 0 ? ((v / t) * 100).toFixed(2) : "—";
|
||||||
|
const valFromPct = !isNaN(p) && !isNaN(t) ? ((p / 100) * t).toFixed(2) : "—";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
<div><Label>Valoare</Label><Input type="number" value={value} onChange={(e) => setValue(e.target.value)} className="mt-1" /></div>
|
<div>
|
||||||
<div><Label>Total</Label><Input type="number" value={total} onChange={(e) => setTotal(e.target.value)} className="mt-1" /></div>
|
<Label>Valoare</Label>
|
||||||
<div><Label>Procent</Label><Input type="number" value={percent} onChange={(e) => setPercent(e.target.value)} className="mt-1" /></div>
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Total</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={total}
|
||||||
|
onChange={(e) => setTotal(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Procent</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={percent}
|
||||||
|
onChange={(e) => setPercent(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 rounded-md border bg-muted/30 p-3 text-sm">
|
<div className="space-y-2 rounded-md border bg-muted/30 p-3 text-sm">
|
||||||
<p><strong>{value || '?'}</strong> din <strong>{total || '?'}</strong> = <strong>{pctOfTotal}%</strong></p>
|
<p>
|
||||||
<p><strong>{percent || '?'}%</strong> din <strong>{total || '?'}</strong> = <strong>{valFromPct}</strong></p>
|
<strong>{value || "?"}</strong> din <strong>{total || "?"}</strong> ={" "}
|
||||||
|
<strong>{pctOfTotal}%</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>{percent || "?"}%</strong> din <strong>{total || "?"}</strong>{" "}
|
||||||
|
= <strong>{valFromPct}</strong>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AreaConverter() {
|
function AreaConverter() {
|
||||||
const [mp, setMp] = useState('');
|
const [mp, setMp] = useState("");
|
||||||
const v = parseFloat(mp);
|
const v = parseFloat(mp);
|
||||||
|
|
||||||
const conversions = !isNaN(v) ? [
|
const conversions = !isNaN(v)
|
||||||
{ label: 'mp (m²)', value: v.toFixed(2) },
|
? [
|
||||||
{ label: 'ari (100 m²)', value: (v / 100).toFixed(4) },
|
{ label: "mp (m²)", value: v.toFixed(2) },
|
||||||
{ label: 'hectare (10.000 m²)', value: (v / 10000).toFixed(6) },
|
{ label: "ari (100 m²)", value: (v / 100).toFixed(4) },
|
||||||
{ label: 'km²', value: (v / 1000000).toFixed(8) },
|
{ label: "hectare (10.000 m²)", value: (v / 10000).toFixed(6) },
|
||||||
{ label: 'sq ft', value: (v * 10.7639).toFixed(2) },
|
{ label: "km²", value: (v / 1000000).toFixed(8) },
|
||||||
] : [];
|
{ label: "sq ft", value: (v * 10.7639).toFixed(2) },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div><Label>Suprafață (m²)</Label><Input type="number" value={mp} onChange={(e) => setMp(e.target.value)} className="mt-1" placeholder="Introdu suprafața..." /></div>
|
<div>
|
||||||
|
<Label>Suprafață (m²)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={mp}
|
||||||
|
onChange={(e) => setMp(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Introdu suprafața..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{conversions.length > 0 && (
|
{conversions.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{conversions.map(({ label, value: val }) => (
|
{conversions.map(({ label, value: val }) => (
|
||||||
<div key={label} className="flex items-center gap-2">
|
<div key={label} className="flex items-center gap-2">
|
||||||
<code className="flex-1 rounded border bg-muted/30 px-2 py-1 text-xs">{val}</code>
|
<code className="flex-1 rounded border bg-muted/30 px-2 py-1 text-xs">
|
||||||
<span className="w-36 text-xs text-muted-foreground">{label}</span>
|
{val}
|
||||||
|
</code>
|
||||||
|
<span className="w-36 text-xs text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
<CopyButton text={val} />
|
<CopyButton text={val} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -128,31 +248,603 @@ function AreaConverter() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── U-value → R-value Converter ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
function UValueConverter() {
|
||||||
|
const [uValue, setUValue] = useState("");
|
||||||
|
const [thickness, setThickness] = useState("");
|
||||||
|
|
||||||
|
const u = parseFloat(uValue);
|
||||||
|
const t = parseFloat(thickness);
|
||||||
|
const rValue = !isNaN(u) && u > 0 ? (1 / u).toFixed(4) : null;
|
||||||
|
const rsi = 0.13;
|
||||||
|
const rse = 0.04;
|
||||||
|
const rTotal =
|
||||||
|
rValue !== null ? (parseFloat(rValue) + rsi + rse).toFixed(4) : null;
|
||||||
|
const lambda =
|
||||||
|
rValue !== null && !isNaN(t) && t > 0
|
||||||
|
? (t / 100 / parseFloat(rValue)).toFixed(4)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label>Coeficient U (W/m²K)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={uValue}
|
||||||
|
onChange={(e) => setUValue(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="ex: 0.35"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Grosime material (cm) — opțional</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
value={thickness}
|
||||||
|
onChange={(e) => setThickness(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="ex: 20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{rValue !== null && (
|
||||||
|
<div className="space-y-2 rounded-md border bg-muted/30 p-3 text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium">R = 1/U</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<code className="rounded border bg-muted px-2 py-0.5">
|
||||||
|
{rValue} m²K/W
|
||||||
|
</code>
|
||||||
|
<CopyButton text={rValue} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-muted-foreground">
|
||||||
|
<span>Rsi (suprafață interioară)</span>
|
||||||
|
<code className="rounded border bg-muted px-2 py-0.5">
|
||||||
|
{rsi} m²K/W
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-muted-foreground">
|
||||||
|
<span>Rse (suprafață exterioară)</span>
|
||||||
|
<code className="rounded border bg-muted px-2 py-0.5">
|
||||||
|
{rse} m²K/W
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between font-medium border-t pt-2 mt-1">
|
||||||
|
<span>R total (cu Rsi + Rse)</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<code className="rounded border bg-muted px-2 py-0.5">
|
||||||
|
{rTotal} m²K/W
|
||||||
|
</code>
|
||||||
|
<CopyButton text={rTotal ?? ""} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{lambda !== null && (
|
||||||
|
<div className="flex items-center justify-between text-muted-foreground border-t pt-2 mt-1">
|
||||||
|
<span>Conductivitate λ = d/R</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<code className="rounded border bg-muted px-2 py-0.5">
|
||||||
|
{lambda} W/mK
|
||||||
|
</code>
|
||||||
|
<CopyButton text={lambda} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AI Artifact Cleaner ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AiArtifactCleaner() {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
|
||||||
|
const clean = (text: string): string => {
|
||||||
|
let r = text;
|
||||||
|
// Strip markdown
|
||||||
|
r = r.replace(/^#{1,6}\s+/gm, "");
|
||||||
|
r = r.replace(/\*\*(.+?)\*\*/g, "$1");
|
||||||
|
r = r.replace(/\*(.+?)\*/g, "$1");
|
||||||
|
r = r.replace(/_{2}(.+?)_{2}/g, "$1");
|
||||||
|
r = r.replace(/_(.+?)_/g, "$1");
|
||||||
|
r = r.replace(/```[\s\S]*?```/g, "");
|
||||||
|
r = r.replace(/`(.+?)`/g, "$1");
|
||||||
|
r = r.replace(/^[*\-+]\s+/gm, "");
|
||||||
|
r = r.replace(/^\d+\.\s+/gm, "");
|
||||||
|
r = r.replace(/^[-_*]{3,}$/gm, "");
|
||||||
|
r = r.replace(/\[(.+?)\]\(.*?\)/g, "$1");
|
||||||
|
r = r.replace(/^>\s+/gm, "");
|
||||||
|
// Fix encoding artifacts (UTF-8 mojibake)
|
||||||
|
r = r.replace(/â/g, "â");
|
||||||
|
r = r.replace(/î/g, "î");
|
||||||
|
r = r.replace(/Ã /g, "à");
|
||||||
|
r = r.replace(/Å£/g, "ț");
|
||||||
|
r = r.replace(/È™/g, "ș");
|
||||||
|
r = r.replace(/È›/g, "ț");
|
||||||
|
r = r.replace(/Èš/g, "Ț");
|
||||||
|
r = r.replace(/\u015f/g, "ș");
|
||||||
|
r = r.replace(/\u0163/g, "ț");
|
||||||
|
// Remove zero-width and invisible chars
|
||||||
|
r = r.replace(/[\u200b\u200c\u200d\ufeff]/g, "");
|
||||||
|
// Remove emoji
|
||||||
|
r = r.replace(/\p{Extended_Pictographic}/gu, "");
|
||||||
|
r = r.replace(/[\u{1F1E0}-\u{1F1FF}]/gu, ""); // flag emoji
|
||||||
|
r = r.replace(/[\u{FE00}-\u{FE0F}\u{20D0}-\u{20FF}]/gu, ""); // variation selectors
|
||||||
|
// Normalize typography
|
||||||
|
r = r.replace(/[""]/g, '"');
|
||||||
|
r = r.replace(/['']/g, "'");
|
||||||
|
r = r.replace(/[–—]/g, "-");
|
||||||
|
r = r.replace(/…/g, "...");
|
||||||
|
// Normalize spacing
|
||||||
|
r = r.replace(/ {2,}/g, " ");
|
||||||
|
r = r.replace(/\n{3,}/g, "\n\n");
|
||||||
|
return r.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleaned = input ? clean(input) : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label>Text original (output AI)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
className="mt-1 h-72 font-mono text-xs"
|
||||||
|
placeholder="Lipește textul generat de AI..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Text curățat</Label>
|
||||||
|
{cleaned && <CopyButton text={cleaned} />}
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={cleaned}
|
||||||
|
readOnly
|
||||||
|
className="mt-1 h-72 font-mono text-xs bg-muted/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Operații: eliminare markdown (###, **, `, liste, citate), emoji,
|
||||||
|
corectare encoding românesc (mojibake), curățare Unicode invizibil,
|
||||||
|
normalizare ghilimele / cratime / spații multiple.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MDLPA Date Locale ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function MdlpaValidator() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||||
|
<a
|
||||||
|
href="https://datelocale.mdlpa.ro"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary underline underline-offset-2"
|
||||||
|
>
|
||||||
|
Deschide datelocale.mdlpa.ro ↗
|
||||||
|
</a>
|
||||||
|
<span className="text-muted-foreground">•</span>
|
||||||
|
<a
|
||||||
|
href="https://datelocale.mdlpa.ro/ro/about/tutorials"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary underline underline-offset-2"
|
||||||
|
>
|
||||||
|
Tutoriale video ↗
|
||||||
|
</a>
|
||||||
|
<span className="text-muted-foreground">•</span>
|
||||||
|
<a
|
||||||
|
href="https://datelocale.mdlpa.ro/ro/about/info/reguli"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary underline underline-offset-2"
|
||||||
|
>
|
||||||
|
Reguli de calcul ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="overflow-hidden rounded-md border"
|
||||||
|
style={{ height: "560px" }}
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
src="https://datelocale.mdlpa.ro"
|
||||||
|
className="h-full w-full"
|
||||||
|
title="MDLPA — Date Locale"
|
||||||
|
allow="fullscreen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PDF Reducer (Stirling PDF) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PdfReducer() {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [optimizeLevel, setOptimizeLevel] = useState("2");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleCompress = async () => {
|
||||||
|
if (!file) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("fileInput", file);
|
||||||
|
formData.append("optimizeLevel", optimizeLevel);
|
||||||
|
const res = await fetch("/api/compress-pdf", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error ?? `Eroare server: ${res.status}`);
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = file.name.replace(/\.pdf$/i, "-comprimat.pdf");
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Nu s-a putut contacta Stirling PDF.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Fișier PDF</Label>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".pdf"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
setFile(e.target.files?.[0] ?? null);
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={() => fileRef.current?.click()}>
|
||||||
|
Selectează PDF...
|
||||||
|
</Button>
|
||||||
|
{file && (
|
||||||
|
<span className="text-sm text-muted-foreground">{file.name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Nivel compresie</Label>
|
||||||
|
<select
|
||||||
|
value={optimizeLevel}
|
||||||
|
onChange={(e) => setOptimizeLevel(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="0">0 — fără modificări (test)</option>
|
||||||
|
<option value="1">1 — compresie minimă</option>
|
||||||
|
<option value="2">2 — echilibrat (recomandat)</option>
|
||||||
|
<option value="3">3 — compresie mare</option>
|
||||||
|
<option value="4">4 — compresie maximă</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button onClick={handleCompress} disabled={!file || loading}>
|
||||||
|
{loading ? "Se comprimă..." : "Comprimă PDF"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" asChild>
|
||||||
|
<a
|
||||||
|
href="http://10.10.10.166:8087/compress-pdf"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Deschide Stirling PDF ↗
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Quick OCR ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function QuickOcr() {
|
||||||
|
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [lang, setLang] = useState("ron+eng");
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const runOcr = async (src: string) => {
|
||||||
|
if (loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setText("");
|
||||||
|
setProgress(0);
|
||||||
|
try {
|
||||||
|
const { createWorker } = await import("tesseract.js");
|
||||||
|
const worker = await createWorker(lang.split("+"), 1, {
|
||||||
|
logger: (m: { status: string; progress: number }) => {
|
||||||
|
if (m.status === "recognizing text")
|
||||||
|
setProgress(Math.round(m.progress * 100));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { data } = await worker.recognize(src);
|
||||||
|
setText(data.text.trim());
|
||||||
|
await worker.terminate();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Eroare OCR necunoscută");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFile = (file: File) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const src = e.target?.result as string;
|
||||||
|
setImageSrc(src);
|
||||||
|
runOcr(src);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const file = Array.from(e.dataTransfer.files).find((f) =>
|
||||||
|
f.type.startsWith("image/"),
|
||||||
|
);
|
||||||
|
if (file) handleFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = (e: React.ClipboardEvent) => {
|
||||||
|
const item = Array.from(e.clipboardData.items).find((i) =>
|
||||||
|
i.type.startsWith("image/"),
|
||||||
|
);
|
||||||
|
const file = item?.getAsFile();
|
||||||
|
if (file) handleFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3" onPaste={handlePaste}>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={lang}
|
||||||
|
onChange={(e) => setLang(e.target.value)}
|
||||||
|
className="rounded-md border bg-background px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="ron+eng">Română + Engleză</option>
|
||||||
|
<option value="ron">Română</option>
|
||||||
|
<option value="eng">Engleză</option>
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
sau Ctrl+V pentru a lipi imaginea
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex min-h-[120px] cursor-pointer items-center justify-center rounded-md border-2 border-dashed p-4 text-sm text-muted-foreground transition-colors hover:border-primary/50"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{imageSrc ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
alt="preview"
|
||||||
|
className="max-h-48 max-w-full rounded object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>Trage o imagine aici, apasă pentru a selecta, sau Ctrl+V</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) handleFile(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>Se procesează... (primul rulaj descarcă modelul ~10 MB)</span>
|
||||||
|
<span>{progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
{text && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Text extras</Label>
|
||||||
|
<CopyButton text={text} />
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={text}
|
||||||
|
readOnly
|
||||||
|
className="mt-1 h-56 font-mono text-xs bg-muted/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Module ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function MiniUtilitiesModule() {
|
export function MiniUtilitiesModule() {
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="text-case" className="space-y-4">
|
<Tabs defaultValue="text-case" className="space-y-4">
|
||||||
<TabsList className="flex-wrap">
|
<TabsList className="flex-wrap">
|
||||||
<TabsTrigger value="text-case"><Type className="mr-1 h-3.5 w-3.5" /> Transformare text</TabsTrigger>
|
<TabsTrigger value="text-case">
|
||||||
<TabsTrigger value="char-count"><Hash className="mr-1 h-3.5 w-3.5" /> Numărare caractere</TabsTrigger>
|
<Type className="mr-1 h-3.5 w-3.5" /> Transformare text
|
||||||
<TabsTrigger value="percentage"><Percent className="mr-1 h-3.5 w-3.5" /> Procente</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="area"><Ruler className="mr-1 h-3.5 w-3.5" /> Convertor suprafețe</TabsTrigger>
|
<TabsTrigger value="char-count">
|
||||||
|
<Hash className="mr-1 h-3.5 w-3.5" /> Numărare caractere
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="percentage">
|
||||||
|
<Percent className="mr-1 h-3.5 w-3.5" /> Procente
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="area">
|
||||||
|
<Ruler className="mr-1 h-3.5 w-3.5" /> Suprafețe
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="u-value">
|
||||||
|
<Zap className="mr-1 h-3.5 w-3.5" /> U → R
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="ai-cleaner">
|
||||||
|
<Wand2 className="mr-1 h-3.5 w-3.5" /> Curățare AI
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="mdlpa">
|
||||||
|
<Building2 className="mr-1 h-3.5 w-3.5" /> MDLPA
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="pdf-reducer">
|
||||||
|
<FileDown className="mr-1 h-3.5 w-3.5" /> Reducere PDF
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="ocr">
|
||||||
|
<ScanText className="mr-1 h-3.5 w-3.5" /> OCR
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="text-case">
|
<TabsContent value="text-case">
|
||||||
<Card><CardHeader><CardTitle className="text-base">Transformare text</CardTitle></CardHeader>
|
<Card>
|
||||||
<CardContent><TextCaseConverter /></CardContent></Card>
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Transformare text</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TextCaseConverter />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="char-count">
|
<TabsContent value="char-count">
|
||||||
<Card><CardHeader><CardTitle className="text-base">Numărare caractere</CardTitle></CardHeader>
|
<Card>
|
||||||
<CardContent><CharacterCounter /></CardContent></Card>
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Numărare caractere</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CharacterCounter />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="percentage">
|
<TabsContent value="percentage">
|
||||||
<Card><CardHeader><CardTitle className="text-base">Calculator procente</CardTitle></CardHeader>
|
<Card>
|
||||||
<CardContent><PercentageCalculator /></CardContent></Card>
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Calculator procente</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PercentageCalculator />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="area">
|
<TabsContent value="area">
|
||||||
<Card><CardHeader><CardTitle className="text-base">Convertor suprafețe</CardTitle></CardHeader>
|
<Card>
|
||||||
<CardContent><AreaConverter /></CardContent></Card>
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Convertor suprafețe</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<AreaConverter />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="u-value">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
Convertor U → R (termoizolație)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<UValueConverter />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="ai-cleaner">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Curățare text AI</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<AiArtifactCleaner />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="mdlpa">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
MDLPA — Date locale construcții
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<MdlpaValidator />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="pdf-reducer">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Reducere dimensiune PDF</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PdfReducer />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="ocr">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
OCR — extragere text din imagini
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<QuickOcr />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,34 +1,139 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { Plus, Pencil, Trash2, Search, Eye, EyeOff, Copy, ExternalLink } from 'lucide-react';
|
import {
|
||||||
import { Button } from '@/shared/components/ui/button';
|
Plus,
|
||||||
import { Input } from '@/shared/components/ui/input';
|
Pencil,
|
||||||
import { Label } from '@/shared/components/ui/label';
|
Trash2,
|
||||||
import { Textarea } from '@/shared/components/ui/textarea';
|
Search,
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
Eye,
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
EyeOff,
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
Copy,
|
||||||
import type { VaultEntry, VaultEntryCategory } from '../types';
|
ExternalLink,
|
||||||
import { useVault } from '../hooks/use-vault';
|
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> = {
|
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 = "";
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function PasswordVaultModule() {
|
export function PasswordVaultModule() {
|
||||||
const { entries, allEntries, loading, filters, updateFilter, addEntry, updateEntry, removeEntry } = useVault();
|
const {
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
entries,
|
||||||
|
allEntries,
|
||||||
|
loading,
|
||||||
|
filters,
|
||||||
|
updateFilter,
|
||||||
|
addEntry,
|
||||||
|
updateEntry,
|
||||||
|
removeEntry,
|
||||||
|
} = useVault();
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||||
const [editingEntry, setEditingEntry] = useState<VaultEntry | null>(null);
|
const [editingEntry, setEditingEntry] = useState<VaultEntry | null>(null);
|
||||||
const [visiblePasswords, setVisiblePasswords] = useState<Set<string>>(new Set());
|
const [visiblePasswords, setVisiblePasswords] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const togglePassword = (id: string) => {
|
const togglePassword = (id: string) => {
|
||||||
setVisiblePasswords((prev) => {
|
setVisiblePasswords((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(id)) next.delete(id); else next.add(id);
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -38,58 +143,116 @@ export function PasswordVaultModule() {
|
|||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
setCopiedId(id);
|
setCopiedId(id);
|
||||||
setTimeout(() => setCopiedId(null), 2000);
|
setTimeout(() => setCopiedId(null), 2000);
|
||||||
} catch { /* silent */ }
|
} catch {
|
||||||
|
/* silent */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (data: Omit<VaultEntry, 'id' | 'createdAt' | 'updatedAt'>) => {
|
const handleSubmit = async (
|
||||||
if (viewMode === 'edit' && editingEntry) {
|
data: Omit<VaultEntry, "id" | "createdAt" | "updatedAt">,
|
||||||
|
) => {
|
||||||
|
if (viewMode === "edit" && editingEntry) {
|
||||||
await updateEntry(editingEntry.id, data);
|
await updateEntry(editingEntry.id, data);
|
||||||
} else {
|
} else {
|
||||||
await addEntry(data);
|
await addEntry(data);
|
||||||
}
|
}
|
||||||
setViewMode('list');
|
setViewMode("list");
|
||||||
setEditingEntry(null);
|
setEditingEntry(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (deletingId) {
|
||||||
|
await removeEntry(deletingId);
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400">
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-2 text-xs text-amber-700 dark:text-amber-400">
|
||||||
Atenție: Parolele sunt stocate local (localStorage). Nu sunt criptate. Folosiți un manager de parole dedicat pentru date sensibile.
|
Atenție: Parolele sunt stocate local (localStorage). Nu sunt criptate.
|
||||||
|
Folosiți un manager de parole dedicat pentru date sensibile.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total</p><p className="text-2xl font-bold">{allEntries.length}</p></CardContent></Card>
|
<Card>
|
||||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Web</p><p className="text-2xl font-bold">{allEntries.filter((e) => e.category === 'web').length}</p></CardContent></Card>
|
<CardContent className="p-4">
|
||||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Server</p><p className="text-2xl font-bold">{allEntries.filter((e) => e.category === 'server').length}</p></CardContent></Card>
|
<p className="text-xs text-muted-foreground">Total</p>
|
||||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">API</p><p className="text-2xl font-bold">{allEntries.filter((e) => e.category === 'api').length}</p></CardContent></Card>
|
<p className="text-2xl font-bold">{allEntries.length}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Web</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{allEntries.filter((e) => e.category === "web").length}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Server</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{allEntries.filter((e) => e.category === "server").length}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">API</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{allEntries.filter((e) => e.category === "api").length}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === 'list' && (
|
{viewMode === "list" && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="relative min-w-[200px] flex-1">
|
<div className="relative min-w-[200px] flex-1">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input placeholder="Caută..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
|
<Input
|
||||||
|
placeholder="Caută..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => updateFilter("search", e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select value={filters.category} onValueChange={(v) => updateFilter('category', v as VaultEntryCategory | 'all')}>
|
<Select
|
||||||
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
|
value={filters.category}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateFilter("category", v as VaultEntryCategory | "all")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[150px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Toate</SelectItem>
|
<SelectItem value="all">Toate</SelectItem>
|
||||||
{(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map((c) => (
|
{(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map(
|
||||||
<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>
|
(c) => (
|
||||||
))}
|
<SelectItem key={c} value={c}>
|
||||||
|
{CATEGORY_LABELS[c]}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button onClick={() => setViewMode('add')} className="shrink-0">
|
<Button onClick={() => setViewMode("add")} className="shrink-0">
|
||||||
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Se încarcă...
|
||||||
|
</p>
|
||||||
) : entries.length === 0 ? (
|
) : entries.length === 0 ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">Nicio intrare găsită.</p>
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Nicio intrare găsită.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
@@ -98,32 +261,82 @@ export function PasswordVaultModule() {
|
|||||||
<div className="min-w-0 flex-1 space-y-1">
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className="font-medium">{entry.label}</p>
|
<p className="font-medium">{entry.label}</p>
|
||||||
<Badge variant="outline" className="text-[10px]">{CATEGORY_LABELS[entry.category]}</Badge>
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{CATEGORY_LABELS[entry.category]}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">{entry.username}</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{entry.username}
|
||||||
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="text-xs">
|
<code className="text-xs">
|
||||||
{visiblePasswords.has(entry.id) ? entry.encryptedPassword : '••••••••••'}
|
{visiblePasswords.has(entry.id)
|
||||||
|
? entry.password
|
||||||
|
: "••••••••••"}
|
||||||
</code>
|
</code>
|
||||||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => togglePassword(entry.id)}>
|
<Button
|
||||||
{visiblePasswords.has(entry.id) ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5"
|
||||||
|
onClick={() => togglePassword(entry.id)}
|
||||||
|
>
|
||||||
|
{visiblePasswords.has(entry.id) ? (
|
||||||
|
<EyeOff className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => handleCopy(entry.encryptedPassword, entry.id)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5"
|
||||||
|
onClick={() => handleCopy(entry.password, entry.id)}
|
||||||
|
>
|
||||||
<Copy className="h-3 w-3" />
|
<Copy className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
{copiedId === entry.id && <span className="text-[10px] text-green-500">Copiat!</span>}
|
{copiedId === entry.id && (
|
||||||
|
<span className="text-[10px] text-green-500">
|
||||||
|
Copiat!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{entry.url && (
|
{entry.url && (
|
||||||
<p className="flex items-center gap-1 text-xs text-muted-foreground">
|
<p className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<ExternalLink className="h-3 w-3" /> {entry.url}
|
<ExternalLink className="h-3 w-3" /> {entry.url}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{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]"
|
||||||
|
>
|
||||||
|
{cf.key}: {cf.value}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingEntry(entry); setViewMode('edit'); }}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingEntry(entry);
|
||||||
|
setViewMode("edit");
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeEntry(entry.id)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
onClick={() => setDeletingId(entry.id)}
|
||||||
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,50 +348,382 @@ export function PasswordVaultModule() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(viewMode === 'add' || viewMode === 'edit') && (
|
{(viewMode === "add" || viewMode === "edit") && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare' : 'Intrare nouă'}</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{viewMode === "edit" ? "Editare" : "Intrare nouă"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<VaultForm initial={editingEntry ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingEntry(null); }} />
|
<VaultForm
|
||||||
|
initial={editingEntry ?? undefined}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={() => {
|
||||||
|
setViewMode("list");
|
||||||
|
setEditingEntry(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<Dialog
|
||||||
|
open={deletingId !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDeletingId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Confirmare ștergere</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm">
|
||||||
|
Ești sigur că vrei să ștergi această intrare? Acțiunea este
|
||||||
|
ireversibilă.
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeletingId(null)}>
|
||||||
|
Anulează
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDeleteConfirm}>
|
||||||
|
Șterge
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VaultForm({ initial, onSubmit, onCancel }: {
|
function VaultForm({
|
||||||
|
initial,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
initial?: VaultEntry;
|
initial?: VaultEntry;
|
||||||
onSubmit: (data: Omit<VaultEntry, 'id' | 'createdAt' | 'updatedAt'>) => void;
|
onSubmit: (data: Omit<VaultEntry, "id" | "createdAt" | "updatedAt">) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [label, setLabel] = useState(initial?.label ?? '');
|
const [label, setLabel] = useState(initial?.label ?? "");
|
||||||
const [username, setUsername] = useState(initial?.username ?? '');
|
const [username, setUsername] = useState(initial?.username ?? "");
|
||||||
const [password, setPassword] = useState(initial?.encryptedPassword ?? '');
|
const [password, setPassword] = useState(initial?.password ?? "");
|
||||||
const [url, setUrl] = useState(initial?.url ?? '');
|
const [url, setUrl] = useState(initial?.url ?? "");
|
||||||
const [category, setCategory] = useState<VaultEntryCategory>(initial?.category ?? 'web');
|
const [category, setCategory] = useState<VaultEntryCategory>(
|
||||||
const [notes, setNotes] = useState(initial?.notes ?? '');
|
initial?.category ?? "web",
|
||||||
|
);
|
||||||
|
const [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);
|
||||||
|
const [genUpper, setGenUpper] = useState(true);
|
||||||
|
const [genLower, setGenLower] = useState(true);
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCustomField = () => {
|
||||||
|
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 removeCustomField = (index: number) => {
|
||||||
|
setCustomFields(customFields.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ label, username, encryptedPassword: password, url, category, notes, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'admin' }); }} className="space-y-4">
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit({
|
||||||
|
label,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
url,
|
||||||
|
category,
|
||||||
|
company,
|
||||||
|
notes,
|
||||||
|
customFields: customFields.filter((cf) => cf.key.trim()),
|
||||||
|
tags: initial?.tags ?? [],
|
||||||
|
visibility: initial?.visibility ?? "admin",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div><Label>Nume/Etichetă</Label><Input value={label} onChange={(e) => setLabel(e.target.value)} className="mt-1" required /></div>
|
<div>
|
||||||
<div><Label>Categorie</Label>
|
<Label>Nume/Etichetă *</Label>
|
||||||
<Select value={category} onValueChange={(v) => setCategory(v as VaultEntryCategory)}>
|
<Input
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
value={label}
|
||||||
<SelectContent>{(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map((c) => (<SelectItem key={c} value={c}>{CATEGORY_LABELS[c]}</SelectItem>))}</SelectContent>
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Categorie</Label>
|
||||||
|
<Select
|
||||||
|
value={category}
|
||||||
|
onValueChange={(v) => setCategory(v as VaultEntryCategory)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map(
|
||||||
|
(c) => (
|
||||||
|
<SelectItem key={c} value={c}>
|
||||||
|
{CATEGORY_LABELS[c]}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div><Label>Utilizator</Label><Input value={username} onChange={(e) => setUsername(e.target.value)} className="mt-1" /></div>
|
<div>
|
||||||
<div><Label>Parolă</Label><Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="mt-1" /></div>
|
<Label>Companie</Label>
|
||||||
|
<Select
|
||||||
|
value={company}
|
||||||
|
onValueChange={(v) => setCompany(v as CompanyId)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(COMPANY_LABELS) as CompanyId[]).map((c) => (
|
||||||
|
<SelectItem key={c} value={c}>
|
||||||
|
{COMPANY_LABELS[c]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Utilizator</Label>
|
||||||
|
<Input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Parolă</Label>
|
||||||
|
<div className="mt-1 flex gap-1.5">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="flex-1 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
title="Generează parolă"
|
||||||
|
>
|
||||||
|
<KeyRound className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{password && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">Forță:</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
strength.level === 3
|
||||||
|
? "text-emerald-600 font-medium"
|
||||||
|
: strength.level === 2
|
||||||
|
? "text-green-600 font-medium"
|
||||||
|
: strength.level === 1
|
||||||
|
? "text-yellow-600 font-medium"
|
||||||
|
: "text-red-600 font-medium"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{strength.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className={`h-full ${strength.color} transition-all`}
|
||||||
|
style={{ width: `${(strength.level + 1) * 25}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password generator options */}
|
||||||
|
<div className="rounded border p-3 space-y-2">
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
</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}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Custom fields */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Câmpuri personalizate</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addCustomField}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" /> Adaugă câmp
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{customFields.length > 0 && (
|
||||||
|
<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)}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Note</Label>
|
||||||
|
<Textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div><Label>URL</Label><Input value={url} onChange={(e) => setUrl(e.target.value)} className="mt-1" placeholder="https://..." /></div>
|
|
||||||
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div>
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
|
Anulează
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
import type { Visibility } from '@/core/module-registry/types';
|
import type { Visibility } from "@/core/module-registry/types";
|
||||||
|
import type { CompanyId } from "@/core/auth/types";
|
||||||
|
|
||||||
export type VaultEntryCategory =
|
export type VaultEntryCategory =
|
||||||
| 'web'
|
| "web"
|
||||||
| 'email'
|
| "email"
|
||||||
| 'server'
|
| "server"
|
||||||
| 'database'
|
| "database"
|
||||||
| 'api'
|
| "api"
|
||||||
| 'other';
|
| "other";
|
||||||
|
|
||||||
|
/** Custom key-value field */
|
||||||
|
export interface CustomField {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VaultEntry {
|
export interface VaultEntry {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
username: string;
|
username: string;
|
||||||
encryptedPassword: string;
|
password: string;
|
||||||
url: string;
|
url: string;
|
||||||
category: VaultEntryCategory;
|
category: VaultEntryCategory;
|
||||||
|
company: CompanyId;
|
||||||
|
/** Custom key-value fields */
|
||||||
|
customFields: CustomField[];
|
||||||
notes: string;
|
notes: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
187
src/modules/registratura/components/deadline-add-dialog.tsx
Normal file
187
src/modules/registratura/components/deadline-add-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/modules/registratura/components/deadline-card.tsx
Normal file
93
src/modules/registratura/components/deadline-card.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
160
src/modules/registratura/components/deadline-dashboard.tsx
Normal file
160
src/modules/registratura/components/deadline-dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/modules/registratura/components/deadline-resolve-dialog.tsx
Normal file
100
src/modules/registratura/components/deadline-resolve-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/modules/registratura/components/deadline-table.tsx
Normal file
127
src/modules/registratura/components/deadline-table.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,35 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { Button } from '@/shared/components/ui/button';
|
import { Button } from '@/shared/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
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';
|
||||||
import { useRegistry } from '../hooks/use-registry';
|
import { useRegistry } from '../hooks/use-registry';
|
||||||
import { RegistryFilters } from './registry-filters';
|
import { RegistryFilters } from './registry-filters';
|
||||||
import { RegistryTable } from './registry-table';
|
import { RegistryTable } from './registry-table';
|
||||||
import { RegistryEntryForm } from './registry-entry-form';
|
import { RegistryEntryForm } from './registry-entry-form';
|
||||||
import type { RegistryEntry } from '../types';
|
import { DeadlineDashboard } from './deadline-dashboard';
|
||||||
|
import { getOverdueDays } from '../services/registry-service';
|
||||||
|
import { aggregateDeadlines } from '../services/deadline-service';
|
||||||
|
import type { RegistryEntry, DeadlineResolution } from '../types';
|
||||||
|
|
||||||
type ViewMode = 'list' | 'add' | 'edit';
|
type ViewMode = 'list' | 'add' | 'edit';
|
||||||
|
|
||||||
export function RegistraturaModule() {
|
export function RegistraturaModule() {
|
||||||
const {
|
const {
|
||||||
entries, allEntries, loading, filters, updateFilter,
|
entries, allEntries, loading, filters, updateFilter,
|
||||||
addEntry, updateEntry, removeEntry,
|
addEntry, updateEntry, removeEntry, closeEntry,
|
||||||
|
addDeadline, resolveDeadline, removeDeadline,
|
||||||
} = useRegistry();
|
} = useRegistry();
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
const [editingEntry, setEditingEntry] = useState<RegistryEntry | null>(null);
|
const [editingEntry, setEditingEntry] = useState<RegistryEntry | null>(null);
|
||||||
|
const [closingId, setClosingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleAdd = async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
|
const handleAdd = async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
|
||||||
await addEntry(data);
|
await addEntry(data);
|
||||||
@@ -43,25 +52,79 @@ export function RegistraturaModule() {
|
|||||||
await removeEntry(id);
|
await removeEntry(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCloseRequest = (id: string) => {
|
||||||
|
const entry = allEntries.find((e) => e.id === id);
|
||||||
|
if (entry && (entry.linkedEntryIds ?? []).length > 0) {
|
||||||
|
setClosingId(id);
|
||||||
|
} else {
|
||||||
|
closeEntry(id, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseConfirm = (closeLinked: boolean) => {
|
||||||
|
if (closingId) {
|
||||||
|
closeEntry(closingId, closeLinked);
|
||||||
|
setClosingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setViewMode('list');
|
setViewMode('list');
|
||||||
setEditingEntry(null);
|
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
|
// Stats
|
||||||
const total = allEntries.length;
|
const total = allEntries.length;
|
||||||
const incoming = allEntries.filter((e) => e.type === 'incoming').length;
|
const open = allEntries.filter((e) => e.status === 'deschis').length;
|
||||||
const outgoing = allEntries.filter((e) => e.type === 'outgoing').length;
|
const overdue = allEntries.filter((e) => {
|
||||||
const inProgress = allEntries.filter((e) => e.status === 'in-progress').length;
|
if (e.status !== 'deschis') return false;
|
||||||
|
const days = getOverdueDays(e.deadline);
|
||||||
|
return days !== null && days > 0;
|
||||||
|
}).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 (
|
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">
|
<div className="space-y-6">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<StatCard label="Total" value={total} />
|
<StatCard label="Total" value={total} />
|
||||||
<StatCard label="Intrare" value={incoming} />
|
<StatCard label="Deschise" value={open} />
|
||||||
<StatCard label="Ieșire" value={outgoing} />
|
<StatCard label="Depășite" value={overdue} variant={overdue > 0 ? 'destructive' : undefined} />
|
||||||
<StatCard label="În lucru" value={inProgress} />
|
<StatCard label="Intrate" value={intrat} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === 'list' && (
|
{viewMode === 'list' && (
|
||||||
@@ -78,6 +141,7 @@ export function RegistraturaModule() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onClose={handleCloseRequest}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!loading && (
|
{!loading && (
|
||||||
@@ -97,7 +161,11 @@ export function RegistraturaModule() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<RegistryEntryForm onSubmit={handleAdd} onCancel={handleCancel} />
|
<RegistryEntryForm
|
||||||
|
allEntries={allEntries}
|
||||||
|
onSubmit={handleAdd}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -108,20 +176,61 @@ export function RegistraturaModule() {
|
|||||||
<CardTitle>Editare — {editingEntry.number}</CardTitle>
|
<CardTitle>Editare — {editingEntry.number}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<RegistryEntryForm initial={editingEntry} onSubmit={handleUpdate} onCancel={handleCancel} />
|
<RegistryEntryForm
|
||||||
|
initial={editingEntry}
|
||||||
|
allEntries={allEntries}
|
||||||
|
onSubmit={handleUpdate}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Close confirmation dialog */}
|
||||||
|
<Dialog open={closingId !== null} onOpenChange={(open) => { if (!open) setClosingId(null); }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Închide înregistrarea</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
<p className="text-sm">
|
||||||
|
Această înregistrare are {closingEntry?.linkedEntryIds?.length ?? 0} înregistrări legate.
|
||||||
|
Vrei să le închizi și pe acestea?
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setClosingId(null)}>Anulează</Button>
|
||||||
|
<Button variant="secondary" onClick={() => handleCloseConfirm(false)}>
|
||||||
|
Doar aceasta
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => handleCloseConfirm(true)}>
|
||||||
|
Închide toate legate
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="termene">
|
||||||
|
<DeadlineDashboard
|
||||||
|
entries={allEntries}
|
||||||
|
onResolveDeadline={handleDashboardResolve}
|
||||||
|
onAddChainedDeadline={handleAddChainedDeadline}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ label, value }: { label: string; value: number }) {
|
function StatCard({ label, value, variant }: { label: string; value: number; variant?: 'destructive' }) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
<p className="text-2xl font-bold">{value}</p>
|
<p className={`text-2xl font-bold ${variant === 'destructive' && value > 0 ? 'text-destructive' : ''}`}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,87 +1,384 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useMemo, useRef } from "react";
|
||||||
import type { CompanyId } from '@/core/auth/types';
|
import { Paperclip, X, Clock, Plus } from "lucide-react";
|
||||||
import type { RegistryEntry, RegistryEntryType, RegistryEntryStatus } from '../types';
|
import type { CompanyId } from "@/core/auth/types";
|
||||||
import { Input } from '@/shared/components/ui/input';
|
import type {
|
||||||
import { Label } from '@/shared/components/ui/label';
|
RegistryEntry,
|
||||||
import { Textarea } from '@/shared/components/ui/textarea';
|
RegistryDirection,
|
||||||
import { Button } from '@/shared/components/ui/button';
|
RegistryStatus,
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
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 {
|
interface RegistryEntryFormProps {
|
||||||
initial?: RegistryEntry;
|
initial?: RegistryEntry;
|
||||||
onSubmit: (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => void;
|
allEntries?: RegistryEntry[];
|
||||||
|
onSubmit: (
|
||||||
|
data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
|
||||||
|
) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RegistryEntryForm({ initial, onSubmit, onCancel }: RegistryEntryFormProps) {
|
const DOC_TYPE_LABELS: Record<DocumentType, string> = {
|
||||||
const [type, setType] = useState<RegistryEntryType>(initial?.type ?? 'incoming');
|
contract: "Contract",
|
||||||
const [subject, setSubject] = useState(initial?.subject ?? '');
|
oferta: "Ofertă",
|
||||||
const [date, setDate] = useState(initial?.date ?? new Date().toISOString().slice(0, 10));
|
factura: "Factură",
|
||||||
const [sender, setSender] = useState(initial?.sender ?? '');
|
scrisoare: "Scrisoare",
|
||||||
const [recipient, setRecipient] = useState(initial?.recipient ?? '');
|
aviz: "Aviz",
|
||||||
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
|
"nota-de-comanda": "Notă de comandă",
|
||||||
const [status, setStatus] = useState<RegistryEntryStatus>(initial?.status ?? 'registered');
|
raport: "Raport",
|
||||||
const [notes, setNotes] = useState(initial?.notes ?? '');
|
cerere: "Cerere",
|
||||||
|
altele: "Altele",
|
||||||
|
};
|
||||||
|
|
||||||
|
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 [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);
|
||||||
|
const [recipientFocused, setRecipientFocused] = useState(false);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}, [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);
|
||||||
|
}, [allContacts, recipient]);
|
||||||
|
|
||||||
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files) return;
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const base64 = reader.result as string;
|
||||||
|
setAttachments((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: uuid(),
|
||||||
|
name: file.name,
|
||||||
|
data: base64,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
addedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAttachment = (id: string) => {
|
||||||
|
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSubmit({
|
onSubmit({
|
||||||
type,
|
direction,
|
||||||
|
documentType,
|
||||||
subject,
|
subject,
|
||||||
date,
|
date,
|
||||||
sender,
|
sender,
|
||||||
|
senderContactId: senderContactId || undefined,
|
||||||
recipient,
|
recipient,
|
||||||
|
recipientContactId: recipientContactId || undefined,
|
||||||
company,
|
company,
|
||||||
status,
|
status,
|
||||||
|
deadline: deadline || undefined,
|
||||||
|
linkedEntryIds,
|
||||||
|
attachments,
|
||||||
|
trackedDeadlines:
|
||||||
|
trackedDeadlines.length > 0 ? trackedDeadlines : undefined,
|
||||||
notes,
|
notes,
|
||||||
tags: initial?.tags ?? [],
|
tags: initial?.tags ?? [],
|
||||||
visibility: initial?.visibility ?? 'all',
|
visibility: initial?.visibility ?? "all",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
{/* Row 1: Direction + Document type + Date */}
|
||||||
|
<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>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="intrat">Intrat</SelectItem>
|
||||||
|
<SelectItem value="iesit">Ieșit</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Tip document</Label>
|
<Label>Tip document</Label>
|
||||||
<Select value={type} onValueChange={(v) => setType(v as RegistryEntryType)}>
|
<Select
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
value={documentType}
|
||||||
|
onValueChange={(v) => setDocumentType(v as DocumentType)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="incoming">Intrare</SelectItem>
|
{(
|
||||||
<SelectItem value="outgoing">Ieșire</SelectItem>
|
Object.entries(DOC_TYPE_LABELS) as [DocumentType, string][]
|
||||||
<SelectItem value="internal">Intern</SelectItem>
|
).map(([key, label]) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Data</Label>
|
<Label>Data</Label>
|
||||||
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} className="mt-1" />
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Subject */}
|
||||||
<div>
|
<div>
|
||||||
<Label>Subiect</Label>
|
<Label>Subiect *</Label>
|
||||||
<Input value={subject} onChange={(e) => setSubject(e.target.value)} className="mt-1" required />
|
<Input
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sender / Recipient with autocomplete */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div className="relative">
|
||||||
<Label>Expeditor</Label>
|
<Label>Expeditor</Label>
|
||||||
<Input value={sender} onChange={(e) => setSender(e.target.value)} className="mt-1" />
|
<Input
|
||||||
|
value={sender}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSender(e.target.value);
|
||||||
|
setSenderContactId("");
|
||||||
|
}}
|
||||||
|
onFocus={() => setSenderFocused(true)}
|
||||||
|
onBlur={() => setTimeout(() => setSenderFocused(false), 200)}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Nume sau companie..."
|
||||||
|
/>
|
||||||
|
{senderFocused && senderSuggestions.length > 0 && (
|
||||||
|
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-1 shadow-md">
|
||||||
|
{senderSuggestions.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={() => {
|
||||||
|
setSender(c.company ? `${c.name} (${c.company})` : c.name);
|
||||||
|
setSenderContactId(c.id);
|
||||||
|
setSenderFocused(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{c.name}</span>
|
||||||
|
{c.company && (
|
||||||
|
<span className="ml-1 text-muted-foreground text-xs">
|
||||||
|
{c.company}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
<Label>Destinatar</Label>
|
<Label>Destinatar</Label>
|
||||||
<Input value={recipient} onChange={(e) => setRecipient(e.target.value)} className="mt-1" />
|
<Input
|
||||||
|
value={recipient}
|
||||||
|
onChange={(e) => {
|
||||||
|
setRecipient(e.target.value);
|
||||||
|
setRecipientContactId("");
|
||||||
|
}}
|
||||||
|
onFocus={() => setRecipientFocused(true)}
|
||||||
|
onBlur={() => setTimeout(() => setRecipientFocused(false), 200)}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Nume sau companie..."
|
||||||
|
/>
|
||||||
|
{recipientFocused && recipientSuggestions.length > 0 && (
|
||||||
|
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-1 shadow-md">
|
||||||
|
{recipientSuggestions.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={() => {
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
{/* Company + Status + Deadline */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>Companie</Label>
|
<Label>Companie</Label>
|
||||||
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
|
<Select
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
value={company}
|
||||||
|
onValueChange={(v) => setCompany(v as CompanyId)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="beletage">Beletage</SelectItem>
|
<SelectItem value="beletage">Beletage</SelectItem>
|
||||||
<SelectItem value="urban-switch">Urban Switch</SelectItem>
|
<SelectItem value="urban-switch">Urban Switch</SelectItem>
|
||||||
@@ -92,26 +389,198 @@ export function RegistryEntryForm({ initial, onSubmit, onCancel }: RegistryEntry
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Status</Label>
|
<Label>Status</Label>
|
||||||
<Select value={status} onValueChange={(v) => setStatus(v as RegistryEntryStatus)}>
|
<Select
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
value={status}
|
||||||
|
onValueChange={(v) => setStatus(v as RegistryStatus)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="registered">Înregistrat</SelectItem>
|
<SelectItem value="deschis">Deschis</SelectItem>
|
||||||
<SelectItem value="in-progress">În lucru</SelectItem>
|
<SelectItem value="inchis">Închis</SelectItem>
|
||||||
<SelectItem value="completed">Finalizat</SelectItem>
|
|
||||||
<SelectItem value="archived">Arhivat</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Termen limită</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={deadline}
|
||||||
|
onChange={(e) => setDeadline(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Linked entries */}
|
||||||
|
{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) => {
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{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 "Adaugă termen" 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()}
|
||||||
|
>
|
||||||
|
<Paperclip className="mr-1 h-3.5 w-3.5" /> Adaugă fișier
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.xls,.xlsx"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
<div>
|
<div>
|
||||||
<Label>Note</Label>
|
<Label>Note</Label>
|
||||||
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={3} className="mt-1" />
|
<Textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
|
Anulează
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ interface RegistryFiltersProps {
|
|||||||
onUpdate: <K extends keyof Filters>(key: K, value: Filters[K]) => void;
|
onUpdate: <K extends keyof Filters>(key: K, value: Filters[K]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||||
|
contract: 'Contract',
|
||||||
|
oferta: 'Ofertă',
|
||||||
|
factura: 'Factură',
|
||||||
|
scrisoare: 'Scrisoare',
|
||||||
|
aviz: 'Aviz',
|
||||||
|
'nota-de-comanda': 'Notă de comandă',
|
||||||
|
raport: 'Raport',
|
||||||
|
cerere: 'Cerere',
|
||||||
|
altele: 'Altele',
|
||||||
|
};
|
||||||
|
|
||||||
export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
|
export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
@@ -23,28 +35,37 @@ export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select value={filters.type} onValueChange={(v) => onUpdate('type', v as Filters['type'])}>
|
<Select value={filters.direction} onValueChange={(v) => onUpdate('direction', v as Filters['direction'])}>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-[130px]">
|
||||||
<SelectValue placeholder="Tip" />
|
<SelectValue placeholder="Direcție" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Toate</SelectItem>
|
||||||
|
<SelectItem value="intrat">Intrat</SelectItem>
|
||||||
|
<SelectItem value="iesit">Ieșit</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={filters.documentType} onValueChange={(v) => onUpdate('documentType', v as Filters['documentType'])}>
|
||||||
|
<SelectTrigger className="w-[160px]">
|
||||||
|
<SelectValue placeholder="Tip document" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Toate tipurile</SelectItem>
|
<SelectItem value="all">Toate tipurile</SelectItem>
|
||||||
<SelectItem value="incoming">Intrare</SelectItem>
|
{Object.entries(DOC_TYPE_LABELS).map(([key, label]) => (
|
||||||
<SelectItem value="outgoing">Ieșire</SelectItem>
|
<SelectItem key={key} value={key}>{label}</SelectItem>
|
||||||
<SelectItem value="internal">Intern</SelectItem>
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={filters.status} onValueChange={(v) => onUpdate('status', v as Filters['status'])}>
|
<Select value={filters.status} onValueChange={(v) => onUpdate('status', v as Filters['status'])}>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-[130px]">
|
||||||
<SelectValue placeholder="Status" />
|
<SelectValue placeholder="Status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Toate</SelectItem>
|
<SelectItem value="all">Toate</SelectItem>
|
||||||
<SelectItem value="registered">Înregistrat</SelectItem>
|
<SelectItem value="deschis">Deschis</SelectItem>
|
||||||
<SelectItem value="in-progress">În lucru</SelectItem>
|
<SelectItem value="inchis">Închis</SelectItem>
|
||||||
<SelectItem value="completed">Finalizat</SelectItem>
|
|
||||||
<SelectItem value="archived">Arhivat</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Pencil, Trash2 } from 'lucide-react';
|
import { Pencil, Trash2, CheckCircle2, Link2, Clock } from 'lucide-react';
|
||||||
import { Button } from '@/shared/components/ui/button';
|
import { Button } from '@/shared/components/ui/button';
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
import { Badge } from '@/shared/components/ui/badge';
|
||||||
import type { RegistryEntry } from '../types';
|
import type { RegistryEntry, DocumentType } from '../types';
|
||||||
|
import { getOverdueDays } from '../services/registry-service';
|
||||||
import { cn } from '@/shared/lib/utils';
|
import { cn } from '@/shared/lib/utils';
|
||||||
|
|
||||||
interface RegistryTableProps {
|
interface RegistryTableProps {
|
||||||
@@ -11,29 +12,32 @@ interface RegistryTableProps {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
onEdit: (entry: RegistryEntry) => void;
|
onEdit: (entry: RegistryEntry) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
|
onClose: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_LABELS: Record<string, string> = {
|
const DIRECTION_LABELS: Record<string, string> = {
|
||||||
incoming: 'Intrare',
|
intrat: 'Intrat',
|
||||||
outgoing: 'Ieșire',
|
iesit: 'Ieșit',
|
||||||
internal: 'Intern',
|
};
|
||||||
|
|
||||||
|
const DOC_TYPE_LABELS: Record<DocumentType, string> = {
|
||||||
|
contract: 'Contract',
|
||||||
|
oferta: 'Ofertă',
|
||||||
|
factura: 'Factură',
|
||||||
|
scrisoare: 'Scrisoare',
|
||||||
|
aviz: 'Aviz',
|
||||||
|
'nota-de-comanda': 'Notă comandă',
|
||||||
|
raport: 'Raport',
|
||||||
|
cerere: 'Cerere',
|
||||||
|
altele: 'Altele',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
registered: 'Înregistrat',
|
deschis: 'Deschis',
|
||||||
'in-progress': 'În lucru',
|
inchis: 'Închis',
|
||||||
completed: 'Finalizat',
|
|
||||||
archived: 'Arhivat',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'outline' | 'destructive'> = {
|
export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: RegistryTableProps) {
|
||||||
registered: 'default',
|
|
||||||
'in-progress': 'secondary',
|
|
||||||
completed: 'outline',
|
|
||||||
archived: 'outline',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function RegistryTable({ entries, loading, onEdit, onDelete }: RegistryTableProps) {
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>;
|
return <p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>;
|
||||||
}
|
}
|
||||||
@@ -53,30 +57,85 @@ export function RegistryTable({ entries, loading, onEdit, onDelete }: RegistryTa
|
|||||||
<tr className="border-b bg-muted/40">
|
<tr className="border-b bg-muted/40">
|
||||||
<th className="px-3 py-2 text-left font-medium">Nr.</th>
|
<th className="px-3 py-2 text-left font-medium">Nr.</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Data</th>
|
<th className="px-3 py-2 text-left font-medium">Data</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Dir.</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Tip</th>
|
<th className="px-3 py-2 text-left font-medium">Tip</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Subiect</th>
|
<th className="px-3 py-2 text-left font-medium">Subiect</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Expeditor</th>
|
<th className="px-3 py-2 text-left font-medium">Expeditor</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Destinatar</th>
|
<th className="px-3 py-2 text-left font-medium">Destinatar</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Termen</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">Status</th>
|
<th className="px-3 py-2 text-left font-medium">Status</th>
|
||||||
<th className="px-3 py-2 text-right font-medium">Acțiuni</th>
|
<th className="px-3 py-2 text-right font-medium">Acțiuni</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => {
|
||||||
<tr key={entry.id} className={cn('border-b hover:bg-muted/20 transition-colors')}>
|
const overdueDays = (entry.status === 'deschis' || !entry.status) ? getOverdueDays(entry.deadline) : null;
|
||||||
<td className="px-3 py-2 font-mono text-xs">{entry.number}</td>
|
const isOverdue = overdueDays !== null && overdueDays > 0;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={entry.id}
|
||||||
|
className={cn(
|
||||||
|
'border-b transition-colors hover:bg-muted/20',
|
||||||
|
isOverdue && 'bg-destructive/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">{entry.number}</td>
|
||||||
<td className="px-3 py-2 text-xs whitespace-nowrap">{formatDate(entry.date)}</td>
|
<td className="px-3 py-2 text-xs whitespace-nowrap">{formatDate(entry.date)}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<Badge variant="outline" className="text-xs">{TYPE_LABELS[entry.type]}</Badge>
|
<Badge
|
||||||
|
variant={entry.direction === 'intrat' ? 'default' : 'secondary'}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{DIRECTION_LABELS[entry.direction] ?? entry.direction ?? '—'}
|
||||||
|
</Badge>
|
||||||
|
</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 && (
|
||||||
|
<Link2 className="ml-1 inline h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{(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>
|
||||||
|
<td className="px-3 py-2 text-xs whitespace-nowrap">
|
||||||
|
{entry.deadline ? (
|
||||||
|
<span className={cn(isOverdue && 'font-medium text-destructive')}>
|
||||||
|
{formatDate(entry.deadline)}
|
||||||
|
{overdueDays !== null && overdueDays > 0 && (
|
||||||
|
<span className="ml-1 text-[10px]">({overdueDays}z depășit)</span>
|
||||||
|
)}
|
||||||
|
{overdueDays !== null && overdueDays < 0 && (
|
||||||
|
<span className="ml-1 text-[10px] text-muted-foreground">({Math.abs(overdueDays)}z)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 max-w-[250px] truncate">{entry.subject}</td>
|
|
||||||
<td className="px-3 py-2 max-w-[150px] truncate">{entry.sender}</td>
|
|
||||||
<td className="px-3 py-2 max-w-[150px] truncate">{entry.recipient}</td>
|
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<Badge variant={STATUS_VARIANT[entry.status]}>{STATUS_LABELS[entry.status]}</Badge>
|
<Badge variant={entry.status === 'deschis' ? 'default' : 'outline'}>
|
||||||
|
{STATUS_LABELS[entry.status]}
|
||||||
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
|
{entry.status === 'deschis' && (
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-green-600" onClick={() => onClose(entry.id)} title="Închide">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onEdit(entry)}>
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onEdit(entry)}>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -86,7 +145,8 @@ export function RegistryTable({ entries, loading, onEdit, onDelete }: RegistryTa
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
28
src/modules/registratura/hooks/use-deadline-filters.ts
Normal file
28
src/modules/registratura/hooks/use-deadline-filters.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -3,13 +3,16 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useStorage } from '@/core/storage';
|
import { useStorage } from '@/core/storage';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import type { RegistryEntry, RegistryEntryType, RegistryEntryStatus } from '../types';
|
import type { RegistryEntry, RegistryDirection, RegistryStatus, DocumentType, TrackedDeadline, DeadlineResolution } from '../types';
|
||||||
import { getAllEntries, saveEntry, deleteEntry, generateRegistryNumber } from '../services/registry-service';
|
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 {
|
export interface RegistryFilters {
|
||||||
search: string;
|
search: string;
|
||||||
type: RegistryEntryType | 'all';
|
direction: RegistryDirection | 'all';
|
||||||
status: RegistryEntryStatus | 'all';
|
status: RegistryStatus | 'all';
|
||||||
|
documentType: DocumentType | 'all';
|
||||||
company: string;
|
company: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,8 +22,9 @@ export function useRegistry() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filters, setFilters] = useState<RegistryFilters>({
|
const [filters, setFilters] = useState<RegistryFilters>({
|
||||||
search: '',
|
search: '',
|
||||||
type: 'all',
|
direction: 'all',
|
||||||
status: 'all',
|
status: 'all',
|
||||||
|
documentType: 'all',
|
||||||
company: 'all',
|
company: 'all',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,18 +40,18 @@ export function useRegistry() {
|
|||||||
|
|
||||||
const addEntry = useCallback(async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
|
const addEntry = useCallback(async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const nextIndex = entries.length + 1;
|
const number = generateRegistryNumber(data.company, data.date, entries);
|
||||||
const entry: RegistryEntry = {
|
const entry: RegistryEntry = {
|
||||||
...data,
|
...data,
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
number: generateRegistryNumber(data.date, nextIndex),
|
number,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
await saveEntry(storage, entry);
|
await saveEntry(storage, entry);
|
||||||
await refresh();
|
await refresh();
|
||||||
return entry;
|
return entry;
|
||||||
}, [storage, refresh, entries.length]);
|
}, [storage, refresh, entries]);
|
||||||
|
|
||||||
const updateEntry = useCallback(async (id: string, updates: Partial<RegistryEntry>) => {
|
const updateEntry = useCallback(async (id: string, updates: Partial<RegistryEntry>) => {
|
||||||
const existing = entries.find((e) => e.id === id);
|
const existing = entries.find((e) => e.id === id);
|
||||||
@@ -69,13 +73,101 @@ export function useRegistry() {
|
|||||||
await refresh();
|
await refresh();
|
||||||
}, [storage, refresh]);
|
}, [storage, refresh]);
|
||||||
|
|
||||||
|
/** Close an entry and optionally its linked entries */
|
||||||
|
const closeEntry = useCallback(async (id: string, closeLinked: boolean) => {
|
||||||
|
const entry = entries.find((e) => e.id === id);
|
||||||
|
if (!entry) return;
|
||||||
|
await updateEntry(id, { status: 'inchis' });
|
||||||
|
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 = {
|
||||||
|
...linked,
|
||||||
|
status: 'inchis',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await saveEntry(storage, updatedLinked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
}, [entries, updateEntry, storage, refresh]);
|
||||||
|
|
||||||
const updateFilter = useCallback(<K extends keyof RegistryFilters>(key: K, value: RegistryFilters[K]) => {
|
const updateFilter = useCallback(<K extends keyof RegistryFilters>(key: K, value: RegistryFilters[K]) => {
|
||||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
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) => {
|
const filteredEntries = entries.filter((entry) => {
|
||||||
if (filters.type !== 'all' && entry.type !== filters.type) return false;
|
if (filters.direction !== 'all' && entry.direction !== filters.direction) return false;
|
||||||
if (filters.status !== 'all' && entry.status !== filters.status) return false;
|
if (filters.status !== 'all' && entry.status !== filters.status) return false;
|
||||||
|
if (filters.documentType !== 'all' && entry.documentType !== filters.documentType) return false;
|
||||||
if (filters.company !== 'all' && entry.company !== filters.company) return false;
|
if (filters.company !== 'all' && entry.company !== filters.company) return false;
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
const q = filters.search.toLowerCase();
|
const q = filters.search.toLowerCase();
|
||||||
@@ -83,7 +175,7 @@ export function useRegistry() {
|
|||||||
entry.subject.toLowerCase().includes(q) ||
|
entry.subject.toLowerCase().includes(q) ||
|
||||||
entry.sender.toLowerCase().includes(q) ||
|
entry.sender.toLowerCase().includes(q) ||
|
||||||
entry.recipient.toLowerCase().includes(q) ||
|
entry.recipient.toLowerCase().includes(q) ||
|
||||||
entry.number.includes(q)
|
entry.number.toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -98,6 +190,10 @@ export function useRegistry() {
|
|||||||
addEntry,
|
addEntry,
|
||||||
updateEntry,
|
updateEntry,
|
||||||
removeEntry,
|
removeEntry,
|
||||||
|
closeEntry,
|
||||||
|
addDeadline,
|
||||||
|
resolveDeadline,
|
||||||
|
removeDeadline,
|
||||||
refresh,
|
refresh,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
export { registraturaConfig } from './config';
|
export { registraturaConfig } from './config';
|
||||||
export { RegistraturaModule } from './components/registratura-module';
|
export { RegistraturaModule } from './components/registratura-module';
|
||||||
export type { RegistryEntry, RegistryEntryType, RegistryEntryStatus } from './types';
|
export type {
|
||||||
|
RegistryEntry, RegistryDirection, RegistryStatus, DocumentType,
|
||||||
|
DeadlineDayType, DeadlineResolution, DeadlineCategory,
|
||||||
|
DeadlineTypeDef, TrackedDeadline,
|
||||||
|
} from './types';
|
||||||
|
|||||||
220
src/modules/registratura/services/deadline-catalog.ts
Normal file
220
src/modules/registratura/services/deadline-catalog.ts
Normal 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);
|
||||||
|
}
|
||||||
146
src/modules/registratura/services/deadline-service.ts
Normal file
146
src/modules/registratura/services/deadline-service.ts
Normal 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}`;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { CompanyId } from '@/core/auth/types';
|
||||||
import type { RegistryEntry } from '../types';
|
import type { RegistryEntry } from '../types';
|
||||||
|
|
||||||
const STORAGE_PREFIX = 'entry:';
|
const STORAGE_PREFIX = 'entry:';
|
||||||
@@ -30,9 +31,44 @@ export async function deleteEntry(storage: RegistryStorage, id: string): Promise
|
|||||||
await storage.delete(`${STORAGE_PREFIX}${id}`);
|
await storage.delete(`${STORAGE_PREFIX}${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateRegistryNumber(date: string, index: number): string {
|
const COMPANY_PREFIXES: Record<CompanyId, string> = {
|
||||||
|
beletage: 'B',
|
||||||
|
'urban-switch': 'US',
|
||||||
|
'studii-de-teren': 'SDT',
|
||||||
|
group: 'G',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate company-specific registry number: B-0001/2026
|
||||||
|
* Uses the next sequential number for that company in that year.
|
||||||
|
*/
|
||||||
|
export function generateRegistryNumber(
|
||||||
|
company: CompanyId,
|
||||||
|
date: string,
|
||||||
|
existingEntries: RegistryEntry[]
|
||||||
|
): string {
|
||||||
const d = new Date(date);
|
const d = new Date(date);
|
||||||
const year = d.getFullYear();
|
const year = d.getFullYear();
|
||||||
const padded = String(index).padStart(4, '0');
|
const prefix = COMPANY_PREFIXES[company];
|
||||||
return `${padded}/${year}`;
|
|
||||||
|
// Count existing entries for this company in this year
|
||||||
|
const sameCompanyYear = existingEntries.filter((e) => {
|
||||||
|
const entryYear = new Date(e.date).getFullYear();
|
||||||
|
return e.company === company && entryYear === year;
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextIndex = sameCompanyYear.length + 1;
|
||||||
|
const padded = String(nextIndex).padStart(4, '0');
|
||||||
|
return `${prefix}-${padded}/${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calculate days overdue (negative = days remaining, positive = overdue) */
|
||||||
|
export function getOverdueDays(deadline: string | undefined): number | null {
|
||||||
|
if (!deadline) return null;
|
||||||
|
const now = new Date();
|
||||||
|
now.setHours(0, 0, 0, 0);
|
||||||
|
const dl = new Date(deadline);
|
||||||
|
dl.setHours(0, 0, 0, 0);
|
||||||
|
const diff = now.getTime() - dl.getTime();
|
||||||
|
return Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
}
|
}
|
||||||
|
|||||||
146
src/modules/registratura/services/working-days.ts
Normal file
146
src/modules/registratura/services/working-days.ts
Normal 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 1900–2099)
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,24 +1,97 @@
|
|||||||
import type { Visibility } from '@/core/module-registry/types';
|
import type { Visibility } from '@/core/module-registry/types';
|
||||||
import type { CompanyId } from '@/core/auth/types';
|
import type { CompanyId } from '@/core/auth/types';
|
||||||
|
|
||||||
export type RegistryEntryType = 'incoming' | 'outgoing' | 'internal';
|
/** Document direction — simplified from the old 3-way type */
|
||||||
|
export type RegistryDirection = 'intrat' | 'iesit';
|
||||||
|
|
||||||
export type RegistryEntryStatus =
|
/** Document type categories */
|
||||||
| 'registered'
|
export type DocumentType =
|
||||||
| 'in-progress'
|
| 'contract'
|
||||||
| 'completed'
|
| 'oferta'
|
||||||
| 'archived';
|
| 'factura'
|
||||||
|
| 'scrisoare'
|
||||||
|
| 'aviz'
|
||||||
|
| 'nota-de-comanda'
|
||||||
|
| 'raport'
|
||||||
|
| 'cerere'
|
||||||
|
| 'altele';
|
||||||
|
|
||||||
|
/** Status — simplified to open/closed */
|
||||||
|
export type RegistryStatus = 'deschis' | 'inchis';
|
||||||
|
|
||||||
|
/** File attachment */
|
||||||
|
export interface RegistryAttachment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** base64-encoded content or URL */
|
||||||
|
data: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
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 {
|
export interface RegistryEntry {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Company-specific number: B-0001/2026, US-0001/2026, SDT-0001/2026 */
|
||||||
number: string;
|
number: string;
|
||||||
date: string;
|
date: string;
|
||||||
type: RegistryEntryType;
|
direction: RegistryDirection;
|
||||||
|
documentType: DocumentType;
|
||||||
subject: string;
|
subject: string;
|
||||||
|
/** Expeditor — free text or linked contact ID */
|
||||||
sender: string;
|
sender: string;
|
||||||
|
senderContactId?: string;
|
||||||
|
/** Destinatar — free text or linked contact ID */
|
||||||
recipient: string;
|
recipient: string;
|
||||||
|
recipientContactId?: string;
|
||||||
company: CompanyId;
|
company: CompanyId;
|
||||||
status: RegistryEntryStatus;
|
status: RegistryStatus;
|
||||||
|
/** Deadline date (YYYY-MM-DD) */
|
||||||
|
deadline?: string;
|
||||||
|
/** Linked entry IDs (for closing/archiving related entries) */
|
||||||
|
linkedEntryIds: string[];
|
||||||
|
/** File attachments */
|
||||||
|
attachments: RegistryAttachment[];
|
||||||
|
/** Tracked legal deadlines */
|
||||||
|
trackedDeadlines?: TrackedDeadline[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
notes: string;
|
notes: string;
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
|
|||||||
@@ -1,129 +1,408 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useMemo } from "react";
|
||||||
import { Plus, Trash2, Tag as TagIcon } from 'lucide-react';
|
import {
|
||||||
import { Button } from '@/shared/components/ui/button';
|
Plus,
|
||||||
import { Input } from '@/shared/components/ui/input';
|
Trash2,
|
||||||
import { Label } from '@/shared/components/ui/label';
|
Pencil,
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
Check,
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
X,
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
Download,
|
||||||
import { useTags } from '@/core/tagging';
|
ChevronDown,
|
||||||
import type { TagCategory, TagScope } from '@/core/tagging/types';
|
ChevronRight,
|
||||||
import { cn } from '@/shared/lib/utils';
|
Tag as TagIcon,
|
||||||
|
Search,
|
||||||
const CATEGORY_LABELS: Record<TagCategory, string> = {
|
FolderTree,
|
||||||
project: 'Proiect',
|
} from "lucide-react";
|
||||||
phase: 'Fază',
|
import { Button } from "@/shared/components/ui/button";
|
||||||
activity: 'Activitate',
|
import { Input } from "@/shared/components/ui/input";
|
||||||
'document-type': 'Tip document',
|
import { Label } from "@/shared/components/ui/label";
|
||||||
company: 'Companie',
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
priority: 'Prioritate',
|
import {
|
||||||
status: 'Status',
|
Card,
|
||||||
custom: 'Personalizat',
|
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 { useTags } from "@/core/tagging";
|
||||||
|
import type { Tag, TagCategory, TagScope } from "@/core/tagging/types";
|
||||||
|
import { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from "@/core/tagging/types";
|
||||||
|
import type { CompanyId } from "@/core/auth/types";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import { getManicTimeSeedTags } from "../services/seed-data";
|
||||||
|
|
||||||
const SCOPE_LABELS: Record<TagScope, string> = {
|
const SCOPE_LABELS: Record<TagScope, string> = {
|
||||||
global: 'Global',
|
global: "Global",
|
||||||
module: 'Modul',
|
module: "Modul",
|
||||||
company: 'Companie',
|
company: "Companie",
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMPANY_LABELS: Record<CompanyId, string> = {
|
||||||
|
beletage: "Beletage",
|
||||||
|
"urban-switch": "Urban Switch",
|
||||||
|
"studii-de-teren": "Studii de Teren",
|
||||||
|
group: "Grup",
|
||||||
};
|
};
|
||||||
|
|
||||||
const TAG_COLORS = [
|
const TAG_COLORS = [
|
||||||
'#ef4444', '#f97316', '#f59e0b', '#84cc16',
|
"#ef4444",
|
||||||
'#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6',
|
"#f97316",
|
||||||
'#ec4899', '#64748b',
|
"#f59e0b",
|
||||||
|
"#84cc16",
|
||||||
|
"#22c55e",
|
||||||
|
"#06b6d4",
|
||||||
|
"#3b82f6",
|
||||||
|
"#8b5cf6",
|
||||||
|
"#ec4899",
|
||||||
|
"#64748b",
|
||||||
|
"#22B5AB",
|
||||||
|
"#6366f1",
|
||||||
];
|
];
|
||||||
|
|
||||||
export function TagManagerModule() {
|
export function TagManagerModule() {
|
||||||
const { tags, loading, createTag, deleteTag } = useTags();
|
const { tags, loading, createTag, updateTag, deleteTag, importTags } =
|
||||||
const [newLabel, setNewLabel] = useState('');
|
useTags();
|
||||||
const [newCategory, setNewCategory] = useState<TagCategory>('custom');
|
|
||||||
const [newScope, setNewScope] = useState<TagScope>('global');
|
|
||||||
const [newColor, setNewColor] = useState(TAG_COLORS[5]);
|
|
||||||
const [filterCategory, setFilterCategory] = useState<TagCategory | 'all'>('all');
|
|
||||||
|
|
||||||
|
// ── 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("");
|
||||||
|
|
||||||
|
// ── Filter / search state ──
|
||||||
|
const [filterCategory, setFilterCategory] = useState<TagCategory | "all">(
|
||||||
|
"all",
|
||||||
|
);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||||
|
() => 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");
|
||||||
|
|
||||||
|
// ── Seed import state ──
|
||||||
|
const [showSeedDialog, setShowSeedDialog] = useState(false);
|
||||||
|
const [seedImporting, setSeedImporting] = useState(false);
|
||||||
|
const [seedResult, setSeedResult] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ── Computed ──
|
||||||
|
const filteredTags = useMemo(() => {
|
||||||
|
let result = tags;
|
||||||
|
if (filterCategory !== "all") {
|
||||||
|
result = result.filter((t) => t.category === filterCategory);
|
||||||
|
}
|
||||||
|
if (searchQuery) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
result = result.filter(
|
||||||
|
(t) =>
|
||||||
|
t.label.toLowerCase().includes(q) ||
|
||||||
|
(t.projectCode?.toLowerCase().includes(q) ?? false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [tags, filterCategory, searchQuery]);
|
||||||
|
|
||||||
|
const groupedByCategory = useMemo(() => {
|
||||||
|
const groups: Record<string, Tag[]> = {};
|
||||||
|
for (const cat of TAG_CATEGORY_ORDER) {
|
||||||
|
const catTags = filteredTags.filter((t) => t.category === cat);
|
||||||
|
if (catTags.length > 0) {
|
||||||
|
groups[cat] = catTags;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [filteredTags]);
|
||||||
|
|
||||||
|
/** Build a parent→children map for hierarchy display */
|
||||||
|
const childrenMap = useMemo(() => {
|
||||||
|
const map: Record<string, Tag[]> = {};
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (tag.parentId) {
|
||||||
|
const existing = map[tag.parentId];
|
||||||
|
if (existing) {
|
||||||
|
existing.push(tag);
|
||||||
|
} else {
|
||||||
|
map[tag.parentId] = [tag];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [tags]);
|
||||||
|
|
||||||
|
const parentCandidates = useMemo(() => {
|
||||||
|
return tags.filter((t) => t.category === newCategory && !t.parentId);
|
||||||
|
}, [tags, newCategory]);
|
||||||
|
|
||||||
|
// ── Validation state ──
|
||||||
|
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// ── Handlers ──
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!newLabel.trim()) return;
|
const errors: string[] = [];
|
||||||
|
if (!newLabel.trim()) {
|
||||||
|
errors.push("Numele etichetei este obligatoriu.");
|
||||||
|
}
|
||||||
|
if (newCategory === "project" && !newProjectCode.trim()) {
|
||||||
|
errors.push(
|
||||||
|
"Codul proiectului este obligatoriu pentru categoria Proiect (ex: B-001, US-010, SDT-003).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (newCategory === "project" && newScope !== "company") {
|
||||||
|
errors.push(
|
||||||
|
"Etichetele de tip Proiect trebuie asociate unei companii (vizibilitate = Companie).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (errors.length > 0) {
|
||||||
|
setValidationErrors(errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setValidationErrors([]);
|
||||||
await createTag({
|
await createTag({
|
||||||
label: newLabel.trim(),
|
label: newLabel.trim(),
|
||||||
category: newCategory,
|
category: newCategory,
|
||||||
scope: newScope,
|
scope: newScope,
|
||||||
color: newColor,
|
color: newColor,
|
||||||
|
companyId: newScope === "company" ? newCompanyId : undefined,
|
||||||
|
projectCode:
|
||||||
|
newCategory === "project" && newProjectCode
|
||||||
|
? newProjectCode
|
||||||
|
: undefined,
|
||||||
|
parentId: newParentId || undefined,
|
||||||
});
|
});
|
||||||
setNewLabel('');
|
setNewLabel("");
|
||||||
|
setNewProjectCode("");
|
||||||
|
setNewParentId("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredTags = filterCategory === 'all'
|
const startEdit = (tag: Tag) => {
|
||||||
? tags
|
setEditingTag(tag);
|
||||||
: tags.filter((t) => t.category === filterCategory);
|
setEditLabel(tag.label);
|
||||||
|
setEditColor(tag.color ?? "#3b82f6");
|
||||||
|
setEditProjectCode(tag.projectCode ?? "");
|
||||||
|
setEditScope(tag.scope);
|
||||||
|
setEditCompanyId(tag.companyId ?? "beletage");
|
||||||
|
};
|
||||||
|
|
||||||
const groupedByCategory = filteredTags.reduce<Record<string, typeof tags>>((acc, tag) => {
|
const saveEdit = async () => {
|
||||||
const key = tag.category;
|
if (!editingTag || !editLabel.trim()) return;
|
||||||
if (!acc[key]) acc[key] = [];
|
await updateTag(editingTag.id, {
|
||||||
acc[key].push(tag);
|
label: editLabel.trim(),
|
||||||
return acc;
|
color: editColor,
|
||||||
}, {});
|
projectCode:
|
||||||
|
editingTag.category === "project" && editProjectCode
|
||||||
|
? editProjectCode
|
||||||
|
: undefined,
|
||||||
|
scope: editScope,
|
||||||
|
companyId: editScope === "company" ? editCompanyId : undefined,
|
||||||
|
});
|
||||||
|
setEditingTag(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => setEditingTag(null);
|
||||||
|
|
||||||
|
const handleSeedImport = async () => {
|
||||||
|
setSeedImporting(true);
|
||||||
|
setSeedResult(null);
|
||||||
|
const seedTags = getManicTimeSeedTags();
|
||||||
|
const count = await importTags(seedTags);
|
||||||
|
setSeedResult(
|
||||||
|
`${count} etichete importate din ${seedTags.length} disponibile.`,
|
||||||
|
);
|
||||||
|
setSeedImporting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCategory = (cat: string) => {
|
||||||
|
setExpandedCategories((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(cat)) next.delete(cat);
|
||||||
|
else next.add(cat);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Stats ──
|
||||||
|
const projectCount = tags.filter((t) => t.category === "project").length;
|
||||||
|
const phaseCount = tags.filter((t) => t.category === "phase").length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||||
<Card><CardContent className="p-4">
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
<p className="text-xs text-muted-foreground">Total etichete</p>
|
<p className="text-xs text-muted-foreground">Total etichete</p>
|
||||||
<p className="text-2xl font-bold">{tags.length}</p>
|
<p className="text-2xl font-bold">{tags.length}</p>
|
||||||
</CardContent></Card>
|
</CardContent>
|
||||||
<Card><CardContent className="p-4">
|
</Card>
|
||||||
<p className="text-xs text-muted-foreground">Categorii folosite</p>
|
{TAG_CATEGORY_ORDER.map((cat) => (
|
||||||
<p className="text-2xl font-bold">{new Set(tags.map((t) => t.category)).size}</p>
|
<Card key={cat}>
|
||||||
</CardContent></Card>
|
<CardContent className="p-4">
|
||||||
<Card><CardContent className="p-4">
|
<p className="text-xs text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground">Globale</p>
|
{TAG_CATEGORY_LABELS[cat]}
|
||||||
<p className="text-2xl font-bold">{tags.filter((t) => t.scope === 'global').length}</p>
|
</p>
|
||||||
</CardContent></Card>
|
<p className="text-2xl font-bold">
|
||||||
<Card><CardContent className="p-4">
|
{tags.filter((t) => t.category === cat).length}
|
||||||
<p className="text-xs text-muted-foreground">Personalizate</p>
|
</p>
|
||||||
<p className="text-2xl font-bold">{tags.filter((t) => t.category === 'custom').length}</p>
|
</CardContent>
|
||||||
</CardContent></Card>
|
</Card>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Seed import banner */}
|
||||||
|
{tags.length === 0 && !loading && (
|
||||||
|
<Card className="border-dashed border-2">
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowSeedDialog(true)}>
|
||||||
|
<Download className="mr-1.5 h-4 w-4" /> Importă date inițiale
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Create new tag */}
|
{/* Create new tag */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle className="text-base">Etichetă nouă</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Etichetă nouă</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<div className="min-w-[200px] flex-1">
|
<div className="min-w-[200px] flex-1">
|
||||||
<Label>Nume</Label>
|
<Label>Nume</Label>
|
||||||
<Input
|
<Input
|
||||||
value={newLabel}
|
value={newLabel}
|
||||||
onChange={(e) => setNewLabel(e.target.value)}
|
onChange={(e) => setNewLabel(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||||
placeholder="Numele etichetei..."
|
placeholder="Numele etichetei..."
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[160px]">
|
<div className="w-[160px]">
|
||||||
<Label>Categorie</Label>
|
<Label>Categorie</Label>
|
||||||
<Select value={newCategory} onValueChange={(v) => setNewCategory(v as TagCategory)}>
|
<Select
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
value={newCategory}
|
||||||
|
onValueChange={(v) => setNewCategory(v as TagCategory)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(Object.keys(CATEGORY_LABELS) as TagCategory[]).map((cat) => (
|
{TAG_CATEGORY_ORDER.map((cat) => (
|
||||||
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat]}</SelectItem>
|
<SelectItem key={cat} value={cat}>
|
||||||
|
{TAG_CATEGORY_LABELS[cat]}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[130px]">
|
<div className="w-[140px]">
|
||||||
<Label>Vizibilitate</Label>
|
<Label>Vizibilitate</Label>
|
||||||
<Select value={newScope} onValueChange={(v) => setNewScope(v as TagScope)}>
|
<Select
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
value={newScope}
|
||||||
|
onValueChange={(v) => setNewScope(v as TagScope)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(Object.keys(SCOPE_LABELS) as TagScope[]).map((s) => (
|
{(Object.keys(SCOPE_LABELS) as TagScope[]).map((s) => (
|
||||||
<SelectItem key={s} value={s}>{SCOPE_LABELS[s]}</SelectItem>
|
<SelectItem key={s} value={s}>
|
||||||
|
{SCOPE_LABELS[s]}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
{newScope === "company" && (
|
||||||
|
<div className="w-[150px]">
|
||||||
|
<Label>Companie</Label>
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
{newCategory === "project" && (
|
||||||
|
<div className="w-[140px]">
|
||||||
|
<Label>Cod proiect</Label>
|
||||||
|
<Input
|
||||||
|
value={newProjectCode}
|
||||||
|
onChange={(e) => setNewProjectCode(e.target.value)}
|
||||||
|
placeholder="B-001"
|
||||||
|
className="mt-1 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">
|
||||||
|
— Niciun părinte —
|
||||||
|
</SelectItem>
|
||||||
|
{parentCandidates.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
{p.projectCode ? `${p.projectCode} ` : ""}
|
||||||
|
{p.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Culoare</Label>
|
<Label className="mb-1.5 block">Culoare</Label>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
@@ -133,8 +412,10 @@ export function TagManagerModule() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setNewColor(color)}
|
onClick={() => setNewColor(color)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-7 w-7 rounded-full border-2 transition-all',
|
"h-7 w-7 rounded-full border-2 transition-all",
|
||||||
newColor === color ? 'border-primary scale-110' : 'border-transparent hover:scale-105'
|
newColor === color
|
||||||
|
? "border-primary scale-110"
|
||||||
|
: "border-transparent hover:scale-105",
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
/>
|
/>
|
||||||
@@ -145,63 +426,397 @@ export function TagManagerModule() {
|
|||||||
<Plus className="mr-1 h-4 w-4" /> Adaugă
|
<Plus className="mr-1 h-4 w-4" /> Adaugă
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Validation errors */}
|
||||||
|
{validationErrors.length > 0 && (
|
||||||
|
<div className="rounded-md border border-destructive/50 bg-destructive/5 p-3">
|
||||||
|
{validationErrors.map((err) => (
|
||||||
|
<p key={err} className="text-sm text-destructive">
|
||||||
|
{err}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hint for mandatory categories */}
|
||||||
|
{(newCategory === "project" || newCategory === "phase") && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<strong>Notă:</strong> Categoriile <em>Proiect</em> și{" "}
|
||||||
|
<em>Fază</em> sunt obligatorii în structura de etichete.
|
||||||
|
Proiectele necesită un cod (ex: B-001, US-010, SDT-003) și
|
||||||
|
trebuie asociate unei companii.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Filter */}
|
{/* Search + Filter bar */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Label>Filtrează:</Label>
|
<div className="relative min-w-[200px] flex-1">
|
||||||
<Select value={filterCategory} onValueChange={(v) => setFilterCategory(v as TagCategory | 'all')}>
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<SelectTrigger className="w-[180px]"><SelectValue /></SelectTrigger>
|
<Input
|
||||||
|
placeholder="Caută etichete..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={filterCategory}
|
||||||
|
onValueChange={(v) => setFilterCategory(v as TagCategory | "all")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Toate categoriile</SelectItem>
|
<SelectItem value="all">Toate categoriile</SelectItem>
|
||||||
{(Object.keys(CATEGORY_LABELS) as TagCategory[]).map((cat) => (
|
{TAG_CATEGORY_ORDER.map((cat) => (
|
||||||
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat]}</SelectItem>
|
<SelectItem key={cat} value={cat}>
|
||||||
|
{TAG_CATEGORY_LABELS[cat]}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowSeedDialog(true)}
|
||||||
|
>
|
||||||
|
<Download className="mr-1 h-3.5 w-3.5" /> Importă ManicTime
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tag list by category */}
|
{/* Tag list by category with hierarchy */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Se încarcă...
|
||||||
|
</p>
|
||||||
) : Object.keys(groupedByCategory).length === 0 ? (
|
) : Object.keys(groupedByCategory).length === 0 ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">Nicio etichetă găsită. Creează prima etichetă.</p>
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Nicio etichetă găsită. Creează prima etichetă sau importă datele
|
||||||
|
inițiale.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
{Object.entries(groupedByCategory).map(([category, catTags]) => (
|
{Object.entries(groupedByCategory).map(([category, catTags]) => {
|
||||||
|
const isExpanded = expandedCategories.has(category);
|
||||||
|
const rootTags = catTags.filter((t) => !t.parentId);
|
||||||
|
return (
|
||||||
<Card key={category}>
|
<Card key={category}>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader
|
||||||
|
className="cursor-pointer pb-3"
|
||||||
|
onClick={() => toggleCategory(category)}
|
||||||
|
>
|
||||||
<CardTitle className="flex items-center gap-2 text-sm">
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
<TagIcon className="h-4 w-4" />
|
<TagIcon className="h-4 w-4" />
|
||||||
{CATEGORY_LABELS[category as TagCategory] ?? category}
|
{TAG_CATEGORY_LABELS[category as TagCategory] ?? category}
|
||||||
<Badge variant="secondary" className="ml-1">{catTags.length}</Badge>
|
<Badge variant="secondary" className="ml-1">
|
||||||
|
{catTags.length}
|
||||||
|
</Badge>
|
||||||
|
{(category === "project" || category === "phase") && (
|
||||||
|
<Badge variant="default" className="ml-1 text-[10px]">
|
||||||
|
obligatoriu
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
{isExpanded && (
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="space-y-1">
|
||||||
{catTags.map((tag) => (
|
{rootTags.map((tag) => (
|
||||||
<div
|
<TagRow
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
className="group flex items-center gap-1.5 rounded-full border py-1 pl-3 pr-1.5 text-sm"
|
tag={tag}
|
||||||
>
|
children={childrenMap[tag.id]}
|
||||||
{tag.color && (
|
editingTag={editingTag}
|
||||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: tag.color }} />
|
editLabel={editLabel}
|
||||||
|
editColor={editColor}
|
||||||
|
editProjectCode={editProjectCode}
|
||||||
|
editScope={editScope}
|
||||||
|
editCompanyId={editCompanyId}
|
||||||
|
onStartEdit={startEdit}
|
||||||
|
onSaveEdit={saveEdit}
|
||||||
|
onCancelEdit={cancelEdit}
|
||||||
|
onDelete={deleteTag}
|
||||||
|
setEditLabel={setEditLabel}
|
||||||
|
setEditColor={setEditColor}
|
||||||
|
setEditProjectCode={setEditProjectCode}
|
||||||
|
setEditScope={setEditScope}
|
||||||
|
setEditCompanyId={setEditCompanyId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
<span>{tag.label}</span>
|
</Card>
|
||||||
<Badge variant="outline" className="text-[10px] px-1">{SCOPE_LABELS[tag.scope]}</Badge>
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Seed Import Dialog */}
|
||||||
|
<Dialog open={showSeedDialog} onOpenChange={setShowSeedDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Importă date inițiale ManicTime</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowSeedDialog(false)}>
|
||||||
|
Închide
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSeedImport} disabled={seedImporting}>
|
||||||
|
{seedImporting ? "Se importă..." : "Importă"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tag Row with inline editing ──
|
||||||
|
|
||||||
|
interface TagRowProps {
|
||||||
|
tag: Tag;
|
||||||
|
children?: Tag[];
|
||||||
|
editingTag: Tag | null;
|
||||||
|
editLabel: string;
|
||||||
|
editColor: string;
|
||||||
|
editProjectCode: string;
|
||||||
|
editScope: TagScope;
|
||||||
|
editCompanyId: CompanyId;
|
||||||
|
onStartEdit: (tag: Tag) => void;
|
||||||
|
onSaveEdit: () => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
setEditLabel: (v: string) => void;
|
||||||
|
setEditColor: (v: string) => void;
|
||||||
|
setEditProjectCode: (v: string) => void;
|
||||||
|
setEditScope: (v: TagScope) => void;
|
||||||
|
setEditCompanyId: (v: CompanyId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagRow({
|
||||||
|
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);
|
||||||
|
const hasChildren = children && children.length > 0;
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-2">
|
||||||
|
{tag.category === "project" && (
|
||||||
|
<Input
|
||||||
|
value={editProjectCode}
|
||||||
|
onChange={(e) => setEditProjectCode(e.target.value)}
|
||||||
|
className="w-[100px] font-mono text-xs"
|
||||||
|
placeholder="B-001"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
value={editLabel}
|
||||||
|
onChange={(e) => setEditLabel(e.target.value)}
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
<SelectContent>
|
||||||
|
{(Object.keys(COMPANY_LABELS) as CompanyId[]).map((c) => (
|
||||||
|
<SelectItem key={c} value={c}>
|
||||||
|
{COMPANY_LABELS[c]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{TAG_COLORS.slice(0, 6).map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
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",
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="group flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/30">
|
||||||
|
{hasChildren && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => deleteTag(tag.id)}
|
onClick={() => setShowChildren(!showChildren)}
|
||||||
className="ml-0.5 rounded-full p-0.5 opacity-0 transition-opacity hover:bg-destructive/10 group-hover:opacity-100"
|
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 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tag.projectCode && (
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
{tag.projectCode}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex-1 text-sm">{tag.label}</span>
|
||||||
|
{tag.companyId && (
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1.5">
|
||||||
|
{COMPANY_LABELS[tag.companyId]}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1">
|
||||||
|
{SCOPE_LABELS[tag.scope]}
|
||||||
|
</Badge>
|
||||||
|
<div className="flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStartEdit(tag)}
|
||||||
|
className="rounded p-1 hover:bg-muted"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(tag.id)}
|
||||||
|
className="rounded p-1 hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3 text-destructive" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
<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 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{child.projectCode && (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3 text-destructive" />
|
<Trash2 className="h-3 w-3 text-destructive" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { tagManagerConfig } from './config';
|
export { tagManagerConfig } from './config';
|
||||||
export { TagManagerModule } from './components/tag-manager-module';
|
export { TagManagerModule } from './components/tag-manager-module';
|
||||||
export type { Tag, TagCategory, TagScope } from './types';
|
export type { Tag, TagCategory, TagScope } from './types';
|
||||||
|
export { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from './types';
|
||||||
|
|||||||
283
src/modules/tag-manager/services/seed-data.ts
Normal file
283
src/modules/tag-manager/services/seed-data.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import type { Tag, TagCategory } from "@/core/tagging/types";
|
||||||
|
import type { CompanyId } from "@/core/auth/types";
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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;
|
||||||
|
return { code: `${codePrefix}-${padded}`, label };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getManicTimeSeedTags(): SeedTag[] {
|
||||||
|
const tags: 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",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const line of beletageProjects) {
|
||||||
|
const parsed = parseProjectLine(line, "B");
|
||||||
|
if (parsed) {
|
||||||
|
tags.push({
|
||||||
|
label: parsed.label,
|
||||||
|
category: "project",
|
||||||
|
scope: "company",
|
||||||
|
companyId: "beletage" as CompanyId,
|
||||||
|
projectCode: parsed.code,
|
||||||
|
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",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const phase of phases) {
|
||||||
|
tags.push({
|
||||||
|
label: phase,
|
||||||
|
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",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const activity of activities) {
|
||||||
|
tags.push({
|
||||||
|
label: activity,
|
||||||
|
category: "activity",
|
||||||
|
scope: "global",
|
||||||
|
color: "#8b5cf6",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Document type tags ──
|
||||||
|
const docTypes = [
|
||||||
|
"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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export type { Tag, TagCategory, TagScope } from '@/core/tagging/types';
|
export type { Tag, TagCategory, TagScope } from '@/core/tagging/types';
|
||||||
|
export { TAG_CATEGORY_ORDER, TAG_CATEGORY_LABELS } from '@/core/tagging/types';
|
||||||
|
|||||||
@@ -1,74 +1,196 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useRef, useState } from "react";
|
||||||
import { Plus, Pencil, Trash2, Search, FileText, ExternalLink } from 'lucide-react';
|
import {
|
||||||
import { Button } from '@/shared/components/ui/button';
|
Plus,
|
||||||
import { Input } from '@/shared/components/ui/input';
|
Pencil,
|
||||||
import { Label } from '@/shared/components/ui/label';
|
Trash2,
|
||||||
import { Textarea } from '@/shared/components/ui/textarea';
|
Search,
|
||||||
import { Badge } from '@/shared/components/ui/badge';
|
FileText,
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
|
ExternalLink,
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
|
Copy,
|
||||||
import type { CompanyId } from '@/core/auth/types';
|
FolderOpen,
|
||||||
import type { WordTemplate } from '../types';
|
Wand2,
|
||||||
import { useTemplates } from '../hooks/use-templates';
|
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 TEMPLATE_CATEGORIES = [
|
const CATEGORY_LABELS: Record<TemplateCategory, string> = {
|
||||||
'Contract', 'Memoriu tehnic', 'Ofertă', 'Factură', 'Raport', 'Deviz', 'Proces-verbal', '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() {
|
export function WordTemplatesModule() {
|
||||||
const { templates, allTemplates, allCategories, loading, filters, updateFilter, addTemplate, updateTemplate, removeTemplate } = useTemplates();
|
const {
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
templates,
|
||||||
const [editingTemplate, setEditingTemplate] = useState<WordTemplate | null>(null);
|
allTemplates,
|
||||||
|
loading,
|
||||||
|
filters,
|
||||||
|
updateFilter,
|
||||||
|
addTemplate,
|
||||||
|
updateTemplate,
|
||||||
|
cloneTemplate,
|
||||||
|
removeTemplate,
|
||||||
|
} = useTemplates();
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||||
|
const [editingTemplate, setEditingTemplate] = useState<WordTemplate | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleSubmit = async (data: Omit<WordTemplate, 'id' | 'createdAt'>) => {
|
const handleSubmit = async (
|
||||||
if (viewMode === 'edit' && editingTemplate) {
|
data: Omit<WordTemplate, "id" | "createdAt" | "updatedAt">,
|
||||||
|
) => {
|
||||||
|
if (viewMode === "edit" && editingTemplate) {
|
||||||
await updateTemplate(editingTemplate.id, data);
|
await updateTemplate(editingTemplate.id, data);
|
||||||
} else {
|
} else {
|
||||||
await addTemplate(data);
|
await addTemplate(data);
|
||||||
}
|
}
|
||||||
setViewMode('list');
|
setViewMode("list");
|
||||||
setEditingTemplate(null);
|
setEditingTemplate(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterCategories = allCategories.length > 0 ? allCategories : TEMPLATE_CATEGORIES;
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (deletingId) {
|
||||||
|
await removeTemplate(deletingId);
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Total șabloane</p><p className="text-2xl font-bold">{allTemplates.length}</p></CardContent></Card>
|
<Card>
|
||||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Categorii</p><p className="text-2xl font-bold">{allCategories.length}</p></CardContent></Card>
|
<CardContent className="p-4">
|
||||||
<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>
|
<p className="text-xs text-muted-foreground">Total șabloane</p>
|
||||||
<Card><CardContent className="p-4"><p className="text-xs text-muted-foreground">Urban Switch</p><p className="text-2xl font-bold">{allTemplates.filter((t) => t.company === 'urban-switch').length}</p></CardContent></Card>
|
<p className="text-2xl font-bold">{allTemplates.length}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Beletage</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{allTemplates.filter((t) => t.company === "beletage").length}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Urban Switch</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{allTemplates.filter((t) => t.company === "urban-switch").length}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Studii de Teren</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{
|
||||||
|
allTemplates.filter((t) => t.company === "studii-de-teren")
|
||||||
|
.length
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === 'list' && (
|
{viewMode === "list" && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="relative min-w-[200px] flex-1">
|
<div className="relative min-w-[200px] flex-1">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input placeholder="Caută șablon..." value={filters.search} onChange={(e) => updateFilter('search', e.target.value)} className="pl-9" />
|
<Input
|
||||||
|
placeholder="Caută șablon..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => updateFilter("search", e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select value={filters.category} onValueChange={(v) => updateFilter('category', v)}>
|
<Select
|
||||||
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
|
value={filters.category}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateFilter("category", v as TemplateCategory | "all")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[160px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Toate</SelectItem>
|
<SelectItem value="all">Toate categoriile</SelectItem>
|
||||||
{filterCategories.map((c) => (
|
{(Object.keys(CATEGORY_LABELS) as TemplateCategory[]).map(
|
||||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
(c) => (
|
||||||
))}
|
<SelectItem key={c} value={c}>
|
||||||
|
{CATEGORY_LABELS[c]}
|
||||||
|
</SelectItem>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button onClick={() => setViewMode('add')} className="shrink-0">
|
<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>
|
||||||
|
<SelectItem value="urban-switch">Urban Switch</SelectItem>
|
||||||
|
<SelectItem value="studii-de-teren">Studii de Teren</SelectItem>
|
||||||
|
<SelectItem value="group">Grup</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={() => setViewMode("add")} className="shrink-0">
|
||||||
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Se încarcă...
|
||||||
|
</p>
|
||||||
) : templates.length === 0 ? (
|
) : templates.length === 0 ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
Niciun șablon găsit. Adaugă primul șablon Word.
|
Niciun șablon găsit. Adaugă primul șablon Word.
|
||||||
@@ -79,10 +201,32 @@ export function WordTemplatesModule() {
|
|||||||
<Card key={tpl.id} className="group relative">
|
<Card key={tpl.id} className="group relative">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingTemplate(tpl); setViewMode('edit'); }}>
|
<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");
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeTemplate(tpl.id)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
onClick={() => setDeletingId(tpl.id)}
|
||||||
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,13 +236,42 @@ export function WordTemplatesModule() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium">{tpl.name}</p>
|
<p className="font-medium">{tpl.name}</p>
|
||||||
{tpl.description && <p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">{tpl.description}</p>}
|
{tpl.description && (
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{tpl.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
{tpl.category && <Badge variant="outline" className="text-[10px]">{tpl.category}</Badge>}
|
<Badge variant="outline" className="text-[10px]">
|
||||||
<Badge variant="secondary" className="text-[10px]">v{tpl.version}</Badge>
|
{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>
|
</div>
|
||||||
|
{/* Placeholders display */}
|
||||||
|
{(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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{tpl.fileUrl && (
|
{tpl.fileUrl && (
|
||||||
<a href={tpl.fileUrl} target="_blank" rel="noopener noreferrer" className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline">
|
<a
|
||||||
|
href={tpl.fileUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
<ExternalLink className="h-3 w-3" /> Deschide fișier
|
<ExternalLink className="h-3 w-3" /> Deschide fișier
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
@@ -112,46 +285,200 @@ export function WordTemplatesModule() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(viewMode === 'add' || viewMode === 'edit') && (
|
{(viewMode === "add" || viewMode === "edit") && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle>{viewMode === 'edit' ? 'Editare șablon' : 'Șablon nou'}</CardTitle></CardHeader>
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{viewMode === "edit" ? "Editare șablon" : "Șablon nou"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<TemplateForm initial={editingTemplate ?? undefined} onSubmit={handleSubmit} onCancel={() => { setViewMode('list'); setEditingTemplate(null); }} />
|
<TemplateForm
|
||||||
|
initial={editingTemplate ?? undefined}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={() => {
|
||||||
|
setViewMode("list");
|
||||||
|
setEditingTemplate(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<Dialog
|
||||||
|
open={deletingId !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setDeletingId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Confirmare ștergere</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm">
|
||||||
|
Ești sigur că vrei să ștergi acest șablon? Acțiunea este
|
||||||
|
ireversibilă.
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeletingId(null)}>
|
||||||
|
Anulează
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDeleteConfirm}>
|
||||||
|
Șterge
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TemplateForm({ initial, onSubmit, onCancel }: {
|
function TemplateForm({
|
||||||
|
initial,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
initial?: WordTemplate;
|
initial?: WordTemplate;
|
||||||
onSubmit: (data: Omit<WordTemplate, 'id' | 'createdAt'>) => void;
|
onSubmit: (
|
||||||
|
data: Omit<WordTemplate, "id" | "createdAt" | "updatedAt">,
|
||||||
|
) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState(initial?.name ?? '');
|
const [name, setName] = useState(initial?.name ?? "");
|
||||||
const [description, setDescription] = useState(initial?.description ?? '');
|
const [description, setDescription] = useState(initial?.description ?? "");
|
||||||
const [category, setCategory] = useState(initial?.category ?? 'Contract');
|
const [category, setCategory] = useState<TemplateCategory>(
|
||||||
const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? '');
|
initial?.category ?? "contract",
|
||||||
const [company, setCompany] = useState<CompanyId>(initial?.company ?? 'beletage');
|
);
|
||||||
const [version, setVersion] = useState(initial?.version ?? '1.0.0');
|
const [fileUrl, setFileUrl] = useState(initial?.fileUrl ?? "");
|
||||||
|
const [company, setCompany] = useState<CompanyId>(
|
||||||
|
initial?.company ?? "beletage",
|
||||||
|
);
|
||||||
|
const [version, setVersion] = useState(initial?.version ?? "1.0.0");
|
||||||
|
const [placeholdersText, setPlaceholdersText] = useState(
|
||||||
|
(initial?.placeholders ?? []).join(", "),
|
||||||
|
);
|
||||||
|
const [parsing, setParsing] = useState(false);
|
||||||
|
const [parseError, setParseError] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const applyPlaceholders = (found: string[]) => {
|
||||||
|
if (found.length === 0) {
|
||||||
|
setParseError(
|
||||||
|
"Nu s-au găsit placeholder-e de forma {{VARIABILA}} în fișier.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPlaceholdersText(found.join(", "));
|
||||||
|
setParseError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileDetect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setParsing(true);
|
||||||
|
setParseError(null);
|
||||||
|
try {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const found = await parsePlaceholdersFromBuffer(buffer);
|
||||||
|
applyPlaceholders(found);
|
||||||
|
} catch (err) {
|
||||||
|
setParseError(
|
||||||
|
`Eroare la parsare: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setParsing(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUrlDetect = async () => {
|
||||||
|
if (!fileUrl) return;
|
||||||
|
setParsing(true);
|
||||||
|
setParseError(null);
|
||||||
|
try {
|
||||||
|
const found = await parsePlaceholdersFromUrl(fileUrl);
|
||||||
|
applyPlaceholders(found);
|
||||||
|
} catch (err) {
|
||||||
|
setParseError(
|
||||||
|
`Nu s-a putut accesa URL-ul (CORS sau rețea): ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setParsing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ name, description, category, fileUrl, company, version, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4">
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const placeholders = placeholdersText
|
||||||
|
.split(",")
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter((p) => p.length > 0);
|
||||||
|
onSubmit({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
fileUrl,
|
||||||
|
company,
|
||||||
|
version,
|
||||||
|
placeholders,
|
||||||
|
clonedFrom: initial?.clonedFrom,
|
||||||
|
tags: initial?.tags ?? [],
|
||||||
|
visibility: initial?.visibility ?? "all",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div><Label>Nume șablon</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div>
|
<div>
|
||||||
<div><Label>Categorie</Label>
|
<Label>Nume șablon *</Label>
|
||||||
<Select value={category} onValueChange={setCategory}>
|
<Input
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
value={name}
|
||||||
<SelectContent>{TEMPLATE_CATEGORIES.map((c) => (<SelectItem key={c} value={c}>{c}</SelectItem>))}</SelectContent>
|
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>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div><Label>Descriere</Label><Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} className="mt-1" /></div>
|
<div>
|
||||||
|
<Label>Descriere</Label>
|
||||||
|
<Textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div><Label>Companie</Label>
|
<div>
|
||||||
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
|
<Label>Companie</Label>
|
||||||
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
<Select
|
||||||
|
value={company}
|
||||||
|
onValueChange={(v) => setCompany(v as CompanyId)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="beletage">Beletage</SelectItem>
|
<SelectItem value="beletage">Beletage</SelectItem>
|
||||||
<SelectItem value="urban-switch">Urban Switch</SelectItem>
|
<SelectItem value="urban-switch">Urban Switch</SelectItem>
|
||||||
@@ -160,12 +487,94 @@ function TemplateForm({ initial, onSubmit, onCancel }: {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div><Label>Versiune</Label><Input value={version} onChange={(e) => setVersion(e.target.value)} className="mt-1" /></div>
|
<div>
|
||||||
<div><Label>URL fișier</Label><Input value={fileUrl} onChange={(e) => setFileUrl(e.target.value)} className="mt-1" placeholder="https://..." /></div>
|
<Label>Versiune</Label>
|
||||||
|
<Input
|
||||||
|
value={version}
|
||||||
|
onChange={(e) => setVersion(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
|
<Button type="button" variant="outline" onClick={onCancel}>
|
||||||
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>
|
Anulează
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,20 +3,21 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useStorage } from '@/core/storage';
|
import { useStorage } from '@/core/storage';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import type { WordTemplate } from '../types';
|
import type { WordTemplate, TemplateCategory } from '../types';
|
||||||
|
|
||||||
const PREFIX = 'tpl:';
|
const PREFIX = 'tpl:';
|
||||||
|
|
||||||
export interface TemplateFilters {
|
export interface TemplateFilters {
|
||||||
search: string;
|
search: string;
|
||||||
category: string;
|
category: TemplateCategory | 'all';
|
||||||
|
company: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTemplates() {
|
export function useTemplates() {
|
||||||
const storage = useStorage('word-templates');
|
const storage = useStorage('word-templates');
|
||||||
const [templates, setTemplates] = useState<WordTemplate[]>([]);
|
const [templates, setTemplates] = useState<WordTemplate[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filters, setFilters] = useState<TemplateFilters>({ search: '', category: 'all' });
|
const [filters, setFilters] = useState<TemplateFilters>({ search: '', category: 'all', company: 'all' });
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -36,8 +37,9 @@ export function useTemplates() {
|
|||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
useEffect(() => { refresh(); }, [refresh]);
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
const addTemplate = useCallback(async (data: Omit<WordTemplate, 'id' | 'createdAt'>) => {
|
const addTemplate = useCallback(async (data: Omit<WordTemplate, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||||
const template: WordTemplate = { ...data, id: uuid(), createdAt: new Date().toISOString() };
|
const now = new Date().toISOString();
|
||||||
|
const template: WordTemplate = { ...data, id: uuid(), createdAt: now, updatedAt: now };
|
||||||
await storage.set(`${PREFIX}${template.id}`, template);
|
await storage.set(`${PREFIX}${template.id}`, template);
|
||||||
await refresh();
|
await refresh();
|
||||||
return template;
|
return template;
|
||||||
@@ -46,11 +48,32 @@ export function useTemplates() {
|
|||||||
const updateTemplate = useCallback(async (id: string, updates: Partial<WordTemplate>) => {
|
const updateTemplate = useCallback(async (id: string, updates: Partial<WordTemplate>) => {
|
||||||
const existing = templates.find((t) => t.id === id);
|
const existing = templates.find((t) => t.id === id);
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt };
|
const updated: WordTemplate = {
|
||||||
|
...existing, ...updates,
|
||||||
|
id: existing.id, createdAt: existing.createdAt,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
await storage.set(`${PREFIX}${id}`, updated);
|
await storage.set(`${PREFIX}${id}`, updated);
|
||||||
await refresh();
|
await refresh();
|
||||||
}, [storage, refresh, templates]);
|
}, [storage, refresh, templates]);
|
||||||
|
|
||||||
|
const cloneTemplate = useCallback(async (id: string) => {
|
||||||
|
const existing = templates.find((t) => t.id === id);
|
||||||
|
if (!existing) return;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const cloned: WordTemplate = {
|
||||||
|
...existing,
|
||||||
|
id: uuid(),
|
||||||
|
name: `${existing.name} (copie)`,
|
||||||
|
clonedFrom: existing.id,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
await storage.set(`${PREFIX}${cloned.id}`, cloned);
|
||||||
|
await refresh();
|
||||||
|
return cloned;
|
||||||
|
}, [storage, refresh, templates]);
|
||||||
|
|
||||||
const removeTemplate = useCallback(async (id: string) => {
|
const removeTemplate = useCallback(async (id: string) => {
|
||||||
await storage.delete(`${PREFIX}${id}`);
|
await storage.delete(`${PREFIX}${id}`);
|
||||||
await refresh();
|
await refresh();
|
||||||
@@ -60,10 +83,9 @@ export function useTemplates() {
|
|||||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const allCategories = [...new Set(templates.map((t) => t.category).filter(Boolean))];
|
|
||||||
|
|
||||||
const filteredTemplates = templates.filter((t) => {
|
const filteredTemplates = templates.filter((t) => {
|
||||||
if (filters.category !== 'all' && t.category !== filters.category) return false;
|
if (filters.category !== 'all' && t.category !== filters.category) return false;
|
||||||
|
if (filters.company !== 'all' && t.company !== filters.company) return false;
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
const q = filters.search.toLowerCase();
|
const q = filters.search.toLowerCase();
|
||||||
return t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q);
|
return t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q);
|
||||||
@@ -71,5 +93,5 @@ export function useTemplates() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { templates: filteredTemplates, allTemplates: templates, allCategories, loading, filters, updateFilter, addTemplate, updateTemplate, removeTemplate, refresh };
|
return { templates: filteredTemplates, allTemplates: templates, loading, filters, updateFilter, addTemplate, updateTemplate, cloneTemplate, removeTemplate, refresh };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export { wordTemplatesConfig } from './config';
|
export { wordTemplatesConfig } from './config';
|
||||||
export { WordTemplatesModule } from './components/word-templates-module';
|
export { WordTemplatesModule } from './components/word-templates-module';
|
||||||
export type { WordTemplate } from './types';
|
export type { WordTemplate, TemplateCategory } from './types';
|
||||||
|
|||||||
53
src/modules/word-templates/services/placeholder-parser.ts
Normal file
53
src/modules/word-templates/services/placeholder-parser.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,15 +1,30 @@
|
|||||||
import type { Visibility } from '@/core/module-registry/types';
|
import type { Visibility } from '@/core/module-registry/types';
|
||||||
import type { CompanyId } from '@/core/auth/types';
|
import type { CompanyId } from '@/core/auth/types';
|
||||||
|
|
||||||
|
export type TemplateCategory =
|
||||||
|
| 'contract'
|
||||||
|
| 'memoriu'
|
||||||
|
| 'oferta'
|
||||||
|
| 'raport'
|
||||||
|
| 'cerere'
|
||||||
|
| 'aviz'
|
||||||
|
| 'scrisoare'
|
||||||
|
| 'altele';
|
||||||
|
|
||||||
export interface WordTemplate {
|
export interface WordTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
category: string;
|
category: TemplateCategory;
|
||||||
fileUrl: string;
|
fileUrl: string;
|
||||||
company: CompanyId;
|
company: CompanyId;
|
||||||
|
/** Detected placeholders in template */
|
||||||
|
placeholders: string[];
|
||||||
|
/** Cloned from template ID */
|
||||||
|
clonedFrom?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
version: string;
|
version: string;
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useXmlConfig } from '../hooks/use-xml-config';
|
import { useXmlConfig } from "../hooks/use-xml-config";
|
||||||
import { XmlSettings } from './xml-settings';
|
import { XmlSettings } from "./xml-settings";
|
||||||
import { CategoryManager } from './category-manager';
|
import { CategoryManager } from "./category-manager";
|
||||||
import { XmlPreview } from './xml-preview';
|
import { XmlPreview } from "./xml-preview";
|
||||||
import { Separator } from '@/shared/components/ui/separator';
|
import { Separator } from "@/shared/components/ui/separator";
|
||||||
import { Button } from '@/shared/components/ui/button';
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { RotateCcw } from 'lucide-react';
|
import { RotateCcw } from "lucide-react";
|
||||||
|
|
||||||
export function WordXmlModule() {
|
export function WordXmlModule() {
|
||||||
const {
|
const {
|
||||||
config, setMode, setBaseNamespace, setComputeMetrics,
|
config,
|
||||||
setCurrentCategory, updateCategoryFields, addCategory,
|
setMode,
|
||||||
removeCategory, resetCategoryToPreset, clearCategoryFields, resetAll,
|
setBaseNamespace,
|
||||||
|
setCurrentCategory,
|
||||||
|
updateCategoryFields,
|
||||||
|
addCategory,
|
||||||
|
removeCategory,
|
||||||
|
resetCategoryToPreset,
|
||||||
|
clearCategoryFields,
|
||||||
|
resetAll,
|
||||||
} = useXmlConfig();
|
} = useXmlConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -21,10 +28,8 @@ export function WordXmlModule() {
|
|||||||
<XmlSettings
|
<XmlSettings
|
||||||
baseNamespace={config.baseNamespace}
|
baseNamespace={config.baseNamespace}
|
||||||
mode={config.mode}
|
mode={config.mode}
|
||||||
computeMetrics={config.computeMetrics}
|
|
||||||
onSetBaseNamespace={setBaseNamespace}
|
onSetBaseNamespace={setBaseNamespace}
|
||||||
onSetMode={setMode}
|
onSetMode={setMode}
|
||||||
onSetComputeMetrics={setComputeMetrics}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|||||||
@@ -1,28 +1,37 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from "react";
|
||||||
import { Copy, Download, FileArchive } from 'lucide-react';
|
import { Copy, Download, FileArchive } from "lucide-react";
|
||||||
import { Button } from '@/shared/components/ui/button';
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import type { XmlGeneratorConfig } from '../types';
|
import type { XmlGeneratorConfig } from "../types";
|
||||||
import { generateAllCategories, downloadXmlFile, downloadZipAll } from '../services/xml-generator';
|
import {
|
||||||
|
generateAllCategories,
|
||||||
|
downloadXmlFile,
|
||||||
|
downloadZipAll,
|
||||||
|
} from "../services/xml-generator";
|
||||||
|
|
||||||
interface XmlPreviewProps {
|
interface XmlPreviewProps {
|
||||||
config: XmlGeneratorConfig;
|
config: XmlGeneratorConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function XmlPreview({ config }: XmlPreviewProps) {
|
export function XmlPreview({ config }: XmlPreviewProps) {
|
||||||
const [copied, setCopied] = useState<'xml' | 'xpath' | null>(null);
|
const [copied, setCopied] = useState<"xml" | "xpath" | null>(null);
|
||||||
|
|
||||||
const allOutputs = useMemo(
|
const allOutputs = useMemo(
|
||||||
() => generateAllCategories(config.categories, config.baseNamespace, config.mode, config.computeMetrics),
|
() =>
|
||||||
[config.categories, config.baseNamespace, config.mode, config.computeMetrics],
|
generateAllCategories(
|
||||||
|
config.categories,
|
||||||
|
config.baseNamespace,
|
||||||
|
config.mode,
|
||||||
|
),
|
||||||
|
[config.categories, config.baseNamespace, config.mode],
|
||||||
);
|
);
|
||||||
|
|
||||||
const current = allOutputs[config.currentCategory];
|
const current = allOutputs[config.currentCategory];
|
||||||
const xml = current?.xml || '';
|
const xml = current?.xml || "";
|
||||||
const xpaths = current?.xpaths || '';
|
const xpaths = current?.xpaths || "";
|
||||||
|
|
||||||
const handleCopy = async (text: string, type: 'xml' | 'xpath') => {
|
const handleCopy = async (text: string, type: "xml" | "xpath") => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
setCopied(type);
|
setCopied(type);
|
||||||
@@ -32,9 +41,9 @@ export function XmlPreview({ config }: XmlPreviewProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const safeCatName = (config.currentCategory || 'unknown')
|
const safeCatName = (config.currentCategory || "unknown")
|
||||||
.replace(/\s+/g, '_')
|
.replace(/\s+/g, "_")
|
||||||
.replace(/[^A-Za-z0-9_.-]/g, '');
|
.replace(/[^A-Za-z0-9_.-]/g, "");
|
||||||
|
|
||||||
const handleDownloadCurrent = () => {
|
const handleDownloadCurrent = () => {
|
||||||
if (!xml) return;
|
if (!xml) return;
|
||||||
@@ -42,7 +51,7 @@ export function XmlPreview({ config }: XmlPreviewProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadZip = async () => {
|
const handleDownloadZip = async () => {
|
||||||
await downloadZipAll(config.categories, config.baseNamespace, config.mode, config.computeMetrics);
|
await downloadZipAll(config.categories, config.baseNamespace, config.mode);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,7 +59,12 @@ export function XmlPreview({ config }: XmlPreviewProps) {
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h2 className="text-lg font-semibold">Preview & Export</h2>
|
<h2 className="text-lg font-semibold">Preview & Export</h2>
|
||||||
<div className="ml-auto flex gap-2">
|
<div className="ml-auto flex gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={handleDownloadCurrent} disabled={!xml}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDownloadCurrent}
|
||||||
|
disabled={!xml}
|
||||||
|
>
|
||||||
<Download className="mr-1 h-4 w-4" /> XML curent
|
<Download className="mr-1 h-4 w-4" /> XML curent
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={handleDownloadZip}>
|
<Button size="sm" onClick={handleDownloadZip}>
|
||||||
@@ -63,28 +77,45 @@ export function XmlPreview({ config }: XmlPreviewProps) {
|
|||||||
{/* XML preview */}
|
{/* XML preview */}
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1.5 flex items-center justify-between">
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
<span className="text-xs font-medium text-muted-foreground">XML — {config.currentCategory}</span>
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => handleCopy(xml, 'xml')} disabled={!xml}>
|
XML — {config.currentCategory}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-xs"
|
||||||
|
onClick={() => handleCopy(xml, "xml")}
|
||||||
|
disabled={!xml}
|
||||||
|
>
|
||||||
<Copy className="mr-1 h-3 w-3" />
|
<Copy className="mr-1 h-3 w-3" />
|
||||||
{copied === 'xml' ? 'Copiat!' : 'Copiază'}
|
{copied === "xml" ? "Copiat!" : "Copiază"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<pre className="max-h-80 overflow-auto rounded-lg border bg-muted/30 p-3 text-xs">
|
<pre className="max-h-80 overflow-auto rounded-lg border bg-muted/30 p-3 text-xs">
|
||||||
{xml || '<!-- Niciun XML generat. Adaugă câmpuri în categoria curentă. -->'}
|
{xml ||
|
||||||
|
"<!-- Niciun XML generat. Adaugă câmpuri în categoria curentă. -->"}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* XPath preview */}
|
{/* XPath preview */}
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1.5 flex items-center justify-between">
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
<span className="text-xs font-medium text-muted-foreground">XPaths — {config.currentCategory}</span>
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => handleCopy(xpaths, 'xpath')} disabled={!xpaths}>
|
XPaths — {config.currentCategory}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-xs"
|
||||||
|
onClick={() => handleCopy(xpaths, "xpath")}
|
||||||
|
disabled={!xpaths}
|
||||||
|
>
|
||||||
<Copy className="mr-1 h-3 w-3" />
|
<Copy className="mr-1 h-3 w-3" />
|
||||||
{copied === 'xpath' ? 'Copiat!' : 'Copiază'}
|
{copied === "xpath" ? "Copiat!" : "Copiază"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<pre className="max-h-80 overflow-auto rounded-lg border bg-muted/30 p-3 text-xs">
|
<pre className="max-h-80 overflow-auto rounded-lg border bg-muted/30 p-3 text-xs">
|
||||||
{xpaths || ''}
|
{xpaths || ""}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import type { XmlGeneratorMode } from '../types';
|
import type { XmlGeneratorMode } from "../types";
|
||||||
import { Input } from '@/shared/components/ui/input';
|
import { Input } from "@/shared/components/ui/input";
|
||||||
import { Label } from '@/shared/components/ui/label';
|
import { Label } from "@/shared/components/ui/label";
|
||||||
import { Switch } from '@/shared/components/ui/switch';
|
import { cn } from "@/shared/lib/utils";
|
||||||
import { cn } from '@/shared/lib/utils';
|
|
||||||
|
|
||||||
interface XmlSettingsProps {
|
interface XmlSettingsProps {
|
||||||
baseNamespace: string;
|
baseNamespace: string;
|
||||||
mode: XmlGeneratorMode;
|
mode: XmlGeneratorMode;
|
||||||
computeMetrics: boolean;
|
|
||||||
onSetBaseNamespace: (ns: string) => void;
|
onSetBaseNamespace: (ns: string) => void;
|
||||||
onSetMode: (mode: XmlGeneratorMode) => void;
|
onSetMode: (mode: XmlGeneratorMode) => void;
|
||||||
onSetComputeMetrics: (v: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function XmlSettings({
|
export function XmlSettings({
|
||||||
baseNamespace, mode, computeMetrics,
|
baseNamespace,
|
||||||
onSetBaseNamespace, onSetMode, onSetComputeMetrics,
|
mode,
|
||||||
|
onSetBaseNamespace,
|
||||||
|
onSetMode,
|
||||||
}: XmlSettingsProps) {
|
}: XmlSettingsProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -38,31 +37,28 @@ export function XmlSettings({
|
|||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Mod generare</Label>
|
<Label className="mb-1.5 block">Mod generare</Label>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
{(['simple', 'advanced'] as XmlGeneratorMode[]).map((m) => (
|
{(["simple", "advanced"] as XmlGeneratorMode[]).map((m) => (
|
||||||
<button
|
<button
|
||||||
key={m}
|
key={m}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSetMode(m)}
|
onClick={() => onSetMode(m)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-full border px-3 py-1 text-xs font-medium transition-colors',
|
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||||
mode === m
|
mode === m
|
||||||
? 'border-primary bg-primary text-primary-foreground'
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
: 'border-border hover:bg-accent'
|
: "border-border hover:bg-accent",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{m === 'simple' ? 'Simple' : 'Advanced'}
|
{m === "simple" ? "Simple" : "Advanced"}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
{mode === 'simple' ? 'Doar câmpurile definite.' : '+ Short / Upper / Lower / Initials / First.'}
|
{mode === "simple"
|
||||||
|
? "Doar câmpurile definite."
|
||||||
|
: "+ Short / Upper / Lower / Initials / First."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
|
||||||
<Switch checked={computeMetrics} onCheckedChange={onSetComputeMetrics} id="xml-metrics" />
|
|
||||||
<Label htmlFor="xml-metrics" className="cursor-pointer text-sm">POT / CUT automat</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo } from "react";
|
||||||
import type { XmlGeneratorConfig, XmlGeneratorMode } from '../types';
|
import type { XmlGeneratorConfig, XmlGeneratorMode } from "../types";
|
||||||
import { DEFAULT_PRESETS } from '../services/category-presets';
|
import { DEFAULT_PRESETS } from "../services/category-presets";
|
||||||
|
|
||||||
function createDefaultConfig(): XmlGeneratorConfig {
|
function createDefaultConfig(): XmlGeneratorConfig {
|
||||||
const categories: Record<string, { name: string; fieldsText: string }> = {};
|
const categories: Record<string, { name: string; fieldsText: string }> = {};
|
||||||
for (const [name, fields] of Object.entries(DEFAULT_PRESETS)) {
|
for (const [name, fields] of Object.entries(DEFAULT_PRESETS)) {
|
||||||
categories[name] = { name, fieldsText: fields.join('\n') };
|
categories[name] = { name, fieldsText: fields.join("\n") };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
baseNamespace: 'http://schemas.beletage.ro/contract',
|
baseNamespace: "http://schemas.beletage.ro/contract",
|
||||||
mode: 'advanced',
|
mode: "advanced",
|
||||||
computeMetrics: true,
|
|
||||||
categories,
|
categories,
|
||||||
currentCategory: 'Beneficiar',
|
currentCategory: "Beneficiar",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,15 +28,12 @@ export function useXmlConfig() {
|
|||||||
setConfig((prev) => ({ ...prev, baseNamespace }));
|
setConfig((prev) => ({ ...prev, baseNamespace }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setComputeMetrics = useCallback((computeMetrics: boolean) => {
|
|
||||||
setConfig((prev) => ({ ...prev, computeMetrics }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setCurrentCategory = useCallback((name: string) => {
|
const setCurrentCategory = useCallback((name: string) => {
|
||||||
setConfig((prev) => ({ ...prev, currentCategory: name }));
|
setConfig((prev) => ({ ...prev, currentCategory: name }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateCategoryFields = useCallback((categoryName: string, fieldsText: string) => {
|
const updateCategoryFields = useCallback(
|
||||||
|
(categoryName: string, fieldsText: string) => {
|
||||||
setConfig((prev) => {
|
setConfig((prev) => {
|
||||||
const existing = prev.categories[categoryName];
|
const existing = prev.categories[categoryName];
|
||||||
if (!existing) return prev;
|
if (!existing) return prev;
|
||||||
@@ -49,14 +45,16 @@ export function useXmlConfig() {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, []);
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const addCategory = useCallback((name: string) => {
|
const addCategory = useCallback((name: string) => {
|
||||||
setConfig((prev) => {
|
setConfig((prev) => {
|
||||||
if (prev.categories[name]) return prev;
|
if (prev.categories[name]) return prev;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
categories: { ...prev.categories, [name]: { name, fieldsText: '' } },
|
categories: { ...prev.categories, [name]: { name, fieldsText: "" } },
|
||||||
currentCategory: name,
|
currentCategory: name,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -70,7 +68,9 @@ export function useXmlConfig() {
|
|||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
categories: next,
|
categories: next,
|
||||||
currentCategory: keys.includes(prev.currentCategory) ? prev.currentCategory : keys[0] || '',
|
currentCategory: keys.includes(prev.currentCategory)
|
||||||
|
? prev.currentCategory
|
||||||
|
: keys[0] || "",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -82,7 +82,7 @@ export function useXmlConfig() {
|
|||||||
...prev,
|
...prev,
|
||||||
categories: {
|
categories: {
|
||||||
...prev.categories,
|
...prev.categories,
|
||||||
[name]: { name, fieldsText: preset.join('\n') },
|
[name]: { name, fieldsText: preset.join("\n") },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -95,7 +95,7 @@ export function useXmlConfig() {
|
|||||||
...prev,
|
...prev,
|
||||||
categories: {
|
categories: {
|
||||||
...prev.categories,
|
...prev.categories,
|
||||||
[name]: { name: existing.name, fieldsText: '' },
|
[name]: { name: existing.name, fieldsText: "" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -109,11 +109,11 @@ export function useXmlConfig() {
|
|||||||
setConfig(loaded);
|
setConfig(loaded);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return useMemo(() => ({
|
return useMemo(
|
||||||
|
() => ({
|
||||||
config,
|
config,
|
||||||
setMode,
|
setMode,
|
||||||
setBaseNamespace,
|
setBaseNamespace,
|
||||||
setComputeMetrics,
|
|
||||||
setCurrentCategory,
|
setCurrentCategory,
|
||||||
updateCategoryFields,
|
updateCategoryFields,
|
||||||
addCategory,
|
addCategory,
|
||||||
@@ -122,7 +122,19 @@ export function useXmlConfig() {
|
|||||||
clearCategoryFields,
|
clearCategoryFields,
|
||||||
resetAll,
|
resetAll,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
}), [config, setMode, setBaseNamespace, setComputeMetrics, setCurrentCategory,
|
}),
|
||||||
updateCategoryFields, addCategory, removeCategory, resetCategoryToPreset,
|
[
|
||||||
clearCategoryFields, resetAll, loadConfig]);
|
config,
|
||||||
|
setMode,
|
||||||
|
setBaseNamespace,
|
||||||
|
setCurrentCategory,
|
||||||
|
updateCategoryFields,
|
||||||
|
addCategory,
|
||||||
|
removeCategory,
|
||||||
|
resetCategoryToPreset,
|
||||||
|
clearCategoryFields,
|
||||||
|
resetAll,
|
||||||
|
loadConfig,
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import type { XmlGeneratorMode, XmlCategory, GeneratedOutput } from '../types';
|
import type { XmlGeneratorMode, XmlCategory, GeneratedOutput } from "../types";
|
||||||
|
|
||||||
function sanitizeName(name: string): string | null {
|
function sanitizeName(name: string): string | null {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
let n = trimmed.replace(/\s+/g, '_').replace(/[^A-Za-z0-9_.-]/g, '');
|
let n = trimmed.replace(/\s+/g, "_").replace(/[^A-Za-z0-9_.-]/g, "");
|
||||||
if (!/^[A-Za-z_]/.test(n)) n = '_' + n;
|
if (!/^[A-Za-z_]/.test(n)) n = "_" + n;
|
||||||
return n || null;
|
return n || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCategoryNamespace(baseNs: string, category: string): string {
|
function getCategoryNamespace(baseNs: string, category: string): string {
|
||||||
const safeCat = sanitizeName(category) || category;
|
const safeCat = sanitizeName(category) || category;
|
||||||
return baseNs.replace(/\/+$/, '') + '/' + safeCat;
|
return baseNs.replace(/\/+$/, "") + "/" + safeCat;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCategoryRoot(category: string): string {
|
function getCategoryRoot(category: string): string {
|
||||||
const safeCat = sanitizeName(category) || category;
|
const safeCat = sanitizeName(category) || category;
|
||||||
return safeCat + 'Data';
|
return safeCat + "Data";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FieldEntry {
|
interface FieldEntry {
|
||||||
@@ -29,14 +29,13 @@ export function generateCategoryXml(
|
|||||||
catData: XmlCategory,
|
catData: XmlCategory,
|
||||||
baseNamespace: string,
|
baseNamespace: string,
|
||||||
mode: XmlGeneratorMode,
|
mode: XmlGeneratorMode,
|
||||||
computeMetrics: boolean,
|
|
||||||
): GeneratedOutput {
|
): GeneratedOutput {
|
||||||
const raw = catData.fieldsText
|
const raw = catData.fieldsText
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map((l) => l.trim())
|
.map((l) => l.trim())
|
||||||
.filter((l) => l.length > 0);
|
.filter((l) => l.length > 0);
|
||||||
|
|
||||||
if (raw.length === 0) return { xml: '', xpaths: '' };
|
if (raw.length === 0) return { xml: "", xpaths: "" };
|
||||||
|
|
||||||
const ns = getCategoryNamespace(baseNamespace, category);
|
const ns = getCategoryNamespace(baseNamespace, category);
|
||||||
const root = getCategoryRoot(category);
|
const root = getCategoryRoot(category);
|
||||||
@@ -51,19 +50,19 @@ export function generateCategoryXml(
|
|||||||
let baseName = base;
|
let baseName = base;
|
||||||
let idx = 2;
|
let idx = 2;
|
||||||
while (usedNames.has(baseName)) {
|
while (usedNames.has(baseName)) {
|
||||||
baseName = base + '_' + idx;
|
baseName = base + "_" + idx;
|
||||||
idx++;
|
idx++;
|
||||||
}
|
}
|
||||||
usedNames.add(baseName);
|
usedNames.add(baseName);
|
||||||
|
|
||||||
const variants = [baseName];
|
const variants = [baseName];
|
||||||
if (mode === 'advanced') {
|
if (mode === "advanced") {
|
||||||
const suffixes = ['Short', 'Upper', 'Lower', 'Initials', 'First'];
|
const suffixes = ["Short", "Upper", "Lower", "Initials", "First"];
|
||||||
for (const suffix of suffixes) {
|
for (const suffix of suffixes) {
|
||||||
let vn = baseName + suffix;
|
let vn = baseName + suffix;
|
||||||
let k = 2;
|
let k = 2;
|
||||||
while (usedNames.has(vn)) {
|
while (usedNames.has(vn)) {
|
||||||
vn = baseName + suffix + '_' + k;
|
vn = baseName + suffix + "_" + k;
|
||||||
k++;
|
k++;
|
||||||
}
|
}
|
||||||
usedNames.add(vn);
|
usedNames.add(vn);
|
||||||
@@ -74,24 +73,7 @@ export function generateCategoryXml(
|
|||||||
fields.push({ label, baseName, variants });
|
fields.push({ label, baseName, variants });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-add POT/CUT for Suprafete category
|
const allFields = fields;
|
||||||
const extraMetricFields: FieldEntry[] = [];
|
|
||||||
if (computeMetrics && category.toLowerCase().includes('suprafete')) {
|
|
||||||
const hasTeren = fields.some((f) => f.baseName.toLowerCase().includes('suprafatateren'));
|
|
||||||
const hasLaSol = fields.some((f) => f.baseName.toLowerCase().includes('suprafataconstruitalasol'));
|
|
||||||
const hasDesf = fields.some((f) => f.baseName.toLowerCase().includes('suprafatadesfasurata'));
|
|
||||||
|
|
||||||
if (hasTeren && hasLaSol && !usedNames.has('POT')) {
|
|
||||||
usedNames.add('POT');
|
|
||||||
extraMetricFields.push({ label: 'Procent Ocupare Teren', baseName: 'POT', variants: ['POT'] });
|
|
||||||
}
|
|
||||||
if (hasTeren && hasDesf && !usedNames.has('CUT')) {
|
|
||||||
usedNames.add('CUT');
|
|
||||||
extraMetricFields.push({ label: 'Coeficient Utilizare Teren', baseName: 'CUT', variants: ['CUT'] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const allFields = fields.concat(extraMetricFields);
|
|
||||||
|
|
||||||
// Build XML
|
// Build XML
|
||||||
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
||||||
@@ -110,18 +92,8 @@ export function generateCategoryXml(
|
|||||||
for (const v of f.variants) {
|
for (const v of f.variants) {
|
||||||
xp += `/${root}/${v}\n`;
|
xp += `/${root}/${v}\n`;
|
||||||
}
|
}
|
||||||
xp += '\n';
|
xp += "\n";
|
||||||
}
|
}
|
||||||
if (extraMetricFields.length > 0) {
|
|
||||||
xp += '# Metrici auto (POT / CUT)\n';
|
|
||||||
for (const f of extraMetricFields) {
|
|
||||||
for (const v of f.variants) {
|
|
||||||
xp += `/${root}/${v}\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
xp += '\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
return { xml, xpaths: xp };
|
return { xml, xpaths: xp };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,20 +101,19 @@ export function generateAllCategories(
|
|||||||
categories: Record<string, XmlCategory>,
|
categories: Record<string, XmlCategory>,
|
||||||
baseNamespace: string,
|
baseNamespace: string,
|
||||||
mode: XmlGeneratorMode,
|
mode: XmlGeneratorMode,
|
||||||
computeMetrics: boolean,
|
|
||||||
): Record<string, GeneratedOutput> {
|
): Record<string, GeneratedOutput> {
|
||||||
const results: Record<string, GeneratedOutput> = {};
|
const results: Record<string, GeneratedOutput> = {};
|
||||||
for (const cat of Object.keys(categories)) {
|
for (const cat of Object.keys(categories)) {
|
||||||
const catData = categories[cat];
|
const catData = categories[cat];
|
||||||
if (!catData) continue;
|
if (!catData) continue;
|
||||||
results[cat] = generateCategoryXml(cat, catData, baseNamespace, mode, computeMetrics);
|
results[cat] = generateCategoryXml(cat, catData, baseNamespace, mode);
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadXmlFile(xml: string, filename: string): void {
|
export function downloadXmlFile(xml: string, filename: string): void {
|
||||||
const blob = new Blob([xml], { type: 'application/xml' });
|
const blob = new Blob([xml], { type: "application/xml" });
|
||||||
const a = document.createElement('a');
|
const a = document.createElement("a");
|
||||||
a.href = URL.createObjectURL(blob);
|
a.href = URL.createObjectURL(blob);
|
||||||
a.download = filename;
|
a.download = filename;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
@@ -155,13 +126,12 @@ export async function downloadZipAll(
|
|||||||
categories: Record<string, XmlCategory>,
|
categories: Record<string, XmlCategory>,
|
||||||
baseNamespace: string,
|
baseNamespace: string,
|
||||||
mode: XmlGeneratorMode,
|
mode: XmlGeneratorMode,
|
||||||
computeMetrics: boolean,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const JSZip = (await import('jszip')).default;
|
const JSZip = (await import("jszip")).default;
|
||||||
const results = generateAllCategories(categories, baseNamespace, mode, computeMetrics);
|
const results = generateAllCategories(categories, baseNamespace, mode);
|
||||||
|
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
const folder = zip.folder('customXmlParts')!;
|
const folder = zip.folder("customXmlParts")!;
|
||||||
|
|
||||||
let hasAny = false;
|
let hasAny = false;
|
||||||
for (const cat of Object.keys(results)) {
|
for (const cat of Object.keys(results)) {
|
||||||
@@ -175,10 +145,10 @@ export async function downloadZipAll(
|
|||||||
|
|
||||||
if (!hasAny) return;
|
if (!hasAny) return;
|
||||||
|
|
||||||
const content = await zip.generateAsync({ type: 'blob' });
|
const content = await zip.generateAsync({ type: "blob" });
|
||||||
const a = document.createElement('a');
|
const a = document.createElement("a");
|
||||||
a.href = URL.createObjectURL(content);
|
a.href = URL.createObjectURL(content);
|
||||||
a.download = 'beletage_custom_xml_parts.zip';
|
a.download = "beletage_custom_xml_parts.zip";
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type XmlGeneratorMode = 'simple' | 'advanced';
|
export type XmlGeneratorMode = "simple" | "advanced";
|
||||||
|
|
||||||
export interface XmlCategory {
|
export interface XmlCategory {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -8,7 +8,6 @@ export interface XmlCategory {
|
|||||||
export interface XmlGeneratorConfig {
|
export interface XmlGeneratorConfig {
|
||||||
baseNamespace: string;
|
baseNamespace: string;
|
||||||
mode: XmlGeneratorMode;
|
mode: XmlGeneratorMode;
|
||||||
computeMetrics: boolean;
|
|
||||||
categories: Record<string, XmlCategory>;
|
categories: Record<string, XmlCategory>;
|
||||||
currentCategory: string;
|
currentCategory: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,50 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from "next/image";
|
||||||
import Link from 'next/link';
|
import Link from "next/link";
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from "next/navigation";
|
||||||
import { useTheme } from 'next-themes';
|
import { useMemo } from "react";
|
||||||
import { useMemo } from 'react';
|
import * as Icons from "lucide-react";
|
||||||
import * as Icons from 'lucide-react';
|
import { buildNavigation } from "@/config/navigation";
|
||||||
import { buildNavigation } from '@/config/navigation';
|
import { COMPANIES } from "@/config/companies";
|
||||||
import { COMPANIES } from '@/config/companies';
|
import { useFeatureFlag } from "@/core/feature-flags";
|
||||||
import { useFeatureFlag } from '@/core/feature-flags';
|
import { cn } from "@/shared/lib/utils";
|
||||||
import { cn } from '@/shared/lib/utils';
|
import { ScrollArea } from "@/shared/components/ui/scroll-area";
|
||||||
import { ScrollArea } from '@/shared/components/ui/scroll-area';
|
import { Separator } from "@/shared/components/ui/separator";
|
||||||
import { Separator } from '@/shared/components/ui/separator';
|
|
||||||
|
|
||||||
function DynamicIcon({ name, className }: { name: string; className?: string }) {
|
function DynamicIcon({
|
||||||
const pascalName = name.replace(/(^|-)([a-z])/g, (_, _p, c: string) => c.toUpperCase());
|
name,
|
||||||
const IconComponent = (Icons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[pascalName];
|
className,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const pascalName = name.replace(/(^|-)([a-z])/g, (_, _p, c: string) =>
|
||||||
|
c.toUpperCase(),
|
||||||
|
);
|
||||||
|
const IconComponent = (
|
||||||
|
Icons as unknown as Record<
|
||||||
|
string,
|
||||||
|
React.ComponentType<{ className?: string }>
|
||||||
|
>
|
||||||
|
)[pascalName];
|
||||||
if (!IconComponent) return <Icons.Circle className={className} />;
|
if (!IconComponent) return <Icons.Circle className={className} />;
|
||||||
return <IconComponent className={className} />;
|
return <IconComponent className={className} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavItem({ item, isActive }: { item: { id: string; label: string; icon: string; href: string; featureFlag: string }; isActive: boolean }) {
|
function NavItem({
|
||||||
|
item,
|
||||||
|
isActive,
|
||||||
|
}: {
|
||||||
|
item: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
href: string;
|
||||||
|
featureFlag: string;
|
||||||
|
};
|
||||||
|
isActive: boolean;
|
||||||
|
}) {
|
||||||
const enabled = useFeatureFlag(item.featureFlag);
|
const enabled = useFeatureFlag(item.featureFlag);
|
||||||
if (!enabled) return null;
|
if (!enabled) return null;
|
||||||
|
|
||||||
@@ -28,10 +52,10 @@ function NavItem({ item, isActive }: { item: { id: string; label: string; icon:
|
|||||||
<Link
|
<Link
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors',
|
"flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground'
|
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DynamicIcon name={item.icon} className="h-4 w-4 shrink-0" />
|
<DynamicIcon name={item.icon} className="h-4 w-4 shrink-0" />
|
||||||
@@ -41,11 +65,9 @@ function NavItem({ item, isActive }: { item: { id: string; label: string; icon:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SidebarLogo() {
|
function SidebarLogo() {
|
||||||
const { resolvedTheme } = useTheme();
|
const sdt = COMPANIES["studii-de-teren"];
|
||||||
const sdt = COMPANIES['studii-de-teren'];
|
|
||||||
const logoSrc = sdt.logo
|
const logoSrc = sdt.logo?.light ?? null;
|
||||||
? (resolvedTheme === 'dark' ? sdt.logo.dark : sdt.logo.light)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!logoSrc) {
|
if (!logoSrc) {
|
||||||
return <Icons.LayoutDashboard className="h-5 w-5 text-primary" />;
|
return <Icons.LayoutDashboard className="h-5 w-5 text-primary" />;
|
||||||
@@ -58,6 +80,7 @@ function SidebarLogo() {
|
|||||||
width={28}
|
width={28}
|
||||||
height={28}
|
height={28}
|
||||||
className="h-7 w-7 shrink-0"
|
className="h-7 w-7 shrink-0"
|
||||||
|
suppressHydrationWarning
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -77,10 +100,10 @@ export function Sidebar() {
|
|||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className={cn(
|
className={cn(
|
||||||
'mb-1 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors',
|
"mb-1 flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
|
||||||
pathname === '/'
|
pathname === "/"
|
||||||
? 'bg-accent text-accent-foreground font-medium'
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground'
|
: "text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icons.Home className="h-4 w-4" />
|
<Icons.Home className="h-4 w-4" />
|
||||||
|
|||||||
Reference in New Issue
Block a user