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:
@@ -97,7 +97,7 @@ 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 |
|
||||
@@ -246,12 +246,12 @@ 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 |
|
||||
| **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 |
|
||||
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@
|
||||
## 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 |
|
||||
|
||||
+20
-2
@@ -7,18 +7,19 @@
|
||||
## Repository URLs
|
||||
|
||||
| Access | Git Clone URL | Web UI |
|
||||
|---|---|---|
|
||||
| ----------------------- | -------------------------------------------------- | -------------------------------------------- |
|
||||
| **Internal (office)** | `http://10.10.10.166:3002/gitadmin/ArchiTools.git` | http://10.10.10.166:3002/gitadmin/ArchiTools |
|
||||
| **External (internet)** | `https://git.beletage.ro/gitadmin/ArchiTools.git` | https://git.beletage.ro/gitadmin/ArchiTools |
|
||||
|
||||
### Raw File URLs (for AI tools that can fetch URLs)
|
||||
|
||||
Replace `{GITEA}` with whichever base works for you:
|
||||
|
||||
- Internal: `http://10.10.10.166:3002`
|
||||
- External: `https://git.beletage.ro`
|
||||
|
||||
| File | URL |
|
||||
|---|---|
|
||||
| ---------------- | -------------------------------------------------------------- |
|
||||
| CLAUDE.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/CLAUDE.md` |
|
||||
| ROADMAP.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/ROADMAP.md` |
|
||||
| SESSION-LOG.md | `{GITEA}/gitadmin/ArchiTools/raw/branch/main/SESSION-LOG.md` |
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
@@ -12,6 +12,16 @@ import {
|
||||
ExternalLink,
|
||||
KeyRound,
|
||||
X,
|
||||
Globe,
|
||||
Mail,
|
||||
Wifi,
|
||||
Building2,
|
||||
FileCheck2,
|
||||
Fingerprint,
|
||||
Monitor,
|
||||
HardDrive,
|
||||
MoreHorizontal,
|
||||
QrCode,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
@@ -43,14 +53,82 @@ import type { CompanyId } from "@/core/auth/types";
|
||||
import type { VaultEntry, VaultEntryCategory, CustomField } from "../types";
|
||||
import { useVault } from "../hooks/use-vault";
|
||||
|
||||
const CATEGORY_LABELS: Record<VaultEntryCategory, string> = {
|
||||
web: "Web",
|
||||
email: "Email",
|
||||
server: "Server",
|
||||
database: "Bază de date",
|
||||
api: "API",
|
||||
other: "Altele",
|
||||
};
|
||||
// Category definitions with icons
|
||||
|
||||
interface CategoryDef {
|
||||
id: VaultEntryCategory;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const CATEGORY_DEFS: CategoryDef[] = [
|
||||
{
|
||||
id: "web",
|
||||
label: "Web",
|
||||
icon: Globe as unknown as React.ComponentType<{ className?: string }>,
|
||||
description: "Conturi web și platforme online",
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
label: "Email",
|
||||
icon: Mail as unknown as React.ComponentType<{ className?: string }>,
|
||||
description: "Conturi de email",
|
||||
},
|
||||
{
|
||||
id: "wifi",
|
||||
label: "WiFi",
|
||||
icon: Wifi as unknown as React.ComponentType<{ className?: string }>,
|
||||
description: "Rețele WiFi și parole de acces",
|
||||
},
|
||||
{
|
||||
id: "portale-primarii",
|
||||
label: "Portale Primării",
|
||||
icon: Building2 as unknown as React.ComponentType<{ className?: string }>,
|
||||
description: "Conturi portal urbanism",
|
||||
},
|
||||
{
|
||||
id: "avize-online",
|
||||
label: "Avize Online",
|
||||
icon: FileCheck2 as unknown as React.ComponentType<{ className?: string }>,
|
||||
description: "Platforme solicitare avize",
|
||||
},
|
||||
{
|
||||
id: "pin-semnatura",
|
||||
label: "PIN Semnătură",
|
||||
icon: Fingerprint as unknown as React.ComponentType<{ className?: string }>,
|
||||
description: "PIN-uri semnătură electronică",
|
||||
},
|
||||
{
|
||||
id: "software",
|
||||
label: "Software",
|
||||
icon: Monitor as unknown as React.ComponentType<{ className?: string }>,
|
||||
description: "Conturi software (ArchiCAD, Twinmotion etc.)",
|
||||
},
|
||||
{
|
||||
id: "hardware",
|
||||
label: "Echipament HW",
|
||||
icon: HardDrive as unknown as React.ComponentType<{ className?: string }>,
|
||||
description: "iDRAC, PCoIP, VM, ZeroClient",
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
label: "Altele",
|
||||
icon: MoreHorizontal as unknown as React.ComponentType<{
|
||||
className?: string;
|
||||
}>,
|
||||
description: "Alte credențiale",
|
||||
},
|
||||
];
|
||||
|
||||
const CATEGORY_MAP = new Map(CATEGORY_DEFS.map((c) => [c.id, c]));
|
||||
|
||||
function getCategoryDef(id: string): CategoryDef {
|
||||
return (
|
||||
CATEGORY_MAP.get(id as VaultEntryCategory) ??
|
||||
CATEGORY_DEFS[CATEGORY_DEFS.length - 1]!
|
||||
);
|
||||
}
|
||||
|
||||
const COMPANY_LABELS: Record<CompanyId, string> = {
|
||||
beletage: "Beletage",
|
||||
@@ -128,6 +206,7 @@ export function PasswordVaultModule() {
|
||||
);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [qrEntry, setQrEntry] = useState<VaultEntry | null>(null);
|
||||
|
||||
const togglePassword = (id: string) => {
|
||||
setVisiblePasswords((prev) => {
|
||||
@@ -167,13 +246,24 @@ export function PasswordVaultModule() {
|
||||
}
|
||||
};
|
||||
|
||||
// Group entries by category for stats
|
||||
const categoryStats = useMemo(() => {
|
||||
const stats = new Map<string, number>();
|
||||
for (const e of allEntries)
|
||||
stats.set(e.category, (stats.get(e.category) ?? 0) + 1);
|
||||
return stats;
|
||||
}, [allEntries]);
|
||||
|
||||
// Top 3 categories for stat cards
|
||||
const topCategories = useMemo(() => {
|
||||
const sorted = [...categoryStats.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3);
|
||||
return sorted;
|
||||
}, [categoryStats]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-4 py-2 text-xs text-emerald-700 dark:text-emerald-400">
|
||||
Parolele sunt criptate (AES-256-GCM) pe server înainte de stocare.
|
||||
Datele sunt protejate la rest în baza de date.
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Card>
|
||||
@@ -182,30 +272,21 @@ export function PasswordVaultModule() {
|
||||
<p className="text-2xl font-bold">{allEntries.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
{topCategories.map(([catId, count]) => {
|
||||
const def = getCategoryDef(catId);
|
||||
const Icon = def.icon;
|
||||
return (
|
||||
<Card key={catId}>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Web</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{allEntries.filter((e) => e.category === "web").length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Server</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{allEntries.filter((e) => e.category === "server").length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">API</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{allEntries.filter((e) => e.category === "api").length}
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Icon className="h-3 w-3" />
|
||||
{def.label}
|
||||
</p>
|
||||
<p className="text-2xl font-bold">{count}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{viewMode === "list" && (
|
||||
@@ -226,18 +307,22 @@ export function PasswordVaultModule() {
|
||||
updateFilter("category", v as VaultEntryCategory | "all")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toate</SelectItem>
|
||||
{(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map(
|
||||
(c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{CATEGORY_LABELS[c]}
|
||||
<SelectItem value="all">Toate categoriile</SelectItem>
|
||||
{CATEGORY_DEFS.map((c) => {
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{c.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => setViewMode("add")} className="shrink-0">
|
||||
@@ -255,14 +340,22 @@ export function PasswordVaultModule() {
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry) => (
|
||||
{entries.map((entry) => {
|
||||
const catDef = getCategoryDef(entry.category);
|
||||
const CatIcon = catDef.icon;
|
||||
return (
|
||||
<Card key={entry.id} className="group">
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
{/* Category icon */}
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||
<CatIcon className="h-4.5 w-4.5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{entry.label}</p>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{CATEGORY_LABELS[entry.category]}
|
||||
{catDef.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -277,7 +370,7 @@ export function PasswordVaultModule() {
|
||||
<code className="text-xs">
|
||||
{visiblePasswords.has(entry.id)
|
||||
? entry.password
|
||||
: "••••••••••"}
|
||||
: String.fromCharCode(8226).repeat(10)}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -320,7 +413,8 @@ export function PasswordVaultModule() {
|
||||
<ExternalLink className="h-3 w-3" /> {entry.url}
|
||||
</a>
|
||||
)}
|
||||
{entry.customFields && entry.customFields.length > 0 && (
|
||||
{entry.customFields &&
|
||||
entry.customFields.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{entry.customFields.map((cf, i) => (
|
||||
<Badge
|
||||
@@ -335,6 +429,18 @@ export function PasswordVaultModule() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{/* WiFi QR button */}
|
||||
{entry.category === "wifi" && entry.password && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setQrEntry(entry)}
|
||||
title="QR Code WiFi"
|
||||
>
|
||||
<QrCode className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -357,7 +463,8 @@ export function PasswordVaultModule() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -408,10 +515,118 @@ export function PasswordVaultModule() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* WiFi QR Dialog */}
|
||||
<Dialog
|
||||
open={qrEntry !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setQrEntry(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<QrCode className="h-5 w-5" />
|
||||
QR Code WiFi {qrEntry ? `\u2014 ${qrEntry.label}` : ""}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{qrEntry && (
|
||||
<WifiQrDisplay
|
||||
ssid={qrEntry.label}
|
||||
password={qrEntry.password}
|
||||
onCopy={(text) => handleCopy(text, "qr-" + qrEntry.id)}
|
||||
/>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setQrEntry(null)}>
|
||||
Închide
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// WiFi QR Code Display
|
||||
function WifiQrDisplay({
|
||||
ssid,
|
||||
password,
|
||||
onCopy,
|
||||
}: {
|
||||
ssid: string;
|
||||
password: string;
|
||||
onCopy: (text: string) => void;
|
||||
}) {
|
||||
// Build WIFI connection string
|
||||
const wifiString = useMemo(() => {
|
||||
const escaped = (s: string) => s.replace(/[\\;,"`:]/g, (c) => `\\${c}`);
|
||||
return `WIFI:T:WPA;S:${escaped(ssid)};P:${escaped(password)};;`;
|
||||
}, [ssid, password]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{/* Visual WiFi indicator */}
|
||||
<div className="rounded-lg border bg-white p-6 flex flex-col items-center gap-3">
|
||||
<Wifi className="h-16 w-16 text-blue-500" />
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-lg text-gray-900">{ssid}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Copiază string-ul de mai jos într-un generator QR
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection string for copy */}
|
||||
<div className="w-full space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
String conexiune WiFi
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<code className="flex-1 rounded border bg-muted/50 px-3 py-2 text-xs font-mono break-all">
|
||||
{wifiString}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={() => onCopy(wifiString)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Lipește acest text într-un generator QR online (ex:
|
||||
qr-code-generator.com) pentru a crea un QR code scanabil.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Password for quick copy */}
|
||||
<div className="w-full space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Parolă WiFi</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={password}
|
||||
readOnly
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={() => onCopy(password)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Vault Form
|
||||
function VaultForm({
|
||||
initial,
|
||||
onSubmit,
|
||||
@@ -505,6 +720,7 @@ function VaultForm({
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
className="mt-1"
|
||||
required
|
||||
placeholder={category === "wifi" ? "Nume rețea (SSID)" : ""}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -517,15 +733,24 @@ function VaultForm({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(CATEGORY_LABELS) as VaultEntryCategory[]).map(
|
||||
(c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{CATEGORY_LABELS[c]}
|
||||
{CATEGORY_DEFS.map((c) => {
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{c.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{CATEGORY_DEFS.find((c) => c.id === category) && (
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
{CATEGORY_DEFS.find((c) => c.id === category)?.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
@@ -548,14 +773,20 @@ function VaultForm({
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Utilizator</Label>
|
||||
<Label>
|
||||
{category === "pin-semnatura" ? "Titular" : "Utilizator"}
|
||||
</Label>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder={
|
||||
category === "pin-semnatura" ? "Numele titularului" : ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{category !== "wifi" && category !== "pin-semnatura" && (
|
||||
<div>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
@@ -566,15 +797,20 @@ function VaultForm({
|
||||
placeholder="utilizator@exemplu.ro"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label>Parolă</Label>
|
||||
<Label>{category === "pin-semnatura" ? "PIN" : "Parolă"}</Label>
|
||||
<div className="mt-1 flex gap-1.5">
|
||||
<Input
|
||||
type="text"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="flex-1 font-mono text-sm"
|
||||
placeholder={
|
||||
category === "pin-semnatura" ? "PIN semnatura electronica" : ""
|
||||
}
|
||||
/>
|
||||
{category !== "pin-semnatura" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -584,6 +820,7 @@ function VaultForm({
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{password && (
|
||||
<div className="mt-2 space-y-1">
|
||||
@@ -613,7 +850,8 @@ function VaultForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password generator options */}
|
||||
{/* Password generator options - hide for PIN */}
|
||||
{category !== "pin-semnatura" && (
|
||||
<div className="rounded border p-3 space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Generator parolă
|
||||
@@ -624,7 +862,9 @@ function VaultForm({
|
||||
<Input
|
||||
type="number"
|
||||
value={genLength}
|
||||
onChange={(e) => setGenLength(parseInt(e.target.value, 10) || 8)}
|
||||
onChange={(e) =>
|
||||
setGenLength(parseInt(e.target.value, 10) || 8)
|
||||
}
|
||||
className="w-16 text-sm"
|
||||
min={4}
|
||||
max={64}
|
||||
@@ -680,7 +920,9 @@ function VaultForm({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{category !== "wifi" && (
|
||||
<div>
|
||||
<Label>URL</Label>
|
||||
<Input
|
||||
@@ -690,6 +932,7 @@ function VaultForm({
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom fields */}
|
||||
<div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 că nu se mai dorește începerea construcției
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Abandoned state */}
|
||||
{ac.abandonedDeclaration && (
|
||||
<div className="rounded border border-gray-500/30 bg-gray-500/5 p-3 text-xs space-y-2">
|
||||
<p className="font-medium text-muted-foreground flex items-center gap-1.5">
|
||||
<XCircle className="h-4 w-4" />
|
||||
Construcția a fost declarată abandonată
|
||||
</p>
|
||||
{ac.abandonedDate && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Data declarației: {formatDate(ac.abandonedDate)}
|
||||
</p>
|
||||
)}
|
||||
<Textarea
|
||||
value={ac.abandonedReason ?? ""}
|
||||
onChange={(e) => update({ abandonedReason: e.target.value })}
|
||||
placeholder="Motiv (opțional)..."
|
||||
rows={2}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validity extension option (within validity phase) */}
|
||||
{ac.phase === "validity" &&
|
||||
!ac.worksAnnouncedDate &&
|
||||
!ac.abandonedDeclaration &&
|
||||
computedData.daysToExtensionDeadline <= 90 && (
|
||||
<div className="rounded border border-amber-500/30 bg-amber-500/5 p-3 space-y-2">
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300 flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
Opțiune: Prelungire valabilitate AC cu 24 luni
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Termen limită depunere cerere:{" "}
|
||||
<strong>
|
||||
{formatDate(computedData.extensionRequestDeadlineStr)}
|
||||
</strong>{" "}
|
||||
(45 zile lucrătoare înainte de expirare). Atenție: este mai
|
||||
avantajos să declarați începerea lucrărilor și să cereți
|
||||
prelungirea duratei de execuție — astfel aveți mai mult timp
|
||||
total.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import { RegistryFilters } from "./registry-filters";
|
||||
import { RegistryTable } from "./registry-table";
|
||||
import { 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)}
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="intrat">Intrat</SelectItem>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user