/** * 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", "proiect" placeholder: string; // Romanian label width: string; // Tailwind width class fieldType?: TemplateFieldType; defaultValue?: string; } 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} — {proiect}" tokens: TemplateToken[]; fields: TemplateField[]; frequency: number; exampleSubject: string; /** Whether this is a predefined seed template (not learned from DB) */ isSeed?: boolean; } // ── 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", fieldType: "detalii", }, }); } 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; const ft: TemplateFieldType = isYear ? "an" : "nr"; tokens.push({ type: "field", value: "", field: { id: `f${idx}`, name: isYear ? "an" : "nr", placeholder: isYear ? "an" : "nr.", width: isYear ? "w-16" : "w-14", fieldType: ft, }, }); 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(""); } // ── 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: [ // Cereri trimise (iesit) 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"), seedTemplate("Cerere completari nr. {nr}/{an} — {proiect}", "Cerere completari nr. 33/2026 — Bloc rezidential"), ], aviz: [ // Acte administrative primite (intrat) seedTemplate("CU nr. {nr}/{an} — {proiect}", "CU nr. 312/2026 — Farmacie Str. Exemplu"), seedTemplate("AC nr. {nr}/{an} — {proiect}", "AC nr. 89/2026 — Locuinta P+1E"), seedTemplate("Prelungire CU nr. {nr}/{an} — {proiect}", "Prelungire CU nr. 78/2026 — Bloc D4"), seedTemplate("Prelungire AC nr. {nr}/{an} — {proiect}", "Prelungire AC nr. 90/2026 — Hala industriala"), seedTemplate("Aviz ISU nr. {nr}/{an} — {proiect}", "Aviz ISU nr. 55/2026 — Cladire birouri"), seedTemplate("Aviz DSP nr. {nr}/{an} — {proiect}", "Aviz DSP nr. 14/2026 — Gradinita nr. 3"), seedTemplate("Aviz Mediu nr. {nr}/{an} — {proiect}", "Aviz Mediu nr. 22/2026 — Hala logistica"), seedTemplate("Aviz APM nr. {nr}/{an} — {proiect}", "Aviz APM nr. 8/2026 — PUZ Zona centrala"), seedTemplate("Aviz racordare {detalii} nr. {nr}/{an} — {proiect}", "Aviz racordare gaz nr. 31/2026 — Locuinta P+1E"), seedTemplate("Aviz {detalii} nr. {nr}/{an} — {proiect}", "Aviz Electrica nr. 44/2026 — Bloc rezidential"), seedTemplate("Aviz de oportunitate nr. {nr}/{an} — {proiect}", "Aviz de oportunitate nr. 12/2026 — PUZ Zona centrala"), seedTemplate("Acord unic nr. {nr}/{an} — {proiect}", "Acord unic nr. 67/2026 — Cladire birouri"), // Solicitari avize (iesit) seedTemplate("Solicitare aviz {detalii} — {proiect}", "Solicitare aviz DSP — Gradinita nr. 3"), seedTemplate("Solicitare CU — {proiect}", "Solicitare CU — Locuinta unifamiliala"), seedTemplate("Solicitare AC — {proiect}", "Solicitare AC — Cladire birouri"), ], 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("Contract proiectare nr. {nr}/{an} — {proiect}", "Contract proiectare nr. 10/2026 — Bloc rezidential"), seedTemplate("Act aditional nr. {nr} la contract {detalii}", "Act aditional nr. 2 la contract 15/2026"), seedTemplate("PV predare-primire nr. {nr}/{an} — {proiect}", "PV predare-primire nr. 4/2026 — Cladire birouri"), ], 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("Comunicare {detalii} — {proiect}", "Comunicare rezultat analiza — Bloc rezidential"), seedTemplate("Comunicare nr. {nr}/{an} — {proiect}", "Comunicare nr. 18/2026 — Farmacie Str. Exemplu"), seedTemplate("Notificare {detalii} — {proiect}", "Notificare incepere lucrari — Locuinta P+1E"), seedTemplate("Adeverinta {detalii} nr. {nr}/{an}", "Adeverinta stagiu practica nr. 3/2026"), seedTemplate("Raspuns completari nr. {nr}/{an} — {proiect}", "Raspuns completari nr. 33/2026 — Bloc rezidential"), seedTemplate("Instiintare {detalii} — {proiect}", "Instiintare incepere executie — Locuinta P+1E"), seedTemplate("Somatie {detalii} — {proiect}", "Somatie plata factura — Cladire birouri"), ], "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("Memoriu justificativ — {proiect}", "Memoriu justificativ — Locuinta P+1E"), seedTemplate("Memoriu justificativ {detalii} — {proiect}", "Memoriu justificativ arhitectura — Bloc rezidential"), seedTemplate("Studiu geotehnic {detalii} — {proiect}", "Studiu geotehnic preliminar — Hala logistica"), seedTemplate("Studiu de fezabilitate — {proiect}", "Studiu de fezabilitate — Reabilitare scoala"), seedTemplate("Raport nr. {nr}/{an} — {proiect}", "Raport nr. 3/2026 — Audit energetic"), seedTemplate("Expertiza tehnica nr. {nr}/{an} — {proiect}", "Expertiza tehnica nr. 7/2026 — Consolidare bloc"), seedTemplate("RTE nr. {nr}/{an} — {proiect}", "RTE nr. 12/2026 — Reabilitare termica bloc"), seedTemplate("Audit energetic nr. {nr}/{an} — {proiect}", "Audit energetic nr. 4/2026 — Bloc C3"), seedTemplate("Caiet de sarcini — {proiect}", "Caiet de sarcini — Instalatii electrice"), ], "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("PV receptie nr. {nr}/{an} — {proiect}", "PV receptie nr. 5/2026 — Cladire birouri"), seedTemplate("Proces verbal nr. {nr}/{an} — {proiect}", "Proces verbal nr. 3/2026 — Inspectie santier"), seedTemplate("Referat verificare nr. {nr}/{an} — {proiect}", "Referat verificare nr. 11/2026 — Locuinta P+1E"), seedTemplate("Memoriu tehnic — {proiect}", "Memoriu tehnic — Hala industriala"), seedTemplate("Decizie {detalii} nr. {nr}/{an}", "Decizie etapizare nr. 2/2026"), seedTemplate("Borderou {detalii} — {proiect}", "Borderou piese scrise — Cladire birouri"), seedTemplate("Fisa tehnica {detalii} — {proiect}", "Fisa tehnica echipament HVAC — Bloc rezidential"), seedTemplate("Tema de proiectare — {proiect}", "Tema de proiectare — Locuinta unifamiliala"), seedTemplate("Deviz general — {proiect}", "Deviz general — Reabilitare scoala"), seedTemplate("Conventie {detalii} nr. {nr}/{an}", "Conventie colaborare nr. 2/2026"), ], }; // ── Dynamic Placeholders ── const SUBJECT_PLACEHOLDERS_MAP: Record> = { cerere: { intrat: "ex: Cerere completari nr. 33/2026 — Proiect X", iesit: "ex: Cerere CU nr. 123/2026 — Proiect X", }, aviz: { intrat: "ex: CU nr. 312/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: Expertiza tehnica nr. 7/2026 — Proiect X", iesit: "ex: Raport nr. 3/2026 — 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. */ 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 < 3) 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 < 3) 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; }