From 2be0462e0db8d37909ab94abff6ee30f57956c7b Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Fri, 27 Feb 2026 15:33:29 +0200 Subject: [PATCH] feat(registratura): 3.02 bidirectional integration, simplified status, threads - Dynamic document types: string-based DocumentType synced with Tag Manager (new types auto-create tags under 'document-type' category) - Added default types: 'Apel telefonic', 'Videoconferinta' - Bidirectional Address Book: quick-create contacts from sender/recipient/ assignee fields via QuickContactDialog popup - Simplified status: Switch toggle replaces dropdown (default open) - Responsabil (Assignee) field with contact autocomplete (ERP-ready) - Entry threads: threadParentId links entries as replies, ThreadView shows parent/current/children tree with branching support - Info tooltips on deadline, status, and assignee fields - New Resp. column and thread icon in registry table - All changes backward-compatible with existing data --- ROADMAP.md | 31 +- SESSION-LOG.md | 39 ++ .../components/quick-contact-dialog.tsx | 111 ++++ .../components/registratura-module.tsx | 230 +++++-- .../components/registry-entry-form.tsx | 598 ++++++++++++++---- .../components/registry-filters.tsx | 59 +- .../components/registry-table.tsx | 162 +++-- .../registratura/components/thread-view.tsx | 151 +++++ src/modules/registratura/index.ts | 19 +- src/modules/registratura/types.ts | 72 ++- 10 files changed, 1199 insertions(+), 273 deletions(-) create mode 100644 src/modules/registratura/components/quick-contact-dialog.tsx create mode 100644 src/modules/registratura/components/thread-view.tsx diff --git a/ROADMAP.md b/ROADMAP.md index 168e4de..550b9a1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -252,7 +252,7 @@ **Status:** ✅ Done. Sidebar redesigned: logos in centered row (flex-1, theme-aware, dual-render light+dark), "ArchiTools" text below, animated theme toggle (Sun/Moon lucide icons, gradient background, stars/clouds). -### 3.02 `[BUSINESS]` Registratura — Integrare și UX +### 3.02 ✅ `[BUSINESS]` Registratura — Integrare și UX (2026-02-27) **Cerințe noi:** @@ -263,6 +263,14 @@ - **Pregătire Integrare ERP (Responsabil):** Adăugarea unui câmp "Responsabil" (Assignee) pentru a aloca sarcini interne, gândit arhitectural pentru a putea fi expus/sincronizat ușor printr-un API/Webhook viitor către un sistem ERP extern. - **Legături între intrări/ieșiri (Thread-uri & Branches):** Posibilitatea de a lega o ieșire de o intrare specifică (ex: "Răspuns la adresa nr. X"), creând un "fir" (thread) vizual al conversației instituționale. Trebuie să suporte și "branching" (ex: o intrare generează mai multe ieșiri conexe către instituții diferite). UI-ul trebuie să rămână extrem de simplu și intuitiv (ex: vizualizare tip arbore simplificat sau listă indentată în detaliile documentului). +**Status:** ✅ Done. All 6 sub-features implemented: +1. **Dynamic doc types** — Select + inline "Tip nou" input. New types auto-created as Tag Manager tags (category: document-type). Added "Apel telefonic" and "Videoconferință" as defaults. +2. **Bidirectional Address Book** — Autocomplete shows "Creează contact" button when no match. QuickContactDialog popup creates contact in Address Book with minimal data (Name required, Phone/Email optional). +3. **Simplified status** — Replaced Status dropdown with Switch toggle "Închis/Deschis". Default is open. +4. **Internal deadline tooltip** — Added Info tooltip explaining "Termen limită intern" vs legal deadlines. Also added tooltips on "Închis" and "Responsabil" fields. +5. **Responsabil (Assignee)** — New field with contact autocomplete + quick-create. ERP-ready with separate assigneeContactId. Shown in registry table as "Resp." column. +6. **Threads & Branching** — `threadParentId` field links entries as reply-to. Thread search with direction badges. ThreadView component shows parent, current entry, siblings (branches), and child replies as indented tree. Thread icon in table. Click to navigate between threaded entries. + ### 3.03 `[BUSINESS]` Registratura — Termene Legale (Flux Nou) **Cerințe noi:** @@ -519,6 +527,7 @@ AI_DEFAULT_MODEL=claude-sonnet-4-6-20261001 **Status:** ✅ Done. NextAuth v4 + Authentik OIDC provider configured. Group→role mapping (authentik groups → admin/manager/user). Group→company mapping (beletage/urban-switch/studii-de-teren). Cookie-based session. `useAuth()` returns real user. Header shows user name/email + logout. Sign in with Authentik page works. Env vars (hardcoded in docker-compose.yml for Portainer CE): + - `NEXTAUTH_URL=https://tools.beletage.ro` - `NEXTAUTH_SECRET`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET`, `AUTHENTIK_ISSUER` @@ -661,16 +670,16 @@ Env vars (hardcoded in docker-compose.yml for Portainer CE): ## Infrastructure Credentials Needed -| Service | What | When Needed | Status | -| ------------------------ | --------------------------------------- | ------------------- | ------ | -| **US/SDT Logos** | SVG/PNG logo files | Phase 1 (task 1.01) | ✅ Done | -| **US/SDT Addresses** | Office addresses for email signature | Phase 1 (task 1.02) | ✅ Done (placeholder) | -| **Anthropic API Key** | `sk-ant-...` from console.anthropic.com | Phase 5 (task 5.01) | Pending | -| **OpenAI API Key** | `sk-...` from platform.openai.com | Phase 5 (task 5.01) | Pending | -| **Authentik Admin** | Login to create OAuth app at :9100 | Phase 6 (task 6.01) | ✅ Done | -| **MinIO Credentials** | Access key + secret key for :9003 | Phase 7 (task 7.04) | ✅ Done | -| **PostgreSQL** | Database + password | Phase 7 (task 7.01) | ✅ Done | -| **Gitea Actions Runner** | Registration token from Gitea admin | Phase 10 (task 10.01) | Pending | +| Service | What | When Needed | Status | +| ------------------------ | --------------------------------------- | --------------------- | --------------------- | +| **US/SDT Logos** | SVG/PNG logo files | Phase 1 (task 1.01) | ✅ Done | +| **US/SDT Addresses** | Office addresses for email signature | Phase 1 (task 1.02) | ✅ Done (placeholder) | +| **Anthropic API Key** | `sk-ant-...` from console.anthropic.com | Phase 5 (task 5.01) | Pending | +| **OpenAI API Key** | `sk-...` from platform.openai.com | Phase 5 (task 5.01) | Pending | +| **Authentik Admin** | Login to create OAuth app at :9100 | Phase 6 (task 6.01) | ✅ Done | +| **MinIO Credentials** | Access key + secret key for :9003 | Phase 7 (task 7.04) | ✅ Done | +| **PostgreSQL** | Database + password | Phase 7 (task 7.01) | ✅ Done | +| **Gitea Actions Runner** | Registration token from Gitea admin | Phase 10 (task 10.01) | Pending | --- diff --git a/SESSION-LOG.md b/SESSION-LOG.md index 16ea8dd..05e74c7 100644 --- a/SESSION-LOG.md +++ b/SESSION-LOG.md @@ -4,6 +4,45 @@ --- +## Session — 2026-02-27 night (GitHub Copilot - Claude Opus 4.6) + +### Context + +Continued Phase 3 refinements. Picked task 3.02 (HEAVY) — Registratura bidirectional integration and UX improvements. + +### Completed + +- **3.02 ✅ Registratura — Integrare și UX:** + - **Dynamic document types:** DocumentType changed from enum to string-based. Default types include "Apel telefonic" and "Videoconferință". Inline "Tip nou" input with Plus button auto-creates Tag Manager tags under category "document-type". Form select pulls from both defaults and Tag Manager. + - **Bidirectional Address Book:** Sender/Recipient/Assignee autocomplete shows "Creează contact" button when typed name doesn't match any existing contact. QuickContactDialog popup creates contact in Address Book with Name (required), Phone (optional), Email (optional). + - **Simplified status:** Removed Status dropdown. Replaced with Switch toggle — default is "Deschis" (open). Toggle to "Închis" when done. No "deschis" option in UI, everything is implicitly open. + - **Internal deadline tooltip:** Added Info icon tooltips on "Termen limită intern" (explaining it's not a legal deadline), "Închis" (explaining default behavior), and "Responsabil" (explaining ERP prep). + - **Responsabil (Assignee) field:** New field with contact autocomplete + quick-create. Separate `assignee` and `assigneeContactId` on RegistryEntry type. Shown in registry table as "Resp." column with User icon. + - **Entry threads & branching:** New `threadParentId` field on RegistryEntry. Thread parent selector with search. ThreadView component showing parent → current → children tree with direction badges, siblings (branches), and click-to-navigate. Thread icon (GitBranch) in registry table. Supports one-to-many branching (one entry generates multiple outgoing replies). + +### Files Created + +- `src/modules/registratura/components/quick-contact-dialog.tsx` — Rapid contact creation popup from Registratura +- `src/modules/registratura/components/thread-view.tsx` — Thread tree visualization (parent, current, siblings, children) + +### Files Modified + +- `src/modules/registratura/types.ts` — Dynamic DocumentType, DEFAULT_DOC_TYPE_LABELS, new fields (assignee, assigneeContactId, threadParentId) +- `src/modules/registratura/index.ts` — Export new constants +- `src/modules/registratura/components/registry-entry-form.tsx` — Complete rewrite: dynamic doc types, contact quick-create, Switch status, Responsabil field, thread parent selector, tooltips +- `src/modules/registratura/components/registratura-module.tsx` — Wired useTags + useContacts, handleCreateContact + handleCreateDocType callbacks, thread navigation +- `src/modules/registratura/components/registry-table.tsx` — Dynamic doc type labels, Resp. column, thread icon +- `src/modules/registratura/components/registry-filters.tsx` — Uses DEFAULT_DOC_TYPE_LABELS from types.ts +- `ROADMAP.md` — Marked 3.02 as done + +### Notes + +- Existing data is backward-compatible: old entries without `assignee`, `threadParentId` render fine (fields are optional) +- Quick contact creation sets type to "collaborator" and adds note "Creat automat din Registratură" +- DocumentType is now `string` but retains defaults via `DEFAULT_DOCUMENT_TYPES` array + +--- + ## Session — 2026-02-27 evening (GitHub Copilot - Claude Opus 4.6) ### Context diff --git a/src/modules/registratura/components/quick-contact-dialog.tsx b/src/modules/registratura/components/quick-contact-dialog.tsx new file mode 100644 index 0000000..2f7261d --- /dev/null +++ b/src/modules/registratura/components/quick-contact-dialog.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useState } from "react"; +import { UserPlus } from "lucide-react"; +import { Input } from "@/shared/components/ui/input"; +import { Label } from "@/shared/components/ui/label"; +import { Button } from "@/shared/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/shared/components/ui/dialog"; + +interface QuickContactDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Pre-filled name from the text the user typed */ + initialName: string; + onConfirm: (data: { name: string; phone: string; email: string }) => void; +} + +/** + * Rapid popup for creating a new Address Book contact from Registratura. + * Only requires Name; Phone and Email are optional. + */ +export function QuickContactDialog({ + open, + onOpenChange, + initialName, + onConfirm, +}: QuickContactDialogProps) { + const [name, setName] = useState(initialName); + const [phone, setPhone] = useState(""); + const [email, setEmail] = useState(""); + + // Reset when dialog opens with new name + const handleOpenChange = (o: boolean) => { + if (o) { + setName(initialName); + setPhone(""); + setEmail(""); + } + onOpenChange(o); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + onConfirm({ name: name.trim(), phone: phone.trim(), email: email.trim() }); + }; + + return ( + + + + + + Contact nou rapid + + +
+
+ + setName(e.target.value)} + className="mt-1" + required + autoFocus + /> +
+
+
+ + setPhone(e.target.value)} + className="mt-1" + placeholder="Opțional" + /> +
+
+ + setEmail(e.target.value)} + className="mt-1" + placeholder="Opțional" + /> +
+
+ + + + +
+
+
+ ); +} diff --git a/src/modules/registratura/components/registratura-module.tsx b/src/modules/registratura/components/registratura-module.tsx index d1dff6c..3ee85f0 100644 --- a/src/modules/registratura/components/registratura-module.tsx +++ b/src/modules/registratura/components/registratura-module.tsx @@ -1,51 +1,140 @@ -'use client'; +"use client"; -import { useState, useMemo } from 'react'; -import { Plus } from 'lucide-react'; -import { Button } from '@/shared/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card'; -import { Badge } from '@/shared/components/ui/badge'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/components/ui/tabs'; +import { useState, useMemo, useCallback } from "react"; +import { Plus } from "lucide-react"; +import { Button } from "@/shared/components/ui/button"; import { - Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, -} from '@/shared/components/ui/dialog'; -import { useRegistry } from '../hooks/use-registry'; -import { RegistryFilters } from './registry-filters'; -import { RegistryTable } from './registry-table'; -import { RegistryEntryForm } from './registry-entry-form'; -import { DeadlineDashboard } from './deadline-dashboard'; -import { getOverdueDays } from '../services/registry-service'; -import { aggregateDeadlines } from '../services/deadline-service'; -import type { RegistryEntry, DeadlineResolution } from '../types'; + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/shared/components/ui/card"; +import { Badge } from "@/shared/components/ui/badge"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/shared/components/ui/tabs"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/shared/components/ui/dialog"; +import { useRegistry } from "../hooks/use-registry"; +import { useContacts } from "@/modules/address-book/hooks/use-contacts"; +import { useTags } from "@/core/tagging"; +import { RegistryFilters } from "./registry-filters"; +import { RegistryTable } from "./registry-table"; +import { RegistryEntryForm } from "./registry-entry-form"; +import { DeadlineDashboard } from "./deadline-dashboard"; +import { getOverdueDays } from "../services/registry-service"; +import { aggregateDeadlines } from "../services/deadline-service"; +import type { RegistryEntry, DeadlineResolution } from "../types"; +import type { AddressContact } from "@/modules/address-book/types"; -type ViewMode = 'list' | 'add' | 'edit'; +type ViewMode = "list" | "add" | "edit"; export function RegistraturaModule() { const { - entries, allEntries, loading, filters, updateFilter, - addEntry, updateEntry, removeEntry, closeEntry, - addDeadline, resolveDeadline, removeDeadline, + entries, + allEntries, + loading, + filters, + updateFilter, + addEntry, + updateEntry, + removeEntry, + closeEntry, + addDeadline, + resolveDeadline, + removeDeadline, } = useRegistry(); - const [viewMode, setViewMode] = useState('list'); + const { addContact } = useContacts(); + const { createTag } = useTags("document-type"); + + const [viewMode, setViewMode] = useState("list"); const [editingEntry, setEditingEntry] = useState(null); const [closingId, setClosingId] = useState(null); - const handleAdd = async (data: Omit) => { + // ── Bidirectional Address Book integration ── + const handleCreateContact = useCallback( + async (data: { + name: string; + phone: string; + email: string; + }): Promise => { + try { + const contact = await addContact({ + name: data.name, + company: "", + type: "collaborator", + email: data.email, + email2: "", + phone: data.phone, + phone2: "", + address: "", + department: "", + role: "", + website: "", + projectIds: [], + contactPersons: [], + tags: [], + notes: "Creat automat din Registratură", + visibility: "all", + }); + return contact; + } catch { + return undefined; + } + }, + [addContact], + ); + + // ── Bidirectional Tag Manager integration ── + const handleCreateDocType = useCallback( + async (label: string) => { + try { + await createTag({ + label, + category: "document-type", + scope: "global", + color: "#64748b", + }); + } catch { + // tag may already exist — ignore + } + }, + [createTag], + ); + + const handleAdd = async ( + data: Omit, + ) => { await addEntry(data); - setViewMode('list'); + setViewMode("list"); }; const handleEdit = (entry: RegistryEntry) => { setEditingEntry(entry); - setViewMode('edit'); + setViewMode("edit"); }; - const handleUpdate = async (data: Omit) => { + const handleNavigateEntry = (entry: RegistryEntry) => { + setEditingEntry(entry); + setViewMode("edit"); + }; + + const handleUpdate = async ( + data: Omit, + ) => { if (!editingEntry) return; await updateEntry(editingEntry.id, data); setEditingEntry(null); - setViewMode('list'); + setViewMode("list"); }; const handleDelete = async (id: string) => { @@ -69,7 +158,7 @@ export function RegistraturaModule() { }; const handleCancel = () => { - setViewMode('list'); + setViewMode("list"); setEditingEntry(null); }; @@ -84,24 +173,34 @@ export function RegistraturaModule() { await resolveDeadline(entryId, deadlineId, resolution, note); }; - const handleAddChainedDeadline = async (entryId: string, typeId: string, startDate: string, parentId: string) => { + const handleAddChainedDeadline = async ( + entryId: string, + typeId: string, + startDate: string, + parentId: string, + ) => { await addDeadline(entryId, typeId, startDate, parentId); }; // Stats const total = allEntries.length; - const open = allEntries.filter((e) => e.status === 'deschis').length; + const open = allEntries.filter((e) => e.status === "deschis").length; const overdue = allEntries.filter((e) => { - if (e.status !== 'deschis') return false; + if (e.status !== "deschis") return false; const days = getOverdueDays(e.deadline); return days !== null && days > 0; }).length; - const intrat = allEntries.filter((e) => e.direction === 'intrat').length; + const intrat = allEntries.filter((e) => e.direction === "intrat").length; - const deadlineStats = useMemo(() => aggregateDeadlines(allEntries), [allEntries]); + const deadlineStats = useMemo( + () => aggregateDeadlines(allEntries), + [allEntries], + ); const urgentDeadlines = deadlineStats.urgent + deadlineStats.overdue; - const closingEntry = closingId ? allEntries.find((e) => e.id === closingId) : null; + const closingEntry = closingId + ? allEntries.find((e) => e.id === closingId) + : null; return ( @@ -110,7 +209,10 @@ export function RegistraturaModule() { Termene legale {urgentDeadlines > 0 && ( - + {urgentDeadlines} )} @@ -123,15 +225,19 @@ export function RegistraturaModule() {
- 0 ? 'destructive' : undefined} /> + 0 ? "destructive" : undefined} + />
- {viewMode === 'list' && ( + {viewMode === "list" && ( <>
-
@@ -152,12 +258,14 @@ export function RegistraturaModule() { )} - {viewMode === 'add' && ( + {viewMode === "add" && ( Înregistrare nouă - Nr. auto + + Nr. auto + @@ -165,12 +273,14 @@ export function RegistraturaModule() { allEntries={allEntries} onSubmit={handleAdd} onCancel={handleCancel} + onCreateContact={handleCreateContact} + onCreateDocType={handleCreateDocType} /> )} - {viewMode === 'edit' && editingEntry && ( + {viewMode === "edit" && editingEntry && ( Editare — {editingEntry.number} @@ -181,26 +291,40 @@ export function RegistraturaModule() { allEntries={allEntries} onSubmit={handleUpdate} onCancel={handleCancel} + onCreateContact={handleCreateContact} + onCreateDocType={handleCreateDocType} + onNavigateEntry={handleNavigateEntry} /> )} {/* Close confirmation dialog */} - { if (!open) setClosingId(null); }}> + { + if (!open) setClosingId(null); + }} + > Închide înregistrarea

- Această înregistrare are {closingEntry?.linkedEntryIds?.length ?? 0} înregistrări legate. - Vrei să le închizi și pe acestea? + Această înregistrare are{" "} + {closingEntry?.linkedEntryIds?.length ?? 0} înregistrări + legate. Vrei să le închizi și pe acestea?

- - + + ))} + {showCreateButton && ( + + )} + + ); + }; + + // Thread parent entry for display + const threadParent = + threadParentId && allEntries + ? allEntries.find((e) => e.id === threadParentId) + : null; + return (
+ {/* Thread view (if editing an entry that's in a thread) */} + {initial && + allEntries && + (initial.threadParentId || + allEntries.some((e) => e.threadParentId === initial.id)) && ( + + )} + {/* Row 1: Direction + Document type + Date */}
@@ -248,23 +425,48 @@ export function RegistryEntryForm({
- +
+ +
+ {/* Add custom type inline */} +
+ setCustomDocType(e.target.value)} + placeholder="Tip nou..." + className="text-xs h-7" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddCustomDocType(); + } + }} + /> + +
@@ -288,8 +490,9 @@ export function RegistryEntryForm({ />
- {/* Sender / Recipient with autocomplete */} + {/* Sender / Recipient with autocomplete + quick create */}
+ {/* Sender */}
- {senderFocused && senderSuggestions.length > 0 && ( -
- {senderSuggestions.map((c) => ( - - ))} -
+ {renderContactDropdown( + senderSuggestions, + senderFocused, + "sender", + sender, + (c) => { + setSender(c.company ? `${c.name} (${c.company})` : c.name); + setSenderContactId(c.id); + setSenderFocused(false); + }, )}
+ {/* Recipient */}
- {recipientFocused && recipientSuggestions.length > 0 && ( -
- {recipientSuggestions.map((c) => ( - - ))} -
+ {renderContactDropdown( + recipientSuggestions, + recipientFocused, + "recipient", + recipient, + (c) => { + setRecipient(c.company ? `${c.name} (${c.company})` : c.name); + setRecipientContactId(c.id); + setRecipientFocused(false); + }, )}
- {/* Company + Status + Deadline */} + {/* Assignee (Responsabil) */} +
+ + { + setAssignee(e.target.value); + setAssigneeContactId(""); + }} + onFocus={() => setAssigneeFocused(true)} + onBlur={() => setTimeout(() => setAssigneeFocused(false), 200)} + className="mt-1" + placeholder="Persoana responsabilă..." + /> + {renderContactDropdown( + assigneeSuggestions, + assigneeFocused, + "assignee", + assignee, + (c) => { + setAssignee(c.company ? `${c.name} (${c.company})` : c.name); + setAssigneeContactId(c.id); + setAssigneeFocused(false); + }, + )} +
+ + {/* Company + Closed switch + Deadline */}
@@ -388,22 +608,47 @@ export function RegistryEntryForm({
- - + +
+ + + {isClosed ? "Închis" : "Deschis"} + +
- +
+ {/* Thread parent — reply to another entry */} + {allEntries && allEntries.length > 0 && ( +
+ + {threadParent && ( +
+ + {threadParent.direction === "intrat" ? "↓" : "↑"} + + {threadParent.number} + + {threadParent.subject} + + +
+ )} + {!threadParentId && ( + <> + setThreadSearch(e.target.value)} + /> + {threadSearch.trim().length >= 2 && ( +
+ {allEntries + .filter((e) => { + if (e.id === initial?.id) return false; + const q = threadSearch.toLowerCase(); + return ( + e.number.toLowerCase().includes(q) || + e.subject.toLowerCase().includes(q) + ); + }) + .slice(0, 8) + .map((e) => ( + + ))} +
+ )} + + )} +
+ )} + {/* Linked entries */} {allEntries && allEntries.length > 0 && (
@@ -582,6 +926,14 @@ export function RegistryEntryForm({
+ + {/* Quick contact creation dialog */} + ); } diff --git a/src/modules/registratura/components/registry-filters.tsx b/src/modules/registratura/components/registry-filters.tsx index 6d489a7..4c5a8d3 100644 --- a/src/modules/registratura/components/registry-filters.tsx +++ b/src/modules/registratura/components/registry-filters.tsx @@ -1,27 +1,22 @@ -'use client'; +"use client"; -import { Search } from 'lucide-react'; -import { Input } from '@/shared/components/ui/input'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'; -import type { RegistryFilters as Filters } from '../hooks/use-registry'; +import { Search } from "lucide-react"; +import { Input } from "@/shared/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select"; +import type { RegistryFilters as Filters } from "../hooks/use-registry"; +import { DEFAULT_DOC_TYPE_LABELS } from "../types"; interface RegistryFiltersProps { filters: Filters; onUpdate: (key: K, value: Filters[K]) => void; } -const DOC_TYPE_LABELS: Record = { - contract: 'Contract', - oferta: 'Ofertă', - factura: 'Factură', - scrisoare: 'Scrisoare', - aviz: 'Aviz', - 'nota-de-comanda': 'Notă de comandă', - raport: 'Raport', - cerere: 'Cerere', - altele: 'Altele', -}; - export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) { return (
@@ -30,12 +25,15 @@ export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) { onUpdate('search', e.target.value)} + onChange={(e) => onUpdate("search", e.target.value)} className="pl-9" />
- onUpdate("direction", v as Filters["direction"])} + > @@ -46,19 +44,29 @@ export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) { - + onUpdate("documentType", v as Filters["documentType"]) + } + > Toate tipurile - {Object.entries(DOC_TYPE_LABELS).map(([key, label]) => ( - {label} + {Object.entries(DEFAULT_DOC_TYPE_LABELS).map(([key, label]) => ( + + {label} + ))} - onUpdate("status", v as Filters["status"])} + > @@ -69,7 +77,10 @@ export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) { - onUpdate("company", v)} + > diff --git a/src/modules/registratura/components/registry-table.tsx b/src/modules/registratura/components/registry-table.tsx index a484878..997c57b 100644 --- a/src/modules/registratura/components/registry-table.tsx +++ b/src/modules/registratura/components/registry-table.tsx @@ -1,11 +1,20 @@ -'use client'; +"use client"; -import { Pencil, Trash2, CheckCircle2, Link2, Clock } from 'lucide-react'; -import { Button } from '@/shared/components/ui/button'; -import { Badge } from '@/shared/components/ui/badge'; -import type { RegistryEntry, DocumentType } from '../types'; -import { getOverdueDays } from '../services/registry-service'; -import { cn } from '@/shared/lib/utils'; +import { + Pencil, + Trash2, + CheckCircle2, + Link2, + Clock, + GitBranch, + User, +} from "lucide-react"; +import { Button } from "@/shared/components/ui/button"; +import { Badge } from "@/shared/components/ui/badge"; +import type { RegistryEntry } from "../types"; +import { DEFAULT_DOC_TYPE_LABELS } from "../types"; +import { getOverdueDays } from "../services/registry-service"; +import { cn } from "@/shared/lib/utils"; interface RegistryTableProps { entries: RegistryEntry[]; @@ -16,30 +25,36 @@ interface RegistryTableProps { } const DIRECTION_LABELS: Record = { - intrat: 'Intrat', - iesit: 'Ieșit', + intrat: "Intrat", + iesit: "Ieșit", }; -const DOC_TYPE_LABELS: Record = { - contract: 'Contract', - oferta: 'Ofertă', - factura: 'Factură', - scrisoare: 'Scrisoare', - aviz: 'Aviz', - 'nota-de-comanda': 'Notă comandă', - raport: 'Raport', - cerere: 'Cerere', - altele: 'Altele', -}; +/** Resolve doc type label from defaults or capitalize custom type */ +function getDocTypeLabel(type: string): string { + const label = DEFAULT_DOC_TYPE_LABELS[type]; + if (label) return label; + // For custom types, capitalize first letter + return type.replace(/-/g, " ").replace(/^\w/, (c) => c.toUpperCase()); +} const STATUS_LABELS: Record = { - deschis: 'Deschis', - inchis: 'Închis', + deschis: "Deschis", + inchis: "Închis", }; -export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: RegistryTableProps) { +export function RegistryTable({ + entries, + loading, + onEdit, + onDelete, + onClose, +}: RegistryTableProps) { if (loading) { - return

Se încarcă...

; + return ( +

+ Se încarcă... +

+ ); } if (entries.length === 0) { @@ -62,6 +77,7 @@ export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: R Subiect Expeditor Destinatar + Resp. Termen Status Acțiuni @@ -69,29 +85,45 @@ export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: R {entries.map((entry) => { - const overdueDays = (entry.status === 'deschis' || !entry.status) ? getOverdueDays(entry.deadline) : null; + const overdueDays = + entry.status === "deschis" || !entry.status + ? getOverdueDays(entry.deadline) + : null; const isOverdue = overdueDays !== null && overdueDays > 0; return ( - {entry.number} - {formatDate(entry.date)} + + {entry.number} + + + {formatDate(entry.date)} + - {DIRECTION_LABELS[entry.direction] ?? entry.direction ?? '—'} + {DIRECTION_LABELS[entry.direction] ?? + entry.direction ?? + "—"} - {DOC_TYPE_LABELS[entry.documentType] ?? entry.documentType ?? '—'} + + {getDocTypeLabel(entry.documentType)} + {entry.subject} + {entry.threadParentId && ( + + )} {(entry.linkedEntryIds ?? []).length > 0 && ( )} @@ -107,17 +139,39 @@ export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: R )} - {entry.sender} - {entry.recipient} + + {entry.sender} + + + {entry.recipient} + + + {entry.assignee ? ( + + + {entry.assignee} + + ) : ( + + )} + {entry.deadline ? ( - + {formatDate(entry.deadline)} {overdueDays !== null && overdueDays > 0 && ( - ({overdueDays}z depășit) + + ({overdueDays}z depășit) + )} {overdueDays !== null && overdueDays < 0 && ( - ({Math.abs(overdueDays)}z) + + ({Math.abs(overdueDays)}z) + )} ) : ( @@ -125,21 +179,39 @@ export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: R )} - + {STATUS_LABELS[entry.status]}
- {entry.status === 'deschis' && ( - )} - -
@@ -155,7 +227,11 @@ export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: R function formatDate(iso: string): string { try { - return new Date(iso).toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit', year: 'numeric' }); + return new Date(iso).toLocaleDateString("ro-RO", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); } catch { return iso; } diff --git a/src/modules/registratura/components/thread-view.tsx b/src/modules/registratura/components/thread-view.tsx new file mode 100644 index 0000000..e0732f3 --- /dev/null +++ b/src/modules/registratura/components/thread-view.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { CornerDownRight, GitBranch } from "lucide-react"; +import { Badge } from "@/shared/components/ui/badge"; +import type { RegistryEntry } from "../types"; +import { cn } from "@/shared/lib/utils"; + +interface ThreadViewProps { + /** The current entry being viewed */ + entry: RegistryEntry; + /** All entries in the registry (to resolve references) */ + allEntries: RegistryEntry[]; + /** Click on an entry to navigate to it */ + onNavigate?: (entry: RegistryEntry) => void; +} + +/** + * Shows thread relationships for a registry entry: + * - Parent entry (this is a reply to...) + * - Child entries (replies to this entry) + * Displays as an indented tree with direction badges. + */ +export function ThreadView({ entry, allEntries, onNavigate }: ThreadViewProps) { + // Find the parent entry (if this is a reply) + const parent = entry.threadParentId + ? allEntries.find((e) => e.id === entry.threadParentId) + : null; + + // Find child entries (replies to this entry) + const children = allEntries.filter((e) => e.threadParentId === entry.id); + + // Find siblings (other replies to the same parent, excluding this one) + const siblings = entry.threadParentId + ? allEntries.filter( + (e) => e.threadParentId === entry.threadParentId && e.id !== entry.id, + ) + : []; + + if (!parent && children.length === 0) return null; + + return ( +
+
+ + Fir conversație +
+ + {/* Parent */} + {parent && ( +
+

+ Răspuns la: +

+ + {/* Siblings — other branches from same parent */} + {siblings.length > 0 && ( +
+

+ Alte ramuri ({siblings.length}): +

+ {siblings.map((s) => ( + + ))} +
+ )} +
+ )} + + {/* Current entry marker */} +
+ + + {entry.number} + + + {entry.subject} + +
+ + {/* Children — replies to this entry */} + {children.length > 0 && ( +
+

+ Răspunsuri ({children.length}): +

+ {children.map((child) => ( + + ))} +
+ )} +
+ ); +} + +function ThreadEntryChip({ + entry, + onNavigate, + dimmed, +}: { + entry: RegistryEntry; + onNavigate?: (entry: RegistryEntry) => void; + dimmed?: boolean; +}) { + return ( + + ); +} + +function formatDate(iso: string): string { + try { + return new Date(iso).toLocaleDateString("ro-RO", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + } catch { + return iso; + } +} diff --git a/src/modules/registratura/index.ts b/src/modules/registratura/index.ts index 3c9ba69..d646583 100644 --- a/src/modules/registratura/index.ts +++ b/src/modules/registratura/index.ts @@ -1,7 +1,14 @@ -export { registraturaConfig } from './config'; -export { RegistraturaModule } from './components/registratura-module'; +export { registraturaConfig } from "./config"; +export { RegistraturaModule } from "./components/registratura-module"; export type { - RegistryEntry, RegistryDirection, RegistryStatus, DocumentType, - DeadlineDayType, DeadlineResolution, DeadlineCategory, - DeadlineTypeDef, TrackedDeadline, -} from './types'; + RegistryEntry, + RegistryDirection, + RegistryStatus, + DocumentType, + DeadlineDayType, + DeadlineResolution, + DeadlineCategory, + DeadlineTypeDef, + TrackedDeadline, +} from "./types"; +export { DEFAULT_DOCUMENT_TYPES, DEFAULT_DOC_TYPE_LABELS } from "./types"; diff --git a/src/modules/registratura/types.ts b/src/modules/registratura/types.ts index 6020a92..99f4703 100644 --- a/src/modules/registratura/types.ts +++ b/src/modules/registratura/types.ts @@ -1,23 +1,44 @@ -import type { Visibility } from '@/core/module-registry/types'; -import type { CompanyId } from '@/core/auth/types'; +import type { Visibility } from "@/core/module-registry/types"; +import type { CompanyId } from "@/core/auth/types"; /** Document direction — simplified from the old 3-way type */ -export type RegistryDirection = 'intrat' | 'iesit'; +export type RegistryDirection = "intrat" | "iesit"; -/** Document type categories */ -export type DocumentType = - | 'contract' - | 'oferta' - | 'factura' - | 'scrisoare' - | 'aviz' - | 'nota-de-comanda' - | 'raport' - | 'cerere' - | 'altele'; +/** Default document types — user can add custom types that sync with Tag Manager */ +export const DEFAULT_DOCUMENT_TYPES = [ + "contract", + "oferta", + "factura", + "scrisoare", + "aviz", + "nota-de-comanda", + "raport", + "cerere", + "apel-telefonic", + "videoconferinta", + "altele", +] as const; + +/** Document type — string-based for dynamic types from Tag Manager */ +export type DocumentType = (typeof DEFAULT_DOCUMENT_TYPES)[number] | string; + +/** Labels for default document types */ +export const DEFAULT_DOC_TYPE_LABELS: Record = { + contract: "Contract", + oferta: "Ofertă", + factura: "Factură", + scrisoare: "Scrisoare", + aviz: "Aviz", + "nota-de-comanda": "Notă de comandă", + raport: "Raport", + cerere: "Cerere", + "apel-telefonic": "Apel telefonic", + videoconferinta: "Videoconferință", + altele: "Altele", +}; /** Status — simplified to open/closed */ -export type RegistryStatus = 'deschis' | 'inchis'; +export type RegistryStatus = "deschis" | "inchis"; /** File attachment */ export interface RegistryAttachment { @@ -32,11 +53,21 @@ export interface RegistryAttachment { // ── Deadline tracking types ── -export type DeadlineDayType = 'calendar' | 'working'; +export type DeadlineDayType = "calendar" | "working"; -export type DeadlineResolution = 'pending' | 'completed' | 'aprobat-tacit' | 'respins' | 'anulat'; +export type DeadlineResolution = + | "pending" + | "completed" + | "aprobat-tacit" + | "respins" + | "anulat"; -export type DeadlineCategory = 'avize' | 'completari' | 'analiza' | 'autorizare' | 'publicitate'; +export type DeadlineCategory = + | "avize" + | "completari" + | "analiza" + | "autorizare" + | "publicitate"; export interface DeadlineTypeDef { id: string; @@ -86,6 +117,11 @@ export interface RegistryEntry { status: RegistryStatus; /** Deadline date (YYYY-MM-DD) */ deadline?: string; + /** Assignee — person responsible (ERP-ready field) */ + assignee?: string; + assigneeContactId?: string; + /** Thread parent — ID of the entry this is a reply to */ + threadParentId?: string; /** Linked entry IDs (for closing/archiving related entries) */ linkedEntryIds: string[]; /** File attachments */