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": {
File diff suppressed because it is too large Load Diff
+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;