2be0462e0d
- 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
152 lines
4.5 KiB
TypeScript
152 lines
4.5 KiB
TypeScript
"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;
|
|
}
|
|
}
|