Add per-event finance, event team permissions and public door sales state.

Event finance
- Finance tab on the event page: P&L summary with a revenue-to-result
  waterfall, costs and other income in one ledger, and the partner split
  with payouts. Lifecycle stepper (Selling, Adding costs, Ready to close,
  Finalized) with what is left to do; closing the books goes through a
  checklist dialog that freezes the numbers.
- Expense modal shows only the fields each calculation type needs, a live
  preview with the event's real counts, and a category suggested from the
  description. Date inputs follow the UI language.
- Global Finance page: profit per event with outliers clipped and labelled,
  and a "Ready to close" list of past events whose books are still open.
- Calculation service (integer PYG, basis points) with pinned regression
  scenarios; PYG formatting centralised in lib/money with locale-aware
  separators.

Event team permissions
- Per-event members with role presets and requireEventPermission; the
  header stat "Confirmed" is relabelled "Not checked in yet", which is
  what it counts.

Public sales state
- One sales state (online, door, sold out, ended, external, cancelled) for
  the event page, listings and JSON-LD, with the door price and tenders
  shown only while people can still pay at the door.

Frontend unit tests run with vitest (npm test in frontend/).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-10-03 03:11:46 +00:00
co-authored by Claude Opus 5.5
parent b51c1360e2
commit e203fb6c74
80 changed files with 11326 additions and 971 deletions
+505 -3
View File
@@ -91,12 +91,31 @@
"eventEnded": "Evento Finalizado",
"registrationClosed": "Inscripciones Cerradas",
"presaleClosesBefore": "La preventa cierra {duration} antes de que comience el evento.",
"presaleClosesAtStart": "La preventa cierra cuando comienza el evento."
"presaleClosesAtStart": "La preventa cierra cuando comienza el evento.",
"eventHasEnded": "Este evento ya terminó"
},
"booking": {
"join": "Unirse al Evento",
"book": "Reservar Ahora",
"register": "Registrarse"
},
"door": {
"onlineClosed": "Venta online cerrada",
"headline": "Entradas disponibles en la puerta",
"perPerson": "por persona, pago en la puerta",
"onlinePriceWas": "El precio online era {price}",
"spotsLeft": "{n} lugares disponibles",
"spotsLeftOne": "1 lugar disponible",
"spotsLeftAtDoor": "{n} lugares disponibles (en la puerta)",
"accepts": "Aceptamos en la puerta: {methods}",
"doorsOpen": "Las puertas abren a las {time}",
"badge": "Entradas en la puerta",
"methods": {
"cash": "efectivo",
"pos": "tarjeta",
"bitcoin": "Lightning",
"transfer": "transferencia"
}
}
},
"booking": {
@@ -174,6 +193,13 @@
"receivedConfirmShortly": "Pago recibido. Lo confirmaremos en breve.",
"receivedConfirmSoon": "Pago recibido. Lo confirmaremos pronto.",
"confirmForEvent": "Confirmaremos tu pago de {amount} para {event} en breve. No necesitas hacer nada más."
},
"myEvents": {
"tab": "Mis eventos",
"empty": "Todavía no formás parte del equipo de ningún evento.",
"open": "Abrir",
"back": "Volver a mis eventos",
"noAccess": "No tenés acceso a este evento."
}
},
"community": {
@@ -286,7 +312,8 @@
"emails": "Emails",
"gallery": "Galería",
"photos": "Fotos",
"settings": "Configuración"
"settings": "Configuración",
"finance": "Finanzas"
},
"events": {
"title": "Gestionar Eventos",
@@ -334,6 +361,481 @@
"refunded": "Reembolsado",
"failed": "Fallido"
}
},
"eventTabs": {
"overview": "Resumen",
"attendees": "Asistentes",
"tickets": "Entradas",
"email": "Email",
"payments": "Pagos",
"finance": "Finanzas",
"team": "Equipo"
},
"finance": {
"subnav": {
"summary": "Resumen",
"expenses": "Gastos e ingresos",
"split": "Reparto y pagos"
},
"status": {
"open": "Abierto",
"finalized": "Cerrado",
"paid_out": "Pagado"
},
"frozenNote": "Números congelados al cerrar el {date}. Reabrí para hacer cambios.",
"loadError": "No se pudieron cargar las finanzas",
"hiddenStats": "Los números están ocultos. Usá Mostrar estadísticas para verlos.",
"kpi": {
"gross": "Ingresos brutos",
"fees": "Comisiones",
"expenses": "Gastos",
"profit": "Ganancia",
"loss": "Pérdida",
"breakEven": "Punto de equilibrio",
"breakEvenNever": "No alcanzable a este precio",
"presaleDoor": "Preventa {presale} · Puerta {door}",
"ticketsSold": "{count} entradas vendidas",
"ticketRevenue": "Ingresos por entradas",
"revenueDetail": "{count} entradas pagas, sin reembolsos",
"afterFees": "Después de comisiones y gastos",
"breakEvenProgress": "{sold} de {total}",
"beyondCapacity": "Necesita más entradas que la capacidad ({capacity}). Subí el precio o bajá gastos.",
"estimated": "Estimado",
"estimatedHint": "Después de comisiones y los gastos cargados",
"presale": "Preventa",
"door": "Puerta"
},
"waterfall": {
"title": "De los ingresos al resultado",
"gross": "Ingresos brutos",
"refunds": "Reembolsos",
"fees": "Comisiones de pago",
"otherIncome": "Otros ingresos",
"net": "Ingreso neto",
"expenses": "Gastos",
"profit": "Ganancia",
"organization": "Queda para la organización",
"partners": "Pagos a socios",
"legend": {
"in": "Entra",
"out": "Sale",
"total": "Total"
},
"estimatedHint": "Estimado: los números siguen las ventas y los gastos hasta cerrar las cuentas."
},
"charts": {
"byCategory": "Gastos por categoría",
"byMethod": "Ingresos por método de pago",
"cumulative": "Ventas acumuladas",
"breakEvenLine": "Equilibrio",
"revenue": "Ingresos",
"plannedVsActual": "Gastos previstos vs pagados",
"planned": "Previsto",
"paid": "Pagado",
"empty": "Sin datos todavía",
"uncategorized": "Sin categoría",
"breakEvenOffChart": "El equilibrio es {count} entradas, muy por encima de lo vendido.",
"tickets": "Entradas",
"addExpensesHint": "Agregá gastos para ver el desglose de costos."
},
"methods": {
"tpago": "TPago",
"bank_transfer": "Transferencia",
"lightning": "Lightning",
"cash": "Efectivo",
"pos": "POS",
"bancard": "Bancard"
},
"otherIncome": {
"title": "Otros ingresos",
"add": "Agregar ingreso",
"hint": "Sponsors, comisiones del local y otros ingresos que no son entradas.",
"empty": "Sin otros ingresos",
"confirmDelete": "¿Eliminar \"{name}\"?",
"placeholder": "ej. Sponsor: Café Uno",
"edit": "Editar ingreso"
},
"expenses": {
"add": "Agregar gasto",
"applyTemplate": "Aplicar plantilla",
"applyPack": "Aplicar paquete",
"allCategories": "Todas las categorías",
"empty": "Agregá gastos como el lugar, el instructor o snacks, o tus gastos habituales desde una plantilla.",
"description": "Descripción",
"category": "Categoría",
"calcType": "Se calcula",
"amount": "Monto",
"lockedAmount": "Monto fijado (PYG)",
"status": "Estado",
"paidBy": "Pagado por",
"organization": "Organización",
"otherPartner": "Otro socio",
"receipt": "Comprobante",
"uploadReceipt": "Subir comprobante",
"viewReceipt": "Ver comprobante",
"receiptAdminOnly": "La carga de comprobantes está disponible para admins y organizadores.",
"date": "Fecha",
"lock": "Fijar monto",
"locked": "Fijado",
"auto": "Auto",
"lockHint": "Mantené este monto fijo en vez de seguir las ventas.",
"markPaid": "Marcar como pagado",
"markPlanned": "Marcar como no pagado",
"confirmDelete": "¿Eliminar este gasto?",
"created": "Gasto agregado",
"saved": "Gasto guardado",
"deleted": "Gasto eliminado",
"applied": "{count} gasto(s) agregado(s)",
"edit": "Editar gasto",
"new": "Nuevo gasto",
"pickTemplate": "Elegí una plantilla",
"pickPack": "Elegí un paquete",
"noTemplates": "Todavía no hay plantillas. Crealas en Configuración → Plantillas de gastos.",
"ownOnly": "Podés editar los gastos que agregaste.",
"total": "Total",
"descriptionRequired": "Agregá una descripción",
"descriptionPlaceholder": "ej. Alquiler del estudio",
"paidByHint": "Elegí un socio si lo pagó de su bolsillo; se le devuelve en su pago.",
"upload": "Subir",
"useTemplate": "Usar plantilla",
"useTemplateHint": "Agregá tus gastos habituales de una vez",
"packsHeading": "Paquetes (varios gastos juntos)",
"templatesHeading": "Gastos individuales",
"addAll": "Agregar {count}",
"addOne": "Agregar",
"confirmDeleteNamed": "¿Eliminar \"{name}\"?",
"emptyTitle": "Todavía no hay gastos",
"unlockHint": "Fijado. Desbloqueá para volver a seguir las ventas.",
"frozen": "Cerrado: no se pueden cambiar los gastos.",
"filtered": "filtrado",
"amountPerTicket": "Monto por entrada",
"amountPerGuest": "Monto por asistente",
"percentOfRevenue": "Porcentaje de la venta de entradas",
"minimumAmount": "Monto mínimo",
"categorySuggested": "Sugerida según la descripción"
},
"calcTypes": {
"fixed": "Fijo",
"per_ticket_sold": "Por entrada vendida",
"per_checked_in": "Por asistente ingresado",
"percent_of_revenue": "Porcentaje de ingresos",
"minimum_spend": "Consumo mínimo"
},
"calcHints": {
"fixed": "Un monto fijo, ej. honorario del instructor.",
"per_ticket_sold": "Crece con cada entrada pagada, ej. un vale de bebida.",
"per_checked_in": "Crece con cada asistente que llega, ej. alquiler de mat.",
"percent_of_revenue": "Un porcentaje de las ventas, ej. comisión.",
"minimum_spend": "Consumo mínimo del local: el mayor entre el mínimo y el consumo por persona."
},
"expenseStatus": {
"planned": "Previsto",
"paid": "Pagado"
},
"split": {
"partners": "Socios",
"addPartner": "Agregar socio",
"editPartner": "Editar socio",
"empty": "Sin socios: la organización se queda con todo.",
"linkedUser": "Usuario vinculado",
"searchUser": "Buscar usuarios por nombre o email",
"externalName": "O escribí un nombre",
"roleLabel": "Rol",
"shareType": "Acuerdo",
"percent": "Porcentaje (%)",
"fixedAmount": "Monto fijo (PYG)",
"threshold": "Umbral de ganancia (PYG)",
"lossRule": "Si hay pérdida",
"lossCap": "Tope de pérdida (PYG)",
"share": "Participación",
"reimbursement": "Reembolsos",
"payout": "A pagar",
"owes": "Debe",
"organization": "Queda para la organización",
"distributable": "Resto tras acuerdos fijos y de ingresos",
"markPaid": "Marcar como pagado",
"markUnpaid": "Deshacer pago",
"payoutMethod": "Método de pago",
"payoutNote": "Nota",
"paidOn": "Pagado el {date}",
"pending": "Pendiente",
"statement": "Liquidación PDF",
"finalize": "Cerrar",
"unfinalize": "Reabrir",
"unfinalizeConfirm": "¿Reabrir? Los números vuelven a seguir las ventas. Queda registrado.",
"finalized": "Evento cerrado",
"unfinalized": "Evento reabierto",
"finalizeFirst": "Los pagos se registran después de cerrar las cuentas.",
"ownShareOnly": "Ves tu propia participación. El reparto completo lo ven los responsables.",
"partnerSaved": "Socio guardado",
"partnerRemoved": "Socio eliminado",
"confirmRemove": "¿Quitar este socio?",
"needsNameOrUser": "Vinculá un usuario o escribí un nombre",
"auditLog": "Registro de actividad",
"auditEmpty": "Sin actividad todavía",
"who": "Quién",
"whoHint": "Vinculá un usuario para que vea su liquidación, o escribí un nombre.",
"rolePlaceholder": "ej. Local, Co-anfitrión, Instructor",
"payoutMethodPlaceholder": "ej. Transferencia",
"confirmRemoveNamed": "¿Quitar a {name} del reparto?",
"distributableHint": "Las participaciones en la ganancia salen de acá",
"closeBooks": "Cerrar cuentas",
"booksClosed": "Cuentas cerradas",
"closeBooksHint": "Cuando estén todos los gastos, cerrá para congelar los números y pagar a los socios. Se puede reabrir.",
"payoutsProgress": "{paid} de {total} pagos a socios registrados.",
"noPartnersTitle": "No hay socios en este evento",
"noPartnersBody": "Agregá un local, co-anfitrión o instructor que participe del resultado. Su pago se calcula solo.",
"calculation": "Cómo se reparte el resultado",
"ofDistributable": "{rule}, sobre {base} que quedan tras los acuerdos fijos y de ingresos",
"unpaid": "Sin pagar",
"includesReimbursement": "Incluye {amount} de reembolso",
"frozenNoPartners": "Los números están congelados. No hay socios a quienes pagar."
},
"shareTypes": {
"percent_profit": "Porcentaje de la ganancia",
"percent_revenue": "Porcentaje de ingresos",
"fixed": "Monto fijo",
"fixed_plus_percent_above_threshold": "Fijo + porcentaje sobre umbral"
},
"lossRules": {
"proportional": "Comparte la pérdida proporcionalmente",
"none": "No comparte pérdidas",
"capped": "Comparte la pérdida hasta un tope"
},
"empty": {
"title": "Todavía no hay ingresos ni gastos",
"body": "Las ventas aparecen solas. Agregá los gastos del evento para ver la ganancia y el punto de equilibrio."
},
"statement": {
"title": "Resultado",
"ticketSales": "Venta de entradas ({count})",
"carriesLoss": "Asume parte de la pérdida"
},
"formula": {
"perTicket": "{count} entradas × {amount}",
"perGuest": "{count} asistentes × {amount}",
"percent": "{percent} de los ingresos",
"minimum": "mín {minimum} o {count} × {amount}"
},
"shareHints": {
"percent_profit": "Un porcentaje de lo que queda después de los gastos.",
"percent_revenue": "Un porcentaje de las ventas, se paga aunque haya pérdida.",
"fixed": "Un monto fijo, sea cual sea el resultado.",
"fixed_plus_percent_above_threshold": "Un monto fijo más un porcentaje de la ganancia sobre una meta."
},
"lossRuleTitles": {
"none": "La org cubre",
"proportional": "Comparte pérdidas",
"capped": "Comparte hasta un tope"
},
"deal": {
"percentProfit": "{percent} de la ganancia",
"percentRevenue": "{percent} de las ventas",
"fixed": "Fijo {amount}",
"fixedPlus": "{amount} + {percent} de la ganancia sobre {threshold}"
},
"audit": {
"create": "agregado",
"update": "modificado",
"delete": "eliminado",
"apply_template": "agregado desde plantilla",
"apply_pack": "agregado desde paquete",
"mark_paid": "pago registrado",
"mark_unpaid": "pago deshecho",
"finalize": "cuentas cerradas",
"unfinalize": "cuentas reabiertas",
"paid_out": "todos los socios cobraron",
"finalized": "vuelve a cerrado",
"entity": {
"expense": "Gasto",
"other_income": "Otro ingreso",
"partner": "Socio",
"finance_state": "Finanzas",
"member": "Equipo"
}
},
"close": "Cerrar",
"pickDate": "Elegí una fecha",
"clearDate": "Borrar fecha",
"lifecycle": {
"label": "Estado de las cuentas",
"steps": {
"selling": "Vendiendo",
"costs": "Cargando gastos",
"ready": "Listo para cerrar",
"finalized": "Cerrado"
},
"todo": {
"noCosts": "Todavía no hay gastos",
"planned_one": "1 gasto planificado sin marcar como pagado",
"planned_other": "{count} gastos planificados sin marcar como pagados",
"noPartners": "No hay socios",
"payoutsPending_one": "Falta registrar 1 pago a socio",
"payoutsPending_other": "Faltan registrar {count} pagos a socios",
"notEnded": "El evento todavía no terminó"
},
"nudge_one": "Este evento terminó hace 1 día. Agregá los gastos que falten y cerrá las cuentas.",
"nudge_other": "Este evento terminó hace {count} días. Agregá los gastos que falten y cerrá las cuentas.",
"nudgeToday": "Este evento terminó hoy. Agregá los gastos que falten y cerrá las cuentas.",
"reviewCosts": "Revisar gastos",
"frozen": "Números congelados el {date}.",
"paidOut": "Números congelados el {date}. Todos los socios cobraron.",
"nudgeReady_one": "Este evento terminó hace 1 día y todos los gastos están pagados. Podés cerrar las cuentas en Reparto y pagos.",
"nudgeReady_other": "Este evento terminó hace {count} días y todos los gastos están pagados. Podés cerrar las cuentas en Reparto y pagos.",
"nudgeReadyToday": "Este evento terminó hoy y todos los gastos están pagados. Podés cerrar las cuentas en Reparto y pagos."
},
"finalizeDialog": {
"title": "Cerrar cuentas",
"subtitle": "Revisá que esté todo antes de congelar los números.",
"notEnded": "El evento todavía no terminó",
"noExpenses": "No hay gastos cargados",
"expenses_one": "1 gasto cargado",
"expenses_other": "{count} gastos cargados",
"planned_one": "1 gasto planificado sin marcar como pagado",
"planned_other": "{count} gastos planificados sin marcar como pagados",
"allPaid": "Todos los gastos están pagados",
"income_one": "1 ingreso adicional",
"income_other": "{count} ingresos adicionales",
"partners_one": "Pago a 1 socio",
"partners_other": "Pagos a {count} socios",
"noPartners": "Sin socios: la organización se queda con el resultado",
"finalProfit": "Ganancia final",
"finalLoss": "Pérdida final",
"hint": "Los números dejan de seguir las ventas y los gastos. Después podés registrar los pagos a socios, y un admin puede reabrir las cuentas si hace falta.",
"confirm": "Cerrar y congelar números"
},
"breakEven": {
"noCosts": "Todavía sin gastos",
"noCostsHint": "Agregá gastos para ver cuántas entradas los cubren.",
"reached": "Equilibrio alcanzado con {at} de {sold} entradas vendidas",
"needed": "Faltan {count} entradas para cubrir gastos",
"coveredWithoutTickets": "Los gastos ya están cubiertos por otros ingresos"
},
"preview": {
"fixed": "Suma {total} a los gastos del evento",
"perTicket_one": "{amount} × {count} entrada = {total}",
"perTicket_other": "{amount} × {count} entradas = {total}",
"perGuest_one": "{amount} × {count} asistente ingresado = {total}",
"perGuest_other": "{amount} × {count} asistentes ingresados = {total}",
"percent": "{percent} de {sales} en entradas = {total}",
"minimum": "El mayor entre {minimum} o {count} × {amount} = {total}",
"locked": "Fijado en {total}"
},
"ledger": {
"income": "Ingreso",
"costs": "Gastos",
"allRows": "Todos los gastos e ingresos",
"emptyTitle": "Todavía no hay gastos ni ingresos"
}
},
"team": {
"title": "Equipo",
"hint": "Las personas que agregues acá acceden solo a este evento, con los permisos de abajo. Los admins siempre tienen acceso completo.",
"add": "Agregar miembro",
"search": "Buscar usuarios por nombre o email",
"noResults": "No se encontraron usuarios",
"empty": "Todavía no hay miembros",
"role": "Rol",
"remove": "Quitar",
"confirmRemove": "¿Quitar a {name} de este evento?",
"added": "Miembro agregado",
"saved": "Permisos guardados",
"removed": "Miembro quitado",
"override": "Cambiado respecto al rol",
"reset": "Volver a los permisos del rol",
"permissions": "Permisos",
"permissionsSummary": "{on} de {total} activos"
},
"rolePresets": {
"staff": "Staff",
"collaborator": "Colaborador",
"co_manager": "Co-responsable"
},
"permissions": {
"view_overview": "Ver resumen",
"check_in": "Registrar ingresos",
"view_attendees_names": "Ver nombres de asistentes",
"view_attendees_pii": "Ver datos de contacto",
"email_attendees": "Enviar emails a asistentes",
"view_payments": "Ver pagos",
"view_finance": "Ver finanzas",
"edit_expenses": "Editar gastos",
"edit_own_expenses_only": "Agregar y editar sus gastos",
"view_full_split": "Ver el reparto completo",
"edit_event": "Editar evento",
"manage_team": "Gestionar equipo"
},
"expenseSettings": {
"tab": "Plantillas de gastos",
"categories": "Categorías",
"templates": "Plantillas",
"packs": "Paquetes de plantillas",
"fees": "Comisiones por método de pago",
"feesHint": "Se descuentan por cada pago al calcular el ingreso neto de un evento.",
"addCategory": "Agregar categoría",
"addTemplate": "Agregar plantilla",
"addPack": "Agregar paquete",
"nameEn": "Nombre (inglés)",
"nameEs": "Nombre (español)",
"color": "Color",
"name": "Nombre",
"description": "Descripción",
"archived": "Archivado",
"archive": "Archivar",
"unarchive": "Restaurar",
"showArchived": "Mostrar archivados",
"packTemplates": "Plantillas del paquete",
"percent": "Porcentaje (%)",
"fixed": "Fijo (PYG)",
"saved": "Guardado",
"deleted": "Eliminado",
"inUse": "Está en uso. Archivalo.",
"confirmDelete": "¿Eliminar {name}?",
"empty": "Todavía no hay nada"
},
"financeOverview": {
"title": "Finanzas",
"subtitle": "Resultados de todos los eventos. Los eventos cerrados usan sus números congelados.",
"from": "Desde",
"to": "Hasta",
"series": "Serie",
"venue": "Lugar",
"partner": "Socio",
"all": "Todos",
"noSeries": "Sin serie",
"events": "Eventos",
"event": "Evento",
"date": "Fecha",
"perEvent": "Ganancia por evento",
"perSeries": "Por serie",
"perVenue": "Por lugar",
"perPartner": "Totales por socio",
"paid": "Pagado",
"pending": "Pendiente",
"empty": "No hay eventos con datos financieros en este rango",
"filters": "Filtros",
"readyCount_one": "1 evento pasado",
"readyCount_other": "{count} eventos pasados",
"readyHint": "todavía con cuentas abiertas. Agregá los últimos gastos y cerralas.",
"showReady": "Ver listos para cerrar",
"showAll": "Ver todos los eventos",
"readyTitle": "Listos para cerrar",
"readyBadge": "Listo para cerrar",
"clippedHint": "Las barras con una marca de corte están recortadas para que se lean las demás. La etiqueta muestra el valor real."
},
"eventForm": {
"series": "Serie",
"seriesHint": "Agrupa eventos recurrentes (ej. Morning Club) en el resumen de Finanzas."
},
"eventStats": {
"capacity": "Capacidad",
"notCheckedIn": "Sin ingresar todavía",
"checkedIn": "Ingresados",
"revenue": "Ingresos",
"presaleDoor": "Preventa {presale} · Puerta {door}",
"stats": "Datos",
"inShort": "{count} adentro",
"free": "Gratis"
}
},
"footer": {
@@ -392,4 +894,4 @@
"downloading": "Descargando…"
}
}
}
}