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:
AI Assistant
2026-02-28 16:33:36 +02:00
parent 25338ea4d8
commit 3abf0d189c
13 changed files with 2575 additions and 830 deletions
+24 -24
View File
@@ -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
View File
@@ -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 13 COMPLETE (all 42 tasks).** Next: Phase 4 (Quality & Testing). **Phases 13 COMPLETE (all 42 tasks).** Next: Phase 4 (Quality & Testing).
+28 -10
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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 -2
View File
@@ -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,
+6 -3
View File
@@ -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 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 declarați începerea lucrărilor și 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>
);
}
+70
View File
@@ -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;