Initial attendee management
This commit is contained in:
@@ -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}`)
|
||||
314
apps/web/src/lib/file-parser.ts
Normal file
314
apps/web/src/lib/file-parser.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user