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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user