feat(registratura): smart subject autocomplete v2 — seed templates, project linking, dynamic placeholders
- Add SEED_TEMPLATES catalog (11 doc types x 2-4 templates = ~30 predefined patterns)
- Add {proiect} field type with mini-autocomplete from Tag Manager projects
- Pre-fill {an} fields with current year on template selection
- Dynamic placeholder changes based on documentType + direction (22 combinations)
- Dropdown on empty focus: "Sabloane recomandate" + "Recente" sections
- Direction-aware tooltip on Subiect field (intrat vs iesit examples)
- getRecommendedTemplates() merges seeds + DB-learned, DB takes priority
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,8 @@ import {
|
||||
assembleSubject,
|
||||
filterTemplates,
|
||||
filterSubjects,
|
||||
getRecommendedTemplates,
|
||||
getSubjectPlaceholder,
|
||||
} from "../services/subject-template-service";
|
||||
import type { SubjectTemplate } from "../services/subject-template-service";
|
||||
import { SubjectTemplateInput } from "./subject-template-input";
|
||||
@@ -113,6 +115,7 @@ export function RegistryEntryForm({
|
||||
}: RegistryEntryFormProps) {
|
||||
const { allContacts, refresh: refreshContacts } = useContacts();
|
||||
const { tags: docTypeTags } = useTags("document-type");
|
||||
const { tags: projectTags } = useTags("project");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Track locally-added custom types that may not yet be in Tag Manager
|
||||
@@ -336,6 +339,37 @@ export function RegistryEntryForm({
|
||||
[allSubjects, subjectQuery],
|
||||
);
|
||||
|
||||
// Recommended templates for current doc type (seeds + DB-learned)
|
||||
const recommendedTemplates = useMemo(
|
||||
() => getRecommendedTemplates(documentType, allTemplates),
|
||||
[documentType, allTemplates],
|
||||
);
|
||||
|
||||
// Recent subjects from DB (last 5 unique, sorted by newest first)
|
||||
const recentSubjects = useMemo(() => {
|
||||
if (!allEntries || allEntries.length === 0) return [];
|
||||
const sorted = [...allEntries]
|
||||
.filter((e) => e.subject && !e.isReserved)
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const e of sorted) {
|
||||
if (result.length >= 5) break;
|
||||
const lower = e.subject.toLowerCase();
|
||||
if (!seen.has(lower)) {
|
||||
seen.add(lower);
|
||||
result.push(e.subject);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [allEntries]);
|
||||
|
||||
// Dynamic placeholder based on document type + direction
|
||||
const subjectPlaceholder = useMemo(
|
||||
() => getSubjectPlaceholder(documentType, direction),
|
||||
[documentType, direction],
|
||||
);
|
||||
|
||||
// ── Quick contact creation handler ──
|
||||
const openQuickContact = (
|
||||
field: "sender" | "recipient" | "assignee",
|
||||
@@ -717,11 +751,29 @@ export function RegistryEntryForm({
|
||||
<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">
|
||||
Scurt și descriptiv: «Cerere CU nr. 123/2026 — Str.
|
||||
Exemplu» sau «Aviz ISU — Proiect X». Nu folosi prefixe de
|
||||
numerotare.
|
||||
</p>
|
||||
{direction === "intrat" ? (
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium">Documente primite:</p>
|
||||
<ul className="list-disc pl-3 space-y-0.5">
|
||||
<li>Cerere CU nr. 123/2026 — Proiect X</li>
|
||||
<li>Aviz ISU nr. 55/2026 — Proiect X</li>
|
||||
<li>Contract nr. 15/2026 — Proiect X</li>
|
||||
<li>Factura nr. 102/2026 — Proiect X</li>
|
||||
</ul>
|
||||
<p className="text-muted-foreground">Selecteaza un sablon sau tasteaza liber.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium">Documente trimise:</p>
|
||||
<ul className="list-disc pl-3 space-y-0.5">
|
||||
<li>Oferta proiectare DTAC — Proiect X</li>
|
||||
<li>Solicitare aviz DSP — Proiect X</li>
|
||||
<li>Notificare incepere lucrari — Proiect X</li>
|
||||
<li>Raport expertiza tehnica — Proiect X</li>
|
||||
</ul>
|
||||
<p className="text-muted-foreground">Selecteaza un sablon sau tasteaza liber.</p>
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -740,6 +792,7 @@ export function RegistryEntryForm({
|
||||
setActiveTemplate(null);
|
||||
setSubjectQuery(subject);
|
||||
}}
|
||||
projectTags={projectTags}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
@@ -752,23 +805,30 @@ export function RegistryEntryForm({
|
||||
onBlur={() => setTimeout(() => setSubjectFocused(false), 200)}
|
||||
className="mt-1"
|
||||
required
|
||||
placeholder="Tastează pentru sugestii..."
|
||||
placeholder={subjectPlaceholder}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SubjectAutocompleteDropdown
|
||||
templates={matchingTemplates}
|
||||
suggestions={matchingSuggestions}
|
||||
recommendedTemplates={recommendedTemplates}
|
||||
recentSubjects={recentSubjects}
|
||||
visible={
|
||||
subjectFocused &&
|
||||
!activeTemplate &&
|
||||
subjectQuery.length >= 2 &&
|
||||
(matchingTemplates.length > 0 || matchingSuggestions.length > 0)
|
||||
!activeTemplate
|
||||
}
|
||||
onSelectTemplate={(t) => {
|
||||
// Pre-fill year fields with current year
|
||||
const prefill: Record<string, string> = {};
|
||||
for (const field of t.fields) {
|
||||
if (field.defaultValue) {
|
||||
prefill[field.id] = field.defaultValue;
|
||||
}
|
||||
}
|
||||
setActiveTemplate(t);
|
||||
setTemplateFieldValues({});
|
||||
setSubject(assembleSubject(t, {}));
|
||||
setTemplateFieldValues(prefill);
|
||||
setSubject(assembleSubject(t, prefill));
|
||||
setSubjectFocused(false);
|
||||
}}
|
||||
onSelectSuggestion={(s) => {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { Clock, Sparkles } from "lucide-react";
|
||||
import type { SubjectTemplate } from "../services/subject-template-service";
|
||||
|
||||
interface SubjectAutocompleteDropdownProps {
|
||||
/** Filtered templates (when user is typing >=2 chars) */
|
||||
templates: SubjectTemplate[];
|
||||
/** Filtered plain text suggestions (when user is typing >=2 chars) */
|
||||
suggestions: string[];
|
||||
/** Recommended templates for the current document type (shown on empty focus) */
|
||||
recommendedTemplates?: SubjectTemplate[];
|
||||
/** Recent unique subjects from DB (shown on empty focus) */
|
||||
recentSubjects?: string[];
|
||||
visible: boolean;
|
||||
onSelectTemplate: (template: SubjectTemplate) => void;
|
||||
onSelectSuggestion: (subject: string) => void;
|
||||
@@ -13,59 +20,134 @@ interface SubjectAutocompleteDropdownProps {
|
||||
export function SubjectAutocompleteDropdown({
|
||||
templates,
|
||||
suggestions,
|
||||
recommendedTemplates,
|
||||
recentSubjects,
|
||||
visible,
|
||||
onSelectTemplate,
|
||||
onSelectSuggestion,
|
||||
}: SubjectAutocompleteDropdownProps) {
|
||||
if (!visible || (templates.length === 0 && suggestions.length === 0)) {
|
||||
// Determine which mode we're in
|
||||
const hasFiltered = templates.length > 0 || suggestions.length > 0;
|
||||
const hasRecommended = (recommendedTemplates && recommendedTemplates.length > 0) ||
|
||||
(recentSubjects && recentSubjects.length > 0);
|
||||
|
||||
if (!visible || (!hasFiltered && !hasRecommended)) {
|
||||
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="absolute z-10 mt-1 w-full rounded-md border bg-popover p-1 shadow-md max-h-72 overflow-y-auto">
|
||||
{/* ── Mode 1: Filtered results (user is typing) ── */}
|
||||
{hasFiltered && (
|
||||
<>
|
||||
<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>
|
||||
))}
|
||||
{/* Section 1: Templates */}
|
||||
{templates.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Sabloane
|
||||
</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>
|
||||
{t.frequency > 0 && (
|
||||
<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>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
{templates.length > 0 && suggestions.length > 0 && (
|
||||
<div className="border-t my-1" />
|
||||
)}
|
||||
|
||||
{/* Section 2: Plain suggestions */}
|
||||
{suggestions.length > 0 && (
|
||||
{/* ── Mode 2: Empty focus — recommended + recent ── */}
|
||||
{!hasFiltered && hasRecommended && (
|
||||
<>
|
||||
<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>
|
||||
))}
|
||||
{/* Recommended templates */}
|
||||
{recommendedTemplates && recommendedTemplates.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Sabloane recomandate
|
||||
</div>
|
||||
{recommendedTemplates.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>
|
||||
{t.frequency > 0 && !t.isSeed && (
|
||||
<span className="ml-2 text-muted-foreground text-xs">
|
||||
({t.frequency}x)
|
||||
</span>
|
||||
)}
|
||||
{t.isSeed && (
|
||||
<span className="ml-2 text-muted-foreground/60 text-[10px]">
|
||||
sablon
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
{recommendedTemplates && recommendedTemplates.length > 0 &&
|
||||
recentSubjects && recentSubjects.length > 0 && (
|
||||
<div className="border-t my-1" />
|
||||
)}
|
||||
|
||||
{/* Recent subjects */}
|
||||
{recentSubjects && recentSubjects.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Recente
|
||||
</div>
|
||||
{recentSubjects.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>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import type { SubjectTemplate } from "../services/subject-template-service";
|
||||
import type { Tag } from "@/core/tagging/types";
|
||||
|
||||
interface SubjectTemplateInputProps {
|
||||
template: SubjectTemplate;
|
||||
fieldValues: Record<string, string>;
|
||||
onFieldChange: (fieldId: string, value: string) => void;
|
||||
onClear: () => void;
|
||||
/** Project tags for {proiect} field autocomplete */
|
||||
projectTags?: Tag[];
|
||||
}
|
||||
|
||||
export function SubjectTemplateInput({
|
||||
@@ -17,6 +20,7 @@ export function SubjectTemplateInput({
|
||||
fieldValues,
|
||||
onFieldChange,
|
||||
onClear,
|
||||
projectTags,
|
||||
}: SubjectTemplateInputProps) {
|
||||
const firstFieldRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -52,6 +56,20 @@ export function SubjectTemplateInput({
|
||||
const isFirst = fieldRendered === 0;
|
||||
fieldRendered++;
|
||||
|
||||
// Project field — render with mini-autocomplete
|
||||
if (field.fieldType === "proiect" && projectTags && projectTags.length > 0) {
|
||||
return (
|
||||
<ProjectFieldInput
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={fieldValues[field.id] ?? ""}
|
||||
onChange={(val) => onFieldChange(field.id, val)}
|
||||
projectTags={projectTags}
|
||||
inputRef={isFirst ? firstFieldRef : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
key={field.id}
|
||||
@@ -74,10 +92,98 @@ export function SubjectTemplateInput({
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="ml-auto shrink-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Renunță la șablon"
|
||||
title="Renunta la sablon"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Project Field with mini-autocomplete ──
|
||||
|
||||
interface ProjectFieldInputProps {
|
||||
field: { id: string; placeholder: string; width: string };
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
projectTags: Tag[];
|
||||
inputRef?: React.Ref<HTMLInputElement>;
|
||||
}
|
||||
|
||||
function ProjectFieldInput({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
projectTags,
|
||||
inputRef,
|
||||
}: ProjectFieldInputProps) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
if (!value || value.length < 1) return projectTags.slice(0, 8);
|
||||
const q = value.toLowerCase();
|
||||
return projectTags
|
||||
.filter(
|
||||
(t) =>
|
||||
t.label.toLowerCase().includes(q) ||
|
||||
(t.projectCode && t.projectCode.toLowerCase().includes(q)),
|
||||
)
|
||||
.slice(0, 8);
|
||||
}, [value, projectTags]);
|
||||
|
||||
const showDropdown = focused && suggestions.length > 0;
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setTimeout(() => setFocused(false), 200)}
|
||||
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,
|
||||
)}
|
||||
/>
|
||||
{showDropdown && (
|
||||
<div className="absolute top-full left-0 z-20 mt-1 min-w-48 rounded-md border bg-popover p-1 shadow-md max-h-48 overflow-y-auto">
|
||||
{suggestions.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
className="w-full rounded px-2 py-1 text-left text-xs hover:bg-accent flex items-center gap-1.5"
|
||||
onMouseDown={() => {
|
||||
const display = tag.projectCode
|
||||
? `${tag.projectCode} ${tag.label}`
|
||||
: tag.label;
|
||||
onChange(display);
|
||||
setFocused(false);
|
||||
}}
|
||||
>
|
||||
{tag.color && (
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{tag.projectCode && (
|
||||
<span className="font-mono text-muted-foreground mr-1">
|
||||
{tag.projectCode}
|
||||
</span>
|
||||
)}
|
||||
{tag.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user