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,
|
assembleSubject,
|
||||||
filterTemplates,
|
filterTemplates,
|
||||||
filterSubjects,
|
filterSubjects,
|
||||||
|
getRecommendedTemplates,
|
||||||
|
getSubjectPlaceholder,
|
||||||
} from "../services/subject-template-service";
|
} from "../services/subject-template-service";
|
||||||
import type { SubjectTemplate } from "../services/subject-template-service";
|
import type { SubjectTemplate } from "../services/subject-template-service";
|
||||||
import { SubjectTemplateInput } from "./subject-template-input";
|
import { SubjectTemplateInput } from "./subject-template-input";
|
||||||
@@ -113,6 +115,7 @@ export function RegistryEntryForm({
|
|||||||
}: RegistryEntryFormProps) {
|
}: RegistryEntryFormProps) {
|
||||||
const { allContacts, refresh: refreshContacts } = useContacts();
|
const { allContacts, refresh: refreshContacts } = useContacts();
|
||||||
const { tags: docTypeTags } = useTags("document-type");
|
const { tags: docTypeTags } = useTags("document-type");
|
||||||
|
const { tags: projectTags } = useTags("project");
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Track locally-added custom types that may not yet be in Tag Manager
|
// Track locally-added custom types that may not yet be in Tag Manager
|
||||||
@@ -336,6 +339,37 @@ export function RegistryEntryForm({
|
|||||||
[allSubjects, subjectQuery],
|
[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 ──
|
// ── Quick contact creation handler ──
|
||||||
const openQuickContact = (
|
const openQuickContact = (
|
||||||
field: "sender" | "recipient" | "assignee",
|
field: "sender" | "recipient" | "assignee",
|
||||||
@@ -717,11 +751,29 @@ export function RegistryEntryForm({
|
|||||||
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" className="max-w-xs">
|
<TooltipContent side="right" className="max-w-xs">
|
||||||
<p className="text-xs">
|
{direction === "intrat" ? (
|
||||||
Scurt și descriptiv: «Cerere CU nr. 123/2026 — Str.
|
<div className="text-xs space-y-1">
|
||||||
Exemplu» sau «Aviz ISU — Proiect X». Nu folosi prefixe de
|
<p className="font-medium">Documente primite:</p>
|
||||||
numerotare.
|
<ul className="list-disc pl-3 space-y-0.5">
|
||||||
</p>
|
<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>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -740,6 +792,7 @@ export function RegistryEntryForm({
|
|||||||
setActiveTemplate(null);
|
setActiveTemplate(null);
|
||||||
setSubjectQuery(subject);
|
setSubjectQuery(subject);
|
||||||
}}
|
}}
|
||||||
|
projectTags={projectTags}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
@@ -752,23 +805,30 @@ export function RegistryEntryForm({
|
|||||||
onBlur={() => setTimeout(() => setSubjectFocused(false), 200)}
|
onBlur={() => setTimeout(() => setSubjectFocused(false), 200)}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
required
|
required
|
||||||
placeholder="Tastează pentru sugestii..."
|
placeholder={subjectPlaceholder}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SubjectAutocompleteDropdown
|
<SubjectAutocompleteDropdown
|
||||||
templates={matchingTemplates}
|
templates={matchingTemplates}
|
||||||
suggestions={matchingSuggestions}
|
suggestions={matchingSuggestions}
|
||||||
|
recommendedTemplates={recommendedTemplates}
|
||||||
|
recentSubjects={recentSubjects}
|
||||||
visible={
|
visible={
|
||||||
subjectFocused &&
|
subjectFocused &&
|
||||||
!activeTemplate &&
|
!activeTemplate
|
||||||
subjectQuery.length >= 2 &&
|
|
||||||
(matchingTemplates.length > 0 || matchingSuggestions.length > 0)
|
|
||||||
}
|
}
|
||||||
onSelectTemplate={(t) => {
|
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);
|
setActiveTemplate(t);
|
||||||
setTemplateFieldValues({});
|
setTemplateFieldValues(prefill);
|
||||||
setSubject(assembleSubject(t, {}));
|
setSubject(assembleSubject(t, prefill));
|
||||||
setSubjectFocused(false);
|
setSubjectFocused(false);
|
||||||
}}
|
}}
|
||||||
onSelectSuggestion={(s) => {
|
onSelectSuggestion={(s) => {
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Clock, Sparkles } from "lucide-react";
|
||||||
import type { SubjectTemplate } from "../services/subject-template-service";
|
import type { SubjectTemplate } from "../services/subject-template-service";
|
||||||
|
|
||||||
interface SubjectAutocompleteDropdownProps {
|
interface SubjectAutocompleteDropdownProps {
|
||||||
|
/** Filtered templates (when user is typing >=2 chars) */
|
||||||
templates: SubjectTemplate[];
|
templates: SubjectTemplate[];
|
||||||
|
/** Filtered plain text suggestions (when user is typing >=2 chars) */
|
||||||
suggestions: string[];
|
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;
|
visible: boolean;
|
||||||
onSelectTemplate: (template: SubjectTemplate) => void;
|
onSelectTemplate: (template: SubjectTemplate) => void;
|
||||||
onSelectSuggestion: (subject: string) => void;
|
onSelectSuggestion: (subject: string) => void;
|
||||||
@@ -13,21 +20,31 @@ interface SubjectAutocompleteDropdownProps {
|
|||||||
export function SubjectAutocompleteDropdown({
|
export function SubjectAutocompleteDropdown({
|
||||||
templates,
|
templates,
|
||||||
suggestions,
|
suggestions,
|
||||||
|
recommendedTemplates,
|
||||||
|
recentSubjects,
|
||||||
visible,
|
visible,
|
||||||
onSelectTemplate,
|
onSelectTemplate,
|
||||||
onSelectSuggestion,
|
onSelectSuggestion,
|
||||||
}: SubjectAutocompleteDropdownProps) {
|
}: 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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<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 && (
|
||||||
|
<>
|
||||||
{/* Section 1: Templates */}
|
{/* Section 1: Templates */}
|
||||||
{templates.length > 0 && (
|
{templates.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
Șabloane
|
Sabloane
|
||||||
</div>
|
</div>
|
||||||
{templates.map((t) => (
|
{templates.map((t) => (
|
||||||
<button
|
<button
|
||||||
@@ -37,9 +54,11 @@ export function SubjectAutocompleteDropdown({
|
|||||||
onMouseDown={() => onSelectTemplate(t)}
|
onMouseDown={() => onSelectTemplate(t)}
|
||||||
>
|
>
|
||||||
<span className="font-medium">{t.pattern}</span>
|
<span className="font-medium">{t.pattern}</span>
|
||||||
|
{t.frequency > 0 && (
|
||||||
<span className="ml-2 text-muted-foreground text-xs">
|
<span className="ml-2 text-muted-foreground text-xs">
|
||||||
({t.frequency}x)
|
({t.frequency}x)
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -68,6 +87,69 @@ export function SubjectAutocompleteDropdown({
|
|||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Mode 2: Empty focus — recommended + recent ── */}
|
||||||
|
{!hasFiltered && hasRecommended && (
|
||||||
|
<>
|
||||||
|
{/* 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState, useMemo } from "react";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
import type { SubjectTemplate } from "../services/subject-template-service";
|
import type { SubjectTemplate } from "../services/subject-template-service";
|
||||||
|
import type { Tag } from "@/core/tagging/types";
|
||||||
|
|
||||||
interface SubjectTemplateInputProps {
|
interface SubjectTemplateInputProps {
|
||||||
template: SubjectTemplate;
|
template: SubjectTemplate;
|
||||||
fieldValues: Record<string, string>;
|
fieldValues: Record<string, string>;
|
||||||
onFieldChange: (fieldId: string, value: string) => void;
|
onFieldChange: (fieldId: string, value: string) => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
|
/** Project tags for {proiect} field autocomplete */
|
||||||
|
projectTags?: Tag[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SubjectTemplateInput({
|
export function SubjectTemplateInput({
|
||||||
@@ -17,6 +20,7 @@ export function SubjectTemplateInput({
|
|||||||
fieldValues,
|
fieldValues,
|
||||||
onFieldChange,
|
onFieldChange,
|
||||||
onClear,
|
onClear,
|
||||||
|
projectTags,
|
||||||
}: SubjectTemplateInputProps) {
|
}: SubjectTemplateInputProps) {
|
||||||
const firstFieldRef = useRef<HTMLInputElement>(null);
|
const firstFieldRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -52,6 +56,20 @@ export function SubjectTemplateInput({
|
|||||||
const isFirst = fieldRendered === 0;
|
const isFirst = fieldRendered === 0;
|
||||||
fieldRendered++;
|
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 (
|
return (
|
||||||
<input
|
<input
|
||||||
key={field.id}
|
key={field.id}
|
||||||
@@ -74,10 +92,98 @@ export function SubjectTemplateInput({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onClear}
|
onClick={onClear}
|
||||||
className="ml-auto shrink-0 text-muted-foreground hover:text-foreground transition-colors"
|
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" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,16 +2,25 @@
|
|||||||
* Subject Template Service — extracts reusable templates from existing
|
* Subject Template Service — extracts reusable templates from existing
|
||||||
* registry subjects and provides filtering/assembly utilities.
|
* registry subjects and provides filtering/assembly utilities.
|
||||||
*
|
*
|
||||||
|
* Also provides seed templates per document type so the autocomplete
|
||||||
|
* is useful from the very first entry.
|
||||||
|
*
|
||||||
* Pure functions, no React or side effects.
|
* Pure functions, no React or side effects.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { RegistryDirection } from "../types";
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
|
|
||||||
|
export type TemplateFieldType = "nr" | "an" | "detalii" | "proiect" | "text";
|
||||||
|
|
||||||
export interface TemplateField {
|
export interface TemplateField {
|
||||||
id: string; // "f0", "f1", ...
|
id: string; // "f0", "f1", ...
|
||||||
name: string; // "nr", "an", "detalii"
|
name: string; // "nr", "an", "detalii", "proiect"
|
||||||
placeholder: string; // Romanian label
|
placeholder: string; // Romanian label
|
||||||
width: string; // Tailwind width class
|
width: string; // Tailwind width class
|
||||||
|
fieldType?: TemplateFieldType;
|
||||||
|
defaultValue?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TemplateToken {
|
export interface TemplateToken {
|
||||||
@@ -22,11 +31,13 @@ export interface TemplateToken {
|
|||||||
|
|
||||||
export interface SubjectTemplate {
|
export interface SubjectTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
pattern: string; // "Cerere CU nr. {nr}/{an} — {detalii}"
|
pattern: string; // "Cerere CU nr. {nr}/{an} — {proiect}"
|
||||||
tokens: TemplateToken[];
|
tokens: TemplateToken[];
|
||||||
fields: TemplateField[];
|
fields: TemplateField[];
|
||||||
frequency: number;
|
frequency: number;
|
||||||
exampleSubject: string;
|
exampleSubject: string;
|
||||||
|
/** Whether this is a predefined seed template (not learned from DB) */
|
||||||
|
isSeed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Internal helpers ──
|
// ── Internal helpers ──
|
||||||
@@ -75,6 +86,7 @@ function tokenize(subject: string): TemplateToken[] {
|
|||||||
name: "detalii",
|
name: "detalii",
|
||||||
placeholder: "detalii...",
|
placeholder: "detalii...",
|
||||||
width: "flex-1 min-w-32",
|
width: "flex-1 min-w-32",
|
||||||
|
fieldType: "detalii",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -109,6 +121,7 @@ function tokenizeSegment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isYear = match[1] !== undefined;
|
const isYear = match[1] !== undefined;
|
||||||
|
const ft: TemplateFieldType = isYear ? "an" : "nr";
|
||||||
tokens.push({
|
tokens.push({
|
||||||
type: "field",
|
type: "field",
|
||||||
value: "",
|
value: "",
|
||||||
@@ -117,6 +130,7 @@ function tokenizeSegment(
|
|||||||
name: isYear ? "an" : "nr",
|
name: isYear ? "an" : "nr",
|
||||||
placeholder: isYear ? "an" : "nr.",
|
placeholder: isYear ? "an" : "nr.",
|
||||||
width: isYear ? "w-16" : "w-14",
|
width: isYear ? "w-16" : "w-14",
|
||||||
|
fieldType: ft,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
idx++;
|
idx++;
|
||||||
@@ -146,8 +160,242 @@ function buildPattern(tokens: TemplateToken[]): string {
|
|||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Seed template builder ──
|
||||||
|
|
||||||
|
const CURRENT_YEAR = new Date().getFullYear().toString();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a seed SubjectTemplate from a pattern string.
|
||||||
|
* Parses `{fieldName}` placeholders into tokens/fields.
|
||||||
|
*/
|
||||||
|
function seedTemplate(pattern: string, example: string): SubjectTemplate {
|
||||||
|
const tokens: TemplateToken[] = [];
|
||||||
|
const fields: TemplateField[] = [];
|
||||||
|
let fIdx = 0;
|
||||||
|
|
||||||
|
// Split pattern by {fieldName} placeholders
|
||||||
|
const parts = pattern.split(/(\{[^}]+\})/);
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!part) continue;
|
||||||
|
const fieldMatch = part.match(/^\{(\w+)\}$/);
|
||||||
|
if (fieldMatch) {
|
||||||
|
const name = fieldMatch[1]!;
|
||||||
|
const ft = resolveFieldType(name);
|
||||||
|
const field: TemplateField = {
|
||||||
|
id: `f${fIdx}`,
|
||||||
|
name,
|
||||||
|
placeholder: fieldPlaceholder(ft),
|
||||||
|
width: fieldWidth(ft),
|
||||||
|
fieldType: ft,
|
||||||
|
defaultValue: ft === "an" ? CURRENT_YEAR : undefined,
|
||||||
|
};
|
||||||
|
fields.push(field);
|
||||||
|
tokens.push({ type: "field", value: "", field });
|
||||||
|
fIdx++;
|
||||||
|
} else {
|
||||||
|
tokens.push({ type: "static", value: part });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: hashPattern(pattern),
|
||||||
|
pattern,
|
||||||
|
tokens,
|
||||||
|
fields,
|
||||||
|
frequency: 0,
|
||||||
|
exampleSubject: example,
|
||||||
|
isSeed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFieldType(name: string): TemplateFieldType {
|
||||||
|
if (name === "nr") return "nr";
|
||||||
|
if (name === "an") return "an";
|
||||||
|
if (name === "proiect") return "proiect";
|
||||||
|
if (name === "detalii") return "detalii";
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldPlaceholder(ft: TemplateFieldType): string {
|
||||||
|
switch (ft) {
|
||||||
|
case "nr": return "nr.";
|
||||||
|
case "an": return "an";
|
||||||
|
case "proiect": return "proiect...";
|
||||||
|
case "detalii": return "detalii...";
|
||||||
|
case "text": return "text...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldWidth(ft: TemplateFieldType): string {
|
||||||
|
switch (ft) {
|
||||||
|
case "nr": return "w-14";
|
||||||
|
case "an": return "w-16";
|
||||||
|
case "proiect": return "flex-1 min-w-32";
|
||||||
|
case "detalii": return "flex-1 min-w-32";
|
||||||
|
case "text": return "flex-1 min-w-24";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Seed Templates Catalog ──
|
||||||
|
|
||||||
|
const SEED_TEMPLATES: Record<string, SubjectTemplate[]> = {
|
||||||
|
cerere: [
|
||||||
|
seedTemplate("Cerere CU nr. {nr}/{an} — {proiect}", "Cerere CU nr. 123/2026 — Farmacie Str. Exemplu"),
|
||||||
|
seedTemplate("Cerere AC nr. {nr}/{an} — {proiect}", "Cerere AC nr. 456/2026 — Locuinta P+1E"),
|
||||||
|
seedTemplate("Cerere prelungire CU nr. {nr}/{an} — {proiect}", "Cerere prelungire CU nr. 78/2026 — Bloc D4"),
|
||||||
|
seedTemplate("Cerere prelungire AC nr. {nr}/{an} — {proiect}", "Cerere prelungire AC nr. 90/2026 — Hala industriala"),
|
||||||
|
],
|
||||||
|
aviz: [
|
||||||
|
seedTemplate("Aviz {detalii} nr. {nr}/{an} — {proiect}", "Aviz ISU nr. 55/2026 — Cladire birouri"),
|
||||||
|
seedTemplate("Solicitare aviz {detalii} — {proiect}", "Solicitare aviz DSP — Gradinita nr. 3"),
|
||||||
|
seedTemplate("Aviz de oportunitate nr. {nr}/{an} — {proiect}", "Aviz de oportunitate nr. 12/2026 — PUZ Zona centrala"),
|
||||||
|
],
|
||||||
|
contract: [
|
||||||
|
seedTemplate("Contract nr. {nr}/{an} — {proiect}", "Contract nr. 15/2026 — Proiectare locuinta"),
|
||||||
|
seedTemplate("Contract prestari servicii nr. {nr}/{an} — {proiect}", "Contract prestari servicii nr. 22/2026 — Studiu geo"),
|
||||||
|
seedTemplate("Act aditional nr. {nr} la contract {detalii}", "Act aditional nr. 2 la contract 15/2026"),
|
||||||
|
],
|
||||||
|
oferta: [
|
||||||
|
seedTemplate("Oferta proiectare {detalii} — {proiect}", "Oferta proiectare DTAC+PT — Bloc rezidential"),
|
||||||
|
seedTemplate("Oferta nr. {nr}/{an} — {proiect}", "Oferta nr. 8/2026 — Mansardare bloc"),
|
||||||
|
],
|
||||||
|
factura: [
|
||||||
|
seedTemplate("Factura nr. {nr}/{an} — {proiect}", "Factura nr. 102/2026 — Farmacie Str. Exemplu"),
|
||||||
|
seedTemplate("Factura proforma nr. {nr}/{an} — {proiect}", "Factura proforma nr. 5/2026 — Studiu geo"),
|
||||||
|
],
|
||||||
|
scrisoare: [
|
||||||
|
seedTemplate("Adresa nr. {nr}/{an} — {detalii}", "Adresa nr. 45/2026 — Raspuns solicitare informatii"),
|
||||||
|
seedTemplate("Notificare {detalii} — {proiect}", "Notificare incepere lucrari — Locuinta P+1E"),
|
||||||
|
seedTemplate("Adeverinta {detalii} nr. {nr}/{an}", "Adeverinta stagiu practica nr. 3/2026"),
|
||||||
|
],
|
||||||
|
"nota-de-comanda": [
|
||||||
|
seedTemplate("Nota de comanda nr. {nr}/{an} — {proiect}", "Nota de comanda nr. 7/2026 — Instalatii electrice"),
|
||||||
|
seedTemplate("Comanda {detalii} — {proiect}", "Comanda materiale — Santier Bloc A"),
|
||||||
|
],
|
||||||
|
raport: [
|
||||||
|
seedTemplate("Raport {detalii} — {proiect}", "Raport expertiza tehnica — Consolidare bloc"),
|
||||||
|
seedTemplate("Studiu geotehnic {detalii} — {proiect}", "Studiu geotehnic preliminar — Hala logistica"),
|
||||||
|
seedTemplate("Raport nr. {nr}/{an} — {proiect}", "Raport nr. 3/2026 — Audit energetic"),
|
||||||
|
],
|
||||||
|
"apel-telefonic": [
|
||||||
|
seedTemplate("Convorbire {detalii} — {proiect}", "Convorbire primarie avize — Bloc D4"),
|
||||||
|
seedTemplate("Apel {detalii} — {proiect}", "Apel ISU programare — Gradinita nr. 3"),
|
||||||
|
],
|
||||||
|
videoconferinta: [
|
||||||
|
seedTemplate("Sedinta {detalii} — {proiect}", "Sedinta coordonare proiectare — Bloc rezidential"),
|
||||||
|
seedTemplate("Videoconferinta {detalii} — {proiect}", "Videoconferinta revizie PT — Hala industriala"),
|
||||||
|
],
|
||||||
|
altele: [
|
||||||
|
seedTemplate("Documentatie {detalii} — {proiect}", "Documentatie PAC+DDE — Locuinta P+M"),
|
||||||
|
seedTemplate("{detalii} nr. {nr}/{an} — {proiect}", "PV receptie nr. 5/2026 — Cladire birouri"),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Dynamic Placeholders ──
|
||||||
|
|
||||||
|
const SUBJECT_PLACEHOLDERS_MAP: Record<string, Record<string, string>> = {
|
||||||
|
cerere: {
|
||||||
|
intrat: "ex: Cerere CU nr. 123/2026 — Proiect X",
|
||||||
|
iesit: "ex: Cerere prelungire AC nr. 45/2026 — Proiect X",
|
||||||
|
},
|
||||||
|
aviz: {
|
||||||
|
intrat: "ex: Aviz ISU nr. 55/2026 — Proiect X",
|
||||||
|
iesit: "ex: Solicitare aviz DSP — Proiect X",
|
||||||
|
},
|
||||||
|
contract: {
|
||||||
|
intrat: "ex: Contract nr. 15/2026 — Proiect X",
|
||||||
|
iesit: "ex: Contract prestari servicii nr. 22/2026 — Proiect X",
|
||||||
|
},
|
||||||
|
oferta: {
|
||||||
|
intrat: "ex: Oferta nr. 8/2026 — Proiect X",
|
||||||
|
iesit: "ex: Oferta proiectare DTAC+PT — Proiect X",
|
||||||
|
},
|
||||||
|
factura: {
|
||||||
|
intrat: "ex: Factura nr. 102/2026 — Proiect X",
|
||||||
|
iesit: "ex: Factura proforma nr. 5/2026 — Proiect X",
|
||||||
|
},
|
||||||
|
scrisoare: {
|
||||||
|
intrat: "ex: Adresa nr. 45/2026 — Raspuns informatii",
|
||||||
|
iesit: "ex: Notificare incepere lucrari — Proiect X",
|
||||||
|
},
|
||||||
|
"nota-de-comanda": {
|
||||||
|
intrat: "ex: Nota de comanda nr. 7/2026 — Proiect X",
|
||||||
|
iesit: "ex: Comanda materiale — Proiect X",
|
||||||
|
},
|
||||||
|
raport: {
|
||||||
|
intrat: "ex: Studiu geotehnic — Proiect X",
|
||||||
|
iesit: "ex: Raport expertiza tehnica — Proiect X",
|
||||||
|
},
|
||||||
|
"apel-telefonic": {
|
||||||
|
intrat: "ex: Apel primarie avize — Proiect X",
|
||||||
|
iesit: "ex: Convorbire ISU programare — Proiect X",
|
||||||
|
},
|
||||||
|
videoconferinta: {
|
||||||
|
intrat: "ex: Sedinta coordonare — Proiect X",
|
||||||
|
iesit: "ex: Videoconferinta revizie PT — Proiect X",
|
||||||
|
},
|
||||||
|
altele: {
|
||||||
|
intrat: "ex: PV receptie nr. 5/2026 — Proiect X",
|
||||||
|
iesit: "ex: Documentatie PAC+DDE — Proiect X",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_PLACEHOLDER = "Tastati pentru sugestii...";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a dynamic placeholder for the subject input based on document type
|
||||||
|
* and direction.
|
||||||
|
*/
|
||||||
|
export function getSubjectPlaceholder(
|
||||||
|
documentType: string,
|
||||||
|
direction: RegistryDirection,
|
||||||
|
): string {
|
||||||
|
const byType = SUBJECT_PLACEHOLDERS_MAP[documentType];
|
||||||
|
if (!byType) return DEFAULT_PLACEHOLDER;
|
||||||
|
return byType[direction] ?? DEFAULT_PLACEHOLDER;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Public API ──
|
// ── Public API ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get seed templates for a given document type.
|
||||||
|
*/
|
||||||
|
export function getSeedTemplates(documentType: string): SubjectTemplate[] {
|
||||||
|
return SEED_TEMPLATES[documentType] ?? SEED_TEMPLATES["altele"] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recommended templates for a document type: merge seeds + DB-learned.
|
||||||
|
* DB-learned templates take priority (appear first), seeds fill in the rest.
|
||||||
|
* De-duplicates by pattern.
|
||||||
|
*/
|
||||||
|
export function getRecommendedTemplates(
|
||||||
|
documentType: string,
|
||||||
|
dbTemplates: SubjectTemplate[],
|
||||||
|
): SubjectTemplate[] {
|
||||||
|
const seeds = getSeedTemplates(documentType);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: SubjectTemplate[] = [];
|
||||||
|
|
||||||
|
// DB-learned first (higher priority)
|
||||||
|
for (const t of dbTemplates) {
|
||||||
|
if (!seen.has(t.pattern)) {
|
||||||
|
seen.add(t.pattern);
|
||||||
|
result.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then seeds (fill remaining slots)
|
||||||
|
for (const t of seeds) {
|
||||||
|
if (!seen.has(t.pattern)) {
|
||||||
|
seen.add(t.pattern);
|
||||||
|
result.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract reusable templates from an array of existing subjects.
|
* Extract reusable templates from an array of existing subjects.
|
||||||
* Groups by pattern, counts frequency, returns sorted by frequency desc.
|
* Groups by pattern, counts frequency, returns sorted by frequency desc.
|
||||||
|
|||||||
Reference in New Issue
Block a user