export type EntityColumnMetadata = { groupId?: string; keywords?: readonly string[]; }; export type EntityColumnGroup = { id: string; label: string; description?: string; }; export type EntityColumnOption = EntityColumnMetadata & { id: string; label: string; visible: boolean; hideable: boolean; }; /** Normaliza tildes sin equiparar ñ y n, incluidas sus representaciones Unicode descompuestas. */ export function normalizeColumnSearch(value: string): string { return value .trim() .toLowerCase() .normalize("NFD") .replace(/n\u0303/g, "ñ") .replace(/\p{M}/gu, ""); } /** Los IDs conservan identidad; la primera declaración de un grupo u opción prevalece. */ export function groupColumnOptions( options: readonly EntityColumnOption[], groups: readonly EntityColumnGroup[], search: string ) { const term = normalizeColumnSearch(search); const uniqueOptions = new Map(); const uniqueGroups = new Map(); for (const group of groups) { if (!uniqueGroups.has(group.id)) { uniqueGroups.set(group.id, group); } } for (const option of options) { if (!uniqueOptions.has(option.id)) { uniqueOptions.set(option.id, option); } } const matches = [...uniqueOptions.values()].filter((option) => [option.label, ...(option.keywords ?? [])].some((value) => normalizeColumnSearch(value).includes(term) ) ); const grouped = [...uniqueGroups.values()].map((group) => ({ group, options: matches.filter((option) => option.groupId === group.id), })); const ungrouped = matches.filter( (option) => !(option.groupId && uniqueGroups.has(option.groupId)) ); return [ ...grouped.filter((group) => group.options.length > 0), ...(ungrouped.length ? [{ group: undefined, options: ungrouped }] : []), ]; }