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
This commit is contained in:
AI Assistant
2026-02-27 15:33:29 +02:00
parent b2618c041d
commit 2be0462e0d
10 changed files with 1199 additions and 273 deletions
@@ -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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlus className="h-4 w-4" />
Contact nou rapid
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<Label>Nume *</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1"
required
autoFocus
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label>Telefon</Label>
<Input
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="mt-1"
placeholder="Opțional"
/>
</div>
<div>
<Label>Email</Label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1"
placeholder="Opțional"
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Anulează
</Button>
<Button type="submit" disabled={!name.trim()}>
Creează contact
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -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<ViewMode>('list');
const { addContact } = useContacts();
const { createTag } = useTags("document-type");
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [editingEntry, setEditingEntry] = useState<RegistryEntry | null>(null);
const [closingId, setClosingId] = useState<string | null>(null);
const handleAdd = async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
// ── Bidirectional Address Book integration ──
const handleCreateContact = useCallback(
async (data: {
name: string;
phone: string;
email: string;
}): Promise<AddressContact | undefined> => {
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<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
) => {
await addEntry(data);
setViewMode('list');
setViewMode("list");
};
const handleEdit = (entry: RegistryEntry) => {
setEditingEntry(entry);
setViewMode('edit');
setViewMode("edit");
};
const handleUpdate = async (data: Omit<RegistryEntry, 'id' | 'number' | 'createdAt' | 'updatedAt'>) => {
const handleNavigateEntry = (entry: RegistryEntry) => {
setEditingEntry(entry);
setViewMode("edit");
};
const handleUpdate = async (
data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
) => {
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 (
<Tabs defaultValue="registru">
@@ -110,7 +209,10 @@ export function RegistraturaModule() {
<TabsTrigger value="termene">
Termene legale
{urgentDeadlines > 0 && (
<Badge variant="destructive" className="ml-1.5 text-[10px] px-1.5 py-0">
<Badge
variant="destructive"
className="ml-1.5 text-[10px] px-1.5 py-0"
>
{urgentDeadlines}
</Badge>
)}
@@ -123,15 +225,19 @@ export function RegistraturaModule() {
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Total" value={total} />
<StatCard label="Deschise" value={open} />
<StatCard label="Depășite" value={overdue} variant={overdue > 0 ? 'destructive' : undefined} />
<StatCard
label="Depășite"
value={overdue}
variant={overdue > 0 ? "destructive" : undefined}
/>
<StatCard label="Intrate" value={intrat} />
</div>
{viewMode === 'list' && (
{viewMode === "list" && (
<>
<div className="flex items-center justify-between gap-4">
<RegistryFilters filters={filters} onUpdate={updateFilter} />
<Button onClick={() => setViewMode('add')} className="shrink-0">
<Button onClick={() => setViewMode("add")} className="shrink-0">
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
</Button>
</div>
@@ -152,12 +258,14 @@ export function RegistraturaModule() {
</>
)}
{viewMode === 'add' && (
{viewMode === "add" && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
Înregistrare nouă
<Badge variant="outline" className="text-xs">Nr. auto</Badge>
<Badge variant="outline" className="text-xs">
Nr. auto
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
@@ -165,12 +273,14 @@ export function RegistraturaModule() {
allEntries={allEntries}
onSubmit={handleAdd}
onCancel={handleCancel}
onCreateContact={handleCreateContact}
onCreateDocType={handleCreateDocType}
/>
</CardContent>
</Card>
)}
{viewMode === 'edit' && editingEntry && (
{viewMode === "edit" && editingEntry && (
<Card>
<CardHeader>
<CardTitle>Editare {editingEntry.number}</CardTitle>
@@ -181,26 +291,40 @@ export function RegistraturaModule() {
allEntries={allEntries}
onSubmit={handleUpdate}
onCancel={handleCancel}
onCreateContact={handleCreateContact}
onCreateDocType={handleCreateDocType}
onNavigateEntry={handleNavigateEntry}
/>
</CardContent>
</Card>
)}
{/* Close confirmation dialog */}
<Dialog open={closingId !== null} onOpenChange={(open) => { if (!open) setClosingId(null); }}>
<Dialog
open={closingId !== null}
onOpenChange={(open) => {
if (!open) setClosingId(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Închide înregistrarea</DialogTitle>
</DialogHeader>
<div className="py-2">
<p className="text-sm">
Această înregistrare are {closingEntry?.linkedEntryIds?.length ?? 0} înregistrări legate.
Vrei le închizi și pe acestea?
Această înregistrare are{" "}
{closingEntry?.linkedEntryIds?.length ?? 0} înregistrări
legate. Vrei le închizi și pe acestea?
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setClosingId(null)}>Anulează</Button>
<Button variant="secondary" onClick={() => handleCloseConfirm(false)}>
<Button variant="outline" onClick={() => setClosingId(null)}>
Anulează
</Button>
<Button
variant="secondary"
onClick={() => handleCloseConfirm(false)}
>
Doar aceasta
</Button>
<Button onClick={() => handleCloseConfirm(true)}>
@@ -223,12 +347,22 @@ export function RegistraturaModule() {
);
}
function StatCard({ label, value, variant }: { label: string; value: number; variant?: 'destructive' }) {
function StatCard({
label,
value,
variant,
}: {
label: string;
value: number;
variant?: "destructive";
}) {
return (
<Card>
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">{label}</p>
<p className={`text-2xl font-bold ${variant === 'destructive' && value > 0 ? 'text-destructive' : ''}`}>
<p
className={`text-2xl font-bold ${variant === "destructive" && value > 0 ? "text-destructive" : ""}`}
>
{value}
</p>
</CardContent>
@@ -1,22 +1,31 @@
"use client";
import { useState, useMemo, useRef } from "react";
import { Paperclip, X, Clock, Plus } from "lucide-react";
import { useState, useMemo, useRef, useCallback } from "react";
import {
Paperclip,
X,
Clock,
Plus,
UserPlus,
Info,
GitBranch,
} from "lucide-react";
import type { CompanyId } from "@/core/auth/types";
import type {
RegistryEntry,
RegistryDirection,
RegistryStatus,
DocumentType,
RegistryAttachment,
TrackedDeadline,
DeadlineResolution,
} from "../types";
import { DEFAULT_DOC_TYPE_LABELS } from "../types";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Textarea } from "@/shared/components/ui/textarea";
import { Button } from "@/shared/components/ui/button";
import { Badge } from "@/shared/components/ui/badge";
import { Switch } from "@/shared/components/ui/switch";
import {
Select,
SelectContent,
@@ -24,11 +33,21 @@ import {
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { useContacts } from "@/modules/address-book/hooks/use-contacts";
import { useTags } from "@/core/tagging";
import type { AddressContact } from "@/modules/address-book/types";
import { v4 as uuid } from "uuid";
import { DeadlineCard } from "./deadline-card";
import { DeadlineAddDialog } from "./deadline-add-dialog";
import { DeadlineResolveDialog } from "./deadline-resolve-dialog";
import { QuickContactDialog } from "./quick-contact-dialog";
import { ThreadView } from "./thread-view";
import {
createTrackedDeadline,
resolveDeadline as resolveDeadlineFn,
@@ -42,35 +61,55 @@ interface RegistryEntryFormProps {
data: Omit<RegistryEntry, "id" | "number" | "createdAt" | "updatedAt">,
) => void;
onCancel: () => void;
/** Callback to create a new Address Book contact */
onCreateContact?: (data: {
name: string;
phone: string;
email: string;
}) => Promise<AddressContact | undefined>;
/** Callback to create a new document type tag in Tag Manager */
onCreateDocType?: (label: string) => Promise<void>;
/** Navigate to an entry (for thread clicks) */
onNavigateEntry?: (entry: RegistryEntry) => void;
}
const DOC_TYPE_LABELS: Record<DocumentType, string> = {
contract: "Contract",
oferta: "Ofertă",
factura: "Factură",
scrisoare: "Scrisoare",
aviz: "Aviz",
"nota-de-comanda": "Notă de comandă",
raport: "Raport",
cerere: "Cerere",
altele: "Altele",
};
export function RegistryEntryForm({
initial,
allEntries,
onSubmit,
onCancel,
onCreateContact,
onCreateDocType,
onNavigateEntry,
}: RegistryEntryFormProps) {
const { allContacts } = useContacts();
const { allContacts, refresh: refreshContacts } = useContacts();
const { tags: docTypeTags } = useTags("document-type");
const fileInputRef = useRef<HTMLInputElement>(null);
// ── Build dynamic doc type list from defaults + Tag Manager ──
const allDocTypes = useMemo(() => {
const map = new Map<string, string>();
// Add defaults
for (const [key, label] of Object.entries(DEFAULT_DOC_TYPE_LABELS)) {
map.set(key, label);
}
// Add from Tag Manager (document-type category)
for (const tag of docTypeTags) {
const key = tag.label.toLowerCase().replace(/\s+/g, "-");
if (!map.has(key)) {
map.set(key, tag.label);
}
}
return map;
}, [docTypeTags]);
const [direction, setDirection] = useState<RegistryDirection>(
initial?.direction ?? "intrat",
);
const [documentType, setDocumentType] = useState<DocumentType>(
initial?.documentType ?? "scrisoare",
);
const [customDocType, setCustomDocType] = useState("");
const [subject, setSubject] = useState(initial?.subject ?? "");
const [date, setDate] = useState(
initial?.date ?? new Date().toISOString().slice(0, 10),
@@ -86,10 +125,15 @@ export function RegistryEntryForm({
const [company, setCompany] = useState<CompanyId>(
initial?.company ?? "beletage",
);
const [status, setStatus] = useState<RegistryStatus>(
initial?.status ?? "deschis",
);
const [isClosed, setIsClosed] = useState(initial?.status === "inchis");
const [deadline, setDeadline] = useState(initial?.deadline ?? "");
const [assignee, setAssignee] = useState(initial?.assignee ?? "");
const [assigneeContactId, setAssigneeContactId] = useState(
initial?.assigneeContactId ?? "",
);
const [threadParentId, setThreadParentId] = useState(
initial?.threadParentId ?? "",
);
const [notes, setNotes] = useState(initial?.notes ?? "");
const [linkedEntryIds, setLinkedEntryIds] = useState<string[]>(
initial?.linkedEntryIds ?? [],
@@ -101,12 +145,20 @@ export function RegistryEntryForm({
initial?.trackedDeadlines ?? [],
);
const [linkedSearch, setLinkedSearch] = useState("");
const [threadSearch, setThreadSearch] = useState("");
// ── Deadline dialogs ──
const [deadlineAddOpen, setDeadlineAddOpen] = useState(false);
const [resolvingDeadline, setResolvingDeadline] =
useState<TrackedDeadline | null>(null);
// ── Quick contact creation ──
const [quickContactOpen, setQuickContactOpen] = useState(false);
const [quickContactField, setQuickContactField] = useState<
"sender" | "recipient" | "assignee"
>("sender");
const [quickContactName, setQuickContactName] = useState("");
const handleAddDeadline = (
typeId: string,
startDate: string,
@@ -127,7 +179,6 @@ export function RegistryEntryForm({
prev.map((d) => (d.id === resolved.id ? resolved : d)),
);
// Handle chain
if (chainNext) {
const def = getDeadlineType(resolvingDeadline.typeId);
if (def?.chainNextTypeId) {
@@ -147,33 +198,86 @@ export function RegistryEntryForm({
setTrackedDeadlines((prev) => prev.filter((d) => d.id !== deadlineId));
};
// ── Sender/Recipient autocomplete suggestions ──
// ── Contact autocomplete ──
const [senderFocused, setSenderFocused] = useState(false);
const [recipientFocused, setRecipientFocused] = useState(false);
const [assigneeFocused, setAssigneeFocused] = useState(false);
const senderSuggestions = useMemo(() => {
if (!sender || sender.length < 2) return [];
const q = sender.toLowerCase();
return allContacts
.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.company.toLowerCase().includes(q),
)
.slice(0, 5);
}, [allContacts, sender]);
const filterContacts = useCallback(
(query: string) => {
if (!query || query.length < 2) return [];
const q = query.toLowerCase();
return allContacts
.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.company.toLowerCase().includes(q),
)
.slice(0, 5);
},
[allContacts],
);
const recipientSuggestions = useMemo(() => {
if (!recipient || recipient.length < 2) return [];
const q = recipient.toLowerCase();
return allContacts
.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.company.toLowerCase().includes(q),
)
.slice(0, 5);
}, [allContacts, recipient]);
const senderSuggestions = useMemo(
() => filterContacts(sender),
[filterContacts, sender],
);
const recipientSuggestions = useMemo(
() => filterContacts(recipient),
[filterContacts, recipient],
);
const assigneeSuggestions = useMemo(
() => filterContacts(assignee),
[filterContacts, assignee],
);
// ── Quick contact creation handler ──
const openQuickContact = (
field: "sender" | "recipient" | "assignee",
name: string,
) => {
setQuickContactField(field);
setQuickContactName(name);
setQuickContactOpen(true);
};
const handleQuickContactConfirm = async (data: {
name: string;
phone: string;
email: string;
}) => {
if (!onCreateContact) return;
const contact = await onCreateContact(data);
if (contact) {
const displayName = contact.company
? `${contact.name} (${contact.company})`
: contact.name;
if (quickContactField === "sender") {
setSender(displayName);
setSenderContactId(contact.id);
} else if (quickContactField === "recipient") {
setRecipient(displayName);
setRecipientContactId(contact.id);
} else {
setAssignee(displayName);
setAssigneeContactId(contact.id);
}
await refreshContacts();
}
setQuickContactOpen(false);
};
// ── Custom doc type creation ──
const handleAddCustomDocType = async () => {
const label = customDocType.trim();
if (!label) return;
const key = label.toLowerCase().replace(/\s+/g, "-");
setDocumentType(key);
setCustomDocType("");
if (onCreateDocType) {
await onCreateDocType(label);
}
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
@@ -215,8 +319,11 @@ export function RegistryEntryForm({
recipient,
recipientContactId: recipientContactId || undefined,
company,
status,
status: isClosed ? "inchis" : "deschis",
deadline: deadline || undefined,
assignee: assignee || undefined,
assigneeContactId: assigneeContactId || undefined,
threadParentId: threadParentId || undefined,
linkedEntryIds,
attachments,
trackedDeadlines:
@@ -227,8 +334,78 @@ export function RegistryEntryForm({
});
};
// ── Contact autocomplete dropdown renderer ──
const renderContactDropdown = (
suggestions: AddressContact[],
focused: boolean,
fieldName: "sender" | "recipient" | "assignee",
currentValue: string,
onSelect: (c: AddressContact) => void,
) => {
if (!focused) return null;
const hasExactMatch = suggestions.some(
(c) =>
c.name.toLowerCase() === currentValue.toLowerCase() ||
`${c.name} (${c.company})`.toLowerCase() === currentValue.toLowerCase(),
);
const showCreateButton =
currentValue.length >= 2 && !hasExactMatch && onCreateContact;
if (suggestions.length === 0 && !showCreateButton) return null;
return (
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-1 shadow-md">
{suggestions.map((c) => (
<button
key={c.id}
type="button"
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
onMouseDown={() => onSelect(c)}
>
<span className="font-medium">{c.name}</span>
{c.company && (
<span className="ml-1 text-muted-foreground text-xs">
{c.company}
</span>
)}
</button>
))}
{showCreateButton && (
<button
type="button"
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent text-primary flex items-center gap-1.5 border-t mt-1 pt-1.5"
onMouseDown={() => openQuickContact(fieldName, currentValue)}
>
<UserPlus className="h-3.5 w-3.5" />
Creează contact &quot;{currentValue}&quot;
</button>
)}
</div>
);
};
// Thread parent entry for display
const threadParent =
threadParentId && allEntries
? allEntries.find((e) => e.id === threadParentId)
: null;
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Thread view (if editing an entry that's in a thread) */}
{initial &&
allEntries &&
(initial.threadParentId ||
allEntries.some((e) => e.threadParentId === initial.id)) && (
<ThreadView
entry={initial}
allEntries={allEntries}
onNavigate={onNavigateEntry}
/>
)}
{/* Row 1: Direction + Document type + Date */}
<div className="grid gap-4 sm:grid-cols-3">
<div>
@@ -248,23 +425,48 @@ export function RegistryEntryForm({
</div>
<div>
<Label>Tip document</Label>
<Select
value={documentType}
onValueChange={(v) => setDocumentType(v as DocumentType)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(
Object.entries(DOC_TYPE_LABELS) as [DocumentType, string][]
).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="mt-1">
<Select
value={allDocTypes.has(documentType) ? documentType : "altele"}
onValueChange={(v) => setDocumentType(v as DocumentType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from(allDocTypes.entries()).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Add custom type inline */}
<div className="mt-1.5 flex gap-1">
<Input
value={customDocType}
onChange={(e) => setCustomDocType(e.target.value)}
placeholder="Tip nou..."
className="text-xs h-7"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddCustomDocType();
}
}}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2"
onClick={handleAddCustomDocType}
disabled={!customDocType.trim()}
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
<div>
<Label>Data</Label>
@@ -288,8 +490,9 @@ export function RegistryEntryForm({
/>
</div>
{/* Sender / Recipient with autocomplete */}
{/* Sender / Recipient with autocomplete + quick create */}
<div className="grid gap-4 sm:grid-cols-2">
{/* Sender */}
<div className="relative">
<Label>Expeditor</Label>
<Input
@@ -303,30 +506,19 @@ export function RegistryEntryForm({
className="mt-1"
placeholder="Nume sau companie..."
/>
{senderFocused && senderSuggestions.length > 0 && (
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-1 shadow-md">
{senderSuggestions.map((c) => (
<button
key={c.id}
type="button"
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
onMouseDown={() => {
setSender(c.company ? `${c.name} (${c.company})` : c.name);
setSenderContactId(c.id);
setSenderFocused(false);
}}
>
<span className="font-medium">{c.name}</span>
{c.company && (
<span className="ml-1 text-muted-foreground text-xs">
{c.company}
</span>
)}
</button>
))}
</div>
{renderContactDropdown(
senderSuggestions,
senderFocused,
"sender",
sender,
(c) => {
setSender(c.company ? `${c.name} (${c.company})` : c.name);
setSenderContactId(c.id);
setSenderFocused(false);
},
)}
</div>
{/* Recipient */}
<div className="relative">
<Label>Destinatar</Label>
<Input
@@ -340,35 +532,63 @@ export function RegistryEntryForm({
className="mt-1"
placeholder="Nume sau companie..."
/>
{recipientFocused && recipientSuggestions.length > 0 && (
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-1 shadow-md">
{recipientSuggestions.map((c) => (
<button
key={c.id}
type="button"
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
onMouseDown={() => {
setRecipient(
c.company ? `${c.name} (${c.company})` : c.name,
);
setRecipientContactId(c.id);
setRecipientFocused(false);
}}
>
<span className="font-medium">{c.name}</span>
{c.company && (
<span className="ml-1 text-muted-foreground text-xs">
{c.company}
</span>
)}
</button>
))}
</div>
{renderContactDropdown(
recipientSuggestions,
recipientFocused,
"recipient",
recipient,
(c) => {
setRecipient(c.company ? `${c.name} (${c.company})` : c.name);
setRecipientContactId(c.id);
setRecipientFocused(false);
},
)}
</div>
</div>
{/* Company + Status + Deadline */}
{/* Assignee (Responsabil) */}
<div className="relative">
<Label className="flex items-center gap-1.5">
Responsabil
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p className="text-xs">
Persoana din echipă responsabilă cu gestionarea acestei
înregistrări. Câmp pregătit pentru integrare viitoare cu ERP.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<Input
value={assignee}
onChange={(e) => {
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);
},
)}
</div>
{/* Company + Closed switch + Deadline */}
<div className="grid gap-4 sm:grid-cols-3">
<div>
<Label>Companie</Label>
@@ -388,22 +608,47 @@ export function RegistryEntryForm({
</Select>
</div>
<div>
<Label>Status</Label>
<Select
value={status}
onValueChange={(v) => setStatus(v as RegistryStatus)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deschis">Deschis</SelectItem>
<SelectItem value="inchis">Închis</SelectItem>
</SelectContent>
</Select>
<Label className="flex items-center gap-1.5">
Închis
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p className="text-xs">
Implicit, toate înregistrările sunt deschise. Bifează doar
când dosarul este finalizat.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<div className="mt-2.5 flex items-center gap-2">
<Switch checked={isClosed} onCheckedChange={setIsClosed} />
<span className="text-sm text-muted-foreground">
{isClosed ? "Închis" : "Deschis"}
</span>
</div>
</div>
<div>
<Label>Termen limită</Label>
<Label className="flex items-center gap-1.5">
Termen limită intern
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p className="text-xs">
Termen intern pentru a răspunde sau a acționa pe această
înregistrare. Nu este termen legal termenele legale se
adaugă separat mai jos.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<Input
type="date"
value={deadline}
@@ -413,6 +658,105 @@ export function RegistryEntryForm({
</div>
</div>
{/* Thread parent — reply to another entry */}
{allEntries && allEntries.length > 0 && (
<div>
<Label className="flex items-center gap-1.5">
<GitBranch className="h-3.5 w-3.5" />
Răspuns la (Thread)
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p className="text-xs">
Leagă această înregistrare ca răspuns la alta, creând un fir
de conversație instituțională. O intrare poate genera mai
multe ieșiri (branching).
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
{threadParent && (
<div className="mt-1.5 flex items-center gap-2 rounded border border-primary/30 bg-primary/5 px-2 py-1.5 text-sm">
<Badge
variant={
threadParent.direction === "intrat" ? "default" : "secondary"
}
className="text-[10px]"
>
{threadParent.direction === "intrat" ? "↓" : "↑"}
</Badge>
<span className="font-mono text-xs">{threadParent.number}</span>
<span className="truncate text-muted-foreground text-xs">
{threadParent.subject}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="ml-auto h-6 w-6 p-0"
onClick={() => setThreadParentId("")}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
)}
{!threadParentId && (
<>
<Input
className="mt-1.5"
placeholder="Caută după număr sau subiect..."
value={threadSearch}
onChange={(e) => setThreadSearch(e.target.value)}
/>
{threadSearch.trim().length >= 2 && (
<div className="mt-1.5 max-h-32 overflow-y-auto space-y-1">
{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) => (
<button
key={e.id}
type="button"
onClick={() => {
setThreadParentId(e.id);
setThreadSearch("");
}}
className="flex w-full items-center gap-2 rounded border px-2 py-1 text-xs transition-colors hover:bg-accent"
>
<Badge
variant={
e.direction === "intrat" ? "default" : "secondary"
}
className="text-[9px] px-1 py-0"
>
{e.direction === "intrat" ? "↓" : "↑"}
</Badge>
<span className="font-mono">{e.number}</span>
<span className="truncate text-muted-foreground">
{e.subject.length > 40
? e.subject.slice(0, 40) + "…"
: e.subject}
</span>
</button>
))}
</div>
)}
</>
)}
</div>
)}
{/* Linked entries */}
{allEntries && allEntries.length > 0 && (
<div>
@@ -582,6 +926,14 @@ export function RegistryEntryForm({
</Button>
<Button type="submit">{initial ? "Actualizează" : "Adaugă"}</Button>
</div>
{/* Quick contact creation dialog */}
<QuickContactDialog
open={quickContactOpen}
onOpenChange={setQuickContactOpen}
initialName={quickContactName}
onConfirm={handleQuickContactConfirm}
/>
</form>
);
}
@@ -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: <K extends keyof Filters>(key: K, value: Filters[K]) => void;
}
const DOC_TYPE_LABELS: Record<string, string> = {
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 (
<div className="flex flex-wrap items-center gap-3">
@@ -30,12 +25,15 @@ export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
<Input
placeholder="Caută după subiect, expeditor, număr..."
value={filters.search}
onChange={(e) => onUpdate('search', e.target.value)}
onChange={(e) => onUpdate("search", e.target.value)}
className="pl-9"
/>
</div>
<Select value={filters.direction} onValueChange={(v) => onUpdate('direction', v as Filters['direction'])}>
<Select
value={filters.direction}
onValueChange={(v) => onUpdate("direction", v as Filters["direction"])}
>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="Direcție" />
</SelectTrigger>
@@ -46,19 +44,29 @@ export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
</SelectContent>
</Select>
<Select value={filters.documentType} onValueChange={(v) => onUpdate('documentType', v as Filters['documentType'])}>
<Select
value={filters.documentType}
onValueChange={(v) =>
onUpdate("documentType", v as Filters["documentType"])
}
>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Tip document" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toate tipurile</SelectItem>
{Object.entries(DOC_TYPE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
{Object.entries(DEFAULT_DOC_TYPE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filters.status} onValueChange={(v) => onUpdate('status', v as Filters['status'])}>
<Select
value={filters.status}
onValueChange={(v) => onUpdate("status", v as Filters["status"])}
>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
@@ -69,7 +77,10 @@ export function RegistryFilters({ filters, onUpdate }: RegistryFiltersProps) {
</SelectContent>
</Select>
<Select value={filters.company} onValueChange={(v) => onUpdate('company', v)}>
<Select
value={filters.company}
onValueChange={(v) => onUpdate("company", v)}
>
<SelectTrigger className="w-[170px]">
<SelectValue placeholder="Companie" />
</SelectTrigger>
@@ -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<string, string> = {
intrat: 'Intrat',
iesit: 'Ieșit',
intrat: "Intrat",
iesit: "Ieșit",
};
const DOC_TYPE_LABELS: Record<DocumentType, string> = {
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<string, string> = {
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 <p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>;
return (
<p className="py-8 text-center text-sm text-muted-foreground">
Se încarcă...
</p>
);
}
if (entries.length === 0) {
@@ -62,6 +77,7 @@ export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: R
<th className="px-3 py-2 text-left font-medium">Subiect</th>
<th className="px-3 py-2 text-left font-medium">Expeditor</th>
<th className="px-3 py-2 text-left font-medium">Destinatar</th>
<th className="px-3 py-2 text-left font-medium">Resp.</th>
<th className="px-3 py-2 text-left font-medium">Termen</th>
<th className="px-3 py-2 text-left font-medium">Status</th>
<th className="px-3 py-2 text-right font-medium">Acțiuni</th>
@@ -69,29 +85,45 @@ export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: R
</thead>
<tbody>
{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 (
<tr
key={entry.id}
className={cn(
'border-b transition-colors hover:bg-muted/20',
isOverdue && 'bg-destructive/5'
"border-b transition-colors hover:bg-muted/20",
isOverdue && "bg-destructive/5",
)}
>
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">{entry.number}</td>
<td className="px-3 py-2 text-xs whitespace-nowrap">{formatDate(entry.date)}</td>
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">
{entry.number}
</td>
<td className="px-3 py-2 text-xs whitespace-nowrap">
{formatDate(entry.date)}
</td>
<td className="px-3 py-2">
<Badge
variant={entry.direction === 'intrat' ? 'default' : 'secondary'}
variant={
entry.direction === "intrat" ? "default" : "secondary"
}
className="text-xs"
>
{DIRECTION_LABELS[entry.direction] ?? entry.direction ?? '—'}
{DIRECTION_LABELS[entry.direction] ??
entry.direction ??
"—"}
</Badge>
</td>
<td className="px-3 py-2 text-xs">{DOC_TYPE_LABELS[entry.documentType] ?? entry.documentType ?? '—'}</td>
<td className="px-3 py-2 text-xs">
{getDocTypeLabel(entry.documentType)}
</td>
<td className="px-3 py-2 max-w-[200px] truncate">
{entry.subject}
{entry.threadParentId && (
<GitBranch className="ml-1 inline h-3 w-3 text-muted-foreground" />
)}
{(entry.linkedEntryIds ?? []).length > 0 && (
<Link2 className="ml-1 inline h-3 w-3 text-muted-foreground" />
)}
@@ -107,17 +139,39 @@ export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: R
</Badge>
)}
</td>
<td className="px-3 py-2 max-w-[130px] truncate">{entry.sender}</td>
<td className="px-3 py-2 max-w-[130px] truncate">{entry.recipient}</td>
<td className="px-3 py-2 max-w-[130px] truncate">
{entry.sender}
</td>
<td className="px-3 py-2 max-w-[130px] truncate">
{entry.recipient}
</td>
<td className="px-3 py-2 max-w-[100px] truncate text-xs">
{entry.assignee ? (
<span className="flex items-center gap-1">
<User className="h-3 w-3 text-muted-foreground shrink-0" />
{entry.assignee}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-3 py-2 text-xs whitespace-nowrap">
{entry.deadline ? (
<span className={cn(isOverdue && 'font-medium text-destructive')}>
<span
className={cn(
isOverdue && "font-medium text-destructive",
)}
>
{formatDate(entry.deadline)}
{overdueDays !== null && overdueDays > 0 && (
<span className="ml-1 text-[10px]">({overdueDays}z depășit)</span>
<span className="ml-1 text-[10px]">
({overdueDays}z depășit)
</span>
)}
{overdueDays !== null && overdueDays < 0 && (
<span className="ml-1 text-[10px] text-muted-foreground">({Math.abs(overdueDays)}z)</span>
<span className="ml-1 text-[10px] text-muted-foreground">
({Math.abs(overdueDays)}z)
</span>
)}
</span>
) : (
@@ -125,21 +179,39 @@ export function RegistryTable({ entries, loading, onEdit, onDelete, onClose }: R
)}
</td>
<td className="px-3 py-2">
<Badge variant={entry.status === 'deschis' ? 'default' : 'outline'}>
<Badge
variant={entry.status === "deschis" ? "default" : "outline"}
>
{STATUS_LABELS[entry.status]}
</Badge>
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
{entry.status === 'deschis' && (
<Button variant="ghost" size="icon" className="h-7 w-7 text-green-600" onClick={() => onClose(entry.id)} title="Închide">
{entry.status === "deschis" && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-green-600"
onClick={() => onClose(entry.id)}
title="Închide"
>
<CheckCircle2 className="h-3.5 w-3.5" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onEdit(entry)}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onEdit(entry)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => onDelete(entry.id)}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => onDelete(entry.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
@@ -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;
}
@@ -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 (
<div className="rounded-lg border bg-muted/20 p-3 space-y-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<GitBranch className="h-3.5 w-3.5" />
Fir conversație
</div>
{/* Parent */}
{parent && (
<div className="space-y-1">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
Răspuns la:
</p>
<ThreadEntryChip entry={parent} onNavigate={onNavigate} />
{/* Siblings — other branches from same parent */}
{siblings.length > 0 && (
<div className="ml-4 space-y-1">
<p className="text-[10px] text-muted-foreground">
Alte ramuri ({siblings.length}):
</p>
{siblings.map((s) => (
<ThreadEntryChip
key={s.id}
entry={s}
onNavigate={onNavigate}
dimmed
/>
))}
</div>
)}
</div>
)}
{/* Current entry marker */}
<div className={cn("flex items-center gap-1.5", parent && "ml-4")}>
<CornerDownRight className="h-3 w-3 text-primary" />
<Badge variant="default" className="text-[10px]">
{entry.number}
</Badge>
<span className="text-xs text-muted-foreground truncate max-w-[200px]">
{entry.subject}
</span>
</div>
{/* Children — replies to this entry */}
{children.length > 0 && (
<div className={cn("ml-8 space-y-1", !parent && "ml-4")}>
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">
Răspunsuri ({children.length}):
</p>
{children.map((child) => (
<ThreadEntryChip
key={child.id}
entry={child}
onNavigate={onNavigate}
/>
))}
</div>
)}
</div>
);
}
function ThreadEntryChip({
entry,
onNavigate,
dimmed,
}: {
entry: RegistryEntry;
onNavigate?: (entry: RegistryEntry) => void;
dimmed?: boolean;
}) {
return (
<button
type="button"
onClick={() => onNavigate?.(entry)}
className={cn(
"flex items-center gap-1.5 rounded border px-2 py-1 text-left text-xs transition-colors hover:bg-accent w-full",
dimmed && "opacity-60",
)}
>
<Badge
variant={entry.direction === "intrat" ? "default" : "secondary"}
className="text-[9px] px-1 py-0 shrink-0"
>
{entry.direction === "intrat" ? "↓" : "↑"}
</Badge>
<span className="font-mono shrink-0">{entry.number}</span>
<span className="truncate text-muted-foreground">
{entry.subject.length > 40
? entry.subject.slice(0, 40) + "…"
: entry.subject}
</span>
<span className="ml-auto text-[10px] text-muted-foreground shrink-0">
{formatDate(entry.date)}
</span>
</button>
);
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("ro-RO", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return iso;
}
}
+13 -6
View File
@@ -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";
+54 -18
View File
@@ -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<string, string> = {
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 */