From e30b437dce68b33099f258f7bf2df7b35cf7379c Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Tue, 10 Mar 2026 12:46:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(registratura):=20smart=20subject=20autocom?= =?UTF-8?q?plete=20v2=20=E2=80=94=20seed=20templates,=20project=20linking,?= =?UTF-8?q?=20dynamic=20placeholders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../components/registry-entry-form.tsx | 82 +++++- .../subject-autocomplete-dropdown.tsx | 162 ++++++++--- .../components/subject-template-input.tsx | 110 +++++++- .../services/subject-template-service.ts | 252 +++++++++++++++++- 4 files changed, 551 insertions(+), 55 deletions(-) diff --git a/src/modules/registratura/components/registry-entry-form.tsx b/src/modules/registratura/components/registry-entry-form.tsx index cba7d37..6e5540c 100644 --- a/src/modules/registratura/components/registry-entry-form.tsx +++ b/src/modules/registratura/components/registry-entry-form.tsx @@ -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(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(); + 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({ -

- Scurt și descriptiv: «Cerere CU nr. 123/2026 — Str. - Exemplu» sau «Aviz ISU — Proiect X». Nu folosi prefixe de - numerotare. -

+ {direction === "intrat" ? ( +
+

Documente primite:

+
    +
  • Cerere CU nr. 123/2026 — Proiect X
  • +
  • Aviz ISU nr. 55/2026 — Proiect X
  • +
  • Contract nr. 15/2026 — Proiect X
  • +
  • Factura nr. 102/2026 — Proiect X
  • +
+

Selecteaza un sablon sau tasteaza liber.

+
+ ) : ( +
+

Documente trimise:

+
    +
  • Oferta proiectare DTAC — Proiect X
  • +
  • Solicitare aviz DSP — Proiect X
  • +
  • Notificare incepere lucrari — Proiect X
  • +
  • Raport expertiza tehnica — Proiect X
  • +
+

Selecteaza un sablon sau tasteaza liber.

+
+ )}
@@ -740,6 +792,7 @@ export function RegistryEntryForm({ setActiveTemplate(null); setSubjectQuery(subject); }} + projectTags={projectTags} /> ) : ( setTimeout(() => setSubjectFocused(false), 200)} className="mt-1" required - placeholder="Tastează pentru sugestii..." + placeholder={subjectPlaceholder} /> )} = 2 && - (matchingTemplates.length > 0 || matchingSuggestions.length > 0) + !activeTemplate } onSelectTemplate={(t) => { + // Pre-fill year fields with current year + const prefill: Record = {}; + 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) => { diff --git a/src/modules/registratura/components/subject-autocomplete-dropdown.tsx b/src/modules/registratura/components/subject-autocomplete-dropdown.tsx index c340aab..8e9859b 100644 --- a/src/modules/registratura/components/subject-autocomplete-dropdown.tsx +++ b/src/modules/registratura/components/subject-autocomplete-dropdown.tsx @@ -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 ( -
- {/* Section 1: Templates */} - {templates.length > 0 && ( +
+ {/* ── Mode 1: Filtered results (user is typing) ── */} + {hasFiltered && ( <> -
- Șabloane -
- {templates.map((t) => ( - - ))} + {/* Section 1: Templates */} + {templates.length > 0 && ( + <> +
+ Sabloane +
+ {templates.map((t) => ( + + ))} + + )} + + {/* Divider */} + {templates.length > 0 && suggestions.length > 0 && ( +
+ )} + + {/* Section 2: Plain suggestions */} + {suggestions.length > 0 && ( + <> +
+ Sugestii +
+ {suggestions.map((s, i) => ( + + ))} + + )} )} - {/* Divider */} - {templates.length > 0 && suggestions.length > 0 && ( -
- )} - - {/* Section 2: Plain suggestions */} - {suggestions.length > 0 && ( + {/* ── Mode 2: Empty focus — recommended + recent ── */} + {!hasFiltered && hasRecommended && ( <> -
- Sugestii -
- {suggestions.map((s, i) => ( - - ))} + {/* Recommended templates */} + {recommendedTemplates && recommendedTemplates.length > 0 && ( + <> +
+ + Sabloane recomandate +
+ {recommendedTemplates.map((t) => ( + + ))} + + )} + + {/* Divider */} + {recommendedTemplates && recommendedTemplates.length > 0 && + recentSubjects && recentSubjects.length > 0 && ( +
+ )} + + {/* Recent subjects */} + {recentSubjects && recentSubjects.length > 0 && ( + <> +
+ + Recente +
+ {recentSubjects.map((s, i) => ( + + ))} + + )} )}
diff --git a/src/modules/registratura/components/subject-template-input.tsx b/src/modules/registratura/components/subject-template-input.tsx index 8200ccd..79510bf 100644 --- a/src/modules/registratura/components/subject-template-input.tsx +++ b/src/modules/registratura/components/subject-template-input.tsx @@ -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; 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(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 ( + onFieldChange(field.id, val)} + projectTags={projectTags} + inputRef={isFirst ? firstFieldRef : undefined} + /> + ); + } + return (
); } + +// ── 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; +} + +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 ( + + 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 && ( +
+ {suggestions.map((tag) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/modules/registratura/services/subject-template-service.ts b/src/modules/registratura/services/subject-template-service.ts index 3880682..5cabbbf 100644 --- a/src/modules/registratura/services/subject-template-service.ts +++ b/src/modules/registratura/services/subject-template-service.ts @@ -2,16 +2,25 @@ * Subject Template Service — extracts reusable templates from existing * 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. */ +import type { RegistryDirection } from "../types"; + // ── Types ── +export type TemplateFieldType = "nr" | "an" | "detalii" | "proiect" | "text"; + export interface TemplateField { id: string; // "f0", "f1", ... - name: string; // "nr", "an", "detalii" + name: string; // "nr", "an", "detalii", "proiect" placeholder: string; // Romanian label width: string; // Tailwind width class + fieldType?: TemplateFieldType; + defaultValue?: string; } export interface TemplateToken { @@ -22,11 +31,13 @@ export interface TemplateToken { export interface SubjectTemplate { id: string; - pattern: string; // "Cerere CU nr. {nr}/{an} — {detalii}" + pattern: string; // "Cerere CU nr. {nr}/{an} — {proiect}" tokens: TemplateToken[]; fields: TemplateField[]; frequency: number; exampleSubject: string; + /** Whether this is a predefined seed template (not learned from DB) */ + isSeed?: boolean; } // ── Internal helpers ── @@ -75,6 +86,7 @@ function tokenize(subject: string): TemplateToken[] { name: "detalii", placeholder: "detalii...", width: "flex-1 min-w-32", + fieldType: "detalii", }, }); } else { @@ -109,6 +121,7 @@ function tokenizeSegment( } const isYear = match[1] !== undefined; + const ft: TemplateFieldType = isYear ? "an" : "nr"; tokens.push({ type: "field", value: "", @@ -117,6 +130,7 @@ function tokenizeSegment( name: isYear ? "an" : "nr", placeholder: isYear ? "an" : "nr.", width: isYear ? "w-16" : "w-14", + fieldType: ft, }, }); idx++; @@ -146,8 +160,242 @@ function buildPattern(tokens: TemplateToken[]): string { .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 = { + 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> = { + 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 ── +/** + * 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(); + 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. * Groups by pattern, counts frequency, returns sorted by frequency desc.