From b3b585e7c81c8e1270857a1e7d20ea503b46fdf0 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Tue, 10 Mar 2026 08:40:37 +0200 Subject: [PATCH] feat(registratura): subject autocomplete with inline template fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../components/registry-entry-form.tsx | 103 ++++++- .../subject-autocomplete-dropdown.tsx | 73 +++++ .../components/subject-template-input.tsx | 83 +++++ .../services/subject-template-service.ts | 288 ++++++++++++++++++ 4 files changed, 540 insertions(+), 7 deletions(-) create mode 100644 src/modules/registratura/components/subject-autocomplete-dropdown.tsx create mode 100644 src/modules/registratura/components/subject-template-input.tsx create mode 100644 src/modules/registratura/services/subject-template-service.ts diff --git a/src/modules/registratura/components/registry-entry-form.tsx b/src/modules/registratura/components/registry-entry-form.tsx index 70c5705..cba7d37 100644 --- a/src/modules/registratura/components/registry-entry-form.tsx +++ b/src/modules/registratura/components/registry-entry-form.tsx @@ -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(null); + const [templateFieldValues, setTemplateFieldValues] = useState>({}); 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(); + 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({ - {/* Subject */} -
+ {/* Subject with autocomplete + template mode */} +
- setSubject(e.target.value)} - className="mt-1" - required + + {activeTemplate ? ( + { + const next = { ...templateFieldValues, [fieldId]: value }; + setTemplateFieldValues(next); + setSubject(assembleSubject(activeTemplate, next)); + }} + onClear={() => { + setActiveTemplate(null); + setSubjectQuery(subject); + }} + /> + ) : ( + { + setSubjectQuery(e.target.value); + setSubject(e.target.value); + }} + onFocus={() => setSubjectFocused(true)} + onBlur={() => setTimeout(() => setSubjectFocused(false), 200)} + className="mt-1" + required + placeholder="Tastează pentru sugestii..." + /> + )} + + = 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); + }} />
diff --git a/src/modules/registratura/components/subject-autocomplete-dropdown.tsx b/src/modules/registratura/components/subject-autocomplete-dropdown.tsx new file mode 100644 index 0000000..c340aab --- /dev/null +++ b/src/modules/registratura/components/subject-autocomplete-dropdown.tsx @@ -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 ( +
+ {/* Section 1: Templates */} + {templates.length > 0 && ( + <> +
+ Șabloane +
+ {templates.map((t) => ( + + ))} + + )} + + {/* Divider */} + {templates.length > 0 && suggestions.length > 0 && ( +
+ )} + + {/* Section 2: Plain suggestions */} + {suggestions.length > 0 && ( + <> +
+ Sugestii +
+ {suggestions.map((s, i) => ( + + ))} + + )} +
+ ); +} diff --git a/src/modules/registratura/components/subject-template-input.tsx b/src/modules/registratura/components/subject-template-input.tsx new file mode 100644 index 0000000..8200ccd --- /dev/null +++ b/src/modules/registratura/components/subject-template-input.tsx @@ -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; + onFieldChange: (fieldId: string, value: string) => void; + onClear: () => void; +} + +export function SubjectTemplateInput({ + template, + fieldValues, + onFieldChange, + onClear, +}: SubjectTemplateInputProps) { + const firstFieldRef = useRef(null); + + // Auto-focus first field on mount + useEffect(() => { + const timer = setTimeout(() => firstFieldRef.current?.focus(), 50); + return () => clearTimeout(timer); + }, []); + + let fieldRendered = 0; + + return ( +
+ {template.tokens.map((token, i) => { + if (token.type === "static") { + return ( + + {token.value} + + ); + } + + const field = token.field!; + const isFirst = fieldRendered === 0; + fieldRendered++; + + return ( + 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, + )} + /> + ); + })} + +
+ ); +} diff --git a/src/modules/registratura/services/subject-template-service.ts b/src/modules/registratura/services/subject-template-service.ts new file mode 100644 index 0000000..21b72c0 --- /dev/null +++ b/src/modules/registratura/services/subject-template-service.ts @@ -0,0 +1,288 @@ +/** + * Subject Template Service — extracts reusable templates from existing + * registry subjects and provides filtering/assembly utilities. + * + * Pure functions, no React or side effects. + */ + +// ── Types ── + +export interface TemplateField { + id: string; // "f0", "f1", ... + name: string; // "nr", "an", "detalii" + placeholder: string; // Romanian label + width: string; // Tailwind width class +} + +export interface TemplateToken { + type: "static" | "field"; + value: string; // literal text for static, empty for fields + field?: TemplateField; +} + +export interface SubjectTemplate { + id: string; + pattern: string; // "Cerere CU nr. {nr}/{an} — {detalii}" + tokens: TemplateToken[]; + fields: TemplateField[]; + frequency: number; + exampleSubject: string; +} + +// ── Internal helpers ── + +/** Simple hash for template deduplication */ +function hashPattern(s: string): string { + let h = 0; + for (let i = 0; i < s.length; i++) { + h = ((h << 5) - h + s.charCodeAt(i)) | 0; + } + return `t${Math.abs(h).toString(36)}`; +} + +/** + * Tokenize a subject into static text + variable field segments. + * + * Strategy: scan left-to-right, replacing recognized variable patterns with + * field tokens while keeping everything else as static text. + */ +function tokenize(subject: string): TemplateToken[] { + const tokens: TemplateToken[] = []; + let fieldIdx = 0; + + // First check for a separator split (— or – or - with spaces) + const sepMatch = subject.match(/^(.+?)\s([—–])\s(.+)$/); + + if (sepMatch) { + const left = sepMatch[1]!; + const sep = sepMatch[2]!; + const right = sepMatch[3]!; + + // Tokenize the left part (may contain nr/an) + const leftTokens = tokenizeSegment(left, fieldIdx); + tokens.push(...leftTokens.tokens); + fieldIdx = leftTokens.nextIdx; + + // Add separator as static + tokens.push({ type: "static", value: ` ${sep} ` }); + + // Right side is always a "detalii" field + tokens.push({ + type: "field", + value: "", + field: { + id: `f${fieldIdx}`, + name: "detalii", + placeholder: "detalii...", + width: "flex-1 min-w-32", + }, + }); + } else { + // No separator — tokenize the whole thing + const result = tokenizeSegment(subject, fieldIdx); + tokens.push(...result.tokens); + } + + return tokens; +} + +/** + * Tokenize a segment (no separator) by replacing number patterns. + */ +function tokenizeSegment( + segment: string, + startIdx: number, +): { tokens: TemplateToken[]; nextIdx: number } { + const tokens: TemplateToken[] = []; + let idx = startIdx; + + // Combined regex: match year (4-digit 20xx) or other numbers (1-6 digits) + const numPattern = /\b(20\d{2})\b|\b(\d{1,6})\b/g; + + let lastEnd = 0; + let match: RegExpExecArray | null; + + while ((match = numPattern.exec(segment)) !== null) { + // Static text before this match + if (match.index > lastEnd) { + tokens.push({ type: "static", value: segment.slice(lastEnd, match.index) }); + } + + const isYear = match[1] !== undefined; + tokens.push({ + type: "field", + value: "", + field: { + id: `f${idx}`, + name: isYear ? "an" : "nr", + placeholder: isYear ? "an" : "nr.", + width: isYear ? "w-16" : "w-14", + }, + }); + idx++; + lastEnd = match.index + match[0].length; + } + + // Trailing static text + if (lastEnd < segment.length) { + tokens.push({ type: "static", value: segment.slice(lastEnd) }); + } + + // If no fields found, the whole segment is a single static token + if (tokens.length === 0) { + tokens.push({ type: "static", value: segment }); + } + + return { tokens, nextIdx: idx }; +} + +/** + * Build the pattern string from tokens for grouping/display. + * E.g. "Cerere CU nr. {nr}/{an} — {detalii}" + */ +function buildPattern(tokens: TemplateToken[]): string { + return tokens + .map((t) => (t.type === "static" ? t.value : `{${t.field!.name}}`)) + .join(""); +} + +// ── Public API ── + +/** + * Extract reusable templates from an array of existing subjects. + * Groups by pattern, counts frequency, returns sorted by frequency desc. + */ +export function extractTemplates(subjects: string[]): SubjectTemplate[] { + if (subjects.length === 0) return []; + + // Deduplicate subjects + const unique = Array.from(new Set(subjects)); + + // Tokenize each subject and group by pattern + const groups = new Map< + string, + { tokens: TemplateToken[]; example: string; count: number } + >(); + + for (const subj of unique) { + // Skip very short subjects (no useful template) + if (subj.length < 8) continue; + + const tokens = tokenize(subj); + const fields = tokens.filter((t) => t.type === "field"); + + // Only consider subjects that have at least one field (variable part) + if (fields.length === 0) continue; + + const pattern = buildPattern(tokens); + const existing = groups.get(pattern); + if (existing) { + existing.count++; + } else { + groups.set(pattern, { tokens, example: subj, count: 1 }); + } + } + + // Count actual frequency: for each pattern, count how many of the + // original (non-unique) subjects match + // (Already counting unique matches above; for frequency boost, count + // duplicates from the full input array) + const fullCounts = new Map(); + for (const subj of subjects) { + if (subj.length < 8) continue; + const tokens = tokenize(subj); + const fields = tokens.filter((t) => t.type === "field"); + if (fields.length === 0) continue; + const pattern = buildPattern(tokens); + fullCounts.set(pattern, (fullCounts.get(pattern) ?? 0) + 1); + } + + // Build result array + const result: SubjectTemplate[] = []; + + for (const [pattern, group] of groups) { + const freq = fullCounts.get(pattern) ?? group.count; + + // Rebuild fields array from tokens (with sequential IDs) + const fields: TemplateField[] = []; + let fIdx = 0; + const normalizedTokens: TemplateToken[] = group.tokens.map((t) => { + if (t.type === "field" && t.field) { + const field: TemplateField = { ...t.field, id: `f${fIdx}` }; + fields.push(field); + fIdx++; + return { ...t, field }; + } + return t; + }); + + result.push({ + id: hashPattern(pattern), + pattern, + tokens: normalizedTokens, + fields, + frequency: freq, + exampleSubject: group.example, + }); + } + + // Sort by frequency desc, limit to 15 + result.sort((a, b) => b.frequency - a.frequency); + return result.slice(0, 15); +} + +/** + * Assemble a final subject string from a template + field values. + */ +export function assembleSubject( + template: SubjectTemplate, + fieldValues: Record, +): string { + return template.tokens + .map((t) => { + if (t.type === "static") return t.value; + return fieldValues[t.field!.id] ?? ""; + }) + .join(""); +} + +/** + * Filter templates by a query string. + * Matches against both the pattern and the example subject. + */ +export function filterTemplates( + templates: SubjectTemplate[], + query: string, +): SubjectTemplate[] { + if (!query || query.length < 2) return []; + const q = query.toLowerCase(); + return templates.filter( + (t) => + t.pattern.toLowerCase().includes(q) || + t.exampleSubject.toLowerCase().includes(q), + ); +} + +/** + * Filter existing subjects by query (plain text substring match). + * Returns unique matches, limited to `limit`. + */ +export function filterSubjects( + subjects: string[], + query: string, + limit = 5, +): string[] { + if (!query || query.length < 2) return []; + const q = query.toLowerCase(); + const seen = new Set(); + const result: string[] = []; + for (const s of subjects) { + if (result.length >= limit) break; + const lower = s.toLowerCase(); + if (lower.includes(q) && !seen.has(lower)) { + seen.add(lower); + result.push(s); + } + } + return result; +}