feat: Registratura thread explorer, AC validity tracker, interactive I/O toggle + Password Vault rework
Registratura improvements: - Thread Explorer: new 'Fire conversatie' tab with timeline view, search, stats, gap tracking (la noi/la institutie), export to text report - Interactive I/O toggle: replaced direction dropdown with visual blue/orange button group (Intrat/Iesit with icons) - Doc type UX: alphabetical sort + immediate selection after adding custom type - AC Validity Tracker: full Autorizatie de Construire lifecycle workflow (12mo validity, execution phases, extension request, required docs checklist, monthly reminders, abandonment/expiry tracking) Password Vault rework (renamed to 'Parole Uzuale' v0.3.0): - New categories: WiFi, Portale Primarii, Avize Online, PIN Semnatura, Software, Hardware (replaced server/database/api) - Category icons (lucide-react) throughout list and form - WiFi QR code dialog with connection string copy - Context-aware form (PIN vs password label, hide email for WiFi/PIN, hide URL for WiFi, hide generator for PIN) - Dynamic stat cards showing top 3 categories by count - Removed encryption banner - Updated i18n, flags, config
This commit is contained in:
@@ -96,22 +96,22 @@ legacy/ # Original HTML tools for reference
|
|||||||
|
|
||||||
## Implemented Modules (14/14 — zero placeholders)
|
## Implemented Modules (14/14 — zero placeholders)
|
||||||
|
|
||||||
| # | Module | Route | Version | Key Features |
|
| # | Module | Route | Version | Key Features |
|
||||||
| --- | ---------------------- | --------------------- | ------- | --------------------------------------------------------------------------------------------------- |
|
| --- | ---------------------- | --------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 1 | **Dashboard** | `/` | 0.1.0 | KPI cards (6), activity feed (last 20), module grid, external tools |
|
| 1 | **Dashboard** | `/` | 0.1.0 | KPI cards (6), activity feed (last 20), module grid, external tools |
|
||||||
| 2 | **Email Signature** | `/email-signature` | 0.1.0 | Multi-company branding, address toggle (BTG/US/SDT), live preview, zoom/copy/download |
|
| 2 | **Email Signature** | `/email-signature` | 0.1.0 | Multi-company branding, address toggle (BTG/US/SDT), live preview, zoom/copy/download |
|
||||||
| 3 | **Word XML Generator** | `/word-xml` | 0.1.0 | Category-based XML gen, simple/advanced mode, ZIP export |
|
| 3 | **Word XML Generator** | `/word-xml` | 0.1.0 | Category-based XML gen, simple/advanced mode, ZIP export |
|
||||||
| 4 | **Registratura** | `/registratura` | 0.2.0 | CRUD registry, dynamic doc types, bidirectional Address Book, threads, backdating, **legal deadline tracking**, recipient registration, document expiry |
|
| 4 | **Registratura** | `/registratura` | 0.2.0 | CRUD registry, dynamic doc types, bidirectional Address Book, threads, backdating, **legal deadline tracking**, recipient registration, document expiry |
|
||||||
| 5 | **Tag Manager** | `/tag-manager` | 0.2.0 | CRUD tags, category/scope/color, US/SDT seeds, mandatory categories, **ManicTime bidirectional sync** |
|
| 5 | **Tag Manager** | `/tag-manager` | 0.2.0 | CRUD tags, category/scope/color, US/SDT seeds, mandatory categories, **ManicTime bidirectional sync** |
|
||||||
| 6 | **IT Inventory** | `/it-inventory` | 0.2.0 | Dynamic equipment types, rented status (purple pulse), **42U rack visualization**, type/status/company filters |
|
| 6 | **IT Inventory** | `/it-inventory` | 0.2.0 | Dynamic equipment types, rented status (purple pulse), **42U rack visualization**, type/status/company filters |
|
||||||
| 7 | **Address Book** | `/address-book` | 0.1.0 | CRUD contacts, card grid, vCard export, Registratura reverse lookup, **dynamic types (creatable)** |
|
| 7 | **Address Book** | `/address-book` | 0.1.0 | CRUD contacts, card grid, vCard export, Registratura reverse lookup, **dynamic types (creatable)** |
|
||||||
| 8 | **Password Vault** | `/password-vault` | 0.2.0 | CRUD credentials, email field, clickable URLs, strength meter, company scope, **AES-256-GCM encryption** |
|
| 8 | **Password Vault** | `/password-vault` | 0.2.0 | CRUD credentials, email field, clickable URLs, strength meter, company scope, **AES-256-GCM encryption** |
|
||||||
| 9 | **Mini Utilities** | `/mini-utilities` | 0.1.0 | Text case, char counter, percentage, area converter, U→R, artifact cleaner, MDLPA, PDF reducer, OCR |
|
| 9 | **Mini Utilities** | `/mini-utilities` | 0.1.0 | Text case, char counter, percentage, area converter, U→R, artifact cleaner, MDLPA, PDF reducer, OCR |
|
||||||
| 10 | **Prompt Generator** | `/prompt-generator` | 0.2.0 | Template-driven prompt builder, **18 templates** (14 text + 4 image), search bar, target type filter |
|
| 10 | **Prompt Generator** | `/prompt-generator` | 0.2.0 | Template-driven prompt builder, **18 templates** (14 text + 4 image), search bar, target type filter |
|
||||||
| 11 | **Digital Signatures** | `/digital-signatures` | 0.1.0 | CRUD assets, drag-and-drop file upload, tag chips |
|
| 11 | **Digital Signatures** | `/digital-signatures` | 0.1.0 | CRUD assets, drag-and-drop file upload, tag chips |
|
||||||
| 12 | **Word Templates** | `/word-templates` | 0.1.0 | Template library, 8 categories, version tracking, .docx placeholder auto-detection |
|
| 12 | **Word Templates** | `/word-templates` | 0.1.0 | Template library, 8 categories, version tracking, .docx placeholder auto-detection |
|
||||||
| 13 | **AI Chat** | `/ai-chat` | 0.2.0 | Multi-provider (OpenAI/Claude/Ollama/demo), **project linking via Tag Manager**, provider status badge |
|
| 13 | **AI Chat** | `/ai-chat` | 0.2.0 | Multi-provider (OpenAI/Claude/Ollama/demo), **project linking via Tag Manager**, provider status badge |
|
||||||
| 14 | **Hot Desk** | `/hot-desk` | 0.1.0 | 4 desks, week-ahead calendar, room layout (window+door), reserve/cancel |
|
| 14 | **Hot Desk** | `/hot-desk` | 0.1.0 | 4 desks, week-ahead calendar, room layout (window+door), reserve/cancel |
|
||||||
|
|
||||||
### Registratura — Legal Deadline Tracking (Termene Legale)
|
### Registratura — Legal Deadline Tracking (Termene Legale)
|
||||||
|
|
||||||
@@ -245,15 +245,15 @@ src/modules/<name>/
|
|||||||
|
|
||||||
## Current Integrations
|
## Current Integrations
|
||||||
|
|
||||||
| Feature | Status | Notes |
|
| Feature | Status | Notes |
|
||||||
| ------------------- | ---------------------- | --------------------------------------------------------------- |
|
| -------------------- | ---------------------- | --------------------------------------------------------------- |
|
||||||
| **Authentik SSO** | ✅ Active | NextAuth v4 + OIDC, group→role/company mapping |
|
| **Authentik SSO** | ✅ Active | NextAuth v4 + OIDC, group→role/company mapping |
|
||||||
| **PostgreSQL** | ✅ Active | Prisma ORM, `KeyValueStore` model, `/api/storage` route |
|
| **PostgreSQL** | ✅ Active | Prisma ORM, `KeyValueStore` model, `/api/storage` route |
|
||||||
| **MinIO** | Client configured | 10.10.10.166:9002, bucket `tools`, adapter pending |
|
| **MinIO** | Client configured | 10.10.10.166:9002, bucket `tools`, adapter pending |
|
||||||
| **AI Chat API** | ✅ Multi-provider | `/api/ai-chat` — OpenAI/Claude/Ollama/demo; needs API key env |
|
| **AI Chat API** | ✅ Multi-provider | `/api/ai-chat` — OpenAI/Claude/Ollama/demo; needs API key env |
|
||||||
| **Vault Encryption**| ✅ Active | AES-256-GCM server-side, `/api/vault`, ENCRYPTION_SECRET env |
|
| **Vault Encryption** | ✅ Active | AES-256-GCM server-side, `/api/vault`, ENCRYPTION_SECRET env |
|
||||||
| **ManicTime Sync** | ✅ Implemented | `/api/manictime` — bidirectional Tags.txt sync, needs SMB mount |
|
| **ManicTime Sync** | ✅ Implemented | `/api/manictime` — bidirectional Tags.txt sync, needs SMB mount |
|
||||||
| **N8N automations** | Webhook URL configured | For notifications, backups, workflows |
|
| **N8N automations** | Webhook URL configured | For notifications, backups, workflows |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+16
-16
@@ -30,22 +30,22 @@
|
|||||||
|
|
||||||
## Current Module Status (after Phase 3 completion)
|
## Current Module Status (after Phase 3 completion)
|
||||||
|
|
||||||
| # | Module | Version | Status | Remaining Gaps | Future Enhancements |
|
| # | Module | Version | Status | Remaining Gaps | Future Enhancements |
|
||||||
| --- | ------------------ | ------- | -------- | ----------------------------------------------------- | ------------------------------------------- |
|
| --- | ------------------ | ------- | -------- | ---------------------------------------- | ------------------------------------------------- |
|
||||||
| 1 | Registratura | 0.2.0 | COMPLETE | — | Workflow automation, email integration, OCR |
|
| 1 | Registratura | 0.2.0 | COMPLETE | — | Workflow automation, email integration, OCR |
|
||||||
| 2 | Email Signature | 0.1.0 | COMPLETE | US/SDT addresses may need update | AD sync, branding packs, promo banners |
|
| 2 | Email Signature | 0.1.0 | COMPLETE | US/SDT addresses may need update | AD sync, branding packs, promo banners |
|
||||||
| 3 | Word XML | 0.1.0 | COMPLETE | — | Schema validator, visual mapper |
|
| 3 | Word XML | 0.1.0 | COMPLETE | — | Schema validator, visual mapper |
|
||||||
| 4 | Digital Signatures | 0.1.0 | COMPLETE | — | Permission layers, document insertion |
|
| 4 | Digital Signatures | 0.1.0 | COMPLETE | — | Permission layers, document insertion |
|
||||||
| 5 | Password Vault | 0.2.0 | COMPLETE | — | Hardware key, rotation reminders, Passbolt |
|
| 5 | Password Vault | 0.2.0 | COMPLETE | — | Hardware key, rotation reminders, Passbolt |
|
||||||
| 6 | IT Inventory | 0.2.0 | COMPLETE | — | Network scan import |
|
| 6 | IT Inventory | 0.2.0 | COMPLETE | — | Network scan import |
|
||||||
| 7 | Address Book | 0.1.0 | COMPLETE | — | Email sync, deduplication |
|
| 7 | Address Book | 0.1.0 | COMPLETE | — | Email sync, deduplication |
|
||||||
| 8 | Prompt Generator | 0.2.0 | COMPLETE | — | Prompt scoring, more image templates |
|
| 8 | Prompt Generator | 0.2.0 | COMPLETE | — | Prompt scoring, more image templates |
|
||||||
| 9 | Word Templates | 0.1.0 | COMPLETE | No clause library; no Word generation | Diff compare, document generator |
|
| 9 | Word Templates | 0.1.0 | COMPLETE | No clause library; no Word generation | Diff compare, document generator |
|
||||||
| 10 | Tag Manager | 0.2.0 | COMPLETE | ManicTime needs SMB mount on Docker host | Smart suggestions |
|
| 10 | Tag Manager | 0.2.0 | COMPLETE | ManicTime needs SMB mount on Docker host | Smart suggestions |
|
||||||
| 11 | Mini Utilities | 0.1.0 | COMPLETE | — | More converters, DWG→DXF |
|
| 11 | Mini Utilities | 0.1.0 | COMPLETE | — | More converters, DWG→DXF |
|
||||||
| 12 | Dashboard | 0.1.0 | COMPLETE | — | Custom dashboards per role |
|
| 12 | Dashboard | 0.1.0 | COMPLETE | — | Custom dashboards per role |
|
||||||
| 13 | AI Chat | 0.2.0 | COMPLETE | Needs API key env vars for real AI | Streaming, model selector, conversation templates |
|
| 13 | AI Chat | 0.2.0 | COMPLETE | Needs API key env vars for real AI | Streaming, model selector, conversation templates |
|
||||||
| 14 | Hot Desk | 0.1.0 | COMPLETE | — | — |
|
| 14 | Hot Desk | 0.1.0 | COMPLETE | — | — |
|
||||||
|
|
||||||
**Phases 1–3 COMPLETE (all 42 tasks).** Next: Phase 4 (Quality & Testing).
|
**Phases 1–3 COMPLETE (all 42 tasks).** Next: Phase 4 (Quality & Testing).
|
||||||
|
|
||||||
|
|||||||
+28
-10
@@ -6,24 +6,25 @@
|
|||||||
|
|
||||||
## Repository URLs
|
## Repository URLs
|
||||||
|
|
||||||
| Access | Git Clone URL | Web UI |
|
| 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 |
|
| **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 |
|
| **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)
|
### Raw File URLs (for AI tools that can fetch URLs)
|
||||||
|
|
||||||
Replace `{GITEA}` with whichever base works for you:
|
Replace `{GITEA}` with whichever base works for you:
|
||||||
|
|
||||||
- Internal: `http://10.10.10.166:3002`
|
- Internal: `http://10.10.10.166:3002`
|
||||||
- External: `https://git.beletage.ro`
|
- External: `https://git.beletage.ro`
|
||||||
|
|
||||||
| File | URL |
|
| File | URL |
|
||||||
|---|---|
|
| ---------------- | -------------------------------------------------------------- |
|
||||||
| CLAUDE.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/CLAUDE.md` |
|
| CLAUDE.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/CLAUDE.md` |
|
||||||
| ROADMAP.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/ROADMAP.md` |
|
| ROADMAP.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/ROADMAP.md` |
|
||||||
| SESSION-LOG.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/SESSION-LOG.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` |
|
| SESSION-GUIDE.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/SESSION-GUIDE.md` |
|
||||||
| QA-CHECKLIST.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/QA-CHECKLIST.md` |
|
| QA-CHECKLIST.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/QA-CHECKLIST.md` |
|
||||||
|
|
||||||
**Production app:** http://10.10.10.166:3000
|
**Production app:** http://10.10.10.166:3000
|
||||||
|
|
||||||
@@ -187,25 +188,32 @@ Run `npx next build`, push to main, update ROADMAP.md + SESSION-LOG.md, notify m
|
|||||||
## Tool-Specific Notes
|
## Tool-Specific Notes
|
||||||
|
|
||||||
### Claude Code (CLI)
|
### Claude Code (CLI)
|
||||||
|
|
||||||
Works natively. Clone/pull, read files, edit, build, push — all built in.
|
Works natively. Clone/pull, read files, edit, build, push — all built in.
|
||||||
|
|
||||||
### ChatGPT Codex
|
### ChatGPT Codex
|
||||||
|
|
||||||
Give it the repo URL. It can clone via git, read files, and push.
|
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`
|
Use the external URL: `https://git.beletage.ro/gitadmin/ArchiTools.git`
|
||||||
|
|
||||||
### VS Code + Copilot / Cursor / Windsurf
|
### VS Code + Copilot / Cursor / Windsurf
|
||||||
|
|
||||||
Clone the repo locally first, then open in the IDE. The AI agent reads files from disk.
|
Clone the repo locally first, then open in the IDE. The AI agent reads files from disk.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.beletage.ro/gitadmin/ArchiTools.git
|
git clone https://git.beletage.ro/gitadmin/ArchiTools.git
|
||||||
cd ArchiTools && npm install && code .
|
cd ArchiTools && npm install && code .
|
||||||
```
|
```
|
||||||
|
|
||||||
### Google Antigravity
|
### Google Antigravity
|
||||||
|
|
||||||
Give it the repo URL. It can clone and work autonomously.
|
Give it the repo URL. It can clone and work autonomously.
|
||||||
Use: `https://git.beletage.ro/gitadmin/ArchiTools.git`
|
Use: `https://git.beletage.ro/gitadmin/ArchiTools.git`
|
||||||
|
|
||||||
### Phone (ChatGPT app, Claude app)
|
### Phone (ChatGPT app, Claude app)
|
||||||
|
|
||||||
Can't run code directly, but can read files via raw URLs and give guidance:
|
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:
|
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/CLAUDE.md
|
||||||
@@ -218,6 +226,7 @@ https://git.beletage.ro/gitadmin/ArchiTools/raw/branch/main/SESSION-LOG.md
|
|||||||
## Git Workflow
|
## Git Workflow
|
||||||
|
|
||||||
### First time (any device)
|
### First time (any device)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.beletage.ro/gitadmin/ArchiTools.git
|
git clone https://git.beletage.ro/gitadmin/ArchiTools.git
|
||||||
cd ArchiTools
|
cd ArchiTools
|
||||||
@@ -226,12 +235,14 @@ npm run dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Session start (pull latest)
|
### Session start (pull latest)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull origin main
|
git pull origin main
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Session end (push)
|
### Session end (push)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx next build
|
npx next build
|
||||||
git add <specific-files>
|
git add <specific-files>
|
||||||
@@ -246,26 +257,33 @@ git push origin main
|
|||||||
## Files to Update After Every Session
|
## Files to Update After Every Session
|
||||||
|
|
||||||
### 1. `ROADMAP.md` — Mark done tasks
|
### 1. `ROADMAP.md` — Mark done tasks
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
### 1.01 ✅ (2026-02-18) `[LIGHT]` Verify Email Signature Logo Files
|
### 1.01 ✅ (2026-02-18) `[LIGHT]` Verify Email Signature Logo Files
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. `SESSION-LOG.md` — Add entry at the TOP
|
### 2. `SESSION-LOG.md` — Add entry at the TOP
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
## Session — 2026-02-18 (Sonnet 4.6)
|
## Session — 2026-02-18 (Sonnet 4.6)
|
||||||
|
|
||||||
### Completed
|
### Completed
|
||||||
|
|
||||||
- 1.01: Verified logo files
|
- 1.01: Verified logo files
|
||||||
- 1.02: Added address toggle
|
- 1.02: Added address toggle
|
||||||
|
|
||||||
### In Progress
|
### In Progress
|
||||||
|
|
||||||
- 1.03: Prompt templates — 4 of 10 done
|
- 1.03: Prompt templates — 4 of 10 done
|
||||||
|
|
||||||
### Blockers
|
### Blockers
|
||||||
|
|
||||||
- Need logo files from user
|
- Need logo files from user
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|
||||||
- Build passes, commit abc1234
|
- Build passes, commit abc1234
|
||||||
|
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ export const DEFAULT_FLAGS: FeatureFlag[] = [
|
|||||||
{
|
{
|
||||||
key: "module.password-vault",
|
key: "module.password-vault",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
label: "Seif Parole",
|
label: "Parole Uzuale",
|
||||||
description: "Depozit intern de credențiale",
|
description: "Depozit intern de credențiale",
|
||||||
category: "module",
|
category: "module",
|
||||||
overridable: true,
|
overridable: true,
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export const ro: Labels = {
|
|||||||
description: "Bibliotecă semnături digitale și ștampile scanate",
|
description: "Bibliotecă semnături digitale și ștampile scanate",
|
||||||
},
|
},
|
||||||
"password-vault": {
|
"password-vault": {
|
||||||
title: "Seif Parole",
|
title: "Parole Uzuale",
|
||||||
description: "Depozit intern de credențiale partajate",
|
description: "Depozit intern de credențiale partajate",
|
||||||
},
|
},
|
||||||
"it-inventory": {
|
"it-inventory": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -12,6 +12,16 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
X,
|
X,
|
||||||
|
Globe,
|
||||||
|
Mail,
|
||||||
|
Wifi,
|
||||||
|
Building2,
|
||||||
|
FileCheck2,
|
||||||
|
Fingerprint,
|
||||||
|
Monitor,
|
||||||
|
HardDrive,
|
||||||
|
MoreHorizontal,
|
||||||
|
QrCode,
|
||||||
} from "lucide-react";
|
} 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";
|
||||||
@@ -43,14 +53,82 @@ import type { CompanyId } from "@/core/auth/types";
|
|||||||
import type { VaultEntry, VaultEntryCategory, CustomField } from "../types";
|
import type { VaultEntry, VaultEntryCategory, CustomField } from "../types";
|
||||||
import { useVault } from "../hooks/use-vault";
|
import { useVault } from "../hooks/use-vault";
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<VaultEntryCategory, string> = {
|
// Category definitions with icons
|
||||||
web: "Web",
|
|
||||||
email: "Email",
|
interface CategoryDef {
|
||||||
server: "Server",
|
id: VaultEntryCategory;
|
||||||
database: "Bază de date",
|
label: string;
|
||||||
api: "API",
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
other: "Altele",
|
description: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
const CATEGORY_DEFS: CategoryDef[] = [
|
||||||
|
{
|
||||||
|
id: "web",
|
||||||
|
label: "Web",
|
||||||
|
icon: Globe as unknown as React.ComponentType<{ className?: string }>,
|
||||||
|
description: "Conturi web și platforme online",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "email",
|
||||||
|
label: "Email",
|
||||||
|
icon: Mail as unknown as React.ComponentType<{ className?: string }>,
|
||||||
|
description: "Conturi de email",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "wifi",
|
||||||
|
label: "WiFi",
|
||||||
|
icon: Wifi as unknown as React.ComponentType<{ className?: string }>,
|
||||||
|
description: "Rețele WiFi și parole de acces",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "portale-primarii",
|
||||||
|
label: "Portale Primării",
|
||||||
|
icon: Building2 as unknown as React.ComponentType<{ className?: string }>,
|
||||||
|
description: "Conturi portal urbanism",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "avize-online",
|
||||||
|
label: "Avize Online",
|
||||||
|
icon: FileCheck2 as unknown as React.ComponentType<{ className?: string }>,
|
||||||
|
description: "Platforme solicitare avize",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pin-semnatura",
|
||||||
|
label: "PIN Semnătură",
|
||||||
|
icon: Fingerprint as unknown as React.ComponentType<{ className?: string }>,
|
||||||
|
description: "PIN-uri semnătură electronică",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "software",
|
||||||
|
label: "Software",
|
||||||
|
icon: Monitor as unknown as React.ComponentType<{ className?: string }>,
|
||||||
|
description: "Conturi software (ArchiCAD, Twinmotion etc.)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hardware",
|
||||||
|
label: "Echipament HW",
|
||||||
|
icon: HardDrive as unknown as React.ComponentType<{ className?: string }>,
|
||||||
|
description: "iDRAC, PCoIP, VM, ZeroClient",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "other",
|
||||||
|
label: "Altele",
|
||||||
|
icon: MoreHorizontal as unknown as React.ComponentType<{
|
||||||
|
className?: string;
|
||||||
|
}>,
|
||||||
|
description: "Alte credențiale",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const CATEGORY_MAP = new Map(CATEGORY_DEFS.map((c) => [c.id, c]));
|
||||||
|
|
||||||
|
function getCategoryDef(id: string): CategoryDef {
|
||||||
|
return (
|
||||||
|
CATEGORY_MAP.get(id as VaultEntryCategory) ??
|
||||||
|
CATEGORY_DEFS[CATEGORY_DEFS.length - 1]!
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const COMPANY_LABELS: Record<CompanyId, string> = {
|
const COMPANY_LABELS: Record<CompanyId, string> = {
|
||||||
beletage: "Beletage",
|
beletage: "Beletage",
|
||||||
@@ -128,6 +206,7 @@ export function PasswordVaultModule() {
|
|||||||
);
|
);
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [qrEntry, setQrEntry] = useState<VaultEntry | null>(null);
|
||||||
|
|
||||||
const togglePassword = (id: string) => {
|
const togglePassword = (id: string) => {
|
||||||
setVisiblePasswords((prev) => {
|
setVisiblePasswords((prev) => {
|
||||||
@@ -167,13 +246,24 @@ export function PasswordVaultModule() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Group entries by category for stats
|
||||||
|
const categoryStats = useMemo(() => {
|
||||||
|
const stats = new Map<string, number>();
|
||||||
|
for (const e of allEntries)
|
||||||
|
stats.set(e.category, (stats.get(e.category) ?? 0) + 1);
|
||||||
|
return stats;
|
||||||
|
}, [allEntries]);
|
||||||
|
|
||||||
|
// Top 3 categories for stat cards
|
||||||
|
const topCategories = useMemo(() => {
|
||||||
|
const sorted = [...categoryStats.entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 3);
|
||||||
|
return sorted;
|
||||||
|
}, [categoryStats]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-4 py-2 text-xs text-emerald-700 dark:text-emerald-400">
|
|
||||||
Parolele sunt criptate (AES-256-GCM) pe server înainte de stocare.
|
|
||||||
Datele sunt protejate la rest în baza de date.
|
|
||||||
</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>
|
<Card>
|
||||||
@@ -182,30 +272,21 @@ export function PasswordVaultModule() {
|
|||||||
<p className="text-2xl font-bold">{allEntries.length}</p>
|
<p className="text-2xl font-bold">{allEntries.length}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
{topCategories.map(([catId, count]) => {
|
||||||
<CardContent className="p-4">
|
const def = getCategoryDef(catId);
|
||||||
<p className="text-xs text-muted-foreground">Web</p>
|
const Icon = def.icon;
|
||||||
<p className="text-2xl font-bold">
|
return (
|
||||||
{allEntries.filter((e) => e.category === "web").length}
|
<Card key={catId}>
|
||||||
</p>
|
<CardContent className="p-4">
|
||||||
</CardContent>
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
</Card>
|
<Icon className="h-3 w-3" />
|
||||||
<Card>
|
{def.label}
|
||||||
<CardContent className="p-4">
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">Server</p>
|
<p className="text-2xl font-bold">{count}</p>
|
||||||
<p className="text-2xl font-bold">
|
</CardContent>
|
||||||
{allEntries.filter((e) => e.category === "server").length}
|
</Card>
|
||||||
</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" && (
|
||||||
@@ -226,18 +307,22 @@ export function PasswordVaultModule() {
|
|||||||
updateFilter("category", v as VaultEntryCategory | "all")
|
updateFilter("category", v as VaultEntryCategory | "all")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-[180px]">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Toate</SelectItem>
|
<SelectItem value="all">Toate categoriile</SelectItem>
|
||||||
{(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map(
|
{CATEGORY_DEFS.map((c) => {
|
||||||
(c) => (
|
const Icon = c.icon;
|
||||||
<SelectItem key={c} value={c}>
|
return (
|
||||||
{CATEGORY_LABELS[c]}
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{c.label}
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
),
|
);
|
||||||
)}
|
})}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button onClick={() => setViewMode("add")} className="shrink-0">
|
<Button onClick={() => setViewMode("add")} className="shrink-0">
|
||||||
@@ -255,109 +340,131 @@ export function PasswordVaultModule() {
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => {
|
||||||
<Card key={entry.id} className="group">
|
const catDef = getCategoryDef(entry.category);
|
||||||
<CardContent className="flex items-center gap-4 p-4">
|
const CatIcon = catDef.icon;
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<Card key={entry.id} className="group">
|
||||||
<p className="font-medium">{entry.label}</p>
|
<CardContent className="flex items-center gap-4 p-4">
|
||||||
<Badge variant="outline" className="text-[10px]">
|
{/* Category icon */}
|
||||||
{CATEGORY_LABELS[entry.category]}
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||||
</Badge>
|
<CatIcon className="h-4.5 w-4.5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{entry.username}
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
{entry.email && (
|
<div className="flex items-center gap-2">
|
||||||
<span className="ml-2 text-muted-foreground/70">
|
<p className="font-medium">{entry.label}</p>
|
||||||
({entry.email})
|
<Badge variant="outline" className="text-[10px]">
|
||||||
</span>
|
{catDef.label}
|
||||||
)}
|
</Badge>
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="text-xs">
|
|
||||||
{visiblePasswords.has(entry.id)
|
|
||||||
? entry.password
|
|
||||||
: "••••••••••"}
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-5 w-5"
|
|
||||||
onClick={() => togglePassword(entry.id)}
|
|
||||||
>
|
|
||||||
{visiblePasswords.has(entry.id) ? (
|
|
||||||
<EyeOff className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<Eye className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-5 w-5"
|
|
||||||
onClick={() => handleCopy(entry.password, entry.id)}
|
|
||||||
>
|
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
{copiedId === entry.id && (
|
|
||||||
<span className="text-[10px] text-green-500">
|
|
||||||
Copiat!
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{entry.url && (
|
|
||||||
<a
|
|
||||||
href={
|
|
||||||
entry.url.startsWith("http")
|
|
||||||
? entry.url
|
|
||||||
: `https://${entry.url}`
|
|
||||||
}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-3 w-3" /> {entry.url}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{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>
|
||||||
)}
|
<p className="text-xs text-muted-foreground">
|
||||||
</div>
|
{entry.username}
|
||||||
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
{entry.email && (
|
||||||
<Button
|
<span className="ml-2 text-muted-foreground/70">
|
||||||
variant="ghost"
|
({entry.email})
|
||||||
size="icon"
|
</span>
|
||||||
className="h-7 w-7"
|
)}
|
||||||
onClick={() => {
|
</p>
|
||||||
setEditingEntry(entry);
|
<div className="flex items-center gap-2">
|
||||||
setViewMode("edit");
|
<code className="text-xs">
|
||||||
}}
|
{visiblePasswords.has(entry.id)
|
||||||
>
|
? entry.password
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
: String.fromCharCode(8226).repeat(10)}
|
||||||
</Button>
|
</code>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-destructive"
|
className="h-5 w-5"
|
||||||
onClick={() => setDeletingId(entry.id)}
|
onClick={() => togglePassword(entry.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
{visiblePasswords.has(entry.id) ? (
|
||||||
</Button>
|
<EyeOff className="h-3 w-3" />
|
||||||
</div>
|
) : (
|
||||||
</CardContent>
|
<Eye className="h-3 w-3" />
|
||||||
</Card>
|
)}
|
||||||
))}
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5"
|
||||||
|
onClick={() => handleCopy(entry.password, entry.id)}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
{copiedId === entry.id && (
|
||||||
|
<span className="text-[10px] text-green-500">
|
||||||
|
Copiat!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{entry.url && (
|
||||||
|
<a
|
||||||
|
href={
|
||||||
|
entry.url.startsWith("http")
|
||||||
|
? entry.url
|
||||||
|
: `https://${entry.url}`
|
||||||
|
}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3" /> {entry.url}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{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 className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
{/* WiFi QR button */}
|
||||||
|
{entry.category === "wifi" && entry.password && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => setQrEntry(entry)}
|
||||||
|
title="QR Code WiFi"
|
||||||
|
>
|
||||||
|
<QrCode className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingEntry(entry);
|
||||||
|
setViewMode("edit");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
onClick={() => setDeletingId(entry.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -408,10 +515,118 @@ export function PasswordVaultModule() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* WiFi QR Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={qrEntry !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setQrEntry(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<QrCode className="h-5 w-5" />
|
||||||
|
QR Code WiFi {qrEntry ? `\u2014 ${qrEntry.label}` : ""}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{qrEntry && (
|
||||||
|
<WifiQrDisplay
|
||||||
|
ssid={qrEntry.label}
|
||||||
|
password={qrEntry.password}
|
||||||
|
onCopy={(text) => handleCopy(text, "qr-" + qrEntry.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setQrEntry(null)}>
|
||||||
|
Închide
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WiFi QR Code Display
|
||||||
|
function WifiQrDisplay({
|
||||||
|
ssid,
|
||||||
|
password,
|
||||||
|
onCopy,
|
||||||
|
}: {
|
||||||
|
ssid: string;
|
||||||
|
password: string;
|
||||||
|
onCopy: (text: string) => void;
|
||||||
|
}) {
|
||||||
|
// Build WIFI connection string
|
||||||
|
const wifiString = useMemo(() => {
|
||||||
|
const escaped = (s: string) => s.replace(/[\\;,"`:]/g, (c) => `\\${c}`);
|
||||||
|
return `WIFI:T:WPA;S:${escaped(ssid)};P:${escaped(password)};;`;
|
||||||
|
}, [ssid, password]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
{/* Visual WiFi indicator */}
|
||||||
|
<div className="rounded-lg border bg-white p-6 flex flex-col items-center gap-3">
|
||||||
|
<Wifi className="h-16 w-16 text-blue-500" />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="font-medium text-lg text-gray-900">{ssid}</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Copiază string-ul de mai jos într-un generator QR
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection string for copy */}
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
String conexiune WiFi
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<code className="flex-1 rounded border bg-muted/50 px-3 py-2 text-xs font-mono break-all">
|
||||||
|
{wifiString}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => onCopy(wifiString)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
Lipește acest text într-un generator QR online (ex:
|
||||||
|
qr-code-generator.com) pentru a crea un QR code scanabil.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password for quick copy */}
|
||||||
|
<div className="w-full space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Parolă WiFi</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={password}
|
||||||
|
readOnly
|
||||||
|
className="flex-1 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => onCopy(password)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vault Form
|
||||||
function VaultForm({
|
function VaultForm({
|
||||||
initial,
|
initial,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
@@ -505,6 +720,7 @@ function VaultForm({
|
|||||||
onChange={(e) => setLabel(e.target.value)}
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
required
|
required
|
||||||
|
placeholder={category === "wifi" ? "Nume rețea (SSID)" : ""}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -517,15 +733,24 @@ function VaultForm({
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map(
|
{CATEGORY_DEFS.map((c) => {
|
||||||
(c) => (
|
const Icon = c.icon;
|
||||||
<SelectItem key={c} value={c}>
|
return (
|
||||||
{CATEGORY_LABELS[c]}
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{c.label}
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
),
|
);
|
||||||
)}
|
})}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{CATEGORY_DEFS.find((c) => c.id === category) && (
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||||
|
{CATEGORY_DEFS.find((c) => c.id === category)?.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
@@ -548,42 +773,54 @@ function VaultForm({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Utilizator</Label>
|
<Label>
|
||||||
|
{category === "pin-semnatura" ? "Titular" : "Utilizator"}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
|
placeholder={
|
||||||
|
category === "pin-semnatura" ? "Numele titularului" : ""
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{category !== "wifi" && category !== "pin-semnatura" && (
|
||||||
|
<div>
|
||||||
|
<Label>Email</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="utilizator@exemplu.ro"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<Label>Email</Label>
|
<Label>{category === "pin-semnatura" ? "PIN" : "Parolă"}</Label>
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="utilizator@exemplu.ro"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>Parolă</Label>
|
|
||||||
<div className="mt-1 flex gap-1.5">
|
<div className="mt-1 flex gap-1.5">
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="flex-1 font-mono text-sm"
|
className="flex-1 font-mono text-sm"
|
||||||
|
placeholder={
|
||||||
|
category === "pin-semnatura" ? "PIN semnatura electronica" : ""
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Button
|
{category !== "pin-semnatura" && (
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
size="icon"
|
variant="outline"
|
||||||
onClick={handleGenerate}
|
size="icon"
|
||||||
title="Generează parolă"
|
onClick={handleGenerate}
|
||||||
>
|
title="Generează parolă"
|
||||||
<KeyRound className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<KeyRound className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{password && (
|
{password && (
|
||||||
<div className="mt-2 space-y-1">
|
<div className="mt-2 space-y-1">
|
||||||
@@ -613,83 +850,89 @@ function VaultForm({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Password generator options */}
|
{/* Password generator options - hide for PIN */}
|
||||||
<div className="rounded border p-3 space-y-2">
|
{category !== "pin-semnatura" && (
|
||||||
<p className="text-xs font-medium text-muted-foreground">
|
<div className="rounded border p-3 space-y-2">
|
||||||
Generator parolă
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
</p>
|
Generator parolă
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
<Label className="text-xs">Lungime:</Label>
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Label className="text-xs">Lungime:</Label>
|
||||||
type="number"
|
<Input
|
||||||
value={genLength}
|
type="number"
|
||||||
onChange={(e) => setGenLength(parseInt(e.target.value, 10) || 8)}
|
value={genLength}
|
||||||
className="w-16 text-sm"
|
onChange={(e) =>
|
||||||
min={4}
|
setGenLength(parseInt(e.target.value, 10) || 8)
|
||||||
max={64}
|
}
|
||||||
/>
|
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 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>
|
)}
|
||||||
|
|
||||||
<div>
|
{category !== "wifi" && (
|
||||||
<Label>URL</Label>
|
<div>
|
||||||
<Input
|
<Label>URL</Label>
|
||||||
value={url}
|
<Input
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
value={url}
|
||||||
className="mt-1"
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
placeholder="https://..."
|
className="mt-1"
|
||||||
/>
|
placeholder="https://..."
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Custom fields */}
|
{/* Custom fields */}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ModuleConfig } from "@/core/module-registry/types";
|
|||||||
|
|
||||||
export const passwordVaultConfig: ModuleConfig = {
|
export const passwordVaultConfig: ModuleConfig = {
|
||||||
id: "password-vault",
|
id: "password-vault",
|
||||||
name: "Seif Parole",
|
name: "Parole Uzuale",
|
||||||
description:
|
description:
|
||||||
"Manager securizat de parole și credențiale cu criptare AES-256-GCM",
|
"Manager securizat de parole și credențiale cu criptare AES-256-GCM",
|
||||||
icon: "lock",
|
icon: "lock",
|
||||||
@@ -10,7 +10,7 @@ export const passwordVaultConfig: ModuleConfig = {
|
|||||||
category: "operations",
|
category: "operations",
|
||||||
featureFlag: "module.password-vault",
|
featureFlag: "module.password-vault",
|
||||||
visibility: "admin",
|
visibility: "admin",
|
||||||
version: "0.2.0",
|
version: "0.3.0",
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
storageNamespace: "password-vault",
|
storageNamespace: "password-vault",
|
||||||
navOrder: 11,
|
navOrder: 11,
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import type { CompanyId } from "@/core/auth/types";
|
|||||||
export type VaultEntryCategory =
|
export type VaultEntryCategory =
|
||||||
| "web"
|
| "web"
|
||||||
| "email"
|
| "email"
|
||||||
| "server"
|
| "wifi"
|
||||||
| "database"
|
| "portale-primarii"
|
||||||
| "api"
|
| "avize-online"
|
||||||
|
| "pin-semnatura"
|
||||||
|
| "software"
|
||||||
|
| "hardware"
|
||||||
| "other";
|
| "other";
|
||||||
|
|
||||||
/** Custom key-value field */
|
/** Custom key-value field */
|
||||||
|
|||||||
@@ -0,0 +1,741 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Shield,
|
||||||
|
Calendar,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Bell,
|
||||||
|
BellOff,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
FileText,
|
||||||
|
Building2,
|
||||||
|
Newspaper,
|
||||||
|
Construction,
|
||||||
|
Info,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { Label } from "@/shared/components/ui/label";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import { Switch } from "@/shared/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip";
|
||||||
|
import { Textarea } from "@/shared/components/ui/textarea";
|
||||||
|
import type {
|
||||||
|
ACValidityTracking,
|
||||||
|
ACExecutionDuration,
|
||||||
|
ACPhase,
|
||||||
|
} from "../types";
|
||||||
|
import { addWorkingDays, addCalendarDays } from "../services/working-days";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
interface ACValidityTrackerProps {
|
||||||
|
value: ACValidityTracking | undefined;
|
||||||
|
onChange: (value: ACValidityTracking | undefined) => void;
|
||||||
|
/** The entry's date (used as default issuance date) */
|
||||||
|
entryDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXECUTION_LABELS: Record<ACExecutionDuration, string> = {
|
||||||
|
6: "6 luni",
|
||||||
|
12: "12 luni",
|
||||||
|
24: "24 luni",
|
||||||
|
36: "36 luni",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PHASE_LABELS: Record<ACPhase, string> = {
|
||||||
|
validity: "Valabilitate AC",
|
||||||
|
execution: "Execuție lucrări",
|
||||||
|
extended: "Prelungit (+24 luni)",
|
||||||
|
abandoned: "Abandonat",
|
||||||
|
expired: "Expirat",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PHASE_COLORS: Record<ACPhase, string> = {
|
||||||
|
validity: "bg-blue-500",
|
||||||
|
execution: "bg-green-500",
|
||||||
|
extended: "bg-amber-500",
|
||||||
|
abandoned: "bg-gray-500",
|
||||||
|
expired: "bg-red-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
function createDefault(entryDate: string): ACValidityTracking {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
issuanceDate: entryDate,
|
||||||
|
phase: "validity",
|
||||||
|
executionDuration: 12,
|
||||||
|
requiredDocs: {
|
||||||
|
cfNotation: false,
|
||||||
|
newspaperPublication: false,
|
||||||
|
sitePanel: false,
|
||||||
|
},
|
||||||
|
reminder: { snoozeCount: 0, dismissed: false },
|
||||||
|
extensionGranted: false,
|
||||||
|
abandonedDeclaration: false,
|
||||||
|
notes: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString("ro-RO", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysBetween(d1: string, d2: string): number {
|
||||||
|
const a = new Date(d1);
|
||||||
|
const b = new Date(d2);
|
||||||
|
a.setHours(0, 0, 0, 0);
|
||||||
|
b.setHours(0, 0, 0, 0);
|
||||||
|
return Math.round((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthsBetween(d1: string, d2: string): number {
|
||||||
|
const a = new Date(d1);
|
||||||
|
const b = new Date(d2);
|
||||||
|
return (
|
||||||
|
(b.getFullYear() - a.getFullYear()) * 12 + (b.getMonth() - a.getMonth())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ACValidityTracker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
entryDate,
|
||||||
|
}: ACValidityTrackerProps) {
|
||||||
|
const [expanded, setExpanded] = useState(!!value?.enabled);
|
||||||
|
|
||||||
|
const ac = value ?? createDefault(entryDate);
|
||||||
|
|
||||||
|
const handleToggle = (enabled: boolean) => {
|
||||||
|
if (enabled) {
|
||||||
|
const newAc = createDefault(entryDate);
|
||||||
|
onChange(newAc);
|
||||||
|
setExpanded(true);
|
||||||
|
} else {
|
||||||
|
onChange(undefined);
|
||||||
|
setExpanded(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (changes: Partial<ACValidityTracking>) => {
|
||||||
|
onChange({ ...ac, ...changes });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDocs = (changes: Partial<ACValidityTracking["requiredDocs"]>) => {
|
||||||
|
onChange({ ...ac, requiredDocs: { ...ac.requiredDocs, ...changes } });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Computed dates ──
|
||||||
|
const computedData = useMemo(() => {
|
||||||
|
if (!ac.enabled || !ac.issuanceDate) return null;
|
||||||
|
|
||||||
|
const issuance = new Date(ac.issuanceDate);
|
||||||
|
const now = new Date();
|
||||||
|
now.setHours(0, 0, 0, 0);
|
||||||
|
const today = now.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// 12-month validity period
|
||||||
|
const validityEnd = new Date(issuance);
|
||||||
|
validityEnd.setMonth(validityEnd.getMonth() + 12);
|
||||||
|
const validityEndStr = validityEnd.toISOString().slice(0, 10);
|
||||||
|
const daysToValidityEnd = daysBetween(today, validityEndStr);
|
||||||
|
const monthsToValidityEnd = monthsBetween(today, validityEndStr);
|
||||||
|
|
||||||
|
// Announcement deadline: 10 days before starting works (within 12-month window)
|
||||||
|
const announcementDeadline = addCalendarDays(validityEnd, -10);
|
||||||
|
const announcementDeadlineStr = announcementDeadline
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
// Extension request deadline: 45 working days before AC expiry
|
||||||
|
const extensionRequestDeadline = addWorkingDays(validityEnd, -45);
|
||||||
|
const extensionRequestDeadlineStr = extensionRequestDeadline
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10);
|
||||||
|
const daysToExtensionDeadline = daysBetween(
|
||||||
|
today,
|
||||||
|
extensionRequestDeadlineStr,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execution period end (if works started)
|
||||||
|
let executionEnd: string | null = null;
|
||||||
|
if (ac.worksStartDate) {
|
||||||
|
const start = new Date(ac.worksStartDate);
|
||||||
|
start.setMonth(start.getMonth() + ac.executionDuration);
|
||||||
|
executionEnd = start.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension end (if granted, +24 months from original end)
|
||||||
|
let extendedEnd: string | null = null;
|
||||||
|
if (ac.extensionGranted && executionEnd) {
|
||||||
|
const execEnd = new Date(executionEnd);
|
||||||
|
execEnd.setMonth(execEnd.getMonth() + 24);
|
||||||
|
extendedEnd = execEnd.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Are all required docs fulfilled?
|
||||||
|
const allDocsComplete =
|
||||||
|
ac.requiredDocs.cfNotation &&
|
||||||
|
ac.requiredDocs.newspaperPublication &&
|
||||||
|
ac.requiredDocs.sitePanel;
|
||||||
|
|
||||||
|
// Can the validity period be "closed" (works announced)?
|
||||||
|
const canAnnounce = allDocsComplete && !ac.worksAnnouncedDate;
|
||||||
|
|
||||||
|
// Extension prelungire deadline for execution (45 working days before exec end)
|
||||||
|
let execExtensionDeadline: string | null = null;
|
||||||
|
if (executionEnd) {
|
||||||
|
const d = addWorkingDays(new Date(executionEnd), -45);
|
||||||
|
execExtensionDeadline = d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
validityEndStr,
|
||||||
|
daysToValidityEnd,
|
||||||
|
monthsToValidityEnd,
|
||||||
|
announcementDeadlineStr,
|
||||||
|
extensionRequestDeadlineStr,
|
||||||
|
daysToExtensionDeadline,
|
||||||
|
executionEnd,
|
||||||
|
extendedEnd,
|
||||||
|
allDocsComplete,
|
||||||
|
canAnnounce,
|
||||||
|
execExtensionDeadline,
|
||||||
|
};
|
||||||
|
}, [ac]);
|
||||||
|
|
||||||
|
const isEnabled = !!value?.enabled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-indigo-500/30 bg-indigo-500/5 p-3 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="flex items-center gap-1.5 text-sm font-medium text-indigo-700 dark:text-indigo-300">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
Valabilitate Autorizație de Construire (AC)
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" className="max-w-sm">
|
||||||
|
<p className="text-xs">
|
||||||
|
Urmărirea completă a ciclului de viață al AC: valabilitate 12
|
||||||
|
luni, anunțare lucrări, documente obligatorii, durată
|
||||||
|
execuție, prelungire. Conform Legii 50/1991 și normelor
|
||||||
|
aferente.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</Label>
|
||||||
|
<Switch checked={isEnabled} onCheckedChange={handleToggle} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEnabled && computedData && (
|
||||||
|
<>
|
||||||
|
{/* Phase badge + overview */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<div
|
||||||
|
className={cn("h-2.5 w-2.5 rounded-full", PHASE_COLORS[ac.phase])}
|
||||||
|
/>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{PHASE_LABELS[ac.phase]}
|
||||||
|
</Badge>
|
||||||
|
{computedData.daysToValidityEnd > 0 && ac.phase === "validity" && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs",
|
||||||
|
computedData.daysToValidityEnd <= 30
|
||||||
|
? "text-red-600 dark:text-red-400 font-medium"
|
||||||
|
: computedData.daysToValidityEnd <= 90
|
||||||
|
? "text-amber-600 dark:text-amber-400"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{computedData.daysToValidityEnd} zile rămase din valabilitate
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{computedData.daysToValidityEnd <= 0 &&
|
||||||
|
ac.phase === "validity" &&
|
||||||
|
!ac.worksAnnouncedDate && (
|
||||||
|
<span className="text-xs text-red-600 dark:text-red-400 font-medium">
|
||||||
|
Valabilitate expirată!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issuance date + duration */}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Data emitere AC</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={ac.issuanceDate}
|
||||||
|
onChange={(e) => update({ issuanceDate: e.target.value })}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Valabilitate până la</Label>
|
||||||
|
<div className="mt-1 rounded border bg-muted/50 px-3 py-2 text-sm font-mono">
|
||||||
|
{formatDate(computedData.validityEndStr)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Durată execuție</Label>
|
||||||
|
<Select
|
||||||
|
value={String(ac.executionDuration)}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
update({
|
||||||
|
executionDuration: parseInt(v, 10) as ACExecutionDuration,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{([6, 12, 24, 36] as ACExecutionDuration[]).map((d) => (
|
||||||
|
<SelectItem key={d} value={String(d)}>
|
||||||
|
{EXECUTION_LABELS[d]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 1: Required documents for starting works */}
|
||||||
|
{ac.phase === "validity" && !ac.abandonedDeclaration && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="flex items-center gap-1.5 text-xs font-medium text-indigo-700 dark:text-indigo-300"
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Documente obligatorii pentru începere lucrări
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="ml-4 space-y-2 rounded border bg-background/50 p-3">
|
||||||
|
<p className="text-[10px] text-muted-foreground mb-2">
|
||||||
|
Toate cele 3 documente sunt necesare pentru a putea anunța
|
||||||
|
începerea lucrărilor. Minim 10 zile înainte de începerea
|
||||||
|
lucrărilor, anunțați la Primărie și ISC.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* CF Notation */}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={ac.requiredDocs.cfNotation}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
updateDocs({
|
||||||
|
cfNotation: v,
|
||||||
|
cfNotationDate: v
|
||||||
|
? new Date().toISOString().slice(0, 10)
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label className="text-xs flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<FileText className="h-3.5 w-3.5" />
|
||||||
|
Notare în Cartea Funciară (CF)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{ac.requiredDocs.cfNotation && (
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={ac.requiredDocs.cfNotationDate ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateDocs({ cfNotationDate: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-[150px] text-xs h-7"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Newspaper publication */}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={ac.requiredDocs.newspaperPublication}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
updateDocs({
|
||||||
|
newspaperPublication: v,
|
||||||
|
newspaperPublicationDate: v
|
||||||
|
? new Date().toISOString().slice(0, 10)
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label className="text-xs flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<Newspaper className="h-3.5 w-3.5" />
|
||||||
|
Publicare în ziar
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{ac.requiredDocs.newspaperPublication && (
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={ac.requiredDocs.newspaperPublicationDate ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateDocs({
|
||||||
|
newspaperPublicationDate: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-[150px] text-xs h-7"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Site panel */}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={ac.requiredDocs.sitePanel}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
updateDocs({
|
||||||
|
sitePanel: v,
|
||||||
|
sitePanelDate: v
|
||||||
|
? new Date().toISOString().slice(0, 10)
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label className="text-xs flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<Construction className="h-3.5 w-3.5" />
|
||||||
|
Afișare panou de șantier
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{ac.requiredDocs.sitePanel && (
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={ac.requiredDocs.sitePanelDate ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateDocs({ sitePanelDate: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-[150px] text-xs h-7"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress indicator */}
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<div className="h-1.5 flex-1 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-indigo-500 transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${
|
||||||
|
(((ac.requiredDocs.cfNotation ? 1 : 0) +
|
||||||
|
(ac.requiredDocs.newspaperPublication ? 1 : 0) +
|
||||||
|
(ac.requiredDocs.sitePanel ? 1 : 0)) /
|
||||||
|
3) *
|
||||||
|
100
|
||||||
|
}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{(ac.requiredDocs.cfNotation ? 1 : 0) +
|
||||||
|
(ac.requiredDocs.newspaperPublication ? 1 : 0) +
|
||||||
|
(ac.requiredDocs.sitePanel ? 1 : 0)}
|
||||||
|
/3
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Announce works (when all docs are ready) */}
|
||||||
|
{ac.phase === "validity" &&
|
||||||
|
computedData.allDocsComplete &&
|
||||||
|
!ac.worksAnnouncedDate &&
|
||||||
|
!ac.abandonedDeclaration && (
|
||||||
|
<div className="rounded border border-green-500/30 bg-green-500/5 p-3 space-y-2">
|
||||||
|
<p className="text-xs font-medium text-green-700 dark:text-green-300 flex items-center gap-1.5">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
Documente complete! Puteți anunța începerea lucrărilor.
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
Anunțați la Primărie și ISC cu minim 10 zile înainte de
|
||||||
|
începerea lucrărilor. Termen recomandat anunțare:{" "}
|
||||||
|
{formatDate(computedData.announcementDeadlineStr)}
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Data anunțare lucrări</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={ac.worksAnnouncedDate ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value) {
|
||||||
|
update({
|
||||||
|
worksAnnouncedDate: e.target.value,
|
||||||
|
worksStartDate: e.target.value,
|
||||||
|
phase: "execution",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Works announced — execution phase */}
|
||||||
|
{(ac.phase === "execution" || ac.phase === "extended") &&
|
||||||
|
ac.worksStartDate &&
|
||||||
|
computedData.executionEnd && (
|
||||||
|
<div className="rounded border border-green-500/30 bg-green-500/5 p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||||
|
<span className="text-xs font-medium text-green-700 dark:text-green-300">
|
||||||
|
Lucrări în execuție
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Început:</span>
|
||||||
|
<p className="font-mono">{formatDate(ac.worksStartDate)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Durată:</span>
|
||||||
|
<p className="font-medium">
|
||||||
|
{ac.executionDuration} luni
|
||||||
|
{ac.phase === "extended" ? " + 24 luni" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Termen execuție:
|
||||||
|
</span>
|
||||||
|
<p className="font-mono">
|
||||||
|
{formatDate(
|
||||||
|
ac.phase === "extended" && computedData.extendedEnd
|
||||||
|
? computedData.extendedEnd
|
||||||
|
: computedData.executionEnd,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Extension request reminder */}
|
||||||
|
{computedData.execExtensionDeadline &&
|
||||||
|
!ac.extensionGranted &&
|
||||||
|
ac.phase !== "extended" && (
|
||||||
|
<div className="rounded border border-amber-500/30 bg-amber-500/5 p-2 text-[10px]">
|
||||||
|
<p className="text-amber-700 dark:text-amber-400 flex items-center gap-1.5">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
Cererea de prelungire trebuie depusă cu cel puțin 45
|
||||||
|
zile lucrătoare înainte de expirare. Termen limită:{" "}
|
||||||
|
<strong>
|
||||||
|
{formatDate(computedData.execExtensionDeadline)}
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
Prelungirea se acordă o singură dată, gratuit, pentru
|
||||||
|
max 24 luni. Se înscrie pe originalul AC fără
|
||||||
|
documentație nouă. Decizia vine în max 15 zile
|
||||||
|
lucrătoare de la depunere.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!ac.extensionRequestDate && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<Label className="text-[10px]">
|
||||||
|
Data cerere prelungire:
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value) {
|
||||||
|
update({
|
||||||
|
extensionRequestDate: e.target.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-[140px] text-[10px] h-6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ac.extensionRequestDate && !ac.extensionGranted && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<span className="text-green-600 text-[10px]">
|
||||||
|
Cerere depusă la{" "}
|
||||||
|
{formatDate(ac.extensionRequestDate)}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-[10px] px-2"
|
||||||
|
onClick={() =>
|
||||||
|
update({
|
||||||
|
extensionGranted: true,
|
||||||
|
extensionGrantedDate: new Date()
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10),
|
||||||
|
phase: "extended",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Prelungire acordată
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reminders section */}
|
||||||
|
{ac.phase === "validity" &&
|
||||||
|
!ac.worksAnnouncedDate &&
|
||||||
|
!ac.abandonedDeclaration && (
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{ac.reminder.dismissed ? (
|
||||||
|
<BellOff className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Bell className="h-3.5 w-3.5 text-amber-500" />
|
||||||
|
)}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{ac.reminder.dismissed
|
||||||
|
? "Remindere dezactivate"
|
||||||
|
: `Reminder lunar activ (luna ${computedData.monthsToValidityEnd > 0 ? 12 - computedData.monthsToValidityEnd : 12}/12)`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{!ac.reminder.dismissed &&
|
||||||
|
computedData.monthsToValidityEnd >= 2 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-[10px] px-2"
|
||||||
|
onClick={() =>
|
||||||
|
update({
|
||||||
|
reminder: {
|
||||||
|
...ac.reminder,
|
||||||
|
snoozeCount: ac.reminder.snoozeCount + 1,
|
||||||
|
lastSnoozed: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Clock className="mr-1 h-3 w-3" /> Amână 1 lună
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Abandon option (after month 10 or when snooze limit reached) */}
|
||||||
|
{ac.phase === "validity" &&
|
||||||
|
!ac.worksAnnouncedDate &&
|
||||||
|
!ac.abandonedDeclaration && (
|
||||||
|
<div className="border-t pt-3 space-y-2">
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
Dacă nu se mai dorește începerea construcției:
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={ac.abandonedDeclaration}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
if (v) {
|
||||||
|
update({
|
||||||
|
abandonedDeclaration: true,
|
||||||
|
abandonedDate: new Date().toISOString().slice(0, 10),
|
||||||
|
phase: "abandoned",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label className="text-xs cursor-pointer flex items-center gap-1.5">
|
||||||
|
<XCircle className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
Declar că nu se mai dorește începerea construcției
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Abandoned state */}
|
||||||
|
{ac.abandonedDeclaration && (
|
||||||
|
<div className="rounded border border-gray-500/30 bg-gray-500/5 p-3 text-xs space-y-2">
|
||||||
|
<p className="font-medium text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
Construcția a fost declarată abandonată
|
||||||
|
</p>
|
||||||
|
{ac.abandonedDate && (
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
Data declarației: {formatDate(ac.abandonedDate)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Textarea
|
||||||
|
value={ac.abandonedReason ?? ""}
|
||||||
|
onChange={(e) => update({ abandonedReason: e.target.value })}
|
||||||
|
placeholder="Motiv (opțional)..."
|
||||||
|
rows={2}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Validity extension option (within validity phase) */}
|
||||||
|
{ac.phase === "validity" &&
|
||||||
|
!ac.worksAnnouncedDate &&
|
||||||
|
!ac.abandonedDeclaration &&
|
||||||
|
computedData.daysToExtensionDeadline <= 90 && (
|
||||||
|
<div className="rounded border border-amber-500/30 bg-amber-500/5 p-3 space-y-2">
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-300 flex items-center gap-1.5">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||||
|
Opțiune: Prelungire valabilitate AC cu 24 luni
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
Termen limită depunere cerere:{" "}
|
||||||
|
<strong>
|
||||||
|
{formatDate(computedData.extensionRequestDeadlineStr)}
|
||||||
|
</strong>{" "}
|
||||||
|
(45 zile lucrătoare înainte de expirare). Atenție: este mai
|
||||||
|
avantajos să declarați începerea lucrărilor și să cereți
|
||||||
|
prelungirea duratei de execuție — astfel aveți mai mult timp
|
||||||
|
total.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ 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 { DeadlineDashboard } from "./deadline-dashboard";
|
import { DeadlineDashboard } from "./deadline-dashboard";
|
||||||
|
import { ThreadExplorer } from "./thread-explorer";
|
||||||
import { CloseGuardDialog } from "./close-guard-dialog";
|
import { CloseGuardDialog } from "./close-guard-dialog";
|
||||||
import { getOverdueDays } from "../services/registry-service";
|
import { getOverdueDays } from "../services/registry-service";
|
||||||
import { aggregateDeadlines } from "../services/deadline-service";
|
import { aggregateDeadlines } from "../services/deadline-service";
|
||||||
@@ -249,6 +250,7 @@ export function RegistraturaModule() {
|
|||||||
<Tabs defaultValue="registru">
|
<Tabs defaultValue="registru">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="registru">Registru</TabsTrigger>
|
<TabsTrigger value="registru">Registru</TabsTrigger>
|
||||||
|
<TabsTrigger value="fire">Fire conversație</TabsTrigger>
|
||||||
<TabsTrigger value="termene">
|
<TabsTrigger value="termene">
|
||||||
Termene legale
|
Termene legale
|
||||||
{alertCount > 0 && (
|
{alertCount > 0 && (
|
||||||
@@ -408,6 +410,13 @@ export function RegistraturaModule() {
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="fire">
|
||||||
|
<ThreadExplorer
|
||||||
|
entries={allEntries}
|
||||||
|
onNavigateEntry={handleNavigateEntry}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="termene">
|
<TabsContent value="termene">
|
||||||
<DeadlineDashboard
|
<DeadlineDashboard
|
||||||
entries={allEntries}
|
entries={allEntries}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Calendar,
|
Calendar,
|
||||||
Globe,
|
Globe,
|
||||||
|
ArrowDownToLine,
|
||||||
|
ArrowUpFromLine,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { CompanyId } from "@/core/auth/types";
|
import type { CompanyId } from "@/core/auth/types";
|
||||||
import type {
|
import type {
|
||||||
@@ -22,6 +24,7 @@ import type {
|
|||||||
RegistryAttachment,
|
RegistryAttachment,
|
||||||
TrackedDeadline,
|
TrackedDeadline,
|
||||||
DeadlineResolution,
|
DeadlineResolution,
|
||||||
|
ACValidityTracking,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { DEFAULT_DOC_TYPE_LABELS } from "../types";
|
import { DEFAULT_DOC_TYPE_LABELS } from "../types";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
@@ -53,6 +56,8 @@ import { DeadlineResolveDialog } from "./deadline-resolve-dialog";
|
|||||||
import { QuickContactDialog } from "./quick-contact-dialog";
|
import { QuickContactDialog } from "./quick-contact-dialog";
|
||||||
import { ThreadView } from "./thread-view";
|
import { ThreadView } from "./thread-view";
|
||||||
import { ClosureBanner } from "./closure-banner";
|
import { ClosureBanner } from "./closure-banner";
|
||||||
|
import { ACValidityTracker } from "./ac-validity-tracker";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
import {
|
import {
|
||||||
createTrackedDeadline,
|
createTrackedDeadline,
|
||||||
resolveDeadline as resolveDeadlineFn,
|
resolveDeadline as resolveDeadlineFn,
|
||||||
@@ -91,7 +96,12 @@ export function RegistryEntryForm({
|
|||||||
const { tags: docTypeTags } = useTags("document-type");
|
const { tags: docTypeTags } = useTags("document-type");
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// ── Build dynamic doc type list from defaults + Tag Manager ──
|
// Track locally-added custom types that may not yet be in Tag Manager
|
||||||
|
const [localCustomTypes, setLocalCustomTypes] = useState<Map<string, string>>(
|
||||||
|
new Map(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Build dynamic doc type list from defaults + Tag Manager + local ──
|
||||||
const allDocTypes = useMemo(() => {
|
const allDocTypes = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
// Add defaults
|
// Add defaults
|
||||||
@@ -105,8 +115,18 @@ export function RegistryEntryForm({
|
|||||||
map.set(key, tag.label);
|
map.set(key, tag.label);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
// Add locally-created types (before Tag Manager syncs)
|
||||||
}, [docTypeTags]);
|
for (const [key, label] of localCustomTypes) {
|
||||||
|
if (!map.has(key)) {
|
||||||
|
map.set(key, label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort alphabetically by label
|
||||||
|
const sorted = new Map(
|
||||||
|
[...map.entries()].sort((a, b) => a[1].localeCompare(b[1], "ro")),
|
||||||
|
);
|
||||||
|
return sorted;
|
||||||
|
}, [docTypeTags, localCustomTypes]);
|
||||||
|
|
||||||
const [direction, setDirection] = useState<RegistryDirection>(
|
const [direction, setDirection] = useState<RegistryDirection>(
|
||||||
initial?.direction ?? "intrat",
|
initial?.direction ?? "intrat",
|
||||||
@@ -169,6 +189,9 @@ export function RegistryEntryForm({
|
|||||||
const [externalTrackingId, setExternalTrackingId] = useState(
|
const [externalTrackingId, setExternalTrackingId] = useState(
|
||||||
initial?.externalTrackingId ?? "",
|
initial?.externalTrackingId ?? "",
|
||||||
);
|
);
|
||||||
|
const [acValidity, setAcValidity] = useState<ACValidityTracking | undefined>(
|
||||||
|
initial?.acValidity,
|
||||||
|
);
|
||||||
|
|
||||||
// ── Submission lock + file upload tracking ──
|
// ── Submission lock + file upload tracking ──
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
@@ -295,11 +318,14 @@ export function RegistryEntryForm({
|
|||||||
setQuickContactOpen(false);
|
setQuickContactOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Custom doc type creation ──
|
// ── Custom doc type creation — adds to local state immediately + Tag Manager ──
|
||||||
const handleAddCustomDocType = async () => {
|
const handleAddCustomDocType = async () => {
|
||||||
const label = customDocType.trim();
|
const label = customDocType.trim();
|
||||||
if (!label) return;
|
if (!label) return;
|
||||||
const key = label.toLowerCase().replace(/\s+/g, "-");
|
const key = label.toLowerCase().replace(/\s+/g, "-");
|
||||||
|
// Add to local types immediately so it appears in the select
|
||||||
|
setLocalCustomTypes((prev) => new Map(prev).set(key, label));
|
||||||
|
// Select the newly created type
|
||||||
setDocumentType(key);
|
setDocumentType(key);
|
||||||
setCustomDocType("");
|
setCustomDocType("");
|
||||||
if (onCreateDocType) {
|
if (onCreateDocType) {
|
||||||
@@ -367,6 +393,7 @@ export function RegistryEntryForm({
|
|||||||
expiryAlertDays: expiryDate ? expiryAlertDays : undefined,
|
expiryAlertDays: expiryDate ? expiryAlertDays : undefined,
|
||||||
externalStatusUrl: externalStatusUrl || undefined,
|
externalStatusUrl: externalStatusUrl || undefined,
|
||||||
externalTrackingId: externalTrackingId || undefined,
|
externalTrackingId: externalTrackingId || undefined,
|
||||||
|
acValidity: acValidity,
|
||||||
linkedEntryIds,
|
linkedEntryIds,
|
||||||
attachments,
|
attachments,
|
||||||
trackedDeadlines:
|
trackedDeadlines:
|
||||||
@@ -465,18 +492,34 @@ export function RegistryEntryForm({
|
|||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>Direcție</Label>
|
<Label>Direcție</Label>
|
||||||
<Select
|
<div className="mt-1 flex rounded-lg border bg-muted/30 p-1">
|
||||||
value={direction}
|
<button
|
||||||
onValueChange={(v) => setDirection(v as RegistryDirection)}
|
type="button"
|
||||||
>
|
onClick={() => setDirection("intrat")}
|
||||||
<SelectTrigger className="mt-1">
|
className={cn(
|
||||||
<SelectValue />
|
"flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-all",
|
||||||
</SelectTrigger>
|
direction === "intrat"
|
||||||
<SelectContent>
|
? "bg-blue-500 text-white shadow-sm"
|
||||||
<SelectItem value="intrat">Intrat</SelectItem>
|
: "text-muted-foreground hover:text-foreground hover:bg-background",
|
||||||
<SelectItem value="iesit">Ieșit</SelectItem>
|
)}
|
||||||
</SelectContent>
|
>
|
||||||
</Select>
|
<ArrowDownToLine className="h-4 w-4" />
|
||||||
|
Intrat
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDirection("iesit")}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-all",
|
||||||
|
direction === "iesit"
|
||||||
|
? "bg-orange-500 text-white shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-background",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ArrowUpFromLine className="h-4 w-4" />
|
||||||
|
Ieșit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Tip document</Label>
|
<Label>Tip document</Label>
|
||||||
@@ -859,6 +902,13 @@ export function RegistryEntryForm({
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* AC Validity Tracker */}
|
||||||
|
<ACValidityTracker
|
||||||
|
value={acValidity}
|
||||||
|
onChange={setAcValidity}
|
||||||
|
entryDate={date}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Web scraping prep — external tracking */}
|
{/* Web scraping prep — external tracking */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,611 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
GitBranch,
|
||||||
|
Search,
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
Building2,
|
||||||
|
FileDown,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { Label } from "@/shared/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select";
|
||||||
|
import type { RegistryEntry } from "../types";
|
||||||
|
import { DEFAULT_DOC_TYPE_LABELS } from "../types";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
interface ThreadExplorerProps {
|
||||||
|
entries: RegistryEntry[];
|
||||||
|
onNavigateEntry?: (entry: RegistryEntry) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A thread is a chain of entries linked by threadParentId */
|
||||||
|
interface Thread {
|
||||||
|
id: string;
|
||||||
|
/** Root entry (no parent) */
|
||||||
|
root: RegistryEntry;
|
||||||
|
/** All entries in the thread, ordered chronologically */
|
||||||
|
chain: RegistryEntry[];
|
||||||
|
/** Total calendar days from first to last entry */
|
||||||
|
totalDays: number;
|
||||||
|
/** Days spent "at us" vs "at institution" */
|
||||||
|
daysAtUs: number;
|
||||||
|
daysAtInstitution: number;
|
||||||
|
/** Whether thread is still open (has open entries) */
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDocTypeLabel(type: string): string {
|
||||||
|
return DEFAULT_DOC_TYPE_LABELS[type] ?? type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysBetween(d1: string, d2: string): number {
|
||||||
|
const a = new Date(d1);
|
||||||
|
const b = new Date(d2);
|
||||||
|
a.setHours(0, 0, 0, 0);
|
||||||
|
b.setHours(0, 0, 0, 0);
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
Math.round(Math.abs(b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString("ro-RO", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShortDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString("ro-RO", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build threads from all entries */
|
||||||
|
function buildThreads(entries: RegistryEntry[]): Thread[] {
|
||||||
|
const byId = new Map(entries.map((e) => [e.id, e]));
|
||||||
|
const childrenMap = new Map<string, RegistryEntry[]>();
|
||||||
|
const rootIds = new Set<string>();
|
||||||
|
|
||||||
|
// Build parent->children map
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.threadParentId) {
|
||||||
|
const existing = childrenMap.get(entry.threadParentId) ?? [];
|
||||||
|
existing.push(entry);
|
||||||
|
childrenMap.set(entry.threadParentId, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find roots: entries that are parents or have children, but no parent themselves
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.threadParentId && childrenMap.has(entry.id)) {
|
||||||
|
rootIds.add(entry.id);
|
||||||
|
}
|
||||||
|
// Also find the root of entries that have parents
|
||||||
|
if (entry.threadParentId) {
|
||||||
|
let current = entry;
|
||||||
|
while (current.threadParentId) {
|
||||||
|
const parent = byId.get(current.threadParentId);
|
||||||
|
if (!parent) break;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
rootIds.add(current.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const threads: Thread[] = [];
|
||||||
|
|
||||||
|
for (const rootId of rootIds) {
|
||||||
|
const root = byId.get(rootId);
|
||||||
|
if (!root) continue;
|
||||||
|
|
||||||
|
// Collect all entries in this thread (BFS)
|
||||||
|
const chain: RegistryEntry[] = [];
|
||||||
|
const queue = [rootId];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const id = queue.shift()!;
|
||||||
|
if (visited.has(id)) continue;
|
||||||
|
visited.add(id);
|
||||||
|
const entry = byId.get(id);
|
||||||
|
if (!entry) continue;
|
||||||
|
chain.push(entry);
|
||||||
|
const children = childrenMap.get(id) ?? [];
|
||||||
|
for (const child of children) {
|
||||||
|
queue.push(child.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort chronologically
|
||||||
|
chain.sort(
|
||||||
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (chain.length < 2) continue; // Only show actual threads (2+ entries)
|
||||||
|
|
||||||
|
// Calculate days
|
||||||
|
const firstDate = chain[0]!.date;
|
||||||
|
const lastDate = chain[chain.length - 1]!.date;
|
||||||
|
const totalDays = daysBetween(firstDate, lastDate);
|
||||||
|
|
||||||
|
// Calculate days at us vs institution
|
||||||
|
let daysAtUs = 0;
|
||||||
|
let daysAtInstitution = 0;
|
||||||
|
for (let i = 0; i < chain.length - 1; i++) {
|
||||||
|
const current = chain[i]!;
|
||||||
|
const next = chain[i + 1]!;
|
||||||
|
const gap = daysBetween(current.date, next.date);
|
||||||
|
|
||||||
|
// If current is outgoing (iesit), the document is at the institution
|
||||||
|
// If current is incoming (intrat), the document is at us
|
||||||
|
if (current.direction === "iesit") {
|
||||||
|
daysAtInstitution += gap;
|
||||||
|
} else {
|
||||||
|
daysAtUs += gap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = chain.some((e) => e.status === "deschis");
|
||||||
|
|
||||||
|
threads.push({
|
||||||
|
id: rootId,
|
||||||
|
root,
|
||||||
|
chain,
|
||||||
|
totalDays,
|
||||||
|
daysAtUs,
|
||||||
|
daysAtInstitution,
|
||||||
|
isActive,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort threads: active first, then by most recent activity
|
||||||
|
threads.sort((a, b) => {
|
||||||
|
if (a.isActive !== b.isActive) return a.isActive ? -1 : 1;
|
||||||
|
const aLast = a.chain[a.chain.length - 1]!.date;
|
||||||
|
const bLast = b.chain[b.chain.length - 1]!.date;
|
||||||
|
return bLast.localeCompare(aLast);
|
||||||
|
});
|
||||||
|
|
||||||
|
return threads;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a text report for a thread */
|
||||||
|
function generateThreadReport(thread: Thread): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push("═══════════════════════════════════════════════════════");
|
||||||
|
lines.push(`RAPORT FIR CONVERSAȚIE — ${thread.root.subject}`);
|
||||||
|
lines.push("═══════════════════════════════════════════════════════");
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`Nr. înregistrare inițial: ${thread.root.number}`);
|
||||||
|
lines.push(
|
||||||
|
`Perioada: ${formatDate(thread.chain[0]!.date)} — ${formatDate(thread.chain[thread.chain.length - 1]!.date)}`,
|
||||||
|
);
|
||||||
|
lines.push(`Durată totală: ${thread.totalDays} zile`);
|
||||||
|
lines.push(
|
||||||
|
`Zile la noi: ${thread.daysAtUs} | Zile la instituție: ${thread.daysAtInstitution}`,
|
||||||
|
);
|
||||||
|
lines.push(`Documente în fir: ${thread.chain.length}`);
|
||||||
|
lines.push(`Status: ${thread.isActive ? "Activ" : "Finalizat"}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("───────────────────────────────────────────────────────");
|
||||||
|
lines.push("TIMELINE");
|
||||||
|
lines.push("───────────────────────────────────────────────────────");
|
||||||
|
|
||||||
|
for (let i = 0; i < thread.chain.length; i++) {
|
||||||
|
const entry = thread.chain[i]!;
|
||||||
|
const prev = i > 0 ? thread.chain[i - 1] : null;
|
||||||
|
const gap = prev ? daysBetween(prev.date, entry.date) : 0;
|
||||||
|
const location = prev
|
||||||
|
? prev.direction === "iesit"
|
||||||
|
? "la instituție"
|
||||||
|
: "la noi"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (prev && gap > 0) {
|
||||||
|
lines.push(` │ ${gap} zile ${location}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrow = entry.direction === "intrat" ? "↓ INTRAT" : "↑ IEȘIT";
|
||||||
|
const status = entry.status === "inchis" ? "[ÎNCHIS]" : "[DESCHIS]";
|
||||||
|
lines.push("");
|
||||||
|
lines.push(` ${arrow} — ${formatDate(entry.date)} ${status}`);
|
||||||
|
lines.push(` Nr: ${entry.number}`);
|
||||||
|
lines.push(` Tip: ${getDocTypeLabel(entry.documentType)}`);
|
||||||
|
lines.push(` Subiect: ${entry.subject}`);
|
||||||
|
if (entry.sender) lines.push(` Expeditor: ${entry.sender}`);
|
||||||
|
if (entry.recipient) lines.push(` Destinatar: ${entry.recipient}`);
|
||||||
|
if (entry.assignee) lines.push(` Responsabil: ${entry.assignee}`);
|
||||||
|
if (entry.notes) lines.push(` Note: ${entry.notes}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push("═══════════════════════════════════════════════════════");
|
||||||
|
lines.push(`Generat la: ${new Date().toLocaleString("ro-RO")}`);
|
||||||
|
lines.push("ArchiTools — Registratură");
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadReport(thread: Thread) {
|
||||||
|
const text = generateThreadReport(thread);
|
||||||
|
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `thread-${thread.root.number.replace(/[/\\]/g, "-")}.txt`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThreadExplorer({
|
||||||
|
entries,
|
||||||
|
onNavigateEntry,
|
||||||
|
}: ThreadExplorerProps) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "closed">(
|
||||||
|
"all",
|
||||||
|
);
|
||||||
|
const [expandedThreads, setExpandedThreads] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const threads = useMemo(() => buildThreads(entries), [entries]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return threads.filter((t) => {
|
||||||
|
if (statusFilter === "active" && !t.isActive) return false;
|
||||||
|
if (statusFilter === "closed" && t.isActive) return false;
|
||||||
|
if (search.trim()) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return t.chain.some(
|
||||||
|
(e) =>
|
||||||
|
e.number.toLowerCase().includes(q) ||
|
||||||
|
e.subject.toLowerCase().includes(q) ||
|
||||||
|
e.sender.toLowerCase().includes(q) ||
|
||||||
|
e.recipient.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [threads, search, statusFilter]);
|
||||||
|
|
||||||
|
const toggleExpand = (id: string) => {
|
||||||
|
setExpandedThreads((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const totalThreads = threads.length;
|
||||||
|
const activeThreads = threads.filter((t) => t.isActive).length;
|
||||||
|
const avgDuration =
|
||||||
|
totalThreads > 0
|
||||||
|
? Math.round(
|
||||||
|
threads.reduce((sum, t) => sum + t.totalDays, 0) / totalThreads,
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Total fire</p>
|
||||||
|
<p className="text-2xl font-bold">{totalThreads}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Fire active</p>
|
||||||
|
<p className="text-2xl font-bold">{activeThreads}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Finalizate</p>
|
||||||
|
<p className="text-2xl font-bold">{totalThreads - activeThreads}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Durată medie</p>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{avgDuration}{" "}
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
|
zile
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="relative min-w-[200px] flex-1">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Caută după nr., subiect, expeditor..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Status</Label>
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setStatusFilter(v as "all" | "active" | "closed")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 w-[140px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Toate</SelectItem>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="closed">Finalizate</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thread list */}
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<GitBranch className="mx-auto h-10 w-10 text-muted-foreground/40" />
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{totalThreads === 0
|
||||||
|
? "Niciun fir de conversație. Creați legături între înregistrări (Răspuns la) pentru a forma fire."
|
||||||
|
: "Niciun fir corespunde filtrelor."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filtered.map((thread) => {
|
||||||
|
const isExpanded = expandedThreads.has(thread.id);
|
||||||
|
return (
|
||||||
|
<Card key={thread.id} className="overflow-hidden">
|
||||||
|
{/* Thread header */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleExpand(thread.id)}
|
||||||
|
className="w-full text-left"
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 shrink-0">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<GitBranch className="h-4 w-4 text-primary shrink-0" />
|
||||||
|
<span className="font-medium truncate">
|
||||||
|
{thread.root.subject}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant={thread.isActive ? "default" : "secondary"}
|
||||||
|
className="text-[10px]"
|
||||||
|
>
|
||||||
|
{thread.isActive ? "Activ" : "Finalizat"}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{thread.chain.length} doc.
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{formatShortDate(thread.chain[0]!.date)} —{" "}
|
||||||
|
{formatShortDate(
|
||||||
|
thread.chain[thread.chain.length - 1]!.date,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{thread.totalDays} zile total
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Building2 className="h-3 w-3" />
|
||||||
|
{thread.daysAtUs}z la noi ·{" "}
|
||||||
|
{thread.daysAtInstitution}z la inst.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded: Timeline */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t bg-muted/20 px-4 pb-4 pt-3">
|
||||||
|
{/* Timeline */}
|
||||||
|
<div className="relative ml-6">
|
||||||
|
{/* Vertical line */}
|
||||||
|
<div className="absolute left-3 top-0 bottom-0 w-0.5 bg-border" />
|
||||||
|
|
||||||
|
{thread.chain.map((entry, i) => {
|
||||||
|
const prev = i > 0 ? thread.chain[i - 1] : null;
|
||||||
|
const gap = prev
|
||||||
|
? daysBetween(prev.date, entry.date)
|
||||||
|
: 0;
|
||||||
|
const isIncoming = entry.direction === "intrat";
|
||||||
|
const location = prev
|
||||||
|
? prev.direction === "iesit"
|
||||||
|
? "la instituție"
|
||||||
|
: "la noi"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={entry.id}>
|
||||||
|
{/* Gap indicator */}
|
||||||
|
{prev && gap > 0 && (
|
||||||
|
<div className="relative flex items-center py-1.5 pl-8">
|
||||||
|
<div className="absolute left-[9px] h-5 w-3 border-l-2 border-dashed border-muted-foreground/30" />
|
||||||
|
<span className="text-[10px] text-muted-foreground italic">
|
||||||
|
{gap} zile {location}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Entry node */}
|
||||||
|
<div className="relative flex items-start gap-3 py-1.5">
|
||||||
|
{/* Node dot */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 mt-1.5 h-6 w-6 rounded-full border-2 flex items-center justify-center shrink-0",
|
||||||
|
isIncoming
|
||||||
|
? "border-blue-500 bg-blue-50 dark:bg-blue-950"
|
||||||
|
: "border-orange-500 bg-orange-50 dark:bg-orange-950",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isIncoming ? (
|
||||||
|
<ArrowDown className="h-3 w-3 text-blue-600 dark:text-blue-400" />
|
||||||
|
) : (
|
||||||
|
<ArrowUp className="h-3 w-3 text-orange-600 dark:text-orange-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Entry content */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onNavigateEntry?.(entry);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-lg border p-3 text-left transition-colors hover:bg-accent/50",
|
||||||
|
entry.status === "inchis" && "opacity-70",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
isIncoming ? "default" : "secondary"
|
||||||
|
}
|
||||||
|
className="text-[10px]"
|
||||||
|
>
|
||||||
|
{isIncoming ? "↓ Intrat" : "↑ Ieșit"}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{entry.number}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(entry.date)}
|
||||||
|
</span>
|
||||||
|
{entry.status === "inchis" && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px]"
|
||||||
|
>
|
||||||
|
Închis
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm font-medium">
|
||||||
|
{entry.subject}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-x-3 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Tip: {getDocTypeLabel(entry.documentType)}
|
||||||
|
</span>
|
||||||
|
{entry.sender && (
|
||||||
|
<span>De la: {entry.sender}</span>
|
||||||
|
)}
|
||||||
|
{entry.recipient && (
|
||||||
|
<span>Către: {entry.recipient}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day summary + Export */}
|
||||||
|
<div className="mt-4 flex items-center justify-between border-t pt-3">
|
||||||
|
<div className="flex gap-4 text-xs">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<div className="h-2.5 w-2.5 rounded-full bg-blue-500" />
|
||||||
|
Intrate:{" "}
|
||||||
|
{
|
||||||
|
thread.chain.filter((e) => e.direction === "intrat")
|
||||||
|
.length
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<div className="h-2.5 w-2.5 rounded-full bg-orange-500" />
|
||||||
|
Ieșite:{" "}
|
||||||
|
{
|
||||||
|
thread.chain.filter((e) => e.direction === "iesit")
|
||||||
|
.length
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
La noi: {thread.daysAtUs}z · La instituție:{" "}
|
||||||
|
{thread.daysAtInstitution}z
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
downloadReport(thread);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileDown className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Export raport
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{filtered.length} din {totalThreads} fire afișate
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -139,6 +139,74 @@ export interface TrackedDeadline {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── AC (Autorizație de Construire) validity tracking ──
|
||||||
|
|
||||||
|
/** Execution duration options (months) */
|
||||||
|
export type ACExecutionDuration = 6 | 12 | 24 | 36;
|
||||||
|
|
||||||
|
/** Phase of the AC lifecycle */
|
||||||
|
export type ACPhase =
|
||||||
|
| "validity" // 12-month validity period from issuance
|
||||||
|
| "execution" // Execution period after works announcement
|
||||||
|
| "extended" // Extended by 24 months
|
||||||
|
| "abandoned" // Declared not starting
|
||||||
|
| "expired"; // Period expired without action
|
||||||
|
|
||||||
|
/** Required document checklist for starting works */
|
||||||
|
export interface ACRequiredDocs {
|
||||||
|
/** Notare în Cartea Funciară */
|
||||||
|
cfNotation: boolean;
|
||||||
|
cfNotationDate?: string;
|
||||||
|
/** Publicare într-un ziar */
|
||||||
|
newspaperPublication: boolean;
|
||||||
|
newspaperPublicationDate?: string;
|
||||||
|
/** Afișare panou de șantier */
|
||||||
|
sitePanel: boolean;
|
||||||
|
sitePanelDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reminder state */
|
||||||
|
export interface ACReminder {
|
||||||
|
/** ISO date of last snooze */
|
||||||
|
lastSnoozed?: string;
|
||||||
|
/** Number of months snoozed so far */
|
||||||
|
snoozeCount: number;
|
||||||
|
/** Whether the user dismissed monthly reminders */
|
||||||
|
dismissed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full AC validity tracking state */
|
||||||
|
export interface ACValidityTracking {
|
||||||
|
/** Whether AC tracking is enabled for this entry */
|
||||||
|
enabled: boolean;
|
||||||
|
/** Date the AC was issued (YYYY-MM-DD) */
|
||||||
|
issuanceDate: string;
|
||||||
|
/** Current phase */
|
||||||
|
phase: ACPhase;
|
||||||
|
/** Execution duration selected (months) */
|
||||||
|
executionDuration: ACExecutionDuration;
|
||||||
|
/** Date works were announced to City Hall & ISC */
|
||||||
|
worksAnnouncedDate?: string;
|
||||||
|
/** Date works actually started */
|
||||||
|
worksStartDate?: string;
|
||||||
|
/** Required documents checklist */
|
||||||
|
requiredDocs: ACRequiredDocs;
|
||||||
|
/** Reminder state */
|
||||||
|
reminder: ACReminder;
|
||||||
|
/** Extension (prelungire) request date */
|
||||||
|
extensionRequestDate?: string;
|
||||||
|
/** Extension granted (inscribed on original AC) */
|
||||||
|
extensionGranted: boolean;
|
||||||
|
/** Extension granted date */
|
||||||
|
extensionGrantedDate?: string;
|
||||||
|
/** Declared abandonment — user chose not to start */
|
||||||
|
abandonedDeclaration: boolean;
|
||||||
|
abandonedDate?: string;
|
||||||
|
abandonedReason?: string;
|
||||||
|
/** Audit notes */
|
||||||
|
notes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegistryEntry {
|
export interface RegistryEntry {
|
||||||
id: string;
|
id: string;
|
||||||
/** Company-specific number: B-0001/2026, US-0001/2026, SDT-0001/2026 */
|
/** Company-specific number: B-0001/2026, US-0001/2026, SDT-0001/2026 */
|
||||||
@@ -185,6 +253,8 @@ export interface RegistryEntry {
|
|||||||
externalStatusUrl?: string;
|
externalStatusUrl?: string;
|
||||||
/** External tracking ID (e.g., portal reference number) */
|
/** External tracking ID (e.g., portal reference number) */
|
||||||
externalTrackingId?: string;
|
externalTrackingId?: string;
|
||||||
|
/** AC (Autorizație de Construire) validity tracking */
|
||||||
|
acValidity?: ACValidityTracking;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
notes: string;
|
notes: string;
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
|
|||||||
Reference in New Issue
Block a user