feat(registratura): subject autocomplete with inline template fields

- New subject-template-service: extracts reusable templates from existing
  subjects by detecting variable parts (numbers, years, text after separators)
- Template input component: inline editable fields within static text
  (e.g., "Cerere CU nr. [___]/[____] — [___________]")
- Two-tier autocomplete dropdown: templates sorted by frequency (top) +
  matching existing subjects (bottom)
- Learns from database: more entries = better suggestions
- Follows existing contact autocomplete pattern (focus/blur, onMouseDown)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-10 08:40:37 +02:00
parent eb96af3e4b
commit b3b585e7c8
4 changed files with 540 additions and 7 deletions
@@ -20,6 +20,15 @@ import {
Link2,
} from "lucide-react";
import type { CompanyId } from "@/core/auth/types";
import {
extractTemplates,
assembleSubject,
filterTemplates,
filterSubjects,
} from "../services/subject-template-service";
import type { SubjectTemplate } from "../services/subject-template-service";
import { SubjectTemplateInput } from "./subject-template-input";
import { SubjectAutocompleteDropdown } from "./subject-autocomplete-dropdown";
import type {
RegistryEntry,
RegistryDirection,
@@ -146,6 +155,10 @@ export function RegistryEntryForm({
);
const [customDocType, setCustomDocType] = useState("");
const [subject, setSubject] = useState(initial?.subject ?? "");
const [subjectQuery, setSubjectQuery] = useState(initial?.subject ?? "");
const [subjectFocused, setSubjectFocused] = useState(false);
const [activeTemplate, setActiveTemplate] = useState<SubjectTemplate | null>(null);
const [templateFieldValues, setTemplateFieldValues] = useState<Record<string, string>>({});
const [date, setDate] = useState(
initial?.date ?? new Date().toISOString().slice(0, 10),
);
@@ -292,6 +305,37 @@ export function RegistryEntryForm({
[filterContacts, assignee],
);
// ── Subject autocomplete ──
const allSubjects = useMemo(() => {
if (!allEntries) return [];
const unique = new Set<string>();
for (const e of allEntries) {
if (e.subject && !e.isReserved) unique.add(e.subject);
}
return Array.from(unique);
}, [allEntries]);
const allTemplates = useMemo(
() => extractTemplates(allSubjects),
[allSubjects],
);
const matchingTemplates = useMemo(
() =>
subjectQuery.length >= 2
? filterTemplates(allTemplates, subjectQuery).slice(0, 5)
: [],
[allTemplates, subjectQuery],
);
const matchingSuggestions = useMemo(
() =>
subjectQuery.length >= 2
? filterSubjects(allSubjects, subjectQuery, 5)
: [],
[allSubjects, subjectQuery],
);
// ── Quick contact creation handler ──
const openQuickContact = (
field: "sender" | "recipient" | "assignee",
@@ -663,8 +707,8 @@ export function RegistryEntryForm({
</div>
</div>
{/* Subject */}
<div>
{/* Subject with autocomplete + template mode */}
<div className="relative">
<Label className="flex items-center gap-1.5">
Subiect *
<TooltipProvider>
@@ -682,11 +726,56 @@ export function RegistryEntryForm({
</Tooltip>
</TooltipProvider>
</Label>
<Input
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="mt-1"
required
{activeTemplate ? (
<SubjectTemplateInput
template={activeTemplate}
fieldValues={templateFieldValues}
onFieldChange={(fieldId, value) => {
const next = { ...templateFieldValues, [fieldId]: value };
setTemplateFieldValues(next);
setSubject(assembleSubject(activeTemplate, next));
}}
onClear={() => {
setActiveTemplate(null);
setSubjectQuery(subject);
}}
/>
) : (
<Input
value={subjectQuery}
onChange={(e) => {
setSubjectQuery(e.target.value);
setSubject(e.target.value);
}}
onFocus={() => setSubjectFocused(true)}
onBlur={() => setTimeout(() => setSubjectFocused(false), 200)}
className="mt-1"
required
placeholder="Tastează pentru sugestii..."
/>
)}
<SubjectAutocompleteDropdown
templates={matchingTemplates}
suggestions={matchingSuggestions}
visible={
subjectFocused &&
!activeTemplate &&
subjectQuery.length >= 2 &&
(matchingTemplates.length > 0 || matchingSuggestions.length > 0)
}
onSelectTemplate={(t) => {
setActiveTemplate(t);
setTemplateFieldValues({});
setSubject(assembleSubject(t, {}));
setSubjectFocused(false);
}}
onSelectSuggestion={(s) => {
setSubject(s);
setSubjectQuery(s);
setSubjectFocused(false);
}}
/>
</div>
@@ -0,0 +1,73 @@
"use client";
import type { SubjectTemplate } from "../services/subject-template-service";
interface SubjectAutocompleteDropdownProps {
templates: SubjectTemplate[];
suggestions: string[];
visible: boolean;
onSelectTemplate: (template: SubjectTemplate) => void;
onSelectSuggestion: (subject: string) => void;
}
export function SubjectAutocompleteDropdown({
templates,
suggestions,
visible,
onSelectTemplate,
onSelectSuggestion,
}: SubjectAutocompleteDropdownProps) {
if (!visible || (templates.length === 0 && suggestions.length === 0)) {
return null;
}
return (
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-1 shadow-md max-h-64 overflow-y-auto">
{/* Section 1: Templates */}
{templates.length > 0 && (
<>
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
Șabloane
</div>
{templates.map((t) => (
<button
key={t.id}
type="button"
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
onMouseDown={() => onSelectTemplate(t)}
>
<span className="font-medium">{t.pattern}</span>
<span className="ml-2 text-muted-foreground text-xs">
({t.frequency}x)
</span>
</button>
))}
</>
)}
{/* Divider */}
{templates.length > 0 && suggestions.length > 0 && (
<div className="border-t my-1" />
)}
{/* Section 2: Plain suggestions */}
{suggestions.length > 0 && (
<>
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
Sugestii
</div>
{suggestions.map((s, i) => (
<button
key={i}
type="button"
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent truncate"
onMouseDown={() => onSelectSuggestion(s)}
>
{s}
</button>
))}
</>
)}
</div>
);
}
@@ -0,0 +1,83 @@
"use client";
import { useEffect, useRef } from "react";
import { X } from "lucide-react";
import { cn } from "@/shared/lib/utils";
import type { SubjectTemplate } from "../services/subject-template-service";
interface SubjectTemplateInputProps {
template: SubjectTemplate;
fieldValues: Record<string, string>;
onFieldChange: (fieldId: string, value: string) => void;
onClear: () => void;
}
export function SubjectTemplateInput({
template,
fieldValues,
onFieldChange,
onClear,
}: SubjectTemplateInputProps) {
const firstFieldRef = useRef<HTMLInputElement>(null);
// Auto-focus first field on mount
useEffect(() => {
const timer = setTimeout(() => firstFieldRef.current?.focus(), 50);
return () => clearTimeout(timer);
}, []);
let fieldRendered = 0;
return (
<div
className={cn(
"flex flex-wrap items-baseline gap-0.5",
"min-h-9 w-full rounded-md border border-input bg-transparent px-3 py-1.5",
"text-sm shadow-xs mt-1",
)}
>
{template.tokens.map((token, i) => {
if (token.type === "static") {
return (
<span
key={`s${i}`}
className="whitespace-nowrap text-muted-foreground"
>
{token.value}
</span>
);
}
const field = token.field!;
const isFirst = fieldRendered === 0;
fieldRendered++;
return (
<input
key={field.id}
ref={isFirst ? firstFieldRef : undefined}
value={fieldValues[field.id] ?? ""}
onChange={(e) => onFieldChange(field.id, e.target.value)}
placeholder={field.placeholder}
className={cn(
"border-b-2 border-dashed border-primary/40 bg-transparent",
"text-sm font-medium text-foreground",
"px-1 py-0 outline-none",
"focus:border-primary focus:border-solid",
"placeholder:text-muted-foreground/50 placeholder:italic placeholder:text-xs",
field.width,
)}
/>
);
})}
<button
type="button"
onClick={onClear}
className="ml-auto shrink-0 text-muted-foreground hover:text-foreground transition-colors"
title="Renunță la șablon"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
);
}