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)
| # | Module | Route | Version | Key Features |
| --- | ---------------------- | --------------------- | ------- | --------------------------------------------------------------------------------------------------- |
| 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 |
| 3 | **Word XML Generator** | `/word-xml` | 0.1.0 | Category-based XML gen, simple/advanced mode, ZIP export |
| # | Module | Route | Version | Key Features |
| --- | ---------------------- | --------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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 |
| 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 |
| 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 |
| 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** |
| 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 |
| 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 |
| 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 |
| 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 |
| 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** |
| 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 |
| 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 |
| 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 |
### Registratura — Legal Deadline Tracking (Termene Legale)
@@ -245,15 +245,15 @@ src/modules/<name>/
## Current Integrations
| Feature | Status | Notes |
| ------------------- | ---------------------- | --------------------------------------------------------------- |
| **Authentik SSO** | ✅ Active | NextAuth v4 + OIDC, group→role/company mapping |
| **PostgreSQL** | ✅ Active | Prisma ORM, `KeyValueStore` model, `/api/storage` route |
| **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 |
| **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 |
| **N8N automations** | Webhook URL configured | For notifications, backups, workflows |
| Feature | Status | Notes |
| -------------------- | ---------------------- | --------------------------------------------------------------- |
| **Authentik SSO** | ✅ Active | NextAuth v4 + OIDC, group→role/company mapping |
| **PostgreSQL** | ✅ Active | Prisma ORM, `KeyValueStore` model, `/api/storage` route |
| **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 |
| **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 |
| **N8N automations** | Webhook URL configured | For notifications, backups, workflows |
---
+16 -16
View File
@@ -30,22 +30,22 @@
## Current Module Status (after Phase 3 completion)
| # | Module | Version | Status | Remaining Gaps | Future Enhancements |
| --- | ------------------ | ------- | -------- | ----------------------------------------------------- | ------------------------------------------- |
| 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 |
| 3 | Word XML | 0.1.0 | COMPLETE | — | Schema validator, visual mapper |
| 4 | Digital Signatures | 0.1.0 | COMPLETE | — | Permission layers, document insertion |
| 5 | Password Vault | 0.2.0 | COMPLETE | — | Hardware key, rotation reminders, Passbolt |
| 6 | IT Inventory | 0.2.0 | COMPLETE | — | Network scan import |
| 7 | Address Book | 0.1.0 | COMPLETE | — | Email sync, deduplication |
| 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 |
| 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 |
| 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 |
| 14 | Hot Desk | 0.1.0 | COMPLETE | — | — |
| # | Module | Version | Status | Remaining Gaps | Future Enhancements |
| --- | ------------------ | ------- | -------- | ---------------------------------------- | ------------------------------------------------- |
| 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 |
| 3 | Word XML | 0.1.0 | COMPLETE | — | Schema validator, visual mapper |
| 4 | Digital Signatures | 0.1.0 | COMPLETE | — | Permission layers, document insertion |
| 5 | Password Vault | 0.2.0 | COMPLETE | — | Hardware key, rotation reminders, Passbolt |
| 6 | IT Inventory | 0.2.0 | COMPLETE | — | Network scan import |
| 7 | Address Book | 0.1.0 | COMPLETE | — | Email sync, deduplication |
| 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 |
| 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 |
| 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 |
| 14 | Hot Desk | 0.1.0 | COMPLETE | — | — |
**Phases 13 COMPLETE (all 42 tasks).** Next: Phase 4 (Quality & Testing).
+28 -10
View File
@@ -6,24 +6,25 @@
## Repository URLs
| Access | Git Clone URL | Web UI |
|---|---|---|
| **Internal (office)** | `http://10.10.10.166:3002/gitadmin/ArchiTools.git` | http://10.10.10.166:3002/gitadmin/ArchiTools |
| **External (internet)** | `https://git.beletage.ro/gitadmin/ArchiTools.git` | https://git.beletage.ro/gitadmin/ArchiTools |
| Access | Git Clone URL | Web UI |
| ----------------------- | -------------------------------------------------- | -------------------------------------------- |
| **Internal (office)** | `http://10.10.10.166:3002/gitadmin/ArchiTools.git` | http://10.10.10.166:3002/gitadmin/ArchiTools |
| **External (internet)** | `https://git.beletage.ro/gitadmin/ArchiTools.git` | https://git.beletage.ro/gitadmin/ArchiTools |
### Raw File URLs (for AI tools that can fetch URLs)
Replace `{GITEA}` with whichever base works for you:
- Internal: `http://10.10.10.166:3002`
- External: `https://git.beletage.ro`
| File | URL |
|---|---|
| CLAUDE.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/CLAUDE.md` |
| ROADMAP.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/ROADMAP.md` |
| SESSION-LOG.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/SESSION-LOG.md` |
| File | URL |
| ---------------- | -------------------------------------------------------------- |
| CLAUDE.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/CLAUDE.md` |
| ROADMAP.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/ROADMAP.md` |
| SESSION-LOG.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/SESSION-LOG.md` |
| SESSION-GUIDE.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/SESSION-GUIDE.md` |
| 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
@@ -187,25 +188,32 @@ Run `npx next build`, push to main, update ROADMAP.md + SESSION-LOG.md, notify m
## Tool-Specific Notes
### Claude Code (CLI)
Works natively. Clone/pull, read files, edit, build, push — all built in.
### ChatGPT Codex
Give it the repo URL. It can clone via git, read files, and push.
Use the external URL: `https://git.beletage.ro/gitadmin/ArchiTools.git`
### VS Code + Copilot / Cursor / Windsurf
Clone the repo locally first, then open in the IDE. The AI agent reads files from disk.
```bash
git clone https://git.beletage.ro/gitadmin/ArchiTools.git
cd ArchiTools && npm install && code .
```
### Google Antigravity
Give it the repo URL. It can clone and work autonomously.
Use: `https://git.beletage.ro/gitadmin/ArchiTools.git`
### Phone (ChatGPT app, Claude app)
Can't run code directly, but can read files via raw URLs and give guidance:
```
Read these URLs and help me plan the next task:
https://git.beletage.ro/gitadmin/ArchiTools/raw/branch/main/CLAUDE.md
@@ -218,6 +226,7 @@ https://git.beletage.ro/gitadmin/ArchiTools/raw/branch/main/SESSION-LOG.md
## Git Workflow
### First time (any device)
```bash
git clone https://git.beletage.ro/gitadmin/ArchiTools.git
cd ArchiTools
@@ -226,12 +235,14 @@ npm run dev
```
### Session start (pull latest)
```bash
git pull origin main
npm install
```
### Session end (push)
```bash
npx next build
git add <specific-files>
@@ -246,26 +257,33 @@ git push origin main
## Files to Update After Every Session
### 1. `ROADMAP.md` — Mark done tasks
```markdown
### 1.01 ✅ (2026-02-18) `[LIGHT]` Verify Email Signature Logo Files
```
### 2. `SESSION-LOG.md` — Add entry at the TOP
```markdown
## Session — 2026-02-18 (Sonnet 4.6)
### Completed
- 1.01: Verified logo files
- 1.02: Added address toggle
### In Progress
- 1.03: Prompt templates — 4 of 10 done
### Blockers
- Need logo files from user
### Notes
- Build passes, commit abc1234
---
```
+1 -1
View File
@@ -45,7 +45,7 @@ export const DEFAULT_FLAGS: FeatureFlag[] = [
{
key: "module.password-vault",
enabled: true,
label: "Seif Parole",
label: "Parole Uzuale",
description: "Depozit intern de credențiale",
category: "module",
overridable: true,
+1 -1
View File
@@ -79,7 +79,7 @@ export const ro: Labels = {
description: "Bibliotecă semnături digitale și ștampile scanate",
},
"password-vault": {
title: "Seif Parole",
title: "Parole Uzuale",
description: "Depozit intern de credențiale partajate",
},
"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 = {
id: "password-vault",
name: "Seif Parole",
name: "Parole Uzuale",
description:
"Manager securizat de parole și credențiale cu criptare AES-256-GCM",
icon: "lock",
@@ -10,7 +10,7 @@ export const passwordVaultConfig: ModuleConfig = {
category: "operations",
featureFlag: "module.password-vault",
visibility: "admin",
version: "0.2.0",
version: "0.3.0",
dependencies: [],
storageNamespace: "password-vault",
navOrder: 11,
+6 -3
View File
@@ -4,9 +4,12 @@ import type { CompanyId } from "@/core/auth/types";
export type VaultEntryCategory =
| "web"
| "email"
| "server"
| "database"
| "api"
| "wifi"
| "portale-primarii"
| "avize-online"
| "pin-semnatura"
| "software"
| "hardware"
| "other";
/** 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 { RegistryEntryForm } from "./registry-entry-form";
import { DeadlineDashboard } from "./deadline-dashboard";
import { ThreadExplorer } from "./thread-explorer";
import { CloseGuardDialog } from "./close-guard-dialog";
import { getOverdueDays } from "../services/registry-service";
import { aggregateDeadlines } from "../services/deadline-service";
@@ -249,6 +250,7 @@ export function RegistraturaModule() {
<Tabs defaultValue="registru">
<TabsList>
<TabsTrigger value="registru">Registru</TabsTrigger>
<TabsTrigger value="fire">Fire conversație</TabsTrigger>
<TabsTrigger value="termene">
Termene legale
{alertCount > 0 && (
@@ -408,6 +410,13 @@ export function RegistraturaModule() {
</div>
</TabsContent>
<TabsContent value="fire">
<ThreadExplorer
entries={allEntries}
onNavigateEntry={handleNavigateEntry}
/>
</TabsContent>
<TabsContent value="termene">
<DeadlineDashboard
entries={allEntries}
@@ -13,6 +13,8 @@ import {
AlertTriangle,
Calendar,
Globe,
ArrowDownToLine,
ArrowUpFromLine,
} from "lucide-react";
import type { CompanyId } from "@/core/auth/types";
import type {
@@ -22,6 +24,7 @@ import type {
RegistryAttachment,
TrackedDeadline,
DeadlineResolution,
ACValidityTracking,
} from "../types";
import { DEFAULT_DOC_TYPE_LABELS } from "../types";
import { Input } from "@/shared/components/ui/input";
@@ -53,6 +56,8 @@ import { DeadlineResolveDialog } from "./deadline-resolve-dialog";
import { QuickContactDialog } from "./quick-contact-dialog";
import { ThreadView } from "./thread-view";
import { ClosureBanner } from "./closure-banner";
import { ACValidityTracker } from "./ac-validity-tracker";
import { cn } from "@/shared/lib/utils";
import {
createTrackedDeadline,
resolveDeadline as resolveDeadlineFn,
@@ -91,7 +96,12 @@ export function RegistryEntryForm({
const { tags: docTypeTags } = useTags("document-type");
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 map = new Map<string, string>();
// Add defaults
@@ -105,8 +115,18 @@ export function RegistryEntryForm({
map.set(key, tag.label);
}
}
return map;
}, [docTypeTags]);
// Add locally-created types (before Tag Manager syncs)
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>(
initial?.direction ?? "intrat",
@@ -169,6 +189,9 @@ export function RegistryEntryForm({
const [externalTrackingId, setExternalTrackingId] = useState(
initial?.externalTrackingId ?? "",
);
const [acValidity, setAcValidity] = useState<ACValidityTracking | undefined>(
initial?.acValidity,
);
// ── Submission lock + file upload tracking ──
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -295,11 +318,14 @@ export function RegistryEntryForm({
setQuickContactOpen(false);
};
// ── Custom doc type creation ──
// ── Custom doc type creation — adds to local state immediately + Tag Manager ──
const handleAddCustomDocType = async () => {
const label = customDocType.trim();
if (!label) return;
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);
setCustomDocType("");
if (onCreateDocType) {
@@ -367,6 +393,7 @@ export function RegistryEntryForm({
expiryAlertDays: expiryDate ? expiryAlertDays : undefined,
externalStatusUrl: externalStatusUrl || undefined,
externalTrackingId: externalTrackingId || undefined,
acValidity: acValidity,
linkedEntryIds,
attachments,
trackedDeadlines:
@@ -465,18 +492,34 @@ export function RegistryEntryForm({
<div className="grid gap-4 sm:grid-cols-3">
<div>
<Label>Direcție</Label>
<Select
value={direction}
onValueChange={(v) => setDirection(v as RegistryDirection)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="intrat">Intrat</SelectItem>
<SelectItem value="iesit">Ieșit</SelectItem>
</SelectContent>
</Select>
<div className="mt-1 flex rounded-lg border bg-muted/30 p-1">
<button
type="button"
onClick={() => setDirection("intrat")}
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 === "intrat"
? "bg-blue-500 text-white shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background",
)}
>
<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>
<Label>Tip document</Label>
@@ -859,6 +902,13 @@ export function RegistryEntryForm({
})()}
</div>
{/* AC Validity Tracker */}
<ACValidityTracker
value={acValidity}
onChange={setAcValidity}
entryDate={date}
/>
{/* Web scraping prep — external tracking */}
<div className="grid gap-4 sm:grid-cols-2">
<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;
}
// ── 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 {
id: string;
/** Company-specific number: B-0001/2026, US-0001/2026, SDT-0001/2026 */
@@ -185,6 +253,8 @@ export interface RegistryEntry {
externalStatusUrl?: string;
/** External tracking ID (e.g., portal reference number) */
externalTrackingId?: string;
/** AC (Autorizație de Construire) validity tracking */
acValidity?: ACValidityTracking;
tags: string[];
notes: string;
visibility: Visibility;