Initial attendee management

This commit is contained in:
Jose Selesan
2026-09-18 09:42:37 -03:00
parent fa30fe2cff
commit d7ebb5b234
111 changed files with 13784 additions and 36 deletions

View File

@@ -1,9 +1,18 @@
import type {
AttendeeDto,
AttendeeList,
BulkCreateAttendees,
BulkCreateAttendeesResult,
ConnectPayment,
ConnectPaymentResult,
CreateAttendee,
CreateFirstGroup,
CreateFirstGroupResult,
GroupDto,
GroupInviteInfoDto,
GroupList,
InviteTokenResult,
JoinGroupViaInvite,
OnboardingStatusDto,
ProblemDetails,
} from '@gruperly/shared'
@@ -15,7 +24,7 @@ export class ApiError extends Error {
readonly status: number,
readonly problem: ProblemDetails | null,
) {
super(problem?.title ?? `Error ${status}`)
super(problem?.detail ?? problem?.title ?? `Error ${status}`)
this.name = 'ApiError'
}
}
@@ -62,4 +71,38 @@ export const createFirstGroup = (payload: CreateFirstGroup) =>
body: JSON.stringify(payload),
})
export const getGroups = () => apiFetch<GroupList>('/api/v1/groups')
export const getGroups = () => apiFetch<GroupList>('/api/v1/groups')
export const getGroup = (groupId: string) =>
apiFetch<GroupDto>(`/api/v1/groups/${groupId}`)
export const getInviteToken = (groupId: string, regenerate = false) =>
regenerate
? apiFetch<InviteTokenResult>(`/api/v1/groups/${groupId}/invite-token?regenerate=true`, {
method: 'POST',
})
: apiFetch<InviteTokenResult>(`/api/v1/groups/${groupId}/invite-token`)
export const getInviteInfo = (token: string) =>
apiFetch<GroupInviteInfoDto>(`/api/v1/invitations/${token}`)
export const joinViaInvite = (token: string, payload: JoinGroupViaInvite) =>
apiFetch<{ attendee: AttendeeDto; message: string }>(`/api/v1/invitations/${token}/join`, {
method: 'POST',
body: JSON.stringify(payload),
})
export const createAttendee = (groupId: string, payload: CreateAttendee) =>
apiFetch<AttendeeDto>(`/api/v1/groups/${groupId}/attendees`, {
method: 'POST',
body: JSON.stringify(payload),
})
export const bulkCreateAttendees = (groupId: string, payload: BulkCreateAttendees) =>
apiFetch<BulkCreateAttendeesResult>(`/api/v1/groups/${groupId}/attendees/bulk`, {
method: 'POST',
body: JSON.stringify(payload),
})
export const getGroupAttendees = (groupId: string, page = 1, pageSize = 20) =>
apiFetch<AttendeeList>(`/api/v1/groups/${groupId}/attendees?page=${page}&pageSize=${pageSize}`)

View File

@@ -0,0 +1,314 @@
export type ParsedContactRow = {
id: string;
firstName: string;
lastName: string;
fullName: string;
phone: string;
email: string;
notes: string;
isValid: boolean;
error?: string;
};
export function normalizePhone(rawPhone: string): string {
const trimmed = rawPhone.trim();
const hasPlus = trimmed.startsWith('+');
const digitsOnly = trimmed.replace(/\D/g, '');
return hasPlus ? `+${digitsOnly}` : digitsOnly;
}
/**
* Parsea texto CSV con soporte para:
* - Delimitadores automáticos (coma, punto y coma, tabulador)
* - Comillas y comillas escapadas ("")
* - UTF-8 BOM
* - Saltos de línea CRLF o LF
*/
export function parseCsvContent(content: string): string[][] {
const cleanContent = content.replace(/^\uFEFF/, '');
const lines = cleanContent.split(/\r\n|\n|\r/).filter((l) => l.trim().length > 0);
if (lines.length === 0) return [];
// Detectar delimitador basado en la primera línea
const firstLine = lines[0];
const commaCount = (firstLine.match(/,/g) || []).length;
const semicolonCount = (firstLine.match(/;/g) || []).length;
const tabCount = (firstLine.match(/\t/g) || []).length;
let delimiter = ',';
if (semicolonCount > commaCount && semicolonCount >= tabCount) {
delimiter = ';';
} else if (tabCount > commaCount && tabCount > semicolonCount) {
delimiter = '\t';
}
const rows: string[][] = [];
for (const line of lines) {
const row: string[] = [];
let insideQuote = false;
let currentCell = '';
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
if (insideQuote && line[i + 1] === '"') {
currentCell += '"';
i++; // Saltear comilla escapada
} else {
insideQuote = !insideQuote;
}
} else if (char === delimiter && !insideQuote) {
row.push(currentCell.trim());
currentCell = '';
} else {
currentCell += char;
}
}
row.push(currentCell.trim());
rows.push(row);
}
return rows;
}
/**
* Parsea un archivo XLSX de Excel extrayendo sharedStrings y sheet1.xml
* utilizando DecompressionStream nativo del navegador sin dependencias externas.
*/
export async function parseXlsxContent(buffer: ArrayBuffer): Promise<string[][]> {
const view = new DataView(buffer);
const entries = new Map<string, Uint8Array>();
let offset = 0;
while (offset < buffer.byteLength - 30) {
const sig = view.getUint32(offset, true);
if (sig !== 0x04034b50) break;
const compression = view.getUint16(offset + 8, true);
const compressedSize = view.getUint32(offset + 18, true);
const fileNameLen = view.getUint16(offset + 26, true);
const extraLen = view.getUint16(offset + 28, true);
const nameBytes = new Uint8Array(buffer, offset + 30, fileNameLen);
const fileName = new TextDecoder().decode(nameBytes);
const dataStart = offset + 30 + fileNameLen + extraLen;
if (compressedSize > 0 && dataStart + compressedSize <= buffer.byteLength) {
const compressedData = new Uint8Array(buffer, dataStart, compressedSize);
if (compression === 0) {
entries.set(fileName, compressedData);
} else if (compression === 8 && typeof DecompressionStream !== 'undefined') {
try {
const stream = new Response(compressedData).body?.pipeThrough(
new DecompressionStream('deflate-raw'),
);
if (stream) {
const decompressed = await new Response(stream).arrayBuffer();
entries.set(fileName, new Uint8Array(decompressed));
}
} catch {
// Ignorar archivos que no se puedan descomprimir
}
}
}
offset = dataStart + compressedSize;
}
// Parsear strings compartidos
const sharedStringsData = entries.get('xl/sharedStrings.xml');
const sharedStrings: string[] = [];
if (sharedStringsData) {
const xmlText = new TextDecoder().decode(sharedStringsData);
const parser = new DOMParser();
const doc = parser.parseFromString(xmlText, 'application/xml');
const siNodes = doc.getElementsByTagName('si');
for (let i = 0; i < siNodes.length; i++) {
const tNodes = siNodes[i].getElementsByTagName('t');
let str = '';
for (let j = 0; j < tNodes.length; j++) {
str += tNodes[j].textContent ?? '';
}
sharedStrings.push(str);
}
}
// Parsear sheet1.xml
const sheetData = entries.get('xl/worksheets/sheet1.xml');
if (!sheetData) {
throw new Error('No se pudo encontrar la hoja de cálculo principal en el archivo Excel.');
}
const sheetXml = new TextDecoder().decode(sheetData);
const parser = new DOMParser();
const doc = parser.parseFromString(sheetXml, 'application/xml');
const rowNodes = doc.getElementsByTagName('row');
const rows: string[][] = [];
for (let i = 0; i < rowNodes.length; i++) {
const rowEl = rowNodes[i];
const cNodes = rowEl.getElementsByTagName('c');
const rowValues: string[] = [];
for (let j = 0; j < cNodes.length; j++) {
const c = cNodes[j];
const isString = c.getAttribute('t') === 's';
const v = c.getElementsByTagName('v')[0]?.textContent ?? '';
if (isString) {
const index = parseInt(v, 10);
rowValues.push(sharedStrings[index] ?? '');
} else {
rowValues.push(v);
}
}
rows.push(rowValues);
}
return rows;
}
/**
* Normaliza una matriz 2D en objetos de contactos con detección inteligente de columnas.
*/
export function normalizeRowsToContacts(rawRows: string[][]): ParsedContactRow[] {
if (rawRows.length === 0) return [];
const headerRow = rawRows[0].map((h) => h.toLowerCase().trim());
let hasHeader = false;
let nameCol = -1;
let firstNameCol = -1;
let lastNameCol = -1;
let phoneCol = -1;
let emailCol = -1;
let notesCol = -1;
for (let i = 0; i < headerRow.length; i++) {
const col = headerRow[i];
if (col === 'nombre' || col === 'first name' || col === 'firstname') {
firstNameCol = i;
hasHeader = true;
} else if (col === 'apellido' || col === 'last name' || col === 'lastname') {
lastNameCol = i;
hasHeader = true;
} else if (col === 'nombre completo' || col === 'full name' || col === 'fullname' || col === 'alumno') {
nameCol = i;
hasHeader = true;
} else if (
col.includes('tel') ||
col.includes('cel') ||
col.includes('phone') ||
col.includes('whatsapp') ||
col.includes('movil') ||
col.includes('móvil')
) {
phoneCol = i;
hasHeader = true;
} else if (col.includes('email') || col.includes('correo')) {
emailCol = i;
hasHeader = true;
} else if (col.includes('nota') || col.includes('obs')) {
notesCol = i;
hasHeader = true;
}
}
// Fallbacks posicionales si no se detectaron encabezados explícitos
if (!hasHeader) {
firstNameCol = 0;
lastNameCol = 1;
phoneCol = 2;
emailCol = 3;
} else if (firstNameCol === -1 && nameCol === -1) {
firstNameCol = 0;
}
if (phoneCol === -1) {
// Buscar la primera columna que parezca un número telefónico
for (let c = 0; c < (rawRows[1]?.length ?? 0); c++) {
const sample = rawRows[1][c];
if (/^[\d\s+\-()]{6,}$/.test(sample)) {
phoneCol = c;
break;
}
}
if (phoneCol === -1) phoneCol = 1;
}
const dataRows = hasHeader ? rawRows.slice(1) : rawRows;
const contacts: ParsedContactRow[] = [];
for (let idx = 0; idx < dataRows.length; idx++) {
const row = dataRows[idx];
if (row.every((c) => c.trim().length === 0)) continue;
let firstName = '';
let lastName = '';
let fullName = '';
if (nameCol !== -1 && row[nameCol]) {
fullName = row[nameCol].trim();
const parts = fullName.split(/\s+/);
firstName = parts[0] || '';
lastName = parts.slice(1).join(' ') || '';
} else {
firstName = (firstNameCol !== -1 ? row[firstNameCol] : '') || '';
lastName = (lastNameCol !== -1 ? row[lastNameCol] : '') || '';
fullName = `${firstName} ${lastName}`.trim();
}
const rawPhone = (phoneCol !== -1 ? row[phoneCol] : '') || '';
const phone = normalizePhone(rawPhone);
const email = (emailCol !== -1 ? row[emailCol] : '') || '';
const notes = (notesCol !== -1 ? row[notesCol] : '') || '';
const errors: string[] = [];
if (!firstName && !fullName) errors.push('Falta el nombre');
if (!phone || phone.replace(/\D/g, '').length < 6) {
errors.push('Teléfono inválido o faltante');
}
contacts.push({
id: `row-${idx}-${Math.random().toString(36).slice(2, 6)}`,
firstName: firstName || fullName,
lastName,
fullName: fullName || firstName,
phone,
email: email.trim(),
notes: notes.trim(),
isValid: errors.length === 0,
error: errors.join(', '),
});
}
return contacts;
}
/**
* Descarga una plantilla de ejemplo en formato CSV para la carga masiva.
*/
export function downloadAttendeeTemplateCsv() {
const csvContent =
'Nombre,Apellido,Teléfono,Email,Notas\r\n' +
'Sofía,Rodríguez,+5491144556677,sofia@ejemplo.com,Nivel intermedio\r\n' +
'Lucas,Benítez,+5491155667788,lucas@ejemplo.com,Trae instrumento\r\n' +
'Valentina,Morales,+5491166778899,,\r\n';
const blob = new Blob([new Uint8Array([0xef, 0xbb, 0xbf]), csvContent], {
type: 'text/csv;charset=utf-8;',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', 'plantilla_alumnos_gruperly.csv');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}