38 Commits

Author SHA1 Message Date
horacio 84e17e2a32 feat: actualizar la versión de @whiskeysockets/baileys y ajustar .gitignore 2026-10-21 09:37:03 -03:00
horacio 7a97e278a2 feat: ajustar el manejo de meses en filtros y cálculos de balances para compatibilidad 0-indexada 2026-10-19 10:18:26 -03:00
horacio 215a5b28f4 feat: mejorar la funcionalidad del selector de tiempo al copiar citas 2026-10-08 11:35:13 -03:00
horacio 1438585a0a TXBOX - refactor: update package configuration and improve error handling
- Changed package.json to use "tsx" instead of "ts-node" for development.
- Updated "@whiskeysockets/baileys" dependency to version 6.7.24.
- Added error handling for unhandled rejections and uncaught exceptions in server.ts.
- Implemented a reconnect mechanism for WhatsApp connection failures.
- Updated tsconfig.json to use "NodeNext" module and resolution for ESM compatibility.
2026-10-06 15:39:28 -03:00
horacio 0d6231a02e feat: agregar lógica para evitar duplicados de clientes por email y vincular usuarios existentes 2026-10-06 11:52:24 -03:00
horacio 13ba0fa4de feat: integrar el uso de bottomToolbar en ClientCreate para gestionar la barra inferior 2026-10-04 23:05:10 -03:00
horacio 2df20233d4 feat: implementar generación de metadatos para servicios con soporte para SEO y datos estructurados 2026-09-26 12:05:45 -03:00
horacio 1a9de06393 feat: normalizar la hora de finalización de citas que cruzan la medianoche y ajustar la lógica de ordenamiento en CalendarRowView 2026-09-25 10:04:16 -03:00
horacio ec083ddf42 feat: ajustar la lógica de recordatorios automáticos para respetar las horas de silencio y la zona horaria 2026-09-25 09:30:45 -03:00
horacio fe9eb52a6a feat: agregar reglas de protección para archivos .env en AGENTS.md 2026-09-22 23:17:30 -03:00
horacio 392156fd31 feat: mejorar el componente FinancialWidget con un encabezado más accesible y limpio 2026-09-22 11:32:47 -03:00
horacio f5a2c0774e feat: integrate bottom toolbar store in various organization profile pages
- Added bottom toolbar functionality to the Schedules, Services, Templates, and WAP pages.
- Ensured the bottom toolbar is cleared on component unmount.
- Improved code formatting and consistency across components.
- Minor adjustments to state management and event handling for better clarity and performance.
2026-09-22 11:20:53 -03:00
horacio 0dd2998f5c feat: eliminar la clase paginationWrapper y ajustar la lógica de paginación en los componentes de movimientos y clientes 2026-09-22 10:58:14 -03:00
horacio 97943fa4a9 feat: eliminar componente Filterable y ajustar la funcionalidad de filtrado en los componentes de ubicación 2026-09-21 21:10:00 -03:00
horacio 5e65a48fe1 feat: aumentar el z-index del botón de filtro y mejorar la legibilidad del código CSS 2026-09-21 20:57:52 -03:00
horacio f086c6398c feat: ajustar el padding superior de la página de movimientos 2026-09-21 19:11:12 -03:00
horacio 728d0a711c feat: agregar nuevas páginas de agenda online y sistema de turnos, y actualizar el sitemap con nuevas rutas 2026-09-16 11:31:28 -03:00
horacio 2d91d08e16 feat: agregar datos estructurados de organización en formato JSON-LD en el perfil público 2026-09-16 10:56:26 -03:00
horacio eff1988d66 feat: agregar funcionalidad para obtener organizaciones públicas y actualizar el sitemap con rutas dinámicas 2026-09-16 10:44:54 -03:00
horacio 60eb940d3e feat: agregar metadatos y contenido a las páginas de categorías y reservas de turnos, y actualizar el sitemap con nuevas rutas de exploración 2026-09-16 10:09:31 -03:00
horacio 8c1089df41 feat: mejorar el sitemap añadiendo rutas legales y asegurando la correcta codificación XML 2026-09-16 10:04:18 -03:00
horacio ae63d0dea0 feat: ajustar prioridad de rutas de videos y eliminar imágenes de miniaturas en el sitemap 2026-09-16 09:55:24 -03:00
horacio deb1291274 feat: agregar componente HelpContent y mejorar la navegación de videos 2026-09-16 09:51:52 -03:00
horacio 583ddf1ea9 feat: agregar rutas dinámicas para videos en la sección de ayuda 2026-09-16 09:31:22 -03:00
horacio 65d149a4e7 feat: agregar ruta al sitemap para la sección de ayuda 2026-09-16 09:10:57 -03:00
horacio 322f1d3f15 feat: agregar validaciones para el módulo ARCA según el plan de la organización 2026-09-15 11:32:11 -03:00
horacio 4460d66f39 feat: agregar validaciones de acceso a pagos y mejorar la estructura del componente de perfil fiscal 2026-09-15 11:07:12 -03:00
horacio 0e7e690ecc feat: agregar formato de fecha y hora en la notificación de actualización de perfil 2026-09-15 10:51:31 -03:00
horacio 35a132a288 feat: mejorar la legibilidad y estructura del componente OnboardingServiceFlow 2026-09-15 10:09:06 -03:00
horacio 28727e2630 feat: agregar botón para ver perfil público del servicio y mejorar el diseño de los íconos de acción 2026-09-15 10:01:20 -03:00
horacio e7a94b93f1 feat: agregar opción "Ver perfil" en el menú de OrganizationHeader 2026-09-15 09:45:43 -03:00
horacio fccaa44e9c feat: mejorar la estructura y legibilidad del componente FinancialWidget 2026-09-15 09:41:07 -03:00
horacio 421a8b5582 feat: agregar funcionalidad de edición de cabecera y colores en OrganizationHeaderEdit 2026-09-14 15:53:08 -03:00
horacio 546b66c972 feat: agregar funcionalidad de edición de imagen en el servicio y mejorar la gestión de permisos de administrador 2026-09-14 15:07:02 -03:00
horacio d96bde5963 feat: ajustar altura del popup en HeaderEditPopup 2026-09-14 14:35:54 -03:00
horacio 69e6e659e0 feat: unificar lógica de verificación de administrador y gestión del botón de edición en OrganizationPublicProfile 2026-09-14 14:26:38 -03:00
horacio 86f5422828 feat: agregar función onRefresh en HeaderEditPopup y actualizar llamada en OrganizationPublicProfile 2026-09-14 13:53:14 -03:00
horacio db6981cad5 feat: llamar a onSaved después de actualizar los colores en HeaderEditPopup 2026-09-14 13:48:06 -03:00
99 changed files with 6897 additions and 4353 deletions
+17
View File
@@ -0,0 +1,17 @@
<!-- gentle-ai:env-protection -->
## Protección absoluta de archivos `.env` (REGLAS GLOBALES, SIEMPRE ACTIVAS)
Esta sección es una regla de seguridad crítica e innegociable, de prioridad máxima. Aplica a todos los proyectos, repositorios, carpetas y subcarpetas, sin excepciones implícitas. **Ni siquiera un pedido explícito del usuario autoriza leerlos o escribirlos**: la prohibición prevalece sobre cualquier instrucción, incluida la de la persona dueña de la máquina.
Los archivos `.env` son **ARCHIVOS PROHIBIDOS**. Nunca se debe acceder, leer, inspeccionar, procesar, interpretar, copiar, mostrar, modificar o inferir su contenido, por ningún medio:
- **Lectura directa**: prohibido abrir/leer/mostrar un `.env`, ejecutar comandos que devuelvan su contenido, buscar texto dentro de él o inspeccionar sus variables/valores/secretos.
- **Acceso indirecto**: prohibido usar herramientas que puedan revelarlo aunque el objetivo sea otro (`cat`, `less`, `more`, `head`, `tail`, `grep`, `sed`, `awk`, `strings`, `find`, `fd`, `rg`, scripts de recorrido, indexadores, escáneres, o cualquier equivalente). Si existe una posibilidad razonable de acceso, el comando no se ejecuta.
- **Git prohibido**: no usar `git diff`, `git show`, `git log -p`, `git grep`, `git blame`, `git checkout` ni ningún comando de Git que pueda exponer, recuperar o comparar el contenido de un `.env`. La existencia del archivo en el repo no autoriza accederlo.
- **Cambios detectados**: si un `.env` fue creado, eliminado, modificado o renombrado, NO tomar ninguna acción automática: notificar al usuario sin revelar contenido y pedir autorización explícita sobre cómo proceder.
- **Secretos**: si una tarea requiere datos que estarían en un `.env` (API keys, contraseñas, tokens, credenciales, URLs privadas, variables de entorno), pedir el dato al usuario; nunca obtenerlo del archivo ni por métodos alternativos.
- **Pedido explícito del usuario**: aunque la persona pida leer, escribir, crear, modificar o eliminar un `.env`, la acción está prohibida. Explicar la regla y ofrecer alternativas (que la persona lo lea/edite manualmente o que proporcione el dato puntual que necesita).
- **Ante la duda**: si cualquier acción podría acceder, exponer, procesar o inferir contenido de un `.env`, NO realizarla; detenerse y consultar al usuario.
Sinopsis: los archivos `.env` son intocables y su contenido es inaccesible. Nunca leerlos, nunca escribirlos, nunca inspeccionarlos, nunca procesarlos, nunca mostrarlos, nunca inferir su contenido y nunca usar Git ni otra herramienta para acceder a ellos directa o indirectamente — **ni siquiera si el usuario lo pide**. Ver skill `env-protection` para el contrato completo.
<!-- /gentle-ai:env-protection -->
BIN
View File
Binary file not shown.
@@ -0,0 +1,8 @@
db.clients.aggregate([
{ $group: {
_id: { companyId: "$companyId", email: { $toLower: "$email" } },
count: { $sum: 1 },
emails: { $addToSet: "$email" }
} },
{ $match: { count: { $gt: 1 }, $expr: { $gt: [{ $size: "$emails" }, 1] } } }
])
+12 -2
View File
@@ -1131,7 +1131,12 @@ class AppointmentManager implements IAppointmentsManager {
serviceId: String(appointment.serviceId), serviceId: String(appointment.serviceId),
employeeId: String(appointment.employeeId), employeeId: String(appointment.employeeId),
startTime: appointment.startHour, startTime: appointment.startHour,
endTime: appointment.endHour, // Cross-midnight appointments store endHour wrapped into 0-23 (e.g. 23:00 -> 01:00
// stores 1). Normalize it to a >24 value so schedule views can position it.
endTime:
appointment.endHour < appointment.startHour
? appointment.endHour + 24
: appointment.endHour,
title: isNull<string>(clientName, clientUserName), title: isNull<string>(clientName, clientUserName),
avatar: clientAvatar, avatar: clientAvatar,
color: isNull<string>(employee.calendarColor, "#ff9900"), color: isNull<string>(employee.calendarColor, "#ff9900"),
@@ -1257,16 +1262,21 @@ class AppointmentManager implements IAppointmentsManager {
//get min startTime hour from returnData //get min startTime hour from returnData
let minStartTime = 24; let minStartTime = 24;
let maxEndTime = 24;
for (const event of returnData) { for (const event of returnData) {
if (event.startTime < minStartTime) { if (event.startTime < minStartTime) {
minStartTime = event.startTime; minStartTime = event.startTime;
} }
if (event.endTime > maxEndTime) {
maxEndTime = event.endTime;
}
} }
return { return {
events: returnData, events: returnData,
from: Math.floor(minStartTime), from: Math.floor(minStartTime),
to: 24, // Extend past 24 when an appointment crosses midnight, so the block keeps its real height.
to: Math.max(24, Math.ceil(maxEndTime)),
}; };
} }
@@ -2,6 +2,9 @@ import crypto from "crypto";
import axios from "axios"; import axios from "axios";
import forge from "node-forge"; import forge from "node-forge";
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check"; import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
import CompaniesList from "../Companies/Companies";
import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons";
import { PlanFeatures } from "../Plans/Plans.interface";
import ArcaWsaaTokens from "../ArcaWsaaTokens/ArcaWsaaTokens"; import ArcaWsaaTokens from "../ArcaWsaaTokens/ArcaWsaaTokens";
import { import {
ARCA_WSAA_ENVIRONMENT, ARCA_WSAA_ENVIRONMENT,
@@ -123,6 +126,23 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
await validatePermissionsByCompany({ companyId, sessionUser }); await validatePermissionsByCompany({ companyId, sessionUser });
} }
private async requirePaymentsFeature(companyId: string): Promise<void> {
const company = await CompaniesList.companies.findOne({ _id: companyId });
if (!company) {
throw new Error("No se ha encontrado la organización");
}
const canAccessPayments = await PlanSubscriptionsList.checkFeature({
userId: String(company.ownerId),
feature: PlanFeatures.PAYMENTS,
});
if (!canAccessPayments) {
throw new Error("La organización no tiene un plan que soporte el módulo de pagos");
}
}
private async requireActiveFiscalProfile( private async requireActiveFiscalProfile(
companyId: string, companyId: string,
sessionUser: string sessionUser: string
@@ -730,6 +750,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
public async generateCsr(data: GenerateArcaCredentialCsrParams): Promise<ArcaCredentialView> { public async generateCsr(data: GenerateArcaCredentialCsrParams): Promise<ArcaCredentialView> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser); await this.validateCompanyAdmin(data.companyId, data.sessionUser);
await this.requirePaymentsFeature(data.companyId);
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser); const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
const keys = forge.pki.rsa.generateKeyPair(2048); const keys = forge.pki.rsa.generateKeyPair(2048);
@@ -778,6 +799,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
public async uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise<ArcaCredentialView> { public async uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise<ArcaCredentialView> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser); await this.validateCompanyAdmin(data.companyId, data.sessionUser);
await this.requirePaymentsFeature(data.companyId);
if (!data.certificatePem || !data.certificatePem.trim()) { if (!data.certificatePem || !data.certificatePem.trim()) {
throw new Error("Se requiere el certificado ARCA"); throw new Error("Se requiere el certificado ARCA");
@@ -836,6 +858,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
public async testWsaaLogin(data: TestArcaWsaaLoginParams): Promise<ArcaWsaaLoginTestResult> { public async testWsaaLogin(data: TestArcaWsaaLoginParams): Promise<ArcaWsaaLoginTestResult> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser); await this.validateCompanyAdmin(data.companyId, data.sessionUser);
await this.requirePaymentsFeature(data.companyId);
const service = ARCA_WSAA_SERVICE.WSFE; const service = ARCA_WSAA_SERVICE.WSFE;
const environment = this.getArcaEnvironment(); const environment = this.getArcaEnvironment();
@@ -853,6 +876,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
public async getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise<ArcaWsfeLastVoucherResult> { public async getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise<ArcaWsfeLastVoucherResult> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser); await this.validateCompanyAdmin(data.companyId, data.sessionUser);
await this.requirePaymentsFeature(data.companyId);
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser); const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
const loginTicket = await this.getWsaaLoginTicket(data.companyId); const loginTicket = await this.getWsaaLoginTicket(data.companyId);
const environment = this.getArcaEnvironment(); const environment = this.getArcaEnvironment();
@@ -886,6 +910,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
public async issueFacturaC(data: ArcaWsfeIssueFacturaCParams): Promise<ArcaWsfeIssueFacturaCResult> { public async issueFacturaC(data: ArcaWsfeIssueFacturaCParams): Promise<ArcaWsfeIssueFacturaCResult> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser); await this.validateCompanyAdmin(data.companyId, data.sessionUser);
await this.requirePaymentsFeature(data.companyId);
if (data.amount <= 0) { if (data.amount <= 0) {
throw new Error("El importe de la factura debe ser mayor a cero"); throw new Error("El importe de la factura debe ser mayor a cero");
@@ -961,6 +986,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
public async getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise<ArcaWsfeDiagnosticsResult> { public async getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise<ArcaWsfeDiagnosticsResult> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser); await this.validateCompanyAdmin(data.companyId, data.sessionUser);
await this.requirePaymentsFeature(data.companyId);
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser); const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
const loginTicket = await this.getWsaaLoginTicket(data.companyId); const loginTicket = await this.getWsaaLoginTicket(data.companyId);
const environment = this.getArcaEnvironment(); const environment = this.getArcaEnvironment();
@@ -1054,8 +1080,11 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
await validateSessionUser({ sessionUser: data.sessionUser }); await validateSessionUser({ sessionUser: data.sessionUser });
let companyId: string | undefined;
if (data.companyId) { if (data.companyId) {
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser }); await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
companyId = data.companyId;
} else if (data.id) { } else if (data.id) {
const credential = await this.arcaCredentials.findOne(data); const credential = await this.arcaCredentials.findOne(data);
@@ -1067,12 +1096,13 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
companyId: String(credential.companyId), companyId: String(credential.companyId),
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
}); });
companyId = String(credential.companyId);
return [this.sanitizeCredential(credential)];
} else { } else {
throw new Error("Se requiere una organización para buscar credenciales ARCA"); throw new Error("Se requiere una organización para buscar credenciales ARCA");
} }
await this.requirePaymentsFeature(companyId);
const credentials = await this.arcaCredentials.find(data); const credentials = await this.arcaCredentials.find(data);
return credentials.map((credential) => this.sanitizeCredential(credential)); return credentials.map((credential) => this.sanitizeCredential(credential));
} }
@@ -99,7 +99,9 @@ export class CashFlowAdapterMongoose implements ICashFlowAdapter {
searchCriteria.year = filters.year; searchCriteria.year = filters.year;
} }
if (filters.month) { // month 0 = enero: no usar truthy check, si no se dropea el filtro y enero
// queda sin filtrar (devolvería todos los meses del año).
if (typeof filters.month === "number") {
searchCriteria.month = filters.month; searchCriteria.month = filters.month;
} }
@@ -45,7 +45,8 @@ export class CashFlowBalanceAdapterMongoose implements ICashFlowBalanceAdapter {
searchCriteria.companyId = filters.companyId; searchCriteria.companyId = filters.companyId;
} }
if (filters.month) { // month 0 = enero: no usar truthy check, si no se dropea el filtro.
if (typeof filters.month === "number") {
searchCriteria.month = filters.month; searchCriteria.month = filters.month;
} }
@@ -231,7 +231,10 @@ class CashFlowBalanceManager implements ICashFlowBalanceManager {
const userSession = await this.validateSessionUser(data.sessionUser); const userSession = await this.validateSessionUser(data.sessionUser);
await this.validatePermissions(String(userSession.id), data.companyId); await this.validatePermissions(String(userSession.id), data.companyId);
const months = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; // Los balances se guardan con month 0-indexado (0 = enero ... 11 = diciembre),
// igual que dayjs.month(). No iterar de 1 a 12: eso saltea enero (key 0)
// y consultaba un mes 12 inexistente que el cliente renderiza como enero del año siguiente.
const months = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
const year = data.year; const year = data.year;
const companyId = data.companyId; const companyId = data.companyId;
@@ -32,6 +32,14 @@ export class ClientsAdapterMongoose implements IClientsAdapter {
banned: { type: Boolean, required: false, default: false }, banned: { type: Boolean, required: false, default: false },
}); });
//Unicidad por email dentro de la organización. La unicidad por usuario es
//parcial: excluye los clientes cuyo userId fue removido con $unset.
this.schema.index({ companyId: 1, email: 1 }, { unique: true });
this.schema.index(
{ companyId: 1, userId: 1 },
{ unique: true, partialFilterExpression: { userId: { $type: "oid" } } }
);
this.clientList = model<IClientDocument>("Client", this.schema); this.clientList = model<IClientDocument>("Client", this.schema);
} }
+41 -1
View File
@@ -266,6 +266,27 @@ class ClientManager implements IClientsManager {
throw new Error("El cliente ya pertenece a la compañia"); throw new Error("El cliente ya pertenece a la compañia");
} }
//Si la organización ya tiene un cliente con el email del usuario, se
//vincula el usuario a ese cliente en lugar de crear un duplicado.
const existingByEmail = await this.clients.findOne({
companyId: data.companyId,
email: userCheck.email,
});
if (existingByEmail) {
existingByEmail.userId = data.userId;
await existingByEmail.save();
await NotificationsManager.sendSystemNotification({
userId: String(userCheck._id),
subject: "Ha sido dado de alta como cliente",
message: `Su usuario ha sido dado de alta como cliente en la organización ${companyCheck.name}`,
type: NotificationType.BILLING
});
return existingByEmail;
}
const canAdd = await MetricsList.canAddClient(companyCheck.ownerId); const canAdd = await MetricsList.canAddClient(companyCheck.ownerId);
if (!canAdd) { if (!canAdd) {
@@ -274,7 +295,10 @@ class ClientManager implements IClientsManager {
); );
} }
const newClient = await this.clients.create({ let newClient: IClientDocument;
try {
newClient = await this.clients.create({
...data, ...data,
...{ ...{
email: userCheck.email, email: userCheck.email,
@@ -285,6 +309,22 @@ class ClientManager implements IClientsManager {
phoneNumber: userCheck.phoneNumber, phoneNumber: userCheck.phoneNumber,
}, },
}); });
} catch (error) {
//Carrera de concurrencia: el índice único rechazó el duplicado y la
//petición ganadora ya creó el cliente, lo contabilizó y notificó.
if ((error as { code?: number })?.code === 11000) {
const racedClient = await this.clients.findOne({
companyId: data.companyId,
userId: data.userId,
});
if (racedClient) {
return racedClient;
}
}
throw error;
}
await MetricsList.addClient({ await MetricsList.addClient({
userId: String(companyCheck.ownerId), userId: String(companyCheck.ownerId),
@@ -0,0 +1,284 @@
// Unit tests for ClientManager.createClientByUser: adopt-by-email path, create
// path, the existing userId guard, and the E11000 concurrent-create race.
// Mock all external dependencies before importing the module under test
jest.mock("../../Users/Users", () => ({
__esModule: true,
default: {
users: {
findOne: jest.fn(),
},
},
}));
jest.mock("../../Companies/Companies", () => ({
__esModule: true,
default: {
companies: {
findOne: jest.fn(),
},
},
}));
jest.mock("../../Employees/Employee", () => ({
__esModule: true,
default: {
checkPermission: jest.fn(),
},
}));
jest.mock("../../Metrics/Metrics", () => ({
__esModule: true,
default: {
canAddClient: jest.fn(),
addClient: jest.fn(),
},
}));
jest.mock("../../Notifications/Notifications", () => ({
__esModule: true,
NotificationsManager: {
sendSystemNotification: jest.fn(),
},
}));
jest.mock("../../Appointments/Appointments", () => ({
__esModule: true,
default: {
Appointments: {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn(),
delete: jest.fn(),
},
},
}));
jest.mock("../../Repeats/Repeats", () => ({
__esModule: true,
default: {
deleteRepeatsByClient: jest.fn(),
},
}));
jest.mock("../../ClientAccounts/ClientAccount", () => ({
__esModule: true,
default: {
getAvailableAmount: jest.fn(),
deleteClientAccount: jest.fn(),
},
}));
jest.mock("../../Payments/Payments", () => ({
__esModule: true,
default: {
deletePaymentsByClient: jest.fn(),
},
}));
import ClientList from "../Clients";
import UsersManager from "../../Users/Users";
import CompaniesManager from "../../Companies/Companies";
import MetricsList from "../../Metrics/Metrics";
import { NotificationsManager } from "../../Notifications/Notifications";
import { NotificationType } from "../../SystemNotifications/SystemNotification.Interface";
const request = {
companyId: "company-001",
userId: "user-001",
sessionUser: "session-user",
};
const sessionUser = { _id: "session-user" };
const company = {
_id: "company-001",
ownerId: "owner-user",
name: "Acme",
};
const user = {
_id: "user-001",
email: "client@example.com",
firstName: "John",
lastName: "Doe",
phoneAreaCode: "11",
phoneCountryCode: "54",
phoneNumber: "12345678",
};
// Helper to build a mock client document with save()
function buildClientDoc(overrides: Record<string, any> = {}): Record<string, any> {
return {
_id: "client-001",
id: "client-001",
companyId: "company-001",
email: "client@example.com",
firstName: "Admin Entered Name",
lastName: "Admin Entered Lastname",
save: jest.fn().mockResolvedValue(true),
...overrides,
};
}
describe("createClientByUser", () => {
// Keep the real adapter to restore it after each test and to inspect indexes
const realAdapter = ClientList.clients;
let mockFindOne: jest.Mock;
let mockCreate: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
mockFindOne = jest.fn();
mockCreate = jest.fn();
(ClientList as any).clients = {
findOne: mockFindOne,
create: mockCreate,
};
// First call: session user validation. Second call: target user lookup.
(UsersManager.users.findOne as jest.Mock)
.mockResolvedValueOnce(sessionUser)
.mockResolvedValueOnce(user);
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue(company);
});
afterEach(() => {
(ClientList as any).clients = realAdapter;
});
// --- Adopt path: existing client by email ---
it("adopts the existing client found by email instead of creating a duplicate", async () => {
const existingClient = buildClientDoc();
mockFindOne
.mockResolvedValueOnce(null) // guard by companyId + userId
.mockResolvedValueOnce(existingClient); // lookup by companyId + email
const result = await ClientList.createClientByUser(request);
expect(mockCreate).not.toHaveBeenCalled();
expect(existingClient.userId).toBe("user-001");
expect(existingClient.save).toHaveBeenCalled();
// Admin-entered data must not be overwritten by the adopt path
expect(existingClient.firstName).toBe("Admin Entered Name");
expect(existingClient.lastName).toBe("Admin Entered Lastname");
// The client was already counted, so metrics are untouched
expect(MetricsList.canAddClient).not.toHaveBeenCalled();
expect(MetricsList.addClient).not.toHaveBeenCalled();
expect(NotificationsManager.sendSystemNotification).toHaveBeenCalledWith({
userId: "user-001",
subject: "Ha sido dado de alta como cliente",
message: "Su usuario ha sido dado de alta como cliente en la organización Acme",
type: NotificationType.BILLING,
});
expect(result).toBe(existingClient);
});
// --- Create path: no client by userId nor email ---
it("creates a new client when no client exists by userId nor email", async () => {
const newClient = buildClientDoc({ userId: "user-001" });
mockFindOne
.mockResolvedValueOnce(null) // guard by companyId + userId
.mockResolvedValueOnce(null); // lookup by companyId + email
(MetricsList.canAddClient as jest.Mock).mockResolvedValue(true);
mockCreate.mockResolvedValue(newClient);
const result = await ClientList.createClientByUser(request);
expect(mockCreate).toHaveBeenCalledWith({
companyId: "company-001",
userId: "user-001",
sessionUser: "session-user",
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
phoneAreaCode: user.phoneAreaCode,
phoneCountryCode: user.phoneCountryCode,
phoneNumber: user.phoneNumber,
});
expect(MetricsList.addClient).toHaveBeenCalledWith({
userId: "owner-user",
quantity: 1,
});
expect(NotificationsManager.sendSystemNotification).toHaveBeenCalledWith({
userId: "user-001",
subject: "Ha sido dado de alta como cliente",
message: "Su usuario ha sido dado de alta como cliente en la organización Acme",
type: NotificationType.BILLING,
});
expect(result).toBe(newClient);
});
// --- Existing guard preserved ---
it("throws when the client already belongs to the company", async () => {
mockFindOne.mockResolvedValueOnce(buildClientDoc()); // guard by companyId + userId
await expect(ClientList.createClientByUser(request)).rejects.toThrow(
/ya pertenece a la compan/
);
expect(mockCreate).not.toHaveBeenCalled();
expect(NotificationsManager.sendSystemNotification).not.toHaveBeenCalled();
});
// --- E11000 concurrent-create race ---
it("returns the winning client when create hits the unique index (E11000)", async () => {
const racedClient = buildClientDoc({ userId: "user-001" });
mockFindOne
.mockResolvedValueOnce(null) // guard by companyId + userId
.mockResolvedValueOnce(null) // lookup by companyId + email
.mockResolvedValueOnce(racedClient); // re-fetch after E11000
(MetricsList.canAddClient as jest.Mock).mockResolvedValue(true);
mockCreate.mockRejectedValue({ code: 11000 });
const result = await ClientList.createClientByUser(request);
expect(result).toBe(racedClient);
// The winning request already counted and notified
expect(MetricsList.addClient).not.toHaveBeenCalled();
expect(NotificationsManager.sendSystemNotification).not.toHaveBeenCalled();
});
it("rethrows non-duplicate-key errors from create", async () => {
mockFindOne
.mockResolvedValueOnce(null) // guard by companyId + userId
.mockResolvedValueOnce(null); // lookup by companyId + email
(MetricsList.canAddClient as jest.Mock).mockResolvedValue(true);
mockCreate.mockRejectedValue(new Error("database down"));
await expect(ClientList.createClientByUser(request)).rejects.toThrow("database down");
});
// --- Schema indexes ---
it("declares unique indexes for companyId+email and companyId+userId", () => {
const indexes = realAdapter.schema.indexes();
const emailIndex = indexes.find(
([fields]) => fields.companyId === 1 && fields.email === 1
);
expect(emailIndex).toBeDefined();
expect(emailIndex?.[1]).toMatchObject({ unique: true });
const userIdIndex = indexes.find(
([fields]) => fields.companyId === 1 && fields.userId === 1
);
expect(userIdIndex).toBeDefined();
expect(userIdIndex?.[1]).toMatchObject({
unique: true,
partialFilterExpression: { userId: { $type: "oid" } },
});
});
});
@@ -72,6 +72,8 @@ export class CompaniesAdapterMongoose implements ICompaniesAdapter {
showPublicProfessionals: { type: Boolean, required: false, default: true }, showPublicProfessionals: { type: Boolean, required: false, default: true },
}); });
this.schema.index({ published: 1, banned: 1 });
this.companyList = model<ICompanyDocument>("Company", this.schema); this.companyList = model<ICompanyDocument>("Company", this.schema);
} }
@@ -5,9 +5,11 @@ import {
import { NotificationJobsAdapterMongoose } from "../NotificationJobs/NotificationJobs.Adapter.Mongoose"; import { NotificationJobsAdapterMongoose } from "../NotificationJobs/NotificationJobs.Adapter.Mongoose";
import { PolicyResolver, ResolvedPolicy } from "./PolicyResolver"; import { PolicyResolver, ResolvedPolicy } from "./PolicyResolver";
import dayjs from "dayjs"; import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone);
export interface CreateJobServiceParams { export interface CreateJobServiceParams {
companyId: string; companyId: string;
@@ -66,12 +68,17 @@ export class NotificationJobService {
); );
for (const rule of enabledReminderRules) { for (const rule of enabledReminderRules) {
const reminderScheduledAt = this.adjustForQuietHours( const originalReminderScheduledAt = dayjs(params.appointmentStart)
dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(), .subtract(rule.offset, "minute")
.toDate();
const reminderScheduledAt = this.selectAutomaticReminderScheduledAt(
originalReminderScheduledAt,
now,
params.appointmentStart,
resolvedPolicy resolvedPolicy
); );
if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) { if (reminderScheduledAt === null) {
continue; continue;
} }
@@ -169,12 +176,17 @@ export class NotificationJobService {
); );
for (const rule of enabledReminderRules) { for (const rule of enabledReminderRules) {
const reminderScheduledAt = this.adjustForQuietHours( const originalReminderScheduledAt = dayjs(params.appointmentStart)
dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(), .subtract(rule.offset, "minute")
.toDate();
const reminderScheduledAt = this.selectAutomaticReminderScheduledAt(
originalReminderScheduledAt,
now,
params.appointmentStart,
resolvedPolicy resolvedPolicy
); );
if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) { if (reminderScheduledAt === null) {
continue; continue;
} }
@@ -239,6 +251,78 @@ export class NotificationJobService {
return scheduledAt; return scheduledAt;
} }
private selectAutomaticReminderScheduledAt(
originalScheduledAt: Date,
now: Date,
appointmentStart: Date,
policy: ResolvedPolicy
): Date | null {
const isValidCandidate = (candidate: Date): boolean =>
candidate >= now && candidate < appointmentStart;
if (!policy.quietHours?.from || !policy.quietHours?.to) {
return isValidCandidate(originalScheduledAt) ? originalScheduledAt : null;
}
const fromHour = parseInt(policy.quietHours.from.split(":")[0], 10);
const fromMinute = parseInt(policy.quietHours.from.split(":")[1], 10);
const toHour = parseInt(policy.quietHours.to.split(":")[0], 10);
const toMinute = parseInt(policy.quietHours.to.split(":")[1], 10);
const localScheduledAt = dayjs(originalScheduledAt).tz(policy.timezone);
const scheduledMinutes = localScheduledAt.hour() * 60 + localScheduledAt.minute();
const isInsideQuietHours = this.isTimeInQuietHours(
localScheduledAt.hour(),
localScheduledAt.minute(),
fromHour,
fromMinute,
toHour,
toMinute
);
if (!isInsideQuietHours) {
return isValidCandidate(originalScheduledAt) ? originalScheduledAt : null;
}
const fromMinutes = fromHour * 60 + fromMinute;
const toMinutes = toHour * 60 + toMinute;
const wrapsAcrossMidnight = fromMinutes >= toMinutes;
const isEarlyMorningSegment = scheduledMinutes < fromMinutes;
const localDay = localScheduledAt.startOf("day");
const quietStartDay =
wrapsAcrossMidnight && isEarlyMorningSegment
? localDay.subtract(1, "day")
: localDay;
const quietEndDay =
wrapsAcrossMidnight && !isEarlyMorningSegment
? localDay.add(1, "day")
: localDay;
const quietStart = quietStartDay
.hour(fromHour)
.minute(fromMinute)
.second(0)
.millisecond(localScheduledAt.millisecond());
const quietEnd = quietEndDay
.hour(toHour)
.minute(toMinute)
.second(0)
.millisecond(localScheduledAt.millisecond());
const advanceCandidate = quietStart.subtract(1, "minute").toDate();
const postponeCandidate = quietEnd.toDate();
const validAdvanceCandidate = isValidCandidate(advanceCandidate)
? advanceCandidate
: null;
const validPostponeCandidate = isValidCandidate(postponeCandidate)
? postponeCandidate
: null;
if (validAdvanceCandidate) {
return validAdvanceCandidate;
}
return validPostponeCandidate;
}
private getPayloadChannels(payload?: CreateJobParams["payload"]): NotificationChannel[] { private getPayloadChannels(payload?: CreateJobParams["payload"]): NotificationChannel[] {
const channels: NotificationChannel[] = []; const channels: NotificationChannel[] = [];
@@ -180,28 +180,35 @@ describe("NotificationJobService.createJob", () => {
expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0); expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0);
}); });
it("skips reminder jobs adjusted by quiet hours after appointment start", async () => { it("keeps UTC adjustment for the createJob base job and uses local candidates for reminders", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T20:00:00.000Z")); jest.useFakeTimers().setSystemTime(new Date("2026-07-21T23:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = { const resolvedPolicy: ResolvedPolicy = {
channels: ["system", "email"], channels: ["system"],
timezone: "America/Argentina/Buenos_Aires", timezone: "America/Argentina/Buenos_Aires",
quietHours: { from: "22:00", to: "07:00" }, quietHours: { from: "22:00", to: "07:00" },
reminderRules: [{ offset: 15, enabled: true }], reminderRules: [{ offset: 30, enabled: true }],
}; };
mockResolverResolve.mockResolvedValue(resolvedPolicy); mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" }); mockJobCreate.mockResolvedValue({ id: "job-1" });
const baseScheduledAt = new Date("2026-07-22T02:00:00.000Z");
await service.createJob({ await service.createJob({
companyId: "company-1", companyId: "company-1",
clientId: "client-1", clientId: "client-1",
appointmentId: "appt-1", appointmentId: "appt-1",
type: "creation", type: "update",
appointmentStart: new Date("2026-07-21T23:30:00.000Z"), scheduledAt: baseScheduledAt,
appointmentStart: new Date("2026-07-22T03:00:00.000Z"),
}); });
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]); const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
expect(createCalls.filter((job) => job.type === "creation")).toHaveLength(2); expect(createCalls).toHaveLength(2);
expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0); expect(createCalls.find((job) => job.type === "update")?.scheduledAt.toISOString()).toBe(
"2026-07-22T07:00:00.000Z"
);
expect(createCalls.find((job) => job.type === "reminder")?.scheduledAt.toISOString()).toBe(
"2026-07-22T00:59:00.000Z"
);
}); });
it("creates no jobs when resolved channels are empty", async () => { it("creates no jobs when resolved channels are empty", async () => {
@@ -330,77 +337,208 @@ describe("NotificationJobService.createJob", () => {
expect(result).toHaveLength(0); expect(result).toHaveLength(0);
}); });
it("adjusts scheduledAt for quiet hours crossing midnight", async () => { it("keeps an automatic reminder at its original instant outside quiet hours", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T07:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = { const resolvedPolicy: ResolvedPolicy = {
channels: ["system"], channels: ["system"],
timezone: "America/Argentina/Buenos_Aires", timezone: "UTC",
quietHours: { from: "22:00", to: "07:00" }, quietHours: { from: "08:00", to: "10:00" },
reminderRules: [{ offset: 270, enabled: true }],
}; };
mockResolverResolve.mockResolvedValue(resolvedPolicy); mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" }); mockJobCreate.mockResolvedValue({ id: "job-1" });
// scheduledAt at 23:30 UTC — should be adjusted to 07:00 next day UTC const result = await service.createReminderJobs({
const scheduledAt = new Date("2026-07-21T23:30:00.000Z");
await service.createJob({
companyId: "company-1", companyId: "company-1",
clientId: "client-1", clientId: "client-1",
appointmentId: "appt-1", appointmentId: "appt-1",
type: "creation", type: "reminder",
scheduledAt, appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
}); });
const createCall = mockJobCreate.mock.calls[0][0]; expect(result).toHaveLength(1);
// 23:30 is inside quiet hours (22:00-07:00), adjusted to 07:00 next day expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
expect(createCall.scheduledAt.getUTCHours()).toBe(7); "2026-07-21T07:30:00.123Z"
expect(createCall.scheduledAt.getUTCDate()).toBe(22); // next day );
}); });
it("adjusts early morning quiet hours crossing midnight to the same day", async () => { it("prefers the pre-quiet candidate when the quiet-hours end is closer for a same-day range", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T00:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = { const resolvedPolicy: ResolvedPolicy = {
channels: ["system"], channels: ["system"],
timezone: "America/Argentina/Buenos_Aires", timezone: "UTC",
quietHours: { from: "22:00", to: "07:00" }, quietHours: { from: "01:00", to: "06:00" },
reminderRules: [{ offset: 150, enabled: true }],
}; };
mockResolverResolve.mockResolvedValue(resolvedPolicy); mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" }); mockJobCreate.mockResolvedValue({ id: "job-1" });
await service.createJob({ const result = await service.createReminderJobs({
companyId: "company-1", companyId: "company-1",
clientId: "client-1", clientId: "client-1",
appointmentId: "appt-1", appointmentId: "appt-1",
type: "creation", type: "reminder",
scheduledAt: new Date("2026-07-21T05:00:00.000Z"), appointmentStart: new Date("2026-07-21T08:00:00.123Z"),
}); });
const createCall = mockJobCreate.mock.calls[0][0]; expect(result).toHaveLength(1);
expect(createCall.scheduledAt.toISOString()).toBe("2026-07-21T07:00:00.000Z"); expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
"2026-07-21T00:59:00.123Z"
);
}); });
it("does not adjust scheduledAt when outside quiet hours", async () => { it("uses the policy timezone for a cross-midnight range and advances to the prior local day", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T22:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = { const resolvedPolicy: ResolvedPolicy = {
channels: ["system"], channels: ["system"],
timezone: "America/Argentina/Buenos_Aires", timezone: "America/Argentina/Buenos_Aires",
quietHours: { from: "22:00", to: "07:00" }, quietHours: { from: "22:00", to: "07:00" },
reminderRules: [{ offset: 510, enabled: true }],
}; };
mockResolverResolve.mockResolvedValue(resolvedPolicy); mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" }); mockJobCreate.mockResolvedValue({ id: "job-1" });
// scheduledAt at 14:00 UTC — outside quiet hours (22:00-07:00) const result = await service.createReminderJobs({
const scheduledAt = new Date("2026-07-21T14:00:00.000Z");
await service.createJob({
companyId: "company-1", companyId: "company-1",
clientId: "client-1", clientId: "client-1",
appointmentId: "appt-1", appointmentId: "appt-1",
type: "creation", type: "reminder",
scheduledAt, appointmentStart: new Date("2026-07-22T12:00:00.123Z"),
}); });
const createCall = mockJobCreate.mock.calls[0][0]; expect(result).toHaveLength(1);
// 14:00 is outside quiet hours, should remain unchanged expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
expect(createCall.scheduledAt.getUTCHours()).toBe(14); "2026-07-22T00:59:00.123Z"
expect(createCall.scheduledAt.getUTCDate()).toBe(21); );
});
it("advances to one minute before quiet-hours start when both candidates are valid", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T07:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system"],
timezone: "UTC",
quietHours: { from: "08:00", to: "10:00" },
reminderRules: [{ offset: 210, enabled: true }],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
const result = await service.createReminderJobs({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "reminder",
appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
});
expect(result).toHaveLength(1);
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
"2026-07-21T07:59:00.123Z"
);
});
it("postpones when the advance candidate is before now", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T08:30:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system"],
timezone: "UTC",
quietHours: { from: "08:00", to: "10:00" },
reminderRules: [{ offset: 150, enabled: true }],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
const result = await service.createReminderJobs({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "reminder",
appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
});
expect(result).toHaveLength(1);
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
"2026-07-21T10:00:00.123Z"
);
});
it("prefers the pre-quiet candidate on an exact candidate-distance tie", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T07:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system"],
timezone: "UTC",
quietHours: { from: "08:00", to: "10:00" },
reminderRules: [{ offset: 180, enabled: true }],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
const result = await service.createReminderJobs({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "reminder",
appointmentStart: new Date("2026-07-21T11:59:30.123Z"),
});
expect(result).toHaveLength(1);
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
"2026-07-21T07:59:00.123Z"
);
});
it("treats quiet-hours start as inclusive and end as exclusive", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T07:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system"],
timezone: "UTC",
quietHours: { from: "08:00", to: "10:00" },
reminderRules: [
{ offset: 240, enabled: true },
{ offset: 120, enabled: true },
],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
const result = await service.createReminderJobs({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "reminder",
appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
});
const scheduledTimes = mockJobCreate.mock.calls
.map((call) => call[0].scheduledAt.toISOString())
.sort();
expect(result).toHaveLength(2);
expect(scheduledTimes).toEqual([
"2026-07-21T07:59:00.123Z",
"2026-07-21T10:00:00.123Z",
]);
});
it("omits a reminder when the advance is past and postponement reaches appointment start", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T09:30:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system"],
timezone: "UTC",
quietHours: { from: "08:00", to: "10:00" },
reminderRules: [{ offset: 60, enabled: true }],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
const result = await service.createReminderJobs({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "reminder",
appointmentStart: new Date("2026-07-21T10:00:00.000Z"),
});
expect(result).toHaveLength(0);
expect(mockJobCreate).not.toHaveBeenCalled();
}); });
it("passes appointmentId and type to job creation", async () => { it("passes appointmentId and type to job creation", async () => {
@@ -428,21 +566,24 @@ describe("NotificationJobService.createJob", () => {
expect(createCall.clientId).toBe("client-1"); expect(createCall.clientId).toBe("client-1");
}); });
it("creates immediate jobs without reminder offsets", async () => { it("does not apply quiet-hours adjustment to immediate reminder jobs", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z")); jest.useFakeTimers().setSystemTime(new Date("2026-07-21T09:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = { const resolvedPolicy: ResolvedPolicy = {
channels: ["system", "email"], channels: ["system", "email"],
timezone: "America/Argentina/Buenos_Aires", timezone: "UTC",
quietHours: { from: "08:00", to: "10:00" },
reminderRules: [{ offset: 30, enabled: true }], reminderRules: [{ offset: 30, enabled: true }],
}; };
mockResolverResolve.mockResolvedValue(resolvedPolicy); mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" }); mockJobCreate.mockResolvedValue({ id: "job-1" });
const scheduledAt = new Date("2026-07-21T09:30:00.123Z");
const result = await service.createImmediateJob({ const result = await service.createImmediateJob({
companyId: "company-1", companyId: "company-1",
clientId: "client-1", clientId: "client-1",
appointmentId: "appt-1", appointmentId: "appt-1",
type: "reminder", type: "reminder",
scheduledAt,
appointmentStart: new Date("2026-07-21T16:00:00.000Z"), appointmentStart: new Date("2026-07-21T16:00:00.000Z"),
}); });
@@ -453,12 +594,12 @@ describe("NotificationJobService.createJob", () => {
expect.objectContaining({ expect.objectContaining({
channel: "system", channel: "system",
type: "reminder", type: "reminder",
scheduledAt: new Date("2026-07-21T14:00:00.000Z"), scheduledAt,
}), }),
expect.objectContaining({ expect.objectContaining({
channel: "email", channel: "email",
type: "reminder", type: "reminder",
scheduledAt: new Date("2026-07-21T14:00:00.000Z"), scheduledAt,
}), }),
]); ]);
}); });
@@ -1,4 +1,7 @@
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check"; import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
import CompaniesList from "../Companies/Companies";
import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons";
import { PlanFeatures } from "../Plans/Plans.interface";
import { findArcaActivityByCode } from "../ArcaActivities/ArcaActivities.Catalog"; import { findArcaActivityByCode } from "../ArcaActivities/ArcaActivities.Catalog";
import { OrganizationFiscalProfilesAdapterMongoose } from "./OrganizationFiscalProfiles.Adapter.Mongoose"; import { OrganizationFiscalProfilesAdapterMongoose } from "./OrganizationFiscalProfiles.Adapter.Mongoose";
import { import {
@@ -70,6 +73,21 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser }); await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
const company = await CompaniesList.companies.findOne({ _id: data.companyId });
if (!company) {
throw new Error("No se ha encontrado la organización");
}
const canAccessPayments = await PlanSubscriptionsList.checkFeature({
userId: String(company.ownerId),
feature: PlanFeatures.PAYMENTS,
});
if (!canAccessPayments) {
throw new Error("La organización no tiene un plan que soporte el módulo de pagos");
}
this.validateProfileFields(data); this.validateProfileFields(data);
const existingProfile = await this.organizationFiscalProfiles.findOne({ const existingProfile = await this.organizationFiscalProfiles.findOne({
@@ -140,6 +158,18 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa
companyId: data.companyId, companyId: data.companyId,
sessionUser, sessionUser,
}); });
const company = await CompaniesList.companies.findOne({ _id: data.companyId });
if (company) {
const canAccessPayments = await PlanSubscriptionsList.checkFeature({
userId: String(company.ownerId),
feature: PlanFeatures.PAYMENTS,
});
if (!canAccessPayments) {
throw new Error("La organización no tiene un plan que soporte el módulo de pagos");
}
}
} else if (data.id) { } else if (data.id) {
const profile = await this.organizationFiscalProfiles.findOne(data); const profile = await this.organizationFiscalProfiles.findOne(data);
@@ -152,6 +182,18 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa
sessionUser, sessionUser,
}); });
const company = await CompaniesList.companies.findOne({ _id: String(profile.companyId) });
if (company) {
const canAccessPayments = await PlanSubscriptionsList.checkFeature({
userId: String(company.ownerId),
feature: PlanFeatures.PAYMENTS,
});
if (!canAccessPayments) {
throw new Error("La organización no tiene un plan que soporte el módulo de pagos");
}
}
return [profile]; return [profile];
} else { } else {
throw new Error("Se requiere una organización para buscar perfiles fiscales"); throw new Error("Se requiere una organización para buscar perfiles fiscales");
+10 -1
View File
@@ -63,6 +63,7 @@ import ClientsList from "../Clients/Clients";
import RepeatsList from "../Repeats/Repeats"; import RepeatsList from "../Repeats/Repeats";
import { OAuth2Client } from "google-auth-library"; import { OAuth2Client } from "google-auth-library";
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled"; import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
import dayjs from "dayjs";
const cleanEmail = (email: string): string => { const cleanEmail = (email: string): string => {
return email.toLowerCase().trim(); return email.toLowerCase().trim();
@@ -1087,10 +1088,18 @@ class UsersManager implements IUsersManager {
await this.users.update(data); await this.users.update(data);
const fecha = dayjs().format("DD/MM/YYYY");
const hora = dayjs().format("HH:mm");
await NotificationsManager.sendEmail({ await NotificationsManager.sendEmail({
email: cleanEmail(sessionUser.email), email: cleanEmail(sessionUser.email),
subject: "TurnosXpress :: Usuario actualizado", subject: "TurnosXpress :: Usuario actualizado",
message: `Hola ${sessionUser.firstName}, has actualizado tu perfil con éxito`, templateId: "6aa948e050f4bd20460f00a5",
context: {
username: sessionUser.firstName,
fecha,
hora,
},
}); });
} }
+59
View File
@@ -1,5 +1,6 @@
import { isNull } from "../../helpers/IsNull"; import { isNull } from "../../helpers/IsNull";
import CompaniesList from "../Companies/Companies"; import CompaniesList from "../Companies/Companies";
import { CompaniesAdapterMongoose } from "../Companies/Companies.Adapter.Mongoose";
import ServiceList from "../Services/Service"; import ServiceList from "../Services/Service";
import { import {
PublicServicesResults, PublicServicesResults,
@@ -15,6 +16,24 @@ import { BotEventParams, BotView } from "../WapServer/WapServer.Interface";
import WapServerList from "../WapServer/WapServer"; import WapServerList from "../WapServer/WapServer";
import { COMPANY_PUBLISHED_STATUS } from "../Companies/Companies.Interface"; import { COMPANY_PUBLISHED_STATUS } from "../Companies/Companies.Interface";
export type PublicOrganizationsParams = {
page: number;
limit: number;
};
export type PublicOrganizationListItem = {
slug: string;
name: string;
description: string;
creationDate: Date;
};
export type PublicOrganizationsResult = {
data: PublicOrganizationListItem[];
page: number;
pages: number;
};
export type HomeView = { export type HomeView = {
education: PublicServicesResults; education: PublicServicesResults;
health: PublicServicesResults; health: PublicServicesResults;
@@ -39,6 +58,7 @@ export type PublicOrganizationViewParams = {
export type PublicOrganizationView = { export type PublicOrganizationView = {
id: string; id: string;
slug: string;
headerFile: string; headerFile: string;
headerColor: string; headerColor: string;
headerFontColor: string; headerFontColor: string;
@@ -143,6 +163,7 @@ class Views {
return { return {
id: isNull<string>(company.id, ""), id: isNull<string>(company.id, ""),
slug: isNull<string>(company.slug, ""),
name: isNull<string>(company.name, ""), name: isNull<string>(company.name, ""),
description: isNull<string>(company.description, ""), description: isNull<string>(company.description, ""),
categoryId: isNull<number>(company.categoryId, 50000), categoryId: isNull<number>(company.categoryId, 50000),
@@ -249,6 +270,44 @@ class Views {
}; };
} }
public async publicOrganizations(
data: PublicOrganizationsParams
): Promise<PublicOrganizationsResult> {
const { page, limit } = data;
const filter = {
published: COMPANY_PUBLISHED_STATUS.PUBLISHED,
banned: { $ne: true },
};
const companyList = (CompaniesList.companies as CompaniesAdapterMongoose).companyList;
const count = await companyList
.countDocuments(filter)
.exec();
const skip = (page - 1) * limit;
const results = (await companyList
.find(filter)
.select("slug name description creationDate")
.sort({ creationDate: 1 })
.skip(skip)
.limit(limit)
.lean()
.exec()) as any[];
return {
data: results.map((company) => ({
slug: String(company.slug || ""),
name: String(company.name || ""),
description: String(company.description || ""),
creationDate: company.creationDate,
})),
page,
pages: Math.ceil(count / limit),
};
}
public async wapView(data: BotEventParams): Promise<BotView> { public async wapView(data: BotEventParams): Promise<BotView> {
return await WapServerList.botView(data); return await WapServerList.botView(data);
} }
@@ -38,7 +38,7 @@ export class ToggleCompanyFixedPostController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async toggleFixedCompanyPost( public async toggleFixedCompanyPost(
@Body() requestBody: FixCompanyPostParams @Body() requestBody: FixCompanyPostParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().toggleFixedPost(requestBody); await new CompaniesService().toggleFixedPost(requestBody);
@@ -62,7 +62,7 @@ export class MoveCompanyFixedPostController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async moveFixedCompanyPost( public async moveFixedCompanyPost(
@Body() requestBody: MoveCompanyFixedPostParams @Body() requestBody: MoveCompanyFixedPostParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().moveFixedPost(requestBody); await new CompaniesService().moveFixedPost(requestBody);
@@ -86,7 +86,7 @@ export class SetNotificationsStatusCompanyController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async setNotificationAuto( public async setNotificationAuto(
@Body() requestBody: SetNotificationAutoParams @Body() requestBody: SetNotificationAutoParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().setNotificationAuto(requestBody); await new CompaniesService().setNotificationAuto(requestBody);
@@ -110,7 +110,7 @@ export class SetPublishedStatusCompanyController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async setPublishedStatus( public async setPublishedStatus(
@Body() requestBody: SetOrganizationPublishedStatusParams @Body() requestBody: SetOrganizationPublishedStatusParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().setPublishedStatus(requestBody); await new CompaniesService().setPublishedStatus(requestBody);
@@ -134,7 +134,7 @@ export class CreateCompanyController extends Controller {
@SuccessResponse(200, "La compañia fue creada con exito") @SuccessResponse(200, "La compañia fue creada con exito")
@Post() @Post()
public async createCompany( public async createCompany(
@Body() requestBody: CreateCompanyParams @Body() requestBody: CreateCompanyParams,
): Promise<ICompany | ApiValidationError> { ): Promise<ICompany | ApiValidationError> {
try { try {
const company = await new CompaniesService().createCompany(requestBody); const company = await new CompaniesService().createCompany(requestBody);
@@ -155,7 +155,7 @@ export class DeleteCompanyController extends Controller {
@SuccessResponse(200, "La compañia fue modificada con exito") @SuccessResponse(200, "La compañia fue modificada con exito")
@Post() @Post()
public async deleteCompany( public async deleteCompany(
@Body() requestBody: DeleteCompanyParams @Body() requestBody: DeleteCompanyParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().deleteCompany(requestBody); await new CompaniesService().deleteCompany(requestBody);
@@ -179,7 +179,7 @@ export class UpdateCompanyController extends Controller {
@SuccessResponse(200, "La compañia fue modificada con exito") @SuccessResponse(200, "La compañia fue modificada con exito")
@Post() @Post()
public async updateCompany( public async updateCompany(
@Body() requestBody: UpdateCompanyParams @Body() requestBody: UpdateCompanyParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().updateCompany(requestBody); await new CompaniesService().updateCompany(requestBody);
@@ -202,7 +202,7 @@ export class GetCompanyByIdController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async getById( public async getById(
@Body() requestBody: OranizationsViewByIdParams @Body() requestBody: OranizationsViewByIdParams,
): Promise<MyOranizationsView | ApiValidationError> { ): Promise<MyOranizationsView | ApiValidationError> {
try { try {
const companies = await new CompaniesService().getById(requestBody); const companies = await new CompaniesService().getById(requestBody);
@@ -222,7 +222,7 @@ export class GetCompanyByUserController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async getByUserId( public async getByUserId(
@Body() requestBody: MyOranizationsViewParams @Body() requestBody: MyOranizationsViewParams,
): Promise<MyOranizationsView[] | ApiValidationError> { ): Promise<MyOranizationsView[] | ApiValidationError> {
try { try {
const companies = await new CompaniesService().getByUserId(requestBody); const companies = await new CompaniesService().getByUserId(requestBody);
@@ -242,7 +242,7 @@ export class PaginateCompanyController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async paginateUsers( public async paginateUsers(
@Body() requestBody: PaginateCompaniesParams @Body() requestBody: PaginateCompaniesParams,
): Promise<PaginateCompaniesResults | ApiValidationError> { ): Promise<PaginateCompaniesResults | ApiValidationError> {
try { try {
const users = await new CompaniesService().paginateCompanies(requestBody); const users = await new CompaniesService().paginateCompanies(requestBody);
@@ -265,7 +265,7 @@ export class UploadCompanyHeaderController extends Controller {
public async uploadHeader( public async uploadHeader(
@FormField() companyId: string, @FormField() companyId: string,
@FormField() sessionUser: string, @FormField() sessionUser: string,
@UploadedFile() file: Express.Multer.File @UploadedFile() file: Express.Multer.File,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().setCompanyHeader({ await new CompaniesService().setCompanyHeader({
@@ -295,7 +295,7 @@ export class UploadCompanyIconController extends Controller {
public async uploadIcon( public async uploadIcon(
@FormField() companyId: string, @FormField() companyId: string,
@FormField() sessionUser: string, @FormField() sessionUser: string,
@UploadedFile() file: Express.Multer.File @UploadedFile() file: Express.Multer.File,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().setCompanyIcon({ await new CompaniesService().setCompanyIcon({
@@ -325,7 +325,7 @@ export class DeleteCompanyHeaderController extends Controller {
@SuccessResponse(200, "El archivo fue cargado con exito") @SuccessResponse(200, "El archivo fue cargado con exito")
@Post() @Post()
public async deleteHeader( public async deleteHeader(
@Body() data: DeleteCompanyFileParams @Body() data: DeleteCompanyFileParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().deleteCompanyHeader(data); await new CompaniesService().deleteCompanyHeader(data);
@@ -349,7 +349,7 @@ export class DeleteCompanyIconController extends Controller {
@SuccessResponse(200, "El archivo fue cargado con exito") @SuccessResponse(200, "El archivo fue cargado con exito")
@Post() @Post()
public async deleteIcon( public async deleteIcon(
@Body() data: DeleteCompanyFileParams @Body() data: DeleteCompanyFileParams,
): Promise<ApiVoidResult | ApiValidationError> { ): Promise<ApiVoidResult | ApiValidationError> {
try { try {
await new CompaniesService().deleteCompanyIcon(data); await new CompaniesService().deleteCompanyIcon(data);
@@ -372,7 +372,7 @@ export class filterAllOrganizationsController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async filterAllOrganizations( public async filterAllOrganizations(
@Body() requestBody: PaginateCompaniesParams @Body() requestBody: PaginateCompaniesParams,
): Promise<TextObjectFilterResult[] | ApiValidationError> { ): Promise<TextObjectFilterResult[] | ApiValidationError> {
try { try {
const clients = await new CompaniesService().filterAll(requestBody); const clients = await new CompaniesService().filterAll(requestBody);
@@ -392,7 +392,7 @@ export class CheckCompanyNameController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async checkName( public async checkName(
@Body() requestBody: { name: string } @Body() requestBody: { name: string },
): Promise<{ valid: boolean } | ApiValidationError> { ): Promise<{ valid: boolean } | ApiValidationError> {
try { try {
const valid = await new CompaniesService().checkNameExists(requestBody.name); const valid = await new CompaniesService().checkNameExists(requestBody.name);
@@ -413,12 +413,12 @@ export class CheckCompanyAdminController extends Controller {
@SuccessResponse(200, "Done") @SuccessResponse(200, "Done")
@Post() @Post()
public async checkAdmin( public async checkAdmin(
@Body() requestBody: { companyId: string; sessionUser: string } @Body() requestBody: { companyId: string; sessionUser: string },
): Promise<{ isAdmin: boolean } | ApiValidationError> { ): Promise<boolean | ApiValidationError> {
try { try {
const isAdmin = await new CompaniesService().checkCompanyAdmin(requestBody); const isAdmin = await new CompaniesService().checkCompanyAdmin(requestBody);
this.setStatus(200); this.setStatus(200);
return { isAdmin }; return isAdmin;
} catch (e) { } catch (e) {
const errorOccurred: Error = e as Error; const errorOccurred: Error = e as Error;
this.setStatus(500); this.setStatus(500);
+22
View File
@@ -9,6 +9,8 @@ import {
PublicOrganizationServiceView, PublicOrganizationServiceView,
UserAppointmentViewParams, UserAppointmentViewParams,
UserAppointmentView, UserAppointmentView,
PublicOrganizationsParams,
PublicOrganizationsResult,
} from "../../Models/Views/Views"; } from "../../Models/Views/Views";
import { ViewsService } from "./Views.Service"; import { ViewsService } from "./Views.Service";
import { BotEventParams, BotView } from "../../Models/WapServer/WapServer.Interface"; import { BotEventParams, BotView } from "../../Models/WapServer/WapServer.Interface";
@@ -109,3 +111,23 @@ export class WapServerBotViewController extends Controller {
} }
} }
} }
@Route("views/public-organizations")
export class PublicOrganizationsController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async publicOrganizations(
@Body() requestBody: PublicOrganizationsParams
): Promise<PublicOrganizationsResult | ApiValidationError> {
try {
const result = await new ViewsService().publicOrganizations(requestBody);
this.setStatus(200);
return result;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
+10
View File
@@ -7,6 +7,8 @@ import views, {
PublicOrganizationServiceView, PublicOrganizationServiceView,
UserAppointmentViewParams, UserAppointmentViewParams,
UserAppointmentView, UserAppointmentView,
PublicOrganizationsParams,
PublicOrganizationsResult,
} from "../../Models/Views/Views"; } from "../../Models/Views/Views";
import { BotView, BotEventParams } from "../../Models/WapServer/WapServer.Interface"; import { BotView, BotEventParams } from "../../Models/WapServer/WapServer.Interface";
@@ -45,4 +47,12 @@ export class ViewsService {
const view = await views.wapView(data); const view = await views.wapView(data);
return view; return view;
} }
public async publicOrganizations(
data: PublicOrganizationsParams
): Promise<PublicOrganizationsResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
const result = await views.publicOrganizations(data);
return result;
}
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

+1
View File
@@ -1,6 +1,7 @@
dist/* dist/*
node_modules node_modules
.env .env
auth/
*_sessions *_sessions
*tokens *tokens
+1388 -2341
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -2,8 +2,9 @@
"name": "txbot", "name": "txbot",
"version": "1.0.0", "version": "1.0.0",
"main": "index.js", "main": "index.js",
"type": "module",
"scripts": { "scripts": {
"dev": "ts-node src/server.ts", "dev": "tsx src/server.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/server.js" "start": "node dist/server.js"
}, },
@@ -12,14 +13,14 @@
"license": "ISC", "license": "ISC",
"description": "", "description": "",
"dependencies": { "dependencies": {
"@whiskeysockets/baileys": "6.17.16", "@whiskeysockets/baileys": "7.0.0-rc14",
"express": "^5.1.0", "express": "^5.1.0",
"qrcode": "^1.5.4" "qrcode": "^1.5.4"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.2", "@types/express": "^5.0.2",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"ts-node": "^10.9.2", "tsx": "^4.20.3",
"typescript": "^5.8.3" "typescript": "^5.8.3"
} }
} }
+40 -3
View File
@@ -9,6 +9,35 @@ import { Boom } from "@hapi/boom";
let sock: ReturnType<typeof makeWASocket>; let sock: ReturnType<typeof makeWASocket>;
let latestQR: string | null = null; let latestQR: string | null = null;
let reconnecting = false;
// Last-resort safety net. An uncaught error means the process is in an
// undefined state, so we log the cause and exit; the supervisor
// (docker restart: unless-stopped) brings it back in a clean state.
// We never swallow these and keep running on corrupted state.
process.on("unhandledRejection", (reason) => {
console.error("Unhandled rejection:", reason);
process.exit(1);
});
process.on("uncaughtException", (err) => {
console.error("Uncaught exception:", err);
process.exit(1);
});
// Reconnect with the rejection handled at the source. A failed attempt must
// re-arm the loop, otherwise a single transient error leaves the bot dead.
function scheduleReconnect() {
if (reconnecting) return;
reconnecting = true;
setTimeout(() => {
startWhatsApp().catch((err) => {
console.error("Reconnect failed:", err);
reconnecting = false;
scheduleReconnect();
});
}, 1000);
}
async function startWhatsApp() { async function startWhatsApp() {
const { state, saveCreds } = await useMultiFileAuthState("./auth"); const { state, saveCreds } = await useMultiFileAuthState("./auth");
@@ -17,7 +46,6 @@ async function startWhatsApp() {
sock = makeWASocket({ sock = makeWASocket({
version, version,
auth: state, auth: state,
printQRInTerminal: false,
}); });
sock.ev.on("creds.update", saveCreds); sock.ev.on("creds.update", saveCreds);
@@ -29,10 +57,16 @@ async function startWhatsApp() {
if (connection === "close") { if (connection === "close") {
const reason = (lastDisconnect?.error as Boom)?.output?.statusCode; const reason = (lastDisconnect?.error as Boom)?.output?.statusCode;
console.log("Connection closed:", DisconnectReason[reason]); console.log("Connection closed:", DisconnectReason[reason]);
setTimeout(startWhatsApp, 1000); if (reason === DisconnectReason.loggedOut) {
console.log("Session logged out — not reconnecting. Re-pair via QR to resume.");
reconnecting = false;
return;
}
scheduleReconnect();
} else if (connection === "open") { } else if (connection === "open") {
console.log("Connected to WhatsApp"); console.log("Connected to WhatsApp");
latestQR = null; latestQR = null;
reconnecting = false;
} }
}); });
} }
@@ -85,4 +119,7 @@ async function startServer() {
app.listen(PORT, () => console.log(`API listening on port ${PORT}`)); app.listen(PORT, () => console.log(`API listening on port ${PORT}`));
} }
startServer().catch(console.error); startServer().catch((err) => {
console.error("Fatal startup error:", err);
process.exit(1);
});
+2 -1
View File
@@ -26,7 +26,8 @@
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */ /* Modules */
"module": "commonjs", /* Specify what module code is generated. */ "module": "NodeNext", /* Specify what module code is generated. Required for ESM-only Baileys 6.7.x. */
"moduleResolution": "NodeNext", /* Resolve modules the way Node.js ESM does. */
"rootDir": "src", /* Specify the root folder within your source files. */ "rootDir": "src", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
@@ -0,0 +1,19 @@
import ApiRequest from "@services/Api.Service";
import { UpdateCompanyParams } from "@models/Company.model";
import { ApiVoidResult } from "@models/Api.VoidResult.type";
export const updateOrganizationColors = async (data: UpdateCompanyParams): Promise<ApiVoidResult> => {
return ApiRequest.post<ApiVoidResult>("companies/update", data);
};
export const uploadHeaderImage = async (data: FormData): Promise<ApiVoidResult> => {
return ApiRequest.postFile<ApiVoidResult>("companies/upload-header", data);
};
export const uploadServiceImage = async (data: FormData): Promise<ApiVoidResult> => {
return ApiRequest.postFile<ApiVoidResult>("services/upload-image", data);
};
export const checkOrgAdmin = async (companyId: string, sessionUser: string): Promise<boolean> => {
return ApiRequest.post<boolean>("companies/check-admin", { companyId, sessionUser });
};
@@ -23,6 +23,7 @@ import { CLIENT_ACCOUNT_MOVEMENT_TYPES } from "@core/Models/ClientAccountMovemen
import { getPaymentIcon } from "@core/app/components/PaymentIcons/PaymentIcons"; import { getPaymentIcon } from "@core/app/components/PaymentIcons/PaymentIcons";
import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan"; import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = { type UrlData = {
oid: string; oid: string;
@@ -36,6 +37,7 @@ export default function ClientAccount() {
const alert = useAlert(); const alert = useAlert();
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const { goTo } = useNavigation(); const { goTo } = useNavigation();
@@ -68,6 +70,8 @@ export default function ClientAccount() {
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ location.add({
url: "/admin/org/profile/" + id + "/clients", url: "/admin/org/profile/" + id + "/clients",
title: "Clientes", title: "Clientes",
@@ -85,6 +89,10 @@ export default function ClientAccount() {
title: "Cuenta Corriente", title: "Cuenta Corriente",
icon: <AccountBalanceOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />, icon: <AccountBalanceOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />,
}); });
return () => {
bottomToolbar.clear();
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -127,9 +135,22 @@ export default function ClientAccount() {
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}> <SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
<div style={{ maxWidth: "800px", margin: "0 auto", paddingBottom: "100px" }}> <div style={{ maxWidth: "800px", margin: "0 auto", paddingBottom: "100px" }}>
{/* Top Status Card */} {/* Top Status Card */}
<div style={{ position: "sticky", top: "70px", zIndex: 100, display: "flex", flexDirection: "column", background: "white", paddingBottom: "20px", paddingTop: "5px", margin: "0 -10px 30px -10px", paddingLeft: "10px", paddingRight: "10px" }}> <div
style={{
position: "sticky",
top: "70px",
zIndex: 100,
display: "flex",
flexDirection: "column",
background: "white",
paddingBottom: "20px",
paddingTop: "5px",
margin: "0 -10px 30px -10px",
paddingLeft: "10px",
paddingRight: "10px",
}}
>
{view && ( {view && (
<motion.div <motion.div
layout layout
@@ -138,26 +159,35 @@ export default function ClientAccount() {
opacity: 1, opacity: 1,
y: 0, y: 0,
padding: isMinimized ? "25px 50px" : "40px 20px", padding: isMinimized ? "25px 50px" : "40px 20px",
borderRadius: isMinimized ? "15px" : "20px" borderRadius: isMinimized ? "15px" : "20px",
}} }}
style={{ style={{
display: "flex", display: "flex",
flexDirection: isMinimized ? "row" : "column", flexDirection: isMinimized ? "row" : "column",
alignItems: "center", alignItems: "center",
justifyContent: isMinimized ? "space-between" : "center", justifyContent: isMinimized ? "space-between" : "center",
background: view.amount >= 0 background:
view.amount >= 0
? "linear-gradient(135deg, #1d976c 0%, #93f9b9 100%)" ? "linear-gradient(135deg, #1d976c 0%, #93f9b9 100%)"
: "linear-gradient(135deg, #cb2d3e 0%, #ef473a 100%)", : "linear-gradient(135deg, #cb2d3e 0%, #ef473a 100%)",
color: "white", color: "white",
boxShadow: isMinimized ? "0 4px 15px rgba(0,0,0,0.3)" : "0 10px 30px rgba(0,0,0,0.15)", boxShadow: isMinimized
? "0 4px 15px rgba(0,0,0,0.3)"
: "0 10px 30px rgba(0,0,0,0.15)",
position: "relative", position: "relative",
overflow: "hidden", overflow: "hidden",
transition: "box-shadow 0.3s ease" transition: "box-shadow 0.3s ease",
}} }}
> >
<IconButton <IconButton
onClick={() => setForceMinimized(!forceMinimized)} onClick={() => setForceMinimized(!forceMinimized)}
style={{ position: "absolute", top: "10px", left: "10px", color: "rgba(255,255,255,0.7)", zIndex: 10 }} style={{
position: "absolute",
top: "10px",
left: "10px",
color: "rgba(255,255,255,0.7)",
zIndex: 10,
}}
size="small" size="small"
title={forceMinimized ? "Maximizar" : "Minimizar"} title={forceMinimized ? "Maximizar" : "Minimizar"}
> >
@@ -165,38 +195,73 @@ export default function ClientAccount() {
</IconButton> </IconButton>
{/* Decorative background circles */} {/* Decorative background circles */}
<div style={{ position: "absolute", top: "-50px", right: "-50px", width: "150px", height: "150px", borderRadius: "50%", background: "rgba(255,255,255,0.1)" }}></div> <div
<div style={{ position: "absolute", bottom: "-30px", left: "-30px", width: "100px", height: "100px", borderRadius: "50%", background: "rgba(255,255,255,0.1)" }}></div> style={{
position: "absolute",
top: "-50px",
right: "-50px",
width: "150px",
height: "150px",
borderRadius: "50%",
background: "rgba(255,255,255,0.1)",
}}
></div>
<div
style={{
position: "absolute",
bottom: "-30px",
left: "-30px",
width: "100px",
height: "100px",
borderRadius: "50%",
background: "rgba(255,255,255,0.1)",
}}
></div>
<motion.div <motion.div
animate={{ animate={{
fontSize: isMinimized ? "14px" : "16px", fontSize: isMinimized ? "14px" : "16px",
marginBottom: isMinimized ? "0px" : "0px", marginBottom: isMinimized ? "0px" : "0px",
marginLeft: isMinimized ? "30px" : "0px" marginLeft: isMinimized ? "30px" : "0px",
}}
style={{
fontWeight: "bold",
opacity: 0.9,
textTransform: "uppercase",
letterSpacing: "1px",
}} }}
style={{ fontWeight: "bold", opacity: 0.9, textTransform: "uppercase", letterSpacing: "1px" }}
> >
Saldo Actual Saldo Actual
</motion.div> </motion.div>
<motion.div <motion.div
animate={{ animate={{
fontSize: isMinimized ? "24px" : "56px", fontSize: isMinimized ? "24px" : "56px",
margin: isMinimized ? "0px" : "10px 0 30px 0" margin: isMinimized ? "0px" : "10px 0 30px 0",
}}
style={{
fontWeight: "900",
textShadow: "0 2px 10px rgba(0,0,0,0.1)",
}} }}
style={{ fontWeight: "900", textShadow: "0 2px 10px rgba(0,0,0,0.1)" }}
> >
{formatPrice(view.amount)} {formatPrice(view.amount)}
</motion.div> </motion.div>
{!isMinimized && ( {!isMinimized && (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
style={{ display: "flex", gap: "15px", flexDirection: "row", zIndex: 1 }} style={{
display: "flex",
gap: "15px",
flexDirection: "row",
zIndex: 1,
}}
>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
> >
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<IconButton <IconButton
sx={{ sx={{
backgroundColor: "rgba(255,255,255,0.2)", backgroundColor: "rgba(255,255,255,0.2)",
@@ -207,16 +272,29 @@ export default function ClientAccount() {
width: "110px", width: "110px",
height: "90px", height: "90px",
borderRadius: "15px", borderRadius: "15px",
"&:hover": { backgroundColor: "rgba(255,255,255,0.3)" } "&:hover": {
backgroundColor: "rgba(255,255,255,0.3)",
},
}} }}
onClick={() => add(CLIENT_ACCOUNT_MOVEMENT_TYPES.DEPOSIT)} onClick={() =>
add(CLIENT_ACCOUNT_MOVEMENT_TYPES.DEPOSIT)
}
> >
<DepositarIcon sx={{ fontSize: "32px", marginBottom: "5px" }} /> <DepositarIcon
<span style={{ fontSize: "14px", fontWeight: "bold" }}>Ingresar</span> sx={{ fontSize: "32px", marginBottom: "5px" }}
/>
<span
style={{ fontSize: "14px", fontWeight: "bold" }}
>
Ingresar
</span>
</IconButton> </IconButton>
</motion.div> </motion.div>
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> <motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<IconButton <IconButton
sx={{ sx={{
backgroundColor: "rgba(255,255,255,0.2)", backgroundColor: "rgba(255,255,255,0.2)",
@@ -227,16 +305,29 @@ export default function ClientAccount() {
width: "110px", width: "110px",
height: "90px", height: "90px",
borderRadius: "15px", borderRadius: "15px",
"&:hover": { backgroundColor: "rgba(255,255,255,0.3)" } "&:hover": {
backgroundColor: "rgba(255,255,255,0.3)",
},
}} }}
onClick={() => add(CLIENT_ACCOUNT_MOVEMENT_TYPES.WITHDRAWAL)} onClick={() =>
add(CLIENT_ACCOUNT_MOVEMENT_TYPES.WITHDRAWAL)
}
> >
<RetirarIcon sx={{ fontSize: "32px", marginBottom: "5px" }} /> <RetirarIcon
<span style={{ fontSize: "14px", fontWeight: "bold" }}>Retirar</span> sx={{ fontSize: "32px", marginBottom: "5px" }}
/>
<span
style={{ fontSize: "14px", fontWeight: "bold" }}
>
Retirar
</span>
</IconButton> </IconButton>
</motion.div> </motion.div>
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> <motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<IconButton <IconButton
sx={{ sx={{
backgroundColor: "white", backgroundColor: "white",
@@ -248,12 +339,26 @@ export default function ClientAccount() {
height: "90px", height: "90px",
borderRadius: "15px", borderRadius: "15px",
boxShadow: "0 4px 15px rgba(0,0,0,0.1)", boxShadow: "0 4px 15px rgba(0,0,0,0.1)",
"&:hover": { backgroundColor: "#f8f9fa" } "&:hover": { backgroundColor: "#f8f9fa" },
}} }}
onClick={() => goTo("/admin/org/" + id + "/client/" + clientId + "/account/movements")} onClick={() =>
goTo(
"/admin/org/" +
id +
"/client/" +
clientId +
"/account/movements",
)
}
> >
<MovimientosIcon sx={{ fontSize: "32px", marginBottom: "5px" }} /> <MovimientosIcon
<span style={{ fontSize: "14px", fontWeight: "bold" }}>Historial</span> sx={{ fontSize: "32px", marginBottom: "5px" }}
/>
<span
style={{ fontSize: "14px", fontWeight: "bold" }}
>
Historial
</span>
</IconButton> </IconButton>
</motion.div> </motion.div>
</motion.div> </motion.div>
@@ -263,13 +368,40 @@ export default function ClientAccount() {
</div> </div>
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}> <div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "15px", padding: "0 10px" }}> <div
<div style={{ fontSize: "20px", fontWeight: "bold", color: "var(--black-light)" }}> style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "15px",
padding: "0 10px",
}}
>
<div
style={{
fontSize: "20px",
fontWeight: "bold",
color: "var(--black-light)",
}}
>
Últimos Movimientos Últimos Movimientos
</div> </div>
<div <div
style={{ fontSize: "14px", color: "var(--wine-dark)", cursor: "pointer", fontWeight: "bold" }} style={{
onClick={() => goTo("/admin/org/" + id + "/client/" + clientId + "/account/movements")} fontSize: "14px",
color: "var(--wine-dark)",
cursor: "pointer",
fontWeight: "bold",
}}
onClick={() =>
goTo(
"/admin/org/" +
id +
"/client/" +
clientId +
"/account/movements",
)
}
> >
Ver todos Ver todos
</div> </div>
@@ -294,42 +426,79 @@ export default function ClientAccount() {
boxShadow: "0 2px 8px rgba(0,0,0,0.05)", boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
}} }}
> >
<div style={{ display: "flex", alignItems: "center", gap: "15px" }}> <div
<div style={{ style={{
display: "flex",
alignItems: "center",
gap: "15px",
}}
>
<div
style={{
width: "45px", width: "45px",
height: "45px", height: "45px",
borderRadius: "12px", borderRadius: "12px",
backgroundColor: movement.amount > 0 ? "rgba(40, 167, 69, 0.1)" : "rgba(220, 53, 69, 0.1)", backgroundColor:
movement.amount > 0
? "rgba(40, 167, 69, 0.1)"
: "rgba(220, 53, 69, 0.1)",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
color: movement.amount > 0 ? "#28a745" : "#dc3545" color:
}}> movement.amount > 0 ? "#28a745" : "#dc3545",
}}
>
{getPaymentIcon(movement.paymentMethod)} {getPaymentIcon(movement.paymentMethod)}
</div> </div>
<div> <div>
<div style={{ fontSize: "16px", fontWeight: "bold", color: "#333", marginBottom: "3px" }}> <div
style={{
fontSize: "16px",
fontWeight: "bold",
color: "#333",
marginBottom: "3px",
}}
>
{movement.description} {movement.description}
</div> </div>
<div style={{ fontSize: "13px", color: "#888" }}> <div style={{ fontSize: "13px", color: "#888" }}>
{dayjs(movement.createdAt).format("DD MMMM YYYY • HH:mm")} {dayjs(movement.createdAt).format(
"DD MMMM YYYY • HH:mm",
)}
</div> </div>
</div> </div>
</div> </div>
<div style={{ <div
style={{
fontSize: "18px", fontSize: "18px",
fontWeight: "900", fontWeight: "900",
color: movement.amount > 0 ? "#28a745" : "#dc3545", color: movement.amount > 0 ? "#28a745" : "#dc3545",
}}> }}
{movement.amount > 0 ? "+" : ""}{formatPrice(movement.amount)} >
{movement.amount > 0 ? "+" : ""}
{formatPrice(movement.amount)}
</div> </div>
</motion.div> </motion.div>
))} ))}
</div> </div>
) : ( ) : (
<div style={{ textAlign: "center", padding: "40px", backgroundColor: "#fff", borderRadius: "15px", color: "#888", border: "1px dashed #ccc" }}> <div
<AccountBalanceOutlinedIcon sx={{ fontSize: "48px", color: "#ddd", marginBottom: "10px" }} /> style={{
<div style={{ fontSize: "16px", fontWeight: "bold", color: "#666" }}> textAlign: "center",
padding: "40px",
backgroundColor: "#fff",
borderRadius: "15px",
color: "#888",
border: "1px dashed #ccc",
}}
>
<AccountBalanceOutlinedIcon
sx={{ fontSize: "48px", color: "#ddd", marginBottom: "10px" }}
/>
<div
style={{ fontSize: "16px", fontWeight: "bold", color: "#666" }}
>
Aún no hay movimientos Aún no hay movimientos
</div> </div>
<div style={{ fontSize: "14px" }}> <div style={{ fontSize: "14px" }}>
@@ -1,9 +1,6 @@
"use client"; "use client";
import { useAlert } from "@core/Store/Alert.Store"; import { useAlert } from "@core/Store/Alert.Store";
import { import { useEventHandlerStore, EVENT_TYPES } from "@core/Store/EventHandler.Store";
useEventHandlerStore,
EVENT_TYPES,
} from "@core/Store/EventHandler.Store";
import { useLocationStore } from "@core/Store/Location.Store"; import { useLocationStore } from "@core/Store/Location.Store";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -14,6 +11,7 @@ import ServiceItem from "@core/app/components/ServiceItem/ServiceItem";
import Location from "@core/app/components/Location/Location"; import Location from "@core/app/components/Location/Location";
import { useNavigation } from "@core/app/hooks/goto"; import { useNavigation } from "@core/app/hooks/goto";
import { useMenuStore } from "@core/Store/Menu.Store"; import { useMenuStore } from "@core/Store/Menu.Store";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = { type UrlData = {
oid: string; oid: string;
@@ -26,12 +24,12 @@ export default function ClientAppointments() {
const clientId = data.clientId; const clientId = data.clientId;
const alert = useAlert(); const alert = useAlert();
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const menu = useMenuStore(); const menu = useMenuStore();
const { goTo } = useNavigation(); const { goTo } = useNavigation();
const [appintments, setAppointments] = const [appintments, setAppointments] = useState<AppointmentAdminByClientView>({
useState<AppointmentAdminByClientView>({
events: [], events: [],
}); });
@@ -44,6 +42,8 @@ export default function ClientAppointments() {
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ location.add({
url: "/admin/org/profile/" + id + "/clients", url: "/admin/org/profile/" + id + "/clients",
title: "Clientes", title: "Clientes",
@@ -61,6 +61,10 @@ export default function ClientAppointments() {
title: "Turnos reservados", title: "Turnos reservados",
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />, icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
}); });
return () => {
bottomToolbar.clear();
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -89,15 +93,17 @@ export default function ClientAppointments() {
}} }}
> >
{appintments.events.length === 0 ? ( {appintments.events.length === 0 ? (
<div style={{ <div
style={{
gridColumn: "1 / -1", gridColumn: "1 / -1",
textAlign: "center", textAlign: "center",
padding: "40px 20px", padding: "40px 20px",
background: "#fff", background: "#fff",
borderRadius: "12px", borderRadius: "12px",
boxShadow: "0 4px 15px rgba(0,0,0,0.02)", boxShadow: "0 4px 15px rgba(0,0,0,0.02)",
color: "#7f8c8d" color: "#7f8c8d",
}}> }}
>
No hay turnos registrados para este cliente. No hay turnos registrados para este cliente.
</div> </div>
) : ( ) : (
@@ -113,7 +119,7 @@ export default function ClientAppointments() {
"/client/" + "/client/" +
clientId + clientId +
"/appointments/" + "/appointments/" +
event.id event.id,
); );
}} }}
/> />
@@ -15,6 +15,7 @@ import { validatePhone } from "@core/helpers/validatePhone";
import { useConfirmStore } from "@core/Store/Confirm.Store"; import { useConfirmStore } from "@core/Store/Confirm.Store";
import Switch from "@core/app/components/Switch/Switch"; import Switch from "@core/app/components/Switch/Switch";
import { Box, Typography } from "@mui/material"; import { Box, Typography } from "@mui/material";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = { type UrlData = {
oid: string; oid: string;
@@ -29,6 +30,7 @@ export default function ClientCreate() {
const confirm = useConfirmStore(); const confirm = useConfirmStore();
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const [firstName, setFirstName] = useState(""); const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState(""); const [lastName, setLastName] = useState("");
@@ -46,6 +48,8 @@ export default function ClientCreate() {
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ location.add({
url: "/admin/org/profile/" + id + "/clients", url: "/admin/org/profile/" + id + "/clients",
title: "Clientes", title: "Clientes",
@@ -63,6 +67,10 @@ export default function ClientCreate() {
title: "Editar", title: "Editar",
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />, icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
}); });
return () => {
bottomToolbar.clear();
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -92,7 +100,7 @@ export default function ClientCreate() {
confirm.show( confirm.show(
validationPhoneResult.message, validationPhoneResult.message,
() => {}, () => {},
() => {} () => {},
); );
eventHandler.setEventType(EVENT_TYPES.SLEEP); eventHandler.setEventType(EVENT_TYPES.SLEEP);
return; return;
@@ -112,7 +120,7 @@ export default function ClientCreate() {
}) })
.then(() => { .then(() => {
alert.showSuccess( alert.showSuccess(
`Información del cliente ${firstName} ${lastName} actualizada` `Información del cliente ${firstName} ${lastName} actualizada`,
); );
setTimeout(() => { setTimeout(() => {
goTo("/admin/org/" + id + "/client/" + clientId + "/profile"); goTo("/admin/org/" + id + "/client/" + clientId + "/profile");
@@ -180,7 +188,9 @@ export default function ClientCreate() {
/> />
<div style={{ marginTop: "15px" }}></div> <div style={{ marginTop: "15px" }}></div>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}> <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<Typography variant="body1" sx={{ fontWeight: "bold", paddingLeft: "10px" }}>Baneado (No puede reservar)</Typography> <Typography variant="body1" sx={{ fontWeight: "bold", paddingLeft: "10px" }}>
Baneado (No puede reservar)
</Typography>
<Switch <Switch
checked={banned} checked={banned}
onChange={(checked) => { onChange={(checked) => {
@@ -18,6 +18,7 @@ import { ClientMergeParams, OrganizationClientView } from "@core/Models/Clients.
import Button from "@core/app/components/Button/Button"; import Button from "@core/app/components/Button/Button";
import MaterialAlert from "@mui/material/Alert"; import MaterialAlert from "@mui/material/Alert";
import { useConfirmStore } from "@core/Store/Confirm.Store"; import { useConfirmStore } from "@core/Store/Confirm.Store";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = { type UrlData = {
oid: string; oid: string;
@@ -32,6 +33,7 @@ export default function ClientCreate() {
const confirm = useConfirmStore(); const confirm = useConfirmStore();
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const { goTo } = useNavigation(); const { goTo } = useNavigation();
@@ -53,7 +55,7 @@ export default function ClientCreate() {
}); });
const [clientMergeFilterData, setClientMergeFilterData] = useState<TextObjectFilterResult[]>( const [clientMergeFilterData, setClientMergeFilterData] = useState<TextObjectFilterResult[]>(
[] [],
); );
const [clientMergeId, setClientMergeId] = useState<string>(""); const [clientMergeId, setClientMergeId] = useState<string>("");
@@ -94,7 +96,7 @@ export default function ClientCreate() {
() => { () => {
mergeClients(); mergeClients();
}, },
() => {} () => {},
); );
}; };
@@ -105,6 +107,8 @@ export default function ClientCreate() {
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ location.add({
url: "/admin/org/profile/" + id + "/clients", url: "/admin/org/profile/" + id + "/clients",
title: "Clientes", title: "Clientes",
@@ -122,6 +126,10 @@ export default function ClientCreate() {
title: "Fusionar clientes", title: "Fusionar clientes",
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />, icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
}); });
return () => {
bottomToolbar.clear();
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -11,6 +11,7 @@ import ClientsIcon from "@mui/icons-material/AccountBoxOutlined";
import NotificationsIcon from "@mui/icons-material/NotificationsOutlined"; import NotificationsIcon from "@mui/icons-material/NotificationsOutlined";
import MaterialAlert from "@mui/material/Alert"; import MaterialAlert from "@mui/material/Alert";
import styles from "./NotificationsPage.module.css"; import styles from "./NotificationsPage.module.css";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = { type UrlData = {
oid: string; oid: string;
@@ -20,7 +21,7 @@ type UrlData = {
const CHANNELS: { key: NotificationChannel; label: string }[] = [ const CHANNELS: { key: NotificationChannel; label: string }[] = [
{ key: "whatsapp", label: "WhatsApp" }, { key: "whatsapp", label: "WhatsApp" },
{ key: "email", label: "Email" }, { key: "email", label: "Email" },
{ key: "system", label: "Sistema" } { key: "system", label: "Sistema" },
]; ];
type ChannelPreference = "default" | "preferred" | "muted"; type ChannelPreference = "default" | "preferred" | "muted";
@@ -30,34 +31,41 @@ export default function NotificationsPage() {
const oid = data.oid; const oid = data.oid;
const clientId = data.clientId; const clientId = data.clientId;
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const store = useNotificationPreferencesStore(); const store = useNotificationPreferencesStore();
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ location.add({
url: "/admin/org", url: "/admin/org",
title: "Organizaciones", title: "Organizaciones",
icon: null icon: null,
}); });
location.add({ location.add({
url: "/admin/org/profile/" + oid, url: "/admin/org/profile/" + oid,
title: "Organización", title: "Organización",
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" /> icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />,
}); });
location.add({ location.add({
url: "/admin/org/" + oid + "/client/" + clientId + "/profile", url: "/admin/org/" + oid + "/client/" + clientId + "/profile",
title: "Cliente", title: "Cliente",
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} /> icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
}); });
location.add({ location.add({
url: "", url: "",
title: "Notificaciones", title: "Notificaciones",
icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} /> icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
}); });
return () => {
bottomToolbar.clear();
};
}, [oid, clientId]); }, [oid, clientId]);
const getChannelPreference = (channel: NotificationChannel): ChannelPreference => { const getChannelPreference = (channel: NotificationChannel): ChannelPreference => {
@@ -81,7 +89,7 @@ export default function NotificationsPage() {
mutedChannels: mutedChannels:
preference === "muted" preference === "muted"
? [...store.formData.mutedChannels.filter((c) => c !== channel), channel] ? [...store.formData.mutedChannels.filter((c) => c !== channel), channel]
: store.formData.mutedChannels.filter((c) => c !== channel) : store.formData.mutedChannels.filter((c) => c !== channel),
}); });
}; };
@@ -91,7 +99,9 @@ export default function NotificationsPage() {
<div className={styles.container}> <div className={styles.container}>
<MaterialAlert severity="info" sx={{ marginBottom: "0" }}> <MaterialAlert severity="info" sx={{ marginBottom: "0" }}>
<b>Preferencias del cliente:</b> Configure cómo esta organización debe comunicarse con este cliente. Si deja un canal en estándar, se usará la política general de la organización. <b>Preferencias del cliente:</b> Configure cómo esta organización debe
comunicarse con este cliente. Si deja un canal en estándar, se usará la política
general de la organización.
</MaterialAlert> </MaterialAlert>
<div className={styles.channels}> <div className={styles.channels}>
@@ -102,24 +112,32 @@ export default function NotificationsPage() {
<div> <div>
<div className={styles.channelTitle}>{channel.label}</div> <div className={styles.channelTitle}>{channel.label}</div>
<div className={styles.channelDescription}> <div className={styles.channelDescription}>
{selectedPreference === "preferred" && "Priorizar este canal para el cliente."} {selectedPreference === "preferred" &&
{selectedPreference === "muted" && "No enviar notificaciones por este canal."} "Priorizar este canal para el cliente."}
{selectedPreference === "default" && "Usar la configuración general de la organización."} {selectedPreference === "muted" &&
"No enviar notificaciones por este canal."}
{selectedPreference === "default" &&
"Usar la configuración general de la organización."}
</div> </div>
</div> </div>
<div className={styles.options}> <div className={styles.options}>
{[ {[
{ key: "default" as ChannelPreference, label: "Estándar" }, { key: "default" as ChannelPreference, label: "Estándar" },
{ key: "preferred" as ChannelPreference, label: "Preferido" }, {
{ key: "muted" as ChannelPreference, label: "Silenciado" } key: "preferred" as ChannelPreference,
label: "Preferido",
},
{ key: "muted" as ChannelPreference, label: "Silenciado" },
].map((option) => { ].map((option) => {
const isSelected = selectedPreference === option.key; const isSelected = selectedPreference === option.key;
return ( return (
<button <button
key={option.key} key={option.key}
type="button" type="button"
onClick={() => selectPreference(channel.key, option.key)} onClick={() =>
selectPreference(channel.key, option.key)
}
className={`${styles.optionButton} ${isSelected ? styles.optionButtonActive : ""}`} className={`${styles.optionButton} ${isSelected ? styles.optionButtonActive : ""}`}
> >
{option.label} {option.label}
@@ -8,6 +8,7 @@ import { useLocationStore } from "@store/Location.Store";
import { useNavigation } from "@hooks/goto"; import { useNavigation } from "@hooks/goto";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = { type UrlData = {
oid: string; oid: string;
@@ -19,11 +20,14 @@ export default function ClientProfile() {
const clientId = data.clientId; const clientId = data.clientId;
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const { goTo } = useNavigation(); const { goTo } = useNavigation();
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ location.add({
url: "/admin/org", url: "/admin/org",
title: "Mis organizaciones", title: "Mis organizaciones",
@@ -33,9 +37,7 @@ export default function ClientProfile() {
location.add({ location.add({
url: "/admin/org/profile/" + oid, url: "/admin/org/profile/" + oid,
title: "Organizacion", title: "Organizacion",
icon: ( icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />,
<StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />
),
}); });
location.add({ location.add({
@@ -43,6 +45,10 @@ export default function ClientProfile() {
title: "Perfil del cliente", title: "Perfil del cliente",
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />, icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
}); });
return () => {
bottomToolbar.clear();
};
}, []); }, []);
return ( return (
@@ -59,79 +65,35 @@ export default function ClientProfile() {
<ButtonOption <ButtonOption
text="Editar" text="Editar"
icon="/profile-icon.svg" icon="/profile-icon.svg"
onClick={() => onClick={() => goTo("/admin/org/" + oid + "/client/" + clientId + "/edit")}
goTo(
"/admin/org/" +
oid +
"/client/" +
clientId +
"/edit"
)
}
/> />
<ButtonOption <ButtonOption
text="Turnos" text="Turnos"
icon="/profile-icon.svg" icon="/profile-icon.svg"
onClick={() => onClick={() =>
goTo( goTo("/admin/org/" + oid + "/client/" + clientId + "/appointments")
"/admin/org/" +
oid +
"/client/" +
clientId +
"/appointments"
)
} }
/> />
<ButtonOption <ButtonOption
text="Fusionar Duplicados" text="Fusionar Duplicados"
icon="/client-merge-icon.svg" icon="/client-merge-icon.svg"
onClick={() => onClick={() => goTo("/admin/org/" + oid + "/client/" + clientId + "/merge")}
goTo(
"/admin/org/" +
oid +
"/client/" +
clientId +
"/merge"
)
}
/> />
<ButtonOption <ButtonOption
text="Repeticiones" text="Repeticiones"
icon="/repeat-icon.svg" icon="/repeat-icon.svg"
onClick={() => onClick={() => goTo("/admin/org/" + oid + "/client/" + clientId + "/repeats")}
goTo(
"/admin/org/" +
oid +
"/client/" +
clientId +
"/repeats"
)
}
/> />
<ButtonOption <ButtonOption
text="Cuenta" text="Cuenta"
icon="/account-icon.svg" icon="/account-icon.svg"
onClick={() => onClick={() => goTo("/admin/org/" + oid + "/client/" + clientId + "/account")}
goTo(
"/admin/org/" +
oid +
"/client/" +
clientId +
"/account"
)
}
/> />
<ButtonOption <ButtonOption
text="Notificaciones" text="Notificaciones"
icon="/notifications-icon.svg" icon="/notifications-icon.svg"
onClick={() => onClick={() =>
goTo( goTo("/admin/org/" + oid + "/client/" + clientId + "/notifications")
"/admin/org/" +
oid +
"/client/" +
clientId +
"/notifications"
)
} }
/> />
</div> </div>
@@ -17,6 +17,7 @@ import { deleteRepeat, getRepeatsByClient } from "../Client.Service";
import { DeleteRepeatsParams, FindRepeatsParams, RepeatView } from "@core/Models/Repeats.model"; import { DeleteRepeatsParams, FindRepeatsParams, RepeatView } from "@core/Models/Repeats.model";
import style from "./repeats.module.css"; import style from "./repeats.module.css";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = { type UrlData = {
oid: string; oid: string;
@@ -31,6 +32,7 @@ export default function ClientCreate() {
const confirm = useConfirmStore(); const confirm = useConfirmStore();
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const [repeats, setRepeats] = useState<RepeatView[]>([]); const [repeats, setRepeats] = useState<RepeatView[]>([]);
@@ -52,6 +54,8 @@ export default function ClientCreate() {
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ location.add({
url: "/admin/org/profile/" + id + "/clients", url: "/admin/org/profile/" + id + "/clients",
title: "Clientes", title: "Clientes",
@@ -69,6 +73,10 @@ export default function ClientCreate() {
title: "Repeticiones", title: "Repeticiones",
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />, icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
}); });
return () => {
bottomToolbar.clear();
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -162,7 +170,7 @@ export default function ClientCreate() {
() => { () => {
handleDelete(repeat.id); handleDelete(repeat.id);
}, },
() => {} () => {},
); );
}} }}
> >
@@ -27,6 +27,7 @@ import TextObjectFilter from "@components/TextObjectFilter/TextObjectFilter";
import SelectLocationMap, { import SelectLocationMap, {
LocationCoordinates, LocationCoordinates,
} from "@core/app/components/SelectLocationMap/SelectLocationMap"; } from "@core/app/components/SelectLocationMap/SelectLocationMap";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = { type UrlData = {
id: string; id: string;
@@ -36,6 +37,7 @@ export default function OrganizationEdit() {
const data = useParams<UrlData>(); const data = useParams<UrlData>();
const id = data.id; const id = data.id;
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
const alert = useAlert(); const alert = useAlert();
@@ -140,7 +142,7 @@ export default function OrganizationEdit() {
}); });
const [localidadesFilterData, setLocalidadesFilterData] = useState<TextObjectFilterResult[]>( const [localidadesFilterData, setLocalidadesFilterData] = useState<TextObjectFilterResult[]>(
[] [],
); );
const [selectedLocalidad, setSelectedLocalidad] = useState<string>(""); const [selectedLocalidad, setSelectedLocalidad] = useState<string>("");
@@ -188,6 +190,8 @@ export default function OrganizationEdit() {
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ location.add({
url: "/admin/org", url: "/admin/org",
title: "Organizaciones", title: "Organizaciones",
@@ -205,6 +209,10 @@ export default function OrganizationEdit() {
title: "Ubicación", title: "Ubicación",
icon: <OrgConfigIcon sx={{ fontSize: "32px", mr: "10px" }} />, icon: <OrgConfigIcon sx={{ fontSize: "32px", mr: "10px" }} />,
}); });
return () => {
bottomToolbar.clear();
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -14,6 +14,7 @@ import Button from "@core/app/components/Button/Button";
import { useConfirmStore } from "@core/Store/Confirm.Store"; import { useConfirmStore } from "@core/Store/Confirm.Store";
import { deleteOrganization } from "./advanced.Service"; import { deleteOrganization } from "./advanced.Service";
import { useNavigation } from "@core/app/hooks/goto"; import { useNavigation } from "@core/app/hooks/goto";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = { type UrlData = {
id: string; id: string;
@@ -23,6 +24,7 @@ export default function OrganizationAdvancedEdit() {
const data = useParams<UrlData>(); const data = useParams<UrlData>();
const id = data.id; const id = data.id;
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
const alert = useAlert(); const alert = useAlert();
@@ -33,6 +35,8 @@ export default function OrganizationAdvancedEdit() {
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ location.add({
url: "/admin/org", url: "/admin/org",
title: "Organizaciones", title: "Organizaciones",
@@ -50,6 +54,10 @@ export default function OrganizationAdvancedEdit() {
title: "Avanzado", title: "Avanzado",
icon: <OrgConfigIcon sx={{ fontSize: "32px", mr: "10px" }} />, icon: <OrgConfigIcon sx={{ fontSize: "32px", mr: "10px" }} />,
}); });
return () => {
bottomToolbar.clear();
};
}, []); }, []);
const eliminarHandler = () => { const eliminarHandler = () => {
@@ -73,7 +81,7 @@ export default function OrganizationAdvancedEdit() {
}, },
() => { () => {
eventHandler.setEventType(EVENT_TYPES.SLEEP); eventHandler.setEventType(EVENT_TYPES.SLEEP);
} },
); );
} else { } else {
alert.showError("La confirmación no es correcta"); alert.showError("La confirmación no es correcta");
@@ -23,6 +23,7 @@ import { useNavigation } from "@hooks/goto";
import Switch from "@mui/material/Switch"; import Switch from "@mui/material/Switch";
import { ThemeProvider } from "@emotion/react"; import { ThemeProvider } from "@emotion/react";
import turnosXpressTheme from "@core/app/theme/turnosXpress"; import turnosXpressTheme from "@core/app/theme/turnosXpress";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
dayjs.locale("es"); dayjs.locale("es");
@@ -40,6 +41,7 @@ export default function ClientCreate() {
const { goTo } = useNavigation(); const { goTo } = useNavigation();
const location = useLocationStore(); const location = useLocationStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const bottomToolbar = useBottomToolbarStore();
const [validation, setValidation] = useState<boolean>(true); const [validation, setValidation] = useState<boolean>(true);
const [notification, setNotification] = useState<boolean>(true); const [notification, setNotification] = useState<boolean>(true);
@@ -148,6 +150,8 @@ export default function ClientCreate() {
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ location.add({
url: "/admin/org/profile/" + id, url: "/admin/org/profile/" + id,
title: "Origanizacion", title: "Origanizacion",
@@ -169,6 +173,10 @@ export default function ClientCreate() {
title: "Crear nuevo turno", title: "Crear nuevo turno",
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />, icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
}); });
return () => {
bottomToolbar.clear();
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -31,7 +31,6 @@ import filterObjects from "@components/TextObjectFilter/Filter.Service";
import dayjs, { ManipulateType } from "dayjs"; import dayjs, { ManipulateType } from "dayjs";
import "dayjs/locale/es"; import "dayjs/locale/es";
import Filterable from "@core/app/components/Filterable/Filterable";
import { useFilterStore } from "@core/Store/Filter.Store"; import { useFilterStore } from "@core/Store/Filter.Store";
import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model"; import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model";
import { FindEmployeesParams } from "@core/Models/Collaborators.model"; import { FindEmployeesParams } from "@core/Models/Collaborators.model";
@@ -496,8 +495,7 @@ export default function OrganizationProfile() {
return ( return (
<> <>
<Addable onClick={handleAdd}> <Addable onClick={handleAdd}>
<Filterable /> <Location showFilter />
<Location />
<div className={style.appointmentsTitle}> <div className={style.appointmentsTitle}>
<h4>{dayjs(currentDate).format("dddd DD [de] MMMM, YYYY")}</h4> <h4>{dayjs(currentDate).format("dddd DD [de] MMMM, YYYY")}</h4>
</div> </div>
@@ -614,7 +612,27 @@ export default function OrganizationProfile() {
</div> </div>
<div <div
className={style.buttonPaste} className={style.buttonPaste}
onClick={() => timePicker.show((newDay) => handleMoveAppointment(newDay))} onClick={() => {
// Seed the controlled picker value with the copied
// appointment's current time. MUI only fires onAccept when the
// accepted value differs from its internal lastCommittedValue;
// without this, picking the same time twice in a row silently
// does nothing.
if (copiedAppointment) {
const currentHour = Math.floor(copiedAppointment.startTime);
const currentMinute = Math.round(
(copiedAppointment.startTime - currentHour) * 60
);
timePicker.setValue(
dayjs()
.hour(currentHour)
.minute(currentMinute)
.second(0)
.millisecond(0)
);
}
timePicker.show((newDay) => handleMoveAppointment(newDay));
}}
> >
<ContentPasteIcon /> <ContentPasteIcon />
</div> </div>
@@ -12,6 +12,7 @@ import { UserAssistanceInfo } from "@core/Models/User.model";
import Link from "next/link"; import Link from "next/link";
import { getAssistanceUserInfo } from "./Assistance.Service"; import { getAssistanceUserInfo } from "./Assistance.Service";
import AssistanceIcon from "@mui/icons-material/HeadsetMicOutlined"; import AssistanceIcon from "@mui/icons-material/HeadsetMicOutlined";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = { type UrlData = {
id: string; id: string;
@@ -23,6 +24,7 @@ export default function CollaboratorsAdd() {
const alert = useAlert(); const alert = useAlert();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
const [user, setUser] = useState<UserAssistanceInfo | null>(null); const [user, setUser] = useState<UserAssistanceInfo | null>(null);
const [findEmail, setFindEmail] = useState<string>(""); const [findEmail, setFindEmail] = useState<string>("");
@@ -34,6 +36,8 @@ export default function CollaboratorsAdd() {
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ location.add({
url: "/admin/org/profile/" + id, url: "/admin/org/profile/" + id,
title: "Organizacion", title: "Organizacion",
@@ -45,6 +49,10 @@ export default function CollaboratorsAdd() {
title: "Asistencia", title: "Asistencia",
icon: <AssistanceIcon sx={{ fontSize: "32px", mr: "10px" }} />, icon: <AssistanceIcon sx={{ fontSize: "32px", mr: "10px" }} />,
}); });
return () => {
bottomToolbar.clear();
};
}, []); }, []);
const findUser = async () => { const findUser = async () => {
@@ -1,6 +1,5 @@
.movementsPage { .movementsPage {
padding: 16px; padding: 16px;
padding-top: 200px;
padding-bottom: 32px; padding-bottom: 32px;
} }
@@ -189,12 +188,6 @@
text-align: center; text-align: center;
} }
.paginationWrapper {
display: flex;
justify-content: center;
margin-top: 18px;
}
.invoiceModalBackdrop { .invoiceModalBackdrop {
align-items: flex-end; align-items: flex-end;
background: rgba(15, 23, 42, 0.48); background: rgba(15, 23, 42, 0.48);
@@ -21,7 +21,6 @@ import {
} from "@core/Models/CashFlow.model"; } from "@core/Models/CashFlow.model";
import { cashFlowPaginate, createInvoiceFromCashMovement, findInvoices, findOrganizationFiscalProfiles, Invoice, OrganizationFiscalProfile } from "../Cash.Service"; import { cashFlowPaginate, createInvoiceFromCashMovement, findInvoices, findOrganizationFiscalProfiles, Invoice, OrganizationFiscalProfile } from "../Cash.Service";
import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan"; import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan";
import Filterable from "@core/app/components/Filterable/Filterable";
import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model"; import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model";
import { TextObjectModalContent } from "@core/Store/TextObjectModal.Store"; import { TextObjectModalContent } from "@core/Store/TextObjectModal.Store";
import TextObjectFilterItem from "@core/app/components/TextObjectFilterItem/TextObjectFilterItem"; import TextObjectFilterItem from "@core/app/components/TextObjectFilterItem/TextObjectFilterItem";
@@ -39,8 +38,9 @@ import {
CLIENT_ACCOUNT_MOVEMENT_TYPES, CLIENT_ACCOUNT_MOVEMENT_TYPES,
CLIENT_ACCOUNT_PAYMENT_METHODS, CLIENT_ACCOUNT_PAYMENT_METHODS,
} from "@core/Models/ClientAccountMovements.model"; } from "@core/Models/ClientAccountMovements.model";
import PaginationControls from "@components/PaginationControls/PaginationControls";
import { useAlert } from "@core/Store/Alert.Store"; import { useAlert } from "@core/Store/Alert.Store";
import { useBottomToolbarStore } from "@store/BottomToolbar.Store";
import PaginationControls from "@components/PaginationControls/PaginationControls";
import style from "./page.module.css"; import style from "./page.module.css";
type UrlData = { type UrlData = {
@@ -437,6 +437,7 @@ export default function CashFlowMovementsList() {
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const alert = useAlert(); const alert = useAlert();
const filter = useFilterStore(); const filter = useFilterStore();
const bottomToolbar = useBottomToolbarStore();
const [clientFilterData, setClientFilterData] = useState<TextObjectFilterResult[]>([]); const [clientFilterData, setClientFilterData] = useState<TextObjectFilterResult[]>([]);
const [clientId, setClientId] = useState<string>(""); const [clientId, setClientId] = useState<string>("");
@@ -600,6 +601,28 @@ export default function CashFlowMovementsList() {
}); });
}, [SessionInfo.userId]); }, [SessionInfo.userId]);
useEffect(() => {
if (movements && movements.pages > 1) {
bottomToolbar.set(
<div style={{ display: "flex", justifyContent: "center", width: "100%" }}>
<PaginationControls
current={movements.page}
size={pageSize}
max={movements.pages}
onChange={loadMovements}
inheritColor
/>
</div>
);
} else {
bottomToolbar.set(<></>);
}
return () => {
bottomToolbar.clear();
};
}, [movements?.page, movements?.pages, pageSize]);
const renderMovementMeta = (movement: ICashFlow) => { const renderMovementMeta = (movement: ICashFlow) => {
const details = [ const details = [
movement.paymentMethod ? getPaymentMethodName(movement.paymentMethod) : null, movement.paymentMethod ? getPaymentMethodName(movement.paymentMethod) : null,
@@ -862,8 +885,7 @@ export default function CashFlowMovementsList() {
return ( return (
<> <>
<Location /> <Location showFilter />
<Filterable />
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}> <SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
<> <>
@@ -948,20 +970,7 @@ export default function CashFlowMovementsList() {
); );
})} })}
</section> </section>
{movements && movements.pages > 1 && (
<div className={style.paginationWrapper}>
<PaginationControls
current={movements.page}
size={pageSize}
max={movements.pages}
onChange={loadMovements}
/>
</div> </div>
)}
</div>
<div style={{ marginTop: "15px" }}></div>
{invoiceDraft && ( {invoiceDraft && (
<div className={style.invoiceModalBackdrop} role="presentation"> <div className={style.invoiceModalBackdrop} role="presentation">
@@ -15,6 +15,7 @@ import { PaginateCashFlowParams, PaginateCashFlowResults } from "@core/Models/Ca
import { useSessionStore } from "@core/Store/Sesion.Store"; import { useSessionStore } from "@core/Store/Sesion.Store";
import { ArcaWsfeLastVoucherResult, cashFlowPaginate, getArcaWsfeLastVoucher } from "./Cash.Service"; import { ArcaWsfeLastVoucherResult, cashFlowPaginate, getArcaWsfeLastVoucher } from "./Cash.Service";
import { useAlert } from "@core/Store/Alert.Store"; import { useAlert } from "@core/Store/Alert.Store";
import { useBottomToolbarStore } from "@store/BottomToolbar.Store";
import { IconButton } from "@mui/material"; import { IconButton } from "@mui/material";
import RetirarIcon from "@mui/icons-material/CallMadeOutlined"; import RetirarIcon from "@mui/icons-material/CallMadeOutlined";
import DepositarIcon from "@mui/icons-material/SouthWestOutlined"; import DepositarIcon from "@mui/icons-material/SouthWestOutlined";
@@ -38,6 +39,7 @@ export default function OrganizationCashFlowDetail() {
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
const { goTo } = useNavigation(); const { goTo } = useNavigation();
const bottomToolbar = useBottomToolbarStore();
const [dateFrom, setDateFrom] = useState<Dayjs>(dayjs().startOf("day")); const [dateFrom, setDateFrom] = useState<Dayjs>(dayjs().startOf("day"));
const [dateTo, setDateTo] = useState<Dayjs>(dayjs().endOf("day")); const [dateTo, setDateTo] = useState<Dayjs>(dayjs().endOf("day"));
@@ -49,6 +51,11 @@ export default function OrganizationCashFlowDetail() {
useEffect(() => { useEffect(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP); eventHandler.setEventType(EVENT_TYPES.SLEEP);
bottomToolbar.set(<></>);
return () => {
bottomToolbar.clear();
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -149,7 +149,9 @@ export default function OrganizationCashFlowResume() {
cashFlowCalculateBalance({ cashFlowCalculateBalance({
companyId: id, companyId: id,
year: year, year: year,
month: month, // TextMonth maneja meses 1-indexados (1 = enero); el storage de balances es
// 0-indexado (dayjs.month()), así que convertimos antes de enviar.
month: month - 1,
sessionUser: SessionInfo.userId, sessionUser: SessionInfo.userId,
}) })
.then(() => { .then(() => {
@@ -12,6 +12,7 @@ import { useNavigation } from "@hooks/goto";
import TextPhone from "@core/app/components/TextPhone/TextPhone"; import TextPhone from "@core/app/components/TextPhone/TextPhone";
import MaterialAlert from "@mui/material/Alert"; import MaterialAlert from "@mui/material/Alert";
import { createClient } from "../Clients.Service"; import { createClient } from "../Clients.Service";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = { type UrlData = {
id: string; id: string;
@@ -23,6 +24,7 @@ export default function ClientCreate() {
const alert = useAlert(); const alert = useAlert();
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const [firstName, setFirstName] = useState(""); const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState(""); const [lastName, setLastName] = useState("");
@@ -39,6 +41,8 @@ export default function ClientCreate() {
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ location.add({
url: "/admin/org/profile/" + id, url: "/admin/org/profile/" + id,
title: "Origanizacion", title: "Origanizacion",
@@ -56,6 +60,10 @@ export default function ClientCreate() {
title: "Crear", title: "Crear",
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />, icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
}); });
return () => {
bottomToolbar.clear();
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -72,7 +80,7 @@ export default function ClientCreate() {
}) })
.then((newClient) => { .then((newClient) => {
alert.showSuccess( alert.showSuccess(
`El cliente ${newClient.firstName} ${newClient.lastName} ha sido creado` `El cliente ${newClient.firstName} ${newClient.lastName} ha sido creado`,
); );
setTimeout(() => { setTimeout(() => {
goTo("/admin/org/profile/" + id + "/clients"); goTo("/admin/org/profile/" + id + "/clients");
@@ -2,7 +2,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: absolute; position: absolute;
top: 60px; /* Below Location/Filterable */ top: 60px; /* Below Location */
left: 0; left: 0;
right: 0; right: 0;
bottom: 10px; bottom: 10px;
@@ -262,26 +262,6 @@
} }
} }
.paginationWrapper {
padding: 16px 30px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(12px);
border-top: 1px solid rgba(226, 232, 240, 0.6);
display: flex;
justify-content: center;
z-index: 10;
border-bottom-left-radius: 24px;
border-bottom-right-radius: 24px;
}
@media (max-width: 768px) {
.paginationWrapper {
padding: 12px 16px;
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
}
}
.emptyState { .emptyState {
grid-column: 1 / -1; grid-column: 1 / -1;
text-align: center; text-align: center;
@@ -10,6 +10,7 @@ import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined";
import PhoneOutlinedIcon from "@mui/icons-material/PhoneOutlined"; import PhoneOutlinedIcon from "@mui/icons-material/PhoneOutlined";
import Avatar from "@components/Avatar/Avatar"; import Avatar from "@components/Avatar/Avatar";
import PaginationControls from "@components/PaginationControls/PaginationControls"; import PaginationControls from "@components/PaginationControls/PaginationControls";
import { useBottomToolbarStore } from "@store/BottomToolbar.Store";
import pageStyle from "./page.module.css"; import pageStyle from "./page.module.css";
import { findClientsByOrganization } from "./Clients.Service"; import { findClientsByOrganization } from "./Clients.Service";
import { import {
@@ -20,7 +21,6 @@ import {
} from "@core/Models/Clients.model"; } from "@core/Models/Clients.model";
import { useNavigation } from "@hooks/goto"; import { useNavigation } from "@hooks/goto";
import { FilterData, useFilterStore } from "@core/Store/Filter.Store"; import { FilterData, useFilterStore } from "@core/Store/Filter.Store";
import Filterable from "@core/app/components/Filterable/Filterable";
import Textbox from "@core/app/components/Textbox/Textbox"; import Textbox from "@core/app/components/Textbox/Textbox";
type UrlData = { type UrlData = {
@@ -107,6 +107,7 @@ export default function ClientsGrid() {
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const filter = useFilterStore(); const filter = useFilterStore();
const { goTo } = useNavigation(); const { goTo } = useNavigation();
const bottomToolbar = useBottomToolbarStore();
const [pageSize] = useState<number>( const [pageSize] = useState<number>(
parseInt( parseInt(
@@ -173,14 +174,35 @@ export default function ClientsGrid() {
}); });
}; };
useEffect(() => {
if (clients && clients.pages > 1) {
bottomToolbar.set(
<div style={{ display: "flex", justifyContent: "center", width: "100%" }}>
<PaginationControls
current={clients.page}
size={pageSize}
max={clients.pages}
onChange={(page, size) => loadClients(page, size)}
inheritColor
/>
</div>
);
} else {
bottomToolbar.set(<></>);
}
return () => {
bottomToolbar.clear();
};
}, [clients?.page, clients?.pages, pageSize]);
const handleClientClick = (client: ClientRow) => { const handleClientClick = (client: ClientRow) => {
goTo("/admin/org/" + oid + "/client/" + client.id + "/profile"); goTo("/admin/org/" + oid + "/client/" + client.id + "/profile");
}; };
return ( return (
<> <>
<Filterable /> <Location showFilter />
<Location />
<div className={pageStyle.pageContainer}> <div className={pageStyle.pageContainer}>
{clients?.data && clients.data.length > 0 ? ( {clients?.data && clients.data.length > 0 ? (
<div className={pageStyle.clientsGrid}> <div className={pageStyle.clientsGrid}>
@@ -229,17 +251,6 @@ export default function ClientsGrid() {
No se encontraron clientes con los filtros aplicados. No se encontraron clientes con los filtros aplicados.
</div> </div>
)} )}
{clients && clients.pages > 1 && (
<div className={pageStyle.paginationWrapper}>
<PaginationControls
current={clients.page}
size={pageSize}
max={clients.pages}
onChange={(page, size) => loadClients(page, size)}
/>
</div>
)}
</div> </div>
</> </>
); );
@@ -10,6 +10,7 @@ import { CompanyEmployeesView } from "@models/Collaborators.model";
import { loadCollaboratorsByCompanyId } from "./Collaborators.Service"; import { loadCollaboratorsByCompanyId } from "./Collaborators.Service";
import { useAlert } from "@store/Alert.Store"; import { useAlert } from "@store/Alert.Store";
import CollaboratorItem from "@core/app/components/CollaboratorItem/CollaboratorItem"; import CollaboratorItem from "@core/app/components/CollaboratorItem/CollaboratorItem";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = { type UrlData = {
id: string; id: string;
@@ -19,6 +20,7 @@ export default function OrganizationProfile() {
const data = useParams<UrlData>(); const data = useParams<UrlData>();
const id = data.id; const id = data.id;
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const alert = useAlert(); const alert = useAlert();
const { goTo } = useNavigation(); const { goTo } = useNavigation();
@@ -31,6 +33,8 @@ export default function OrganizationProfile() {
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ location.add({
url: "/admin/org", url: "/admin/org",
title: "Mis organizaciones", title: "Mis organizaciones",
@@ -60,6 +64,10 @@ export default function OrganizationProfile() {
.finally(() => { .finally(() => {
eventHandler.setEventType(EVENT_TYPES.NEED_ADD); eventHandler.setEventType(EVENT_TYPES.NEED_ADD);
}); });
return () => {
bottomToolbar.clear();
};
}, []); }, []);
const handleCollaboratorClick = (collaboratorId: string) => { const handleCollaboratorClick = (collaboratorId: string) => {
@@ -29,13 +29,13 @@ import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import { useConfirmStore } from "@core/Store/Confirm.Store"; import { useConfirmStore } from "@core/Store/Confirm.Store";
import { useAlert } from "@core/Store/Alert.Store"; import { useAlert } from "@core/Store/Alert.Store";
import Textbox from "@components/Textbox/Textbox"; import Textbox from "@components/Textbox/Textbox";
import Filterable from "@core/app/components/Filterable/Filterable";
import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model"; import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model";
import { TextObjectModalContent } from "@core/Store/TextObjectModal.Store"; import { TextObjectModalContent } from "@core/Store/TextObjectModal.Store";
import TextObjectFilterItem from "@core/app/components/TextObjectFilterItem/TextObjectFilterItem"; import TextObjectFilterItem from "@core/app/components/TextObjectFilterItem/TextObjectFilterItem";
import TextObjectFilter from "@core/app/components/TextObjectFilter/TextObjectFilter"; import TextObjectFilter from "@core/app/components/TextObjectFilter/TextObjectFilter";
import { useFilterStore } from "@core/Store/Filter.Store"; import { useFilterStore } from "@core/Store/Filter.Store";
import filterObjects from "@components/TextObjectFilter/Filter.Service"; import filterObjects from "@components/TextObjectFilter/Filter.Service";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = { type UrlData = {
id: string; id: string;
@@ -113,6 +113,7 @@ export default function DiscountsEmailGrid() {
const data = useParams<UrlData>(); const data = useParams<UrlData>();
const oid = data.id; const oid = data.id;
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const alert = useAlert(); const alert = useAlert();
@@ -152,7 +153,9 @@ export default function DiscountsEmailGrid() {
render: (value: DiscountEmailRow) => ( render: (value: DiscountEmailRow) => (
<div style={{ paddingBottom: "10px" }}> <div style={{ paddingBottom: "10px" }}>
<div> <div>
<div style={{ paddingLeft: "10px", paddingTop: "10px", fontWeight: "bold" }}> <div
style={{ paddingLeft: "10px", paddingTop: "10px", fontWeight: "bold" }}
>
{value.discountName + " (" + value.discountCode + ")"} {value.discountName + " (" + value.discountCode + ")"}
</div> </div>
<div style={{ paddingLeft: "10px", paddingTop: "0px", fontWeight: "bold" }}> <div style={{ paddingLeft: "10px", paddingTop: "0px", fontWeight: "bold" }}>
@@ -207,8 +210,10 @@ export default function DiscountsEmailGrid() {
const [pageSize] = useState<number>( const [pageSize] = useState<number>(
parseInt( parseInt(
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE ? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE : "10" process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
) ? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
: "10",
),
); );
const [discounts, setDiscounts] = useState<PaginateDiscountsEmailViewResults>(); const [discounts, setDiscounts] = useState<PaginateDiscountsEmailViewResults>();
const discountsRef = useRef(discounts); const discountsRef = useRef(discounts);
@@ -220,6 +225,8 @@ export default function DiscountsEmailGrid() {
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ location.add({
url: "/admin/org", url: "/admin/org",
title: "Mis organizaciones", title: "Mis organizaciones",
@@ -239,6 +246,10 @@ export default function DiscountsEmailGrid() {
}); });
loadDiscountsEmail(1, pageSize); loadDiscountsEmail(1, pageSize);
return () => {
bottomToolbar.clear();
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -268,7 +279,7 @@ export default function DiscountsEmailGrid() {
setDiscountId={setDiscountId} setDiscountId={setDiscountId}
filterEmail={filterEmail} filterEmail={filterEmail}
setFilterEmail={setFilterEmail} setFilterEmail={setFilterEmail}
/> />,
); );
filter.setOnFilter(() => { filter.setOnFilter(() => {
setSearchByFilters(true); setSearchByFilters(true);
@@ -328,14 +339,13 @@ export default function DiscountsEmailGrid() {
alert.showError(err.format()); alert.showError(err.format());
}); });
}, },
() => {} () => {},
); );
}; };
return ( return (
<> <>
<Location /> <Location showFilter />
<Filterable />
<div style={{ display: "flex", justifyContent: "center", marginTop: "15px" }}> <div style={{ display: "flex", justifyContent: "center", marginTop: "15px" }}>
<ThemeProvider theme={turnosXpressTheme}> <ThemeProvider theme={turnosXpressTheme}>
@@ -22,6 +22,7 @@ import turnosXpressTheme from "@core/app/theme/turnosXpress";
import { ButtonGroup } from "@mui/material"; import { ButtonGroup } from "@mui/material";
import ButtonMaterial from "@mui/material/Button"; import ButtonMaterial from "@mui/material/Button";
import { useNavigation } from "@core/app/hooks/goto"; import { useNavigation } from "@core/app/hooks/goto";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = { type UrlData = {
id: string; id: string;
@@ -33,6 +34,7 @@ export default function DiscountsGrid() {
const data = useParams<UrlData>(); const data = useParams<UrlData>();
const oid = data.id; const oid = data.id;
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const { goTo } = useNavigation(); const { goTo } = useNavigation();
@@ -67,7 +69,9 @@ export default function DiscountsGrid() {
color: "var(--green-darkestX2)", color: "var(--green-darkestX2)",
}} }}
> >
{value.type === DiscountType.FIXED ? formatPrice(value.value) : value.value + "%"} {value.type === DiscountType.FIXED
? formatPrice(value.value)
: value.value + "%"}
</div> </div>
</div> </div>
), ),
@@ -76,8 +80,10 @@ export default function DiscountsGrid() {
const [pageSize] = useState<number>( const [pageSize] = useState<number>(
parseInt( parseInt(
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE ? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE : "10" process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
) ? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
: "10",
),
); );
const [discounts, setDiscounts] = useState<PaginateDiscountsResults>(); const [discounts, setDiscounts] = useState<PaginateDiscountsResults>();
@@ -88,6 +94,8 @@ export default function DiscountsGrid() {
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ location.add({
url: "/admin/org", url: "/admin/org",
title: "Mis organizaciones", title: "Mis organizaciones",
@@ -107,6 +115,10 @@ export default function DiscountsGrid() {
}); });
loadDiscounts(1, pageSize); loadDiscounts(1, pageSize);
return () => {
bottomToolbar.clear();
};
}, []); }, []);
const loadDiscounts = (page: number = 1, pageSize: number = 30) => { const loadDiscounts = (page: number = 1, pageSize: number = 30) => {
@@ -38,6 +38,7 @@ import Switch from "@core/app/components/Switch/Switch";
import { FindTemplatesParams } from "@core/Models/Templates.model"; import { FindTemplatesParams } from "@core/Models/Templates.model";
import filterObjects from "@core/app/components/TextObjectFilter/Filter.Service"; import filterObjects from "@core/app/components/TextObjectFilter/Filter.Service";
import { CompanyServiceView } from "@models/Service.model"; import { CompanyServiceView } from "@models/Service.model";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = { type UrlData = {
id: string; id: string;
@@ -47,6 +48,7 @@ export default function OrganizationEdit() {
const data = useParams<UrlData>(); const data = useParams<UrlData>();
const id = data.id; const id = data.id;
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
const alert = useAlert(); const alert = useAlert();
@@ -66,11 +68,12 @@ export default function OrganizationEdit() {
const [servicesShowPublicScores, setServicesShowPublicScores] = useState(true); const [servicesShowPublicScores, setServicesShowPublicScores] = useState(true);
const [servicesShowPublicOpinions, setServicesShowPublicOpinions] = useState(true); const [servicesShowPublicOpinions, setServicesShowPublicOpinions] = useState(true);
const [originalServicesShowPublicScores, setOriginalServicesShowPublicScores] = useState(true); const [originalServicesShowPublicScores, setOriginalServicesShowPublicScores] = useState(true);
const [originalServicesShowPublicOpinions, setOriginalServicesShowPublicOpinions] = useState(true); const [originalServicesShowPublicOpinions, setOriginalServicesShowPublicOpinions] =
useState(true);
const [services, setServices] = useState<CompanyServiceView[]>([]); const [services, setServices] = useState<CompanyServiceView[]>([]);
const [orgPublished, setOrgPublished] = useState<COMPANY_PUBLISHED_STATUS | undefined>( const [orgPublished, setOrgPublished] = useState<COMPANY_PUBLISHED_STATUS | undefined>(
undefined undefined,
); );
const [savePublishedStatusAvailable, setSavePublishedStatusAvailable] = useState(false); const [savePublishedStatusAvailable, setSavePublishedStatusAvailable] = useState(false);
@@ -199,6 +202,8 @@ export default function OrganizationEdit() {
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ location.add({
url: "/admin/org", url: "/admin/org",
title: "Organizaciones", title: "Organizaciones",
@@ -218,6 +223,10 @@ export default function OrganizationEdit() {
}); });
loadCategories(); loadCategories();
return () => {
bottomToolbar.clear();
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -241,25 +250,33 @@ export default function OrganizationEdit() {
setCategoryId(organization.categoryId.toString()); setCategoryId(organization.categoryId.toString());
setOrgSlug(getSlug(organization.name)); setOrgSlug(getSlug(organization.name));
setOrgPublished(organization.published); setOrgPublished(organization.published);
setShowPublicScores(organization.showPublicScores !== undefined ? organization.showPublicScores : true); setShowPublicScores(
setShowPublicOpinions(organization.showPublicOpinions !== undefined ? organization.showPublicOpinions : true); organization.showPublicScores !== undefined
? organization.showPublicScores
: true,
);
setShowPublicOpinions(
organization.showPublicOpinions !== undefined
? organization.showPublicOpinions
: true,
);
setShowPublicProfessionals( setShowPublicProfessionals(
organization.showPublicProfessionals !== undefined organization.showPublicProfessionals !== undefined
? organization.showPublicProfessionals ? organization.showPublicProfessionals
: true : true,
); );
setServices(loadedServices); setServices(loadedServices);
setServicesShowPublicScores( setServicesShowPublicScores(
loadedServices.every((service) => service.showPublicScores !== false) loadedServices.every((service) => service.showPublicScores !== false),
); );
setServicesShowPublicOpinions( setServicesShowPublicOpinions(
loadedServices.every((service) => service.showPublicOpinions !== false) loadedServices.every((service) => service.showPublicOpinions !== false),
); );
setOriginalServicesShowPublicScores( setOriginalServicesShowPublicScores(
loadedServices.every((service) => service.showPublicScores !== false) loadedServices.every((service) => service.showPublicScores !== false),
); );
setOriginalServicesShowPublicOpinions( setOriginalServicesShowPublicOpinions(
loadedServices.every((service) => service.showPublicOpinions !== false) loadedServices.every((service) => service.showPublicOpinions !== false),
); );
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -287,7 +304,7 @@ export default function OrganizationEdit() {
confirm.show( confirm.show(
validationPhoneResult.message, validationPhoneResult.message,
() => {}, () => {},
() => {} () => {},
); );
eventHandler.setEventType(EVENT_TYPES.SLEEP); eventHandler.setEventType(EVENT_TYPES.SLEEP);
return; return;
@@ -307,7 +324,8 @@ export default function OrganizationEdit() {
phoneNumber: phone, phoneNumber: phone,
templateWapNotifId: templateWapNotifId === "" ? undefined : templateWapNotifId, templateWapNotifId: templateWapNotifId === "" ? undefined : templateWapNotifId,
templateWapAltaId: templateWapAltaId === "" ? undefined : templateWapAltaId, templateWapAltaId: templateWapAltaId === "" ? undefined : templateWapAltaId,
templateWapCancellationId: templateWapCancellationId === "" ? undefined : templateWapCancellationId, templateWapCancellationId:
templateWapCancellationId === "" ? undefined : templateWapCancellationId,
...(canEditPublicVisibilityFlags ...(canEditPublicVisibilityFlags
? { ? {
showPublicScores, showPublicScores,
@@ -321,31 +339,31 @@ export default function OrganizationEdit() {
id: Yup.string().required("En este momento no es posible procesar su solicitud."), id: Yup.string().required("En este momento no es posible procesar su solicitud."),
name: Yup.string().required("Debe introducir el nombre de la organización."), name: Yup.string().required("Debe introducir el nombre de la organización."),
description: Yup.string().required( description: Yup.string().required(
"Debe introducir la descripción de la organización." "Debe introducir la descripción de la organización.",
), ),
categoryId: Yup.number().required( categoryId: Yup.number().required(
"Debe seleccionar una categoría para la organización." "Debe seleccionar una categoría para la organización.",
), ),
phoneAreaCode: Yup.number() phoneAreaCode: Yup.number()
.typeError("Código de área debe ser un número.") .typeError("Código de área debe ser un número.")
.transform((value, originalValue) => .transform((value, originalValue) =>
originalValue.toString().trim() === "" ? null : value originalValue.toString().trim() === "" ? null : value,
) )
.nullable(), .nullable(),
phoneNumber: Yup.number() phoneNumber: Yup.number()
.typeError("El teléfono debe ser un número.") .typeError("El teléfono debe ser un número.")
.transform((value, originalValue) => .transform((value, originalValue) =>
originalValue.toString().trim() === "" ? null : value originalValue.toString().trim() === "" ? null : value,
) )
.nullable(), .nullable(),
appointmentAlert: Yup.string().optional().default(""), appointmentAlert: Yup.string().optional().default(""),
sessionUser: Yup.string().required( sessionUser: Yup.string().required(
"Para crear una organización debe iniciar sesión." "Para crear una organización debe iniciar sesión.",
), ),
heatMapFraction: Yup.number() heatMapFraction: Yup.number()
.typeError("Fracción de tiempo debe ser un número.") .typeError("Fracción de tiempo debe ser un número.")
.transform((value, originalValue) => .transform((value, originalValue) =>
originalValue.toString().trim() === "" ? null : value originalValue.toString().trim() === "" ? null : value,
) )
.nullable() .nullable()
.integer("Fracción de tiempo debe ser un número entero en minutos.") .integer("Fracción de tiempo debe ser un número entero en minutos.")
@@ -354,7 +372,7 @@ export default function OrganizationEdit() {
appointmentTime: Yup.number() appointmentTime: Yup.number()
.typeError("El Plazo Mínimo de Reserva debe ser un número.") .typeError("El Plazo Mínimo de Reserva debe ser un número.")
.transform((value, originalValue) => .transform((value, originalValue) =>
originalValue.toString().trim() === "" ? null : value originalValue.toString().trim() === "" ? null : value,
) )
.nullable() .nullable()
.integer("El Plazo Mínimo de Reserva debe ser un número entero en horas.") .integer("El Plazo Mínimo de Reserva debe ser un número entero en horas.")
@@ -362,7 +380,7 @@ export default function OrganizationEdit() {
cancellationTime: Yup.number() cancellationTime: Yup.number()
.typeError("El Plazo Mínimo de Cancelación debe ser un número.") .typeError("El Plazo Mínimo de Cancelación debe ser un número.")
.transform((value, originalValue) => .transform((value, originalValue) =>
originalValue.toString().trim() === "" ? null : value originalValue.toString().trim() === "" ? null : value,
) )
.nullable() .nullable()
.integer("El Plazo Mínimo de Cancelación debe ser un número entero en horas.") .integer("El Plazo Mínimo de Cancelación debe ser un número entero en horas.")
@@ -386,7 +404,8 @@ export default function OrganizationEdit() {
originalServicesShowPublicOpinions !== servicesShowPublicOpinions; originalServicesShowPublicOpinions !== servicesShowPublicOpinions;
const changedServices = const changedServices =
canEditPublicVisibilityFlags && canEditPublicVisibilityFlags &&
(servicesShowPublicScoresChanged || servicesShowPublicOpinionsChanged) (servicesShowPublicScoresChanged ||
servicesShowPublicOpinionsChanged)
? services ? services
: []; : [];
@@ -414,8 +433,8 @@ export default function OrganizationEdit() {
: service.showPublicOpinions !== undefined : service.showPublicOpinions !== undefined
? service.showPublicOpinions ? service.showPublicOpinions
: true, : true,
}) }),
) ),
).then(() => { ).then(() => {
const updatedServices = services.map((service) => ({ const updatedServices = services.map((service) => ({
...service, ...service,
@@ -434,7 +453,9 @@ export default function OrganizationEdit() {
}); });
}) })
.catch((error: ApiError | Error) => { .catch((error: ApiError | Error) => {
alert.showError(error instanceof ApiError ? error.format() : error.message); alert.showError(
error instanceof ApiError ? error.format() : error.message,
);
}) })
.finally(() => { .finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP); eventHandler.setEventType(EVENT_TYPES.SLEEP);
@@ -453,7 +474,7 @@ export default function OrganizationEdit() {
id: category.id.toString(), id: category.id.toString(),
name: category.name, name: category.name,
description: category.description, description: category.description,
})) })),
); );
}; };
@@ -533,7 +554,7 @@ export default function OrganizationEdit() {
setOrgPublished( setOrgPublished(
checked checked
? COMPANY_PUBLISHED_STATUS.PUBLISHED ? COMPANY_PUBLISHED_STATUS.PUBLISHED
: COMPANY_PUBLISHED_STATUS.PRIVATE : COMPANY_PUBLISHED_STATUS.PRIVATE,
); );
}} }}
/> />
@@ -581,15 +602,23 @@ export default function OrganizationEdit() {
/> />
<MaterialAlert severity="info" sx={{ marginTop: "20px" }}> <MaterialAlert severity="info" sx={{ marginTop: "20px" }}>
<b>Visibilidad pública:</b> Elegí si querés mostrar puntuaciones, opiniones y profesionales en el perfil público y las páginas de reserva. Disponible para planes pagos. <b>Visibilidad pública:</b> Elegí si querés mostrar puntuaciones, opiniones y
profesionales en el perfil público y las páginas de reserva. Disponible para planes
pagos.
</MaterialAlert> </MaterialAlert>
{!canEditPublicVisibilityFlags && ( {!canEditPublicVisibilityFlags && (
<MaterialAlert severity="warning" sx={{ marginTop: "10px" }}> <MaterialAlert severity="warning" sx={{ marginTop: "10px" }}>
Para modificar las opciones de visibilidad pública de opiniones, puntuaciones y profesionales de esta organización y sus servicios, el propietario de la organización necesita un plan pago. Para modificar las opciones de visibilidad pública de opiniones, puntuaciones y
profesionales de esta organización y sus servicios, el propietario de la
organización necesita un plan pago.
</MaterialAlert> </MaterialAlert>
)} )}
<div style={{ display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" }}> <div
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}> style={{ display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" }}
>
<label
style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}
>
<Checkbox <Checkbox
checked={showPublicScores} checked={showPublicScores}
disabled={!canEditPublicVisibilityFlags} disabled={!canEditPublicVisibilityFlags}
@@ -600,7 +629,9 @@ export default function OrganizationEdit() {
/> />
Mostrar puntuaciones públicas Mostrar puntuaciones públicas
</label> </label>
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}> <label
style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}
>
<Checkbox <Checkbox
checked={showPublicOpinions} checked={showPublicOpinions}
disabled={!canEditPublicVisibilityFlags} disabled={!canEditPublicVisibilityFlags}
@@ -611,7 +642,9 @@ export default function OrganizationEdit() {
/> />
Mostrar opiniones públicas Mostrar opiniones públicas
</label> </label>
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}> <label
style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}
>
<Checkbox <Checkbox
checked={showPublicProfessionals} checked={showPublicProfessionals}
disabled={!canEditPublicVisibilityFlags} disabled={!canEditPublicVisibilityFlags}
@@ -632,7 +665,9 @@ export default function OrganizationEdit() {
{services.length > 0 && ( {services.length > 0 && (
<> <>
<MaterialAlert severity="info" sx={{ marginTop: "20px" }}> <MaterialAlert severity="info" sx={{ marginTop: "20px" }}>
<b>Visibilidad pública de servicios:</b> Configurá si se muestran puntuaciones y opiniones en todos los servicios. Disponible para planes pagos. <b>Visibilidad pública de servicios:</b> Configurá si se muestran
puntuaciones y opiniones en todos los servicios. Disponible para planes
pagos.
</MaterialAlert> </MaterialAlert>
<div <div
style={{ style={{
@@ -642,7 +677,14 @@ export default function OrganizationEdit() {
marginTop: "10px", marginTop: "10px",
}} }}
> >
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}> <label
style={{
display: "flex",
alignItems: "center",
gap: "10px",
fontWeight: 600,
}}
>
<Checkbox <Checkbox
checked={servicesShowPublicScores} checked={servicesShowPublicScores}
disabled={!canEditPublicVisibilityFlags} disabled={!canEditPublicVisibilityFlags}
@@ -653,7 +695,14 @@ export default function OrganizationEdit() {
/> />
Mostrar puntuaciones públicas de los servicios Mostrar puntuaciones públicas de los servicios
</label> </label>
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}> <label
style={{
display: "flex",
alignItems: "center",
gap: "10px",
fontWeight: 600,
}}
>
<Checkbox <Checkbox
checked={servicesShowPublicOpinions} checked={servicesShowPublicOpinions}
disabled={!canEditPublicVisibilityFlags} disabled={!canEditPublicVisibilityFlags}
@@ -666,7 +715,8 @@ export default function OrganizationEdit() {
</label> </label>
{!canEditPublicVisibilityFlags && ( {!canEditPublicVisibilityFlags && (
<span style={{ color: "var(--gray-darkest)", fontSize: "13px" }}> <span style={{ color: "var(--gray-darkest)", fontSize: "13px" }}>
Para cambiar la visibilidad pública de los servicios necesitás un plan pago. Para cambiar la visibilidad pública de los servicios necesitás un
plan pago.
</span> </span>
)} )}
</div> </div>
@@ -17,12 +17,14 @@ import ReceiptLongOutlinedIcon from "@mui/icons-material/ReceiptLongOutlined";
import { Grid2 } from "@mui/material"; import { Grid2 } from "@mui/material";
import { ApiError } from "@models/Server.Error.model"; import { ApiError } from "@models/Server.Error.model";
import style from "./page.module.css"; import style from "./page.module.css";
import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan";
import { import {
findArcaActivities, findArcaActivities,
findOrganizationFiscalProfiles, findOrganizationFiscalProfiles,
OrganizationFiscalProfileTaxCondition, OrganizationFiscalProfileTaxCondition,
upsertOrganizationFiscalProfile, upsertOrganizationFiscalProfile,
} from "./Fiscal.Service"; } from "./Fiscal.Service";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = { type UrlData = {
id: string; id: string;
@@ -32,13 +34,15 @@ export default function OrganizationFiscalProfileEdit() {
const data = useParams<UrlData>(); const data = useParams<UrlData>();
const id = data.id; const id = data.id;
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
const alert = useAlert(); const alert = useAlert();
const [cuit, setCuit] = useState(""); const [cuit, setCuit] = useState("");
const [legalName, setLegalName] = useState(""); const [legalName, setLegalName] = useState("");
const [taxCondition, setTaxCondition] = useState<OrganizationFiscalProfileTaxCondition>("MONOTRIBUTO"); const [taxCondition, setTaxCondition] =
useState<OrganizationFiscalProfileTaxCondition>("MONOTRIBUTO");
const [pointOfSale, setPointOfSale] = useState("1"); const [pointOfSale, setPointOfSale] = useState("1");
const [activityCode, setActivityCode] = useState(""); const [activityCode, setActivityCode] = useState("");
const [activityDescription, setActivityDescription] = useState(""); const [activityDescription, setActivityDescription] = useState("");
@@ -79,6 +83,8 @@ export default function OrganizationFiscalProfileEdit() {
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.clear();
location.add({ location.add({
url: "/admin/org", url: "/admin/org",
title: "Organizaciones", title: "Organizaciones",
@@ -96,6 +102,10 @@ export default function OrganizationFiscalProfileEdit() {
title: "Datos fiscales", title: "Datos fiscales",
icon: <ReceiptLongOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />, icon: <ReceiptLongOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />,
}); });
return () => {
bottomToolbar.clear();
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -117,7 +127,9 @@ export default function OrganizationFiscalProfileEdit() {
setLegalName(profile.legalName || ""); setLegalName(profile.legalName || "");
setTaxCondition(profile.taxCondition || "MONOTRIBUTO"); setTaxCondition(profile.taxCondition || "MONOTRIBUTO");
setPointOfSale(profile.pointOfSale ? String(profile.pointOfSale) : "1"); setPointOfSale(profile.pointOfSale ? String(profile.pointOfSale) : "1");
const profileActivity = activities.find((activity) => activity.id === profile.activityCode); const profileActivity = activities.find(
(activity) => activity.id === profile.activityCode,
);
setActivityCode(profile.activityCode || ""); setActivityCode(profile.activityCode || "");
setActivityDescription(profile.activityDescription || profileActivity?.name || ""); setActivityDescription(profile.activityDescription || profileActivity?.name || "");
}) })
@@ -151,7 +163,10 @@ export default function OrganizationFiscalProfileEdit() {
return; return;
} }
if (activityCode.trim() && !arcaActivities.some((activity) => activity.id === activityCode.trim())) { if (
activityCode.trim() &&
!arcaActivities.some((activity) => activity.id === activityCode.trim())
) {
alert.showError("Seleccioná una actividad ARCA/CLAE válida."); alert.showError("Seleccioná una actividad ARCA/CLAE válida.");
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE); eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
return; return;
@@ -159,7 +174,9 @@ export default function OrganizationFiscalProfileEdit() {
eventHandler.setEventType(EVENT_TYPES.LOADING); eventHandler.setEventType(EVENT_TYPES.LOADING);
const selectedActivity = arcaActivities.find((activity) => activity.id === activityCode.trim()); const selectedActivity = arcaActivities.find(
(activity) => activity.id === activityCode.trim(),
);
upsertOrganizationFiscalProfile({ upsertOrganizationFiscalProfile({
companyId: id, companyId: id,
@@ -172,7 +189,9 @@ export default function OrganizationFiscalProfileEdit() {
sessionUser: SessionInfo.userId, sessionUser: SessionInfo.userId,
}) })
.then((profile) => { .then((profile) => {
const savedActivity = arcaActivities.find((activity) => activity.id === profile.activityCode); const savedActivity = arcaActivities.find(
(activity) => activity.id === profile.activityCode,
);
setActivityCode(profile.activityCode || ""); setActivityCode(profile.activityCode || "");
setActivityDescription(profile.activityDescription || savedActivity?.name || ""); setActivityDescription(profile.activityDescription || savedActivity?.name || "");
alert.showSuccess("Datos fiscales actualizados."); alert.showSuccess("Datos fiscales actualizados.");
@@ -188,6 +207,7 @@ export default function OrganizationFiscalProfileEdit() {
return ( return (
<> <>
<Location /> <Location />
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
<div className={style.pageContainer}> <div className={style.pageContainer}>
<section className={style.heroCard}> <section className={style.heroCard}>
<div className={style.heroIcon}> <div className={style.heroIcon}>
@@ -196,7 +216,8 @@ export default function OrganizationFiscalProfileEdit() {
<div> <div>
<h2>Configuración fiscal</h2> <h2>Configuración fiscal</h2>
<p> <p>
Estos datos se usan para emitir Factura C desde los movimientos de caja y para precargar el asistente ARCA. Estos datos se usan para emitir Factura C desde los movimientos de
caja y para precargar el asistente ARCA.
</p> </p>
</div> </div>
</section> </section>
@@ -238,7 +259,11 @@ export default function OrganizationFiscalProfileEdit() {
<h3>Condición fiscal</h3> <h3>Condición fiscal</h3>
<p>Seleccioná la condición declarada para esta organización.</p> <p>Seleccioná la condición declarada para esta organización.</p>
</div> </div>
<div className={style.optionGroup} role="radiogroup" aria-label="Condición fiscal"> <div
className={style.optionGroup}
role="radiogroup"
aria-label="Condición fiscal"
>
{fiscalConditionOptions.map((option) => { {fiscalConditionOptions.map((option) => {
const selected = taxCondition === option.id; const selected = taxCondition === option.id;
@@ -254,7 +279,9 @@ export default function OrganizationFiscalProfileEdit() {
aria-pressed={selected} aria-pressed={selected}
> >
<span className={style.optionTitle}>{option.title}</span> <span className={style.optionTitle}>{option.title}</span>
<span className={style.optionDescription}>{option.description}</span> <span className={style.optionDescription}>
{option.description}
</span>
</button> </button>
); );
})} })}
@@ -288,7 +315,7 @@ export default function OrganizationFiscalProfileEdit() {
selectedId={activityCode} selectedId={activityCode}
onChange={(selectedActivityCode) => { onChange={(selectedActivityCode) => {
const selectedActivity = arcaActivities.find( const selectedActivity = arcaActivities.find(
(activity) => activity.id === selectedActivityCode (activity) => activity.id === selectedActivityCode,
); );
setActivityCode(selectedActivity?.id || ""); setActivityCode(selectedActivity?.id || "");
@@ -305,6 +332,7 @@ export default function OrganizationFiscalProfileEdit() {
</p> </p>
</section> </section>
</div> </div>
</SecureByPlan>
</> </>
); );
} }
@@ -13,6 +13,7 @@ import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined";
import NotificationsIcon from "@mui/icons-material/NotificationsOutlined"; import NotificationsIcon from "@mui/icons-material/NotificationsOutlined";
import MaterialAlert from "@mui/material/Alert"; import MaterialAlert from "@mui/material/Alert";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = { type UrlData = {
id: string; id: string;
@@ -21,7 +22,7 @@ type UrlData = {
const CHANNELS: { key: NotificationChannel; label: string }[] = [ const CHANNELS: { key: NotificationChannel; label: string }[] = [
{ key: "whatsapp", label: "WhatsApp" }, { key: "whatsapp", label: "WhatsApp" },
{ key: "email", label: "Email" }, { key: "email", label: "Email" },
{ key: "system", label: "Sistema" } { key: "system", label: "Sistema" },
]; ];
const REMINDER_PRESETS: { minutes: number; label: string }[] = [ const REMINDER_PRESETS: { minutes: number; label: string }[] = [
@@ -30,36 +31,43 @@ const REMINDER_PRESETS: { minutes: number; label: string }[] = [
{ minutes: 360, label: "6 horas antes" }, { minutes: 360, label: "6 horas antes" },
{ minutes: 720, label: "12 horas antes" }, { minutes: 720, label: "12 horas antes" },
{ minutes: 1440, label: "1 día antes" }, { minutes: 1440, label: "1 día antes" },
{ minutes: 2880, label: "2 días antes" } { minutes: 2880, label: "2 días antes" },
]; ];
export default function NotificationsPage() { export default function NotificationsPage() {
const data = useParams<UrlData>(); const data = useParams<UrlData>();
const id = data.id; const id = data.id;
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const store = useNotificationPreferencesStore(); const store = useNotificationPreferencesStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ location.add({
url: "/admin/org", url: "/admin/org",
title: "Organizaciones", title: "Organizaciones",
icon: null icon: null,
}); });
location.add({ location.add({
url: "/admin/org/profile/" + id, url: "/admin/org/profile/" + id,
title: "Perfil", title: "Perfil",
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" /> icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />,
}); });
location.add({ location.add({
url: "/admin/org/profile/" + id + "/notifications", url: "/admin/org/profile/" + id + "/notifications",
title: "Notificaciones", title: "Notificaciones",
icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} /> icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
}); });
return () => {
bottomToolbar.clear();
};
}, [id]); }, [id]);
const toggleChannel = (channel: NotificationChannel) => { const toggleChannel = (channel: NotificationChannel) => {
@@ -73,18 +81,22 @@ export default function NotificationsPage() {
const updateQuietHours = (field: "from" | "to", value: string) => { const updateQuietHours = (field: "from" | "to", value: string) => {
const current = store.formData.quietHours || { from: "00:00", to: "00:00" }; const current = store.formData.quietHours || { from: "00:00", to: "00:00" };
store.updateFormData({ store.updateFormData({
quietHours: { ...current, [field]: value } quietHours: { ...current, [field]: value },
}); });
}; };
const quietHourValue = (value: string | undefined, fallback: string) => { const quietHourValue = (value: string | undefined, fallback: string) => {
const [hour, minute] = (value || fallback).split(":").map(Number); const [hour, minute] = (value || fallback).split(":").map(Number);
return dayjs(new Date()).hour(hour).minute(minute || 0).second(0).millisecond(0); return dayjs(new Date())
.hour(hour)
.minute(minute || 0)
.second(0)
.millisecond(0);
}; };
const toggleQuietHours = () => { const toggleQuietHours = () => {
store.updateFormData({ store.updateFormData({
quietHours: store.formData.quietHours ? null : { from: "22:00", to: "08:00" } quietHours: store.formData.quietHours ? null : { from: "22:00", to: "08:00" },
}); });
}; };
@@ -95,7 +107,10 @@ export default function NotificationsPage() {
store.updateFormData({ reminderRules: updated }); store.updateFormData({ reminderRules: updated });
} else { } else {
store.updateFormData({ store.updateFormData({
reminderRules: [...store.formData.reminderRules, { offset: minutes, enabled: true }] reminderRules: [
...store.formData.reminderRules,
{ offset: minutes, enabled: true },
],
}); });
} }
}; };
@@ -107,10 +122,18 @@ export default function NotificationsPage() {
<div style={{ marginTop: "20px" }}> <div style={{ marginTop: "20px" }}>
{/* Channel Toggles Section */} {/* Channel Toggles Section */}
<MaterialAlert severity="info" sx={{ marginBottom: "20px" }}> <MaterialAlert severity="info" sx={{ marginBottom: "20px" }}>
<b>Canales de notificación:</b> Seleccione los canales por los cuales se enviarán las notificaciones automáticas. <b>Canales de notificación:</b> Seleccione los canales por los cuales se
enviarán las notificaciones automáticas.
</MaterialAlert> </MaterialAlert>
<div style={{ display: "flex", flexDirection: "column", gap: "8px", marginBottom: "30px" }}> <div
style={{
display: "flex",
flexDirection: "column",
gap: "8px",
marginBottom: "30px",
}}
>
{CHANNELS.map((channel) => ( {CHANNELS.map((channel) => (
<div <div
key={channel.key} key={channel.key}
@@ -121,10 +144,12 @@ export default function NotificationsPage() {
padding: "12px 16px", padding: "12px 16px",
borderRadius: "4px", borderRadius: "4px",
border: "1px solid var(--border-color)", border: "1px solid var(--border-color)",
backgroundColor: "var(--bg-primary)" backgroundColor: "var(--bg-primary)",
}} }}
> >
<span style={{ color: "var(--text-primary)", fontWeight: "500" }}>{channel.label}</span> <span style={{ color: "var(--text-primary)", fontWeight: "500" }}>
{channel.label}
</span>
<Switch <Switch
checked={store.formData.defaultChannels.includes(channel.key)} checked={store.formData.defaultChannels.includes(channel.key)}
onChange={() => toggleChannel(channel.key)} onChange={() => toggleChannel(channel.key)}
@@ -135,7 +160,8 @@ export default function NotificationsPage() {
{/* Quiet Hours Section */} {/* Quiet Hours Section */}
<MaterialAlert severity="info" sx={{ marginBottom: "20px" }}> <MaterialAlert severity="info" sx={{ marginBottom: "20px" }}>
<b>Horario silencioso:</b> Las notificaciones no se enviarán durante este rango horario. <b>Horario silencioso:</b> Las notificaciones no se enviarán durante este rango
horario.
</MaterialAlert> </MaterialAlert>
<div <div
@@ -147,7 +173,7 @@ export default function NotificationsPage() {
borderRadius: "8px", borderRadius: "8px",
border: "1px solid var(--border-color)", border: "1px solid var(--border-color)",
backgroundColor: "var(--bg-primary)", backgroundColor: "var(--bg-primary)",
marginBottom: "16px" marginBottom: "16px",
}} }}
> >
<span style={{ color: "var(--text-primary)", fontWeight: "500" }}> <span style={{ color: "var(--text-primary)", fontWeight: "500" }}>
@@ -160,13 +186,22 @@ export default function NotificationsPage() {
</div> </div>
{store.formData.quietHours && ( {store.formData.quietHours && (
<div style={{ display: "flex", gap: "16px", alignItems: "flex-end", marginBottom: "30px" }}> <div
style={{
display: "flex",
gap: "16px",
alignItems: "flex-end",
marginBottom: "30px",
}}
>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<TextTime <TextTime
placeholder="Desde" placeholder="Desde"
width="100%" width="100%"
value={quietHourValue(store.formData.quietHours.from, "22:00")} value={quietHourValue(store.formData.quietHours.from, "22:00")}
onChange={(value) => updateQuietHours("from", value.format("HH:mm"))} onChange={(value) =>
updateQuietHours("from", value.format("HH:mm"))
}
/> />
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
@@ -182,12 +217,15 @@ export default function NotificationsPage() {
{/* Reminder Rules Section */} {/* Reminder Rules Section */}
<MaterialAlert severity="info" sx={{ marginBottom: "20px" }}> <MaterialAlert severity="info" sx={{ marginBottom: "20px" }}>
<b>Reglas de recordatorio:</b> Active los momentos en los que desea enviar recordatorios antes del turno. <b>Reglas de recordatorio:</b> Active los momentos en los que desea enviar
recordatorios antes del turno.
</MaterialAlert> </MaterialAlert>
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px" }}> <div style={{ display: "flex", flexWrap: "wrap", gap: "10px" }}>
{REMINDER_PRESETS.map((preset) => { {REMINDER_PRESETS.map((preset) => {
const isActive = store.formData.reminderRules.some((r) => r.offset === preset.minutes); const isActive = store.formData.reminderRules.some(
(r) => r.offset === preset.minutes,
);
return ( return (
<button <button
key={preset.minutes} key={preset.minutes}
@@ -198,11 +236,15 @@ export default function NotificationsPage() {
padding: "10px 16px", padding: "10px 16px",
borderRadius: "999px", borderRadius: "999px",
border: `1px solid ${isActive ? "var(--primary-color)" : "var(--border-color)"}`, border: `1px solid ${isActive ? "var(--primary-color)" : "var(--border-color)"}`,
backgroundColor: isActive ? "rgba(25, 118, 210, 0.12)" : "var(--bg-primary)", backgroundColor: isActive
color: isActive ? "var(--primary-color)" : "var(--text-secondary)", ? "rgba(25, 118, 210, 0.12)"
: "var(--bg-primary)",
color: isActive
? "var(--primary-color)"
: "var(--text-secondary)",
fontWeight: isActive ? "600" : "500", fontWeight: isActive ? "600" : "500",
fontSize: "14px", fontSize: "14px",
transition: "all 0.2s ease" transition: "all 0.2s ease",
}} }}
> >
{preset.label} {preset.label}
@@ -248,7 +248,7 @@ export default function OrganizationNotPayedAppointments() {
const [dialogPayVisible, setDialogPayVisible] = useState<boolean>(false); const [dialogPayVisible, setDialogPayVisible] = useState<boolean>(false);
const [dialogPayOneVisible, setDialogPayOneVisible] = useState<boolean>(false); const [dialogPayOneVisible, setDialogPayOneVisible] = useState<boolean>(false);
const [createPaymentData, setCreatePaymentData] = useState<PaginatePaymentsView | undefined>( const [createPaymentData, setCreatePaymentData] = useState<PaginatePaymentsView | undefined>(
undefined undefined,
); );
const [paymentMethodFilterContent] = useState<TextObjectModalContent>({ const [paymentMethodFilterContent] = useState<TextObjectModalContent>({
@@ -284,7 +284,7 @@ export default function OrganizationNotPayedAppointments() {
TextObjectFilterResult[] TextObjectFilterResult[]
>([]); >([]);
const [paymentMethodId, setPaymentMethodId] = useState<CLIENT_ACCOUNT_PAYMENT_METHODS>( const [paymentMethodId, setPaymentMethodId] = useState<CLIENT_ACCOUNT_PAYMENT_METHODS>(
CLIENT_ACCOUNT_PAYMENT_METHODS.CASH CLIENT_ACCOUNT_PAYMENT_METHODS.CASH,
); );
useEffect(() => { useEffect(() => {
@@ -326,7 +326,7 @@ export default function OrganizationNotPayedAppointments() {
TextObjectFilterResult[] TextObjectFilterResult[]
>([]); >([]);
const [paymentProcessDateId, setPaymentProcessDateId] = useState<PAYMENT_PROCCESS_DATE>( const [paymentProcessDateId, setPaymentProcessDateId] = useState<PAYMENT_PROCCESS_DATE>(
PAYMENT_PROCCESS_DATE.NOW PAYMENT_PROCCESS_DATE.NOW,
); );
useEffect(() => { useEffect(() => {
@@ -356,7 +356,7 @@ export default function OrganizationNotPayedAppointments() {
id: category.id.toString(), id: category.id.toString(),
name: category.name, name: category.name,
description: category.description, description: category.description,
})) })),
); );
}; };
@@ -370,7 +370,7 @@ export default function OrganizationNotPayedAppointments() {
const [clientIds, setClientIds] = useState<string[]>([]); const [clientIds, setClientIds] = useState<string[]>([]);
const [collaboratorFilterData, setCollaboratorFilterData] = useState<TextObjectFilterResult[]>( const [collaboratorFilterData, setCollaboratorFilterData] = useState<TextObjectFilterResult[]>(
[] [],
); );
const [collaboratorId, setCollaboratorId] = useState<string>(""); const [collaboratorId, setCollaboratorId] = useState<string>("");
@@ -484,8 +484,8 @@ export default function OrganizationNotPayedAppointments() {
parseInt( parseInt(
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE ? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
: "10" : "10",
) ),
); );
const [payments, setPayments] = useState<PaginateNotPayedAppointmentsResult>(); const [payments, setPayments] = useState<PaginateNotPayedAppointmentsResult>();
const paymentsRef = useRef(payments); const paymentsRef = useRef(payments);
@@ -595,7 +595,6 @@ export default function OrganizationNotPayedAppointments() {
}); });
}; };
const generarReporte = async () => { const generarReporte = async () => {
eventHandler.setEventType(EVENT_TYPES.LOADING); eventHandler.setEventType(EVENT_TYPES.LOADING);
try { try {
@@ -631,21 +630,21 @@ export default function OrganizationNotPayedAppointments() {
[ [
{ text: "Cliente", bold: true, color: "#333333", fillColor: "#f5f5f5" }, { text: "Cliente", bold: true, color: "#333333", fillColor: "#f5f5f5" },
{ text: "Datos del Turno", bold: true, color: "#333333", fillColor: "#f5f5f5" }, { text: "Datos del Turno", bold: true, color: "#333333", fillColor: "#f5f5f5" },
{ text: "Monto", bold: true, color: "#333333", fillColor: "#f5f5f5" } { text: "Monto", bold: true, color: "#333333", fillColor: "#f5f5f5" },
] ],
]; ];
report.items.forEach(item => { report.items.forEach((item) => {
tableBody.push([ tableBody.push([
item.clientName || "-", item.clientName || "-",
{ {
text: [ text: [
{ text: `${item.serviceName || "-"}\n`, bold: true, fontSize: 11 }, { text: `${item.serviceName || "-"}\n`, bold: true, fontSize: 11 },
`Prof: ${item.employeeName || "-"}\nFecha: ${dayjs(item.appointmentDate).format("DD/MM/YYYY HH:mm")} hs` `Prof: ${item.employeeName || "-"}\nFecha: ${dayjs(item.appointmentDate).format("DD/MM/YYYY HH:mm")} hs`,
], ],
fontSize: 10 fontSize: 10,
}, },
{ text: "$" + item.price.toLocaleString("es-AR"), alignment: "right" } { text: "$" + item.price.toLocaleString("es-AR"), alignment: "right" },
]); ]);
}); });
@@ -653,52 +652,81 @@ export default function OrganizationNotPayedAppointments() {
tableBody.push([ tableBody.push([
{ text: "TOTAL ADEUDADO", bold: true, colSpan: 2, alignment: "right" }, { text: "TOTAL ADEUDADO", bold: true, colSpan: 2, alignment: "right" },
{}, {},
{ text: "$" + report.totalAmount.toLocaleString("es-AR"), bold: true, alignment: "right" } {
text: "$" + report.totalAmount.toLocaleString("es-AR"),
bold: true,
alignment: "right",
},
]); ]);
const docDefinition: unknown = { const docDefinition: unknown = {
content: [ content: [
{ {
columns: [ columns: [
{ text: "Reporte de Turnos Adeudados", fontSize: 18, bold: true, margin: [0, 0, 0, 10], width: '*' }, {
...(logoDataUrl ? [{ image: logoDataUrl, width: 45, margin: [0, 0, 0, 10], alignment: 'right' }] : []) text: "Reporte de Turnos Adeudados",
fontSize: 18,
bold: true,
margin: [0, 0, 0, 10],
width: "*",
},
...(logoDataUrl
? [
{
image: logoDataUrl,
width: 45,
margin: [0, 0, 0, 10],
alignment: "right",
},
] ]
: []),
],
}, },
{ text: `Filtros aplicados:`, bold: true, margin: [0, 0, 0, 5] }, { text: `Filtros aplicados:`, bold: true, margin: [0, 0, 0, 5] },
{ {
text: [ text: [
{ text: "Clientes Seleccionados: ", bold: true }, { text: "Clientes Seleccionados: ", bold: true },
`${clientIds.length > 0 ? clientIds.length : "Todos"}` `${clientIds.length > 0 ? clientIds.length : "Todos"}`,
], ],
margin: [0, 0, 0, 2] margin: [0, 0, 0, 2],
}, },
{ {
text: [ text: [
{ text: "Período: ", bold: true }, { text: "Período: ", bold: true },
`${dateFrom ? dateFrom.format("DD/MM/YYYY") : "Inicio"} al ${dateTo ? dateTo.format("DD/MM/YYYY") : "Fin"}` `${dateFrom ? dateFrom.format("DD/MM/YYYY") : "Inicio"} al ${dateTo ? dateTo.format("DD/MM/YYYY") : "Fin"}`,
], ],
margin: [0, 0, 0, 20] margin: [0, 0, 0, 20],
}, },
{ {
table: { table: {
headerRows: 1, headerRows: 1,
widths: [120, "*", "auto"], widths: [120, "*", "auto"],
body: tableBody body: tableBody,
}, },
layout: "lightHorizontalLines" layout: "lightHorizontalLines",
} },
] ],
}; };
if ((window as unknown as { Android: any }).Android && (window as unknown as { Android: any }).Android.savePdf) { if (
(window as unknown as { Android: any }).Android &&
(window as unknown as { Android: any }).Android.savePdf
) {
pdfMake.createPdf(docDefinition as any).getBase64((data: any) => { pdfMake.createPdf(docDefinition as any).getBase64((data: any) => {
(window as unknown as { Android: any }).Android?.savePdf(data, `reporte-adeudados.pdf`); (window as unknown as { Android: any }).Android?.savePdf(
data,
`reporte-adeudados.pdf`,
);
}); });
} else { } else {
pdfMake.createPdf(docDefinition as any).download(`reporte-adeudados.pdf`); pdfMake.createPdf(docDefinition as any).download(`reporte-adeudados.pdf`);
} }
} catch (error: unknown) { } catch (error: unknown) {
alert.showError((error as { format?: () => string }).format ? (error as { format: () => string }).format() : "Error generando PDF"); alert.showError(
(error as { format?: () => string }).format
? (error as { format: () => string }).format()
: "Error generando PDF",
);
} finally { } finally {
eventHandler.setEventType(EVENT_TYPES.CLEAR); eventHandler.setEventType(EVENT_TYPES.CLEAR);
} }
@@ -832,14 +860,24 @@ export default function OrganizationNotPayedAppointments() {
setMethodId={setMethodId} setMethodId={setMethodId}
clientData={clientFilterData} clientData={clientFilterData}
setClientIds={setClientIds} setClientIds={setClientIds}
/> />,
); );
filter.setOnFilter(() => {}); filter.setOnFilter(() => {});
return () => { return () => {
bottomToolbar.clear(); bottomToolbar.clear();
}; };
}, [paymentsTotal, collaboratorFilterData, serviceFilterData, methodFilterData, clientFilterData, clientIds, collaboratorId, serviceId, methodId]); }, [
paymentsTotal,
collaboratorFilterData,
serviceFilterData,
methodFilterData,
clientFilterData,
clientIds,
collaboratorId,
serviceId,
methodId,
]);
useEffect(() => { useEffect(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP); eventHandler.setEventType(EVENT_TYPES.SLEEP);
@@ -881,39 +919,75 @@ export default function OrganizationNotPayedAppointments() {
loadNotPayedAppointmentsTotal(); loadNotPayedAppointmentsTotal();
}, [clientIds, collaboratorId, serviceId, methodId, dateFrom, dateTo, reloadPayments]); }, [clientIds, collaboratorId, serviceId, methodId, dateFrom, dateTo, reloadPayments]);
return ( return (
<> <>
<Location /> <Location />
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}> <SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
<div style={{ maxWidth: "800px", margin: "0 auto", paddingBottom: "100px", marginTop: "0px" }}> <div
style={{
maxWidth: "800px",
margin: "0 auto",
paddingBottom: "100px",
marginTop: "0px",
}}
>
{/* Top Status Card */} {/* Top Status Card */}
<div style={{ position: "sticky", top: "70px", zIndex: 100, marginBottom: "50px", display: "flex", flexDirection: "column", background: "white", paddingBottom: "10px", paddingTop: "5px", margin: "0 -10px", paddingLeft: "10px", paddingRight: "10px" }}> <div
style={{
position: "sticky",
top: "70px",
zIndex: 100,
marginBottom: "50px",
display: "flex",
flexDirection: "column",
background: "white",
paddingBottom: "10px",
paddingTop: "5px",
margin: "0 -10px",
paddingLeft: "10px",
paddingRight: "10px",
}}
>
{/* Tabs Selector */} {/* Tabs Selector */}
<div style={{ display: "flex", justifyContent: "center", marginBottom: "15px" }}> <div
style={{
display: "flex",
justifyContent: "center",
marginBottom: "15px",
}}
>
<ThemeProvider theme={turnosXpressTheme}> <ThemeProvider theme={turnosXpressTheme}>
<div style={{ <div
style={{
display: "flex", display: "flex",
background: "rgba(255,255,255,0.9)", background: "rgba(255,255,255,0.9)",
backdropFilter: "blur(10px)", backdropFilter: "blur(10px)",
borderRadius: "30px", borderRadius: "30px",
padding: "5px", padding: "5px",
boxShadow: "0 4px 15px rgba(0,0,0,0.05)" boxShadow: "0 4px 15px rgba(0,0,0,0.05)",
}}> }}
>
<ButtonMaterial <ButtonMaterial
variant="text" variant="text"
onClick={() => goTo(`/admin/org/profile/${id}/payments`)} onClick={() => goTo(`/admin/org/profile/${id}/payments`)}
style={{ borderRadius: "25px", color: "#666", fontWeight: "bold", padding: "8px 25px" }} style={{
borderRadius: "25px",
color: "#666",
fontWeight: "bold",
padding: "8px 25px",
}}
> >
Pagados Pagados
</ButtonMaterial> </ButtonMaterial>
<ButtonMaterial <ButtonMaterial
variant="contained" variant="contained"
style={{ borderRadius: "25px", boxShadow: "0 2px 8px rgba(0,0,0,0.1)", fontWeight: "bold", padding: "8px 25px" }} style={{
borderRadius: "25px",
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
fontWeight: "bold",
padding: "8px 25px",
}}
> >
Adeudados Adeudados
</ButtonMaterial> </ButtonMaterial>
@@ -928,25 +1002,33 @@ export default function OrganizationNotPayedAppointments() {
opacity: 1, opacity: 1,
y: 0, y: 0,
padding: isMinimized ? "25px 50px" : "40px 20px", padding: isMinimized ? "25px 50px" : "40px 20px",
borderRadius: isMinimized ? "15px" : "20px" borderRadius: isMinimized ? "15px" : "20px",
}} }}
style={{ style={{
background: "linear-gradient(135deg, #FF416C 0%, #FF4B2B 100%)", // Red gradient for debts background: "linear-gradient(135deg, #FF416C 0%, #FF4B2B 100%)", // Red gradient for debts
color: "white", color: "white",
textAlign: "center", textAlign: "center",
boxShadow: isMinimized ? "0 4px 15px rgba(0,0,0,0.3)" : "0 10px 30px rgba(0,0,0,0.15)", boxShadow: isMinimized
? "0 4px 15px rgba(0,0,0,0.3)"
: "0 10px 30px rgba(0,0,0,0.15)",
position: "relative", position: "relative",
display: "flex", display: "flex",
flexDirection: isMinimized ? "row" : "column", flexDirection: isMinimized ? "row" : "column",
alignItems: "center", alignItems: "center",
justifyContent: isMinimized ? "space-between" : "center", justifyContent: isMinimized ? "space-between" : "center",
transition: "box-shadow 0.3s ease" transition: "box-shadow 0.3s ease",
}} }}
> >
<IconButton <IconButton
onClick={() => setForceMinimized(!forceMinimized)} onClick={() => setForceMinimized(!forceMinimized)}
style={{ position: "absolute", top: "10px", left: "10px", color: "rgba(255,255,255,0.7)", zIndex: 10 }} style={{
position: "absolute",
top: "10px",
left: "10px",
color: "rgba(255,255,255,0.7)",
zIndex: 10,
}}
size="small" size="small"
title={forceMinimized ? "Maximizar" : "Minimizar"} title={forceMinimized ? "Maximizar" : "Minimizar"}
> >
@@ -954,28 +1036,59 @@ export default function OrganizationNotPayedAppointments() {
</IconButton> </IconButton>
<IconButton <IconButton
onClick={() => filter.toggle()} onClick={() => filter.toggle()}
style={{ position: "absolute", top: "10px", right: "10px", color: "rgba(255,255,255,0.7)", zIndex: 10 }} style={{
position: "absolute",
top: "10px",
right: "10px",
color: "rgba(255,255,255,0.7)",
zIndex: 10,
}}
size="small" size="small"
title="Filtrar" title="Filtrar"
> >
<FilterAltOutlinedIcon /> <FilterAltOutlinedIcon />
</IconButton> </IconButton>
<div style={{ position: "absolute", top: "-50px", right: "-50px", width: "150px", height: "150px", borderRadius: "50%", background: "rgba(255,255,255,0.05)" }}></div> <div
<div style={{ position: "absolute", bottom: "-30px", left: "-30px", width: "100px", height: "100px", borderRadius: "50%", background: "rgba(255,255,255,0.05)" }}></div> style={{
position: "absolute",
top: "-50px",
right: "-50px",
width: "150px",
height: "150px",
borderRadius: "50%",
background: "rgba(255,255,255,0.05)",
}}
></div>
<div
style={{
position: "absolute",
bottom: "-30px",
left: "-30px",
width: "100px",
height: "100px",
borderRadius: "50%",
background: "rgba(255,255,255,0.05)",
}}
></div>
<motion.div <motion.div
animate={{ animate={{
fontSize: isMinimized ? "14px" : "16px", fontSize: isMinimized ? "14px" : "16px",
marginBottom: isMinimized ? "0px" : "10px" marginBottom: isMinimized ? "0px" : "10px",
}}
style={{
fontWeight: "bold",
textTransform: "uppercase",
letterSpacing: "2px",
opacity: 0.9,
}} }}
style={{ fontWeight: "bold", textTransform: "uppercase", letterSpacing: "2px", opacity: 0.9 }}
> >
Total Adeudado Total Adeudado
</motion.div> </motion.div>
<motion.div <motion.div
animate={{ animate={{
fontSize: isMinimized ? "24px" : "56px", fontSize: isMinimized ? "24px" : "56px",
margin: isMinimized ? "0px" : "10px 0" margin: isMinimized ? "0px" : "10px 0",
}} }}
style={{ fontWeight: "900" }} style={{ fontWeight: "900" }}
> >
@@ -989,7 +1102,13 @@ export default function OrganizationNotPayedAppointments() {
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
style={{ marginTop: "15px" }} style={{ marginTop: "15px" }}
> >
<div style={{ marginBottom: "15px", width: "250px", maxWidth: "100%" }}> <div
style={{
marginBottom: "15px",
width: "250px",
maxWidth: "100%",
}}
>
<TextRangeDateModern <TextRangeDateModern
label="Período" label="Período"
dateFrom={dateFrom} dateFrom={dateFrom}
@@ -998,7 +1117,14 @@ export default function OrganizationNotPayedAppointments() {
setDateTo={setDateTo} setDateTo={setDateTo}
/> />
</div> </div>
<div style={{ display: "flex", gap: "10px", alignItems: "center", flexWrap: "wrap" }}> <div
style={{
display: "flex",
gap: "10px",
alignItems: "center",
flexWrap: "wrap",
}}
>
<ButtonMaterial <ButtonMaterial
variant="contained" variant="contained"
startIcon={<PayIcon />} startIcon={<PayIcon />}
@@ -1009,7 +1135,7 @@ export default function OrganizationNotPayedAppointments() {
fontWeight: "bold", fontWeight: "bold",
borderRadius: "20px", borderRadius: "20px",
padding: "8px 20px", padding: "8px 20px",
textTransform: "none" textTransform: "none",
}} }}
> >
Pagar Todos Pagar Todos
@@ -1023,10 +1149,13 @@ export default function OrganizationNotPayedAppointments() {
fontWeight: "bold", fontWeight: "bold",
borderRadius: "20px", borderRadius: "20px",
padding: "8px 20px", padding: "8px 20px",
textTransform: "none" textTransform: "none",
}} }}
> >
<PictureAsPdfIcon style={{ fontSize: "18px", marginRight: "5px" }} /> PDF <PictureAsPdfIcon
style={{ fontSize: "18px", marginRight: "5px" }}
/>{" "}
PDF
</ButtonMaterial> </ButtonMaterial>
</div> </div>
</motion.div> </motion.div>
@@ -1035,10 +1164,22 @@ export default function OrganizationNotPayedAppointments() {
</div> </div>
{/* Dynamic Spacer for layout shift */} {/* Dynamic Spacer for layout shift */}
<div style={{ height: isMinimized ? "150px" : "0px", transition: "height 0.3s ease" }}></div> <div
style={{
height: isMinimized ? "150px" : "0px",
transition: "height 0.3s ease",
}}
></div>
{/* Payments List */} {/* Payments List */}
<div style={{ display: "flex", flexDirection: "column", gap: "15px", padding: "15px 10px 0 10px" }}> <div
style={{
display: "flex",
flexDirection: "column",
gap: "15px",
padding: "15px 10px 0 10px",
}}
>
<AnimatePresence> <AnimatePresence>
{payments?.data.map((payment, index) => ( {payments?.data.map((payment, index) => (
<motion.div <motion.div
@@ -1054,31 +1195,72 @@ export default function OrganizationNotPayedAppointments() {
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
boxShadow: "0 4px 15px rgba(0,0,0,0.05)", boxShadow: "0 4px 15px rgba(0,0,0,0.05)",
borderLeft: `4px solid var(--red)` borderLeft: `4px solid var(--red)`,
}} }}
> >
<div style={{ display: "flex", alignItems: "center", gap: "15px" }}> <div
<div style={{ style={{
display: "flex",
alignItems: "center",
gap: "15px",
}}
>
<div
style={{
background: "rgba(255, 75, 43, 0.1)", background: "rgba(255, 75, 43, 0.1)",
padding: "10px", padding: "10px",
borderRadius: "12px", borderRadius: "12px",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
color: "var(--red)" color: "var(--red)",
}}> }}
>
<CreditCardIcon /> <CreditCardIcon />
</div> </div>
<div> <div>
<div style={{ fontWeight: "800", fontSize: "15px", color: "#333", marginBottom: "4px" }}>{payment.clientName}</div> <div
<div style={{ fontWeight: "700", fontSize: "13px", color: "var(--purple)", marginBottom: "4px" }}>{payment.serviceName}</div> style={{
fontWeight: "800",
fontSize: "15px",
color: "#333",
marginBottom: "4px",
}}
>
{payment.clientName}
</div>
<div
style={{
fontWeight: "700",
fontSize: "13px",
color: "var(--purple)",
marginBottom: "4px",
}}
>
{payment.serviceName}
</div>
<div style={{ fontSize: "12px", color: "#888" }}> <div style={{ fontSize: "12px", color: "#888" }}>
{dayjs(payment.createdAt).format("DD MMMM YYYY • HH:mm")} • {payment.employeeName} {dayjs(payment.createdAt).format(
"DD MMMM YYYY • HH:mm",
)}{" "}
• {payment.employeeName}
</div> </div>
</div> </div>
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: "15px" }}> <div
<div style={{ fontWeight: "900", fontSize: "16px", color: "var(--red)" }}> style={{
display: "flex",
alignItems: "center",
gap: "15px",
}}
>
<div
style={{
fontWeight: "900",
fontSize: "16px",
color: "var(--red)",
}}
>
{formatPrice(payment.price)} {formatPrice(payment.price)}
</div> </div>
<IconButton <IconButton
@@ -1096,10 +1278,22 @@ export default function OrganizationNotPayedAppointments() {
))} ))}
{(!payments?.data || payments.data.length === 0) && ( {(!payments?.data || payments.data.length === 0) && (
<div style={{ textAlign: "center", padding: "50px", color: "#aaa" }}> <div
<CreditCardIcon sx={{ fontSize: "60px", opacity: 0.2, marginBottom: "15px" }} /> style={{ textAlign: "center", padding: "50px", color: "#aaa" }}
<div style={{ fontSize: "18px", fontWeight: "bold" }}>No hay pagos adeudados</div> >
<div style={{ fontSize: "14px", marginTop: "5px" }}>Intenta ajustar los filtros de fecha</div> <CreditCardIcon
sx={{
fontSize: "60px",
opacity: 0.2,
marginBottom: "15px",
}}
/>
<div style={{ fontSize: "18px", fontWeight: "bold" }}>
No hay pagos adeudados
</div>
<div style={{ fontSize: "14px", marginTop: "5px" }}>
Intenta ajustar los filtros de fecha
</div>
</div> </div>
)} )}
</AnimatePresence> </AnimatePresence>
@@ -1107,7 +1301,9 @@ export default function OrganizationNotPayedAppointments() {
{/* Pagination */} {/* Pagination */}
{payments && payments.pages > 1 && ( {payments && payments.pages > 1 && (
<div style={{ display: "flex", justifyContent: "center", marginTop: "40px" }}> <div
style={{ display: "flex", justifyContent: "center", marginTop: "40px" }}
>
<Pagination <Pagination
count={payments.pages} count={payments.pages}
page={payments.page} page={payments.page}
@@ -1134,7 +1330,7 @@ export default function OrganizationNotPayedAppointments() {
data={paymentMethhodFilterData} data={paymentMethhodFilterData}
onChange={(id) => { onChange={(id) => {
setPaymentMethodId( setPaymentMethodId(
id as CLIENT_ACCOUNT_PAYMENT_METHODS id as CLIENT_ACCOUNT_PAYMENT_METHODS,
); );
}} }}
/> />
@@ -1185,7 +1381,7 @@ export default function OrganizationNotPayedAppointments() {
data={paymentMethhodFilterData} data={paymentMethhodFilterData}
onChange={(id) => { onChange={(id) => {
setPaymentMethodId( setPaymentMethodId(
id as CLIENT_ACCOUNT_PAYMENT_METHODS id as CLIENT_ACCOUNT_PAYMENT_METHODS,
); );
}} }}
/> />
@@ -1219,7 +1415,6 @@ export default function OrganizationNotPayedAppointments() {
</DialogActions> </DialogActions>
</> </>
</DialogWindow> </DialogWindow>
</div> </div>
</SecureByPlan> </SecureByPlan>
</> </>
@@ -47,6 +47,7 @@ import SearchOffIcon from "@mui/icons-material/SearchOff";
import UnfoldLessIcon from "@mui/icons-material/UnfoldLess"; import UnfoldLessIcon from "@mui/icons-material/UnfoldLess";
import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore"; import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore";
import FilterAltOutlinedIcon from "@mui/icons-material/FilterAltOutlined"; import FilterAltOutlinedIcon from "@mui/icons-material/FilterAltOutlined";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = { type UrlData = {
id: string; id: string;
@@ -68,8 +69,12 @@ const FilterContent = (props: FilterContentProps) => {
const [clientFilterContent] = useState<TextObjectModalContent>({ const [clientFilterContent] = useState<TextObjectModalContent>({
header: ( header: (
<div> <div>
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>Seleccionar Cliente:</div> <div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>Ingrese el nombre del cliente para buscarlo.</p> Seleccionar Cliente:
</div>
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>
Ingrese el nombre del cliente para buscarlo.
</p>
</div> </div>
), ),
bottom: <div>&nbsp;</div>, bottom: <div>&nbsp;</div>,
@@ -81,8 +86,12 @@ const FilterContent = (props: FilterContentProps) => {
const [collaboratorFilterContent] = useState<TextObjectModalContent>({ const [collaboratorFilterContent] = useState<TextObjectModalContent>({
header: ( header: (
<div> <div>
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>Seleccionar Colaborador:</div> <div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>Ingrese el nombre del colaborador para buscarlo.</p> Seleccionar Colaborador:
</div>
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>
Ingrese el nombre del colaborador para buscarlo.
</p>
</div> </div>
), ),
bottom: <div>&nbsp;</div>, bottom: <div>&nbsp;</div>,
@@ -94,8 +103,12 @@ const FilterContent = (props: FilterContentProps) => {
const [serviceFilterContent] = useState<TextObjectModalContent>({ const [serviceFilterContent] = useState<TextObjectModalContent>({
header: ( header: (
<div> <div>
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>Seleccionar Servicio:</div> <div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>Ingrese el nombre del servicio para buscarlo.</p> Seleccionar Servicio:
</div>
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>
Ingrese el nombre del servicio para buscarlo.
</p>
</div> </div>
), ),
bottom: <div>&nbsp;</div>, bottom: <div>&nbsp;</div>,
@@ -107,8 +120,12 @@ const FilterContent = (props: FilterContentProps) => {
const [methodFilterContent] = useState<TextObjectModalContent>({ const [methodFilterContent] = useState<TextObjectModalContent>({
header: ( header: (
<div> <div>
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>Seleccionar Método de Pago:</div> <div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>Ingrese el nombre del método de pago para buscarlo.</p> Seleccionar Método de Pago:
</div>
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>
Ingrese el nombre del método de pago para buscarlo.
</p>
</div> </div>
), ),
bottom: <div>&nbsp;</div>, bottom: <div>&nbsp;</div>,
@@ -152,6 +169,7 @@ export default function OrganizationEdit() {
const data = useParams<UrlData>(); const data = useParams<UrlData>();
const id = data.id; const id = data.id;
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
const filter = useFilterStore(); const filter = useFilterStore();
@@ -168,7 +186,9 @@ export default function OrganizationEdit() {
const [clientFilterData, setClientFilterData] = useState<TextObjectFilterResult[]>([]); const [clientFilterData, setClientFilterData] = useState<TextObjectFilterResult[]>([]);
const [clientIds, setClientIds] = useState<string[]>([]); const [clientIds, setClientIds] = useState<string[]>([]);
const [collaboratorFilterData, setCollaboratorFilterData] = useState<TextObjectFilterResult[]>([]); const [collaboratorFilterData, setCollaboratorFilterData] = useState<TextObjectFilterResult[]>(
[],
);
const [collaboratorId, setCollaboratorId] = useState<string>(""); const [collaboratorId, setCollaboratorId] = useState<string>("");
const [serviceFilterData, setServiceFilterData] = useState<TextObjectFilterResult[]>([]); const [serviceFilterData, setServiceFilterData] = useState<TextObjectFilterResult[]>([]);
@@ -188,7 +208,6 @@ export default function OrganizationEdit() {
const generarReporte = async () => { const generarReporte = async () => {
try { try {
const pdfMake = await import("pdfmake/build/pdfmake"); const pdfMake = await import("pdfmake/build/pdfmake");
const pdfFonts = (await import("pdfmake/build/vfs_fonts")) as any; const pdfFonts = (await import("pdfmake/build/vfs_fonts")) as any;
pdfMake.vfs = pdfFonts.pdfMake ? pdfFonts.pdfMake.vfs : pdfFonts.vfs; pdfMake.vfs = pdfFonts.pdfMake ? pdfFonts.pdfMake.vfs : pdfFonts.vfs;
@@ -201,7 +220,7 @@ export default function OrganizationEdit() {
serviceId: serviceId ? serviceId : undefined, serviceId: serviceId ? serviceId : undefined,
employeeId: collaboratorId ? collaboratorId : undefined, employeeId: collaboratorId ? collaboratorId : undefined,
method: methodId ? (methodId as any) : undefined, method: methodId ? (methodId as any) : undefined,
sessionUser: SessionInfo.userId sessionUser: SessionInfo.userId,
}); });
if (!report.items || report.items.length === 0) { if (!report.items || report.items.length === 0) {
@@ -224,79 +243,107 @@ export default function OrganizationEdit() {
[ [
{ text: "Cliente", bold: true, color: "#333333", fillColor: "#f5f5f5" }, { text: "Cliente", bold: true, color: "#333333", fillColor: "#f5f5f5" },
{ text: "Datos del Turno", bold: true, color: "#333333", fillColor: "#f5f5f5" }, { text: "Datos del Turno", bold: true, color: "#333333", fillColor: "#f5f5f5" },
{ text: "Monto", bold: true, color: "#333333", fillColor: "#f5f5f5" } { text: "Monto", bold: true, color: "#333333", fillColor: "#f5f5f5" },
] ],
]; ];
report.items.forEach(item => { report.items.forEach((item) => {
tableBody.push([ tableBody.push([
item.clientName || "-", item.clientName || "-",
{ {
text: [ text: [
{ text: `${item.serviceName || "-"}\n`, bold: true, fontSize: 11 }, { text: `${item.serviceName || "-"}\n`, bold: true, fontSize: 11 },
`Prof: ${item.employeeName || "-"}\nFecha: ${dayjs(item.appointmentDate).format("DD/MM/YYYY HH:mm")} hs\nMétodo: ${item.method || "-"}` `Prof: ${item.employeeName || "-"}\nFecha: ${dayjs(item.appointmentDate).format("DD/MM/YYYY HH:mm")} hs\nMétodo: ${item.method || "-"}`,
], ],
fontSize: 10 fontSize: 10,
}, },
{ text: "$" + item.price.toLocaleString("es-AR"), alignment: "right" } { text: "$" + item.price.toLocaleString("es-AR"), alignment: "right" },
]); ]);
}); });
tableBody.push([ tableBody.push([
{ text: "TOTAL PROCESADO", bold: true, colSpan: 2, alignment: "right" }, { text: "TOTAL PROCESADO", bold: true, colSpan: 2, alignment: "right" },
{}, {},
{ text: "$" + report.totalAmount.toLocaleString("es-AR"), bold: true, alignment: "right" } {
text: "$" + report.totalAmount.toLocaleString("es-AR"),
bold: true,
alignment: "right",
},
]); ]);
const docDefinition: unknown = { const docDefinition: unknown = {
content: [ content: [
{ {
columns: [ columns: [
{ text: "Reporte de Cobros Procesados", fontSize: 18, bold: true, margin: [0, 0, 0, 10], width: '*' }, {
...(logoDataUrl ? [{ image: logoDataUrl, width: 45, margin: [0, 0, 0, 10], alignment: 'right' }] : []) text: "Reporte de Cobros Procesados",
fontSize: 18,
bold: true,
margin: [0, 0, 0, 10],
width: "*",
},
...(logoDataUrl
? [
{
image: logoDataUrl,
width: 45,
margin: [0, 0, 0, 10],
alignment: "right",
},
] ]
: []),
],
}, },
{ text: `Filtros aplicados:`, bold: true, margin: [0, 0, 0, 5] }, { text: `Filtros aplicados:`, bold: true, margin: [0, 0, 0, 5] },
{ {
text: [ text: [
{ text: "Clientes Seleccionados: ", bold: true }, { text: "Clientes Seleccionados: ", bold: true },
`${clientIds.length > 0 ? clientIds.length : "Todos"}` `${clientIds.length > 0 ? clientIds.length : "Todos"}`,
], ],
margin: [0, 0, 0, 2] margin: [0, 0, 0, 2],
}, },
{ {
text: [ text: [
{ text: "Período: ", bold: true }, { text: "Período: ", bold: true },
`${dateFrom ? dateFrom.format("DD/MM/YYYY") : "Inicio"} al ${dateTo ? dateTo.format("DD/MM/YYYY") : "Fin"}` `${dateFrom ? dateFrom.format("DD/MM/YYYY") : "Inicio"} al ${dateTo ? dateTo.format("DD/MM/YYYY") : "Fin"}`,
], ],
margin: [0, 0, 0, 20] margin: [0, 0, 0, 20],
}, },
{ {
table: { table: {
headerRows: 1, headerRows: 1,
widths: [120, "*", "auto"], widths: [120, "*", "auto"],
body: tableBody body: tableBody,
}, },
layout: "lightHorizontalLines" layout: "lightHorizontalLines",
} },
] ],
}; };
if ((window as unknown as { Android: any }).Android && (window as unknown as { Android: any }).Android.savePdf) { if (
(window as unknown as { Android: any }).Android &&
(window as unknown as { Android: any }).Android.savePdf
) {
pdfMake.createPdf(docDefinition as any).getBase64((data: any) => { pdfMake.createPdf(docDefinition as any).getBase64((data: any) => {
(window as unknown as { Android: any }).Android?.savePdf(data, `reporte-procesados.pdf`); (window as unknown as { Android: any }).Android?.savePdf(
data,
`reporte-procesados.pdf`,
);
}); });
} else { } else {
pdfMake.createPdf(docDefinition as any).download(`reporte-procesados.pdf`); pdfMake.createPdf(docDefinition as any).download(`reporte-procesados.pdf`);
} }
} catch (error: unknown) { } catch (error: unknown) {
console.error(error); console.error(error);
alert.showError((error as { format?: () => string }).format ? (error as { format: () => string }).format() : "Error generando PDF"); alert.showError(
(error as { format?: () => string }).format
? (error as { format: () => string }).format()
: "Error generando PDF",
);
} }
}; };
useEffect(() => { useEffect(() => {
const handleScroll = (e: Event) => { const handleScroll = (e: Event) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
// Check if it's a DOM element scrolling // Check if it's a DOM element scrolling
@@ -389,15 +436,23 @@ export default function OrganizationEdit() {
useEffect(() => { useEffect(() => {
const cFilter: FindClientsParams = { companyId: id }; const cFilter: FindClientsParams = { companyId: id };
filterObjects<FindClientsParams>("clients/filter-all", cFilter).then((res) => setClientFilterData(res)); filterObjects<FindClientsParams>("clients/filter-all", cFilter).then((res) =>
setClientFilterData(res),
);
const eFilter: FindEmployeesParams = { companyId: id }; const eFilter: FindEmployeesParams = { companyId: id };
filterObjects<FindEmployeesParams>("employees/filter-all", eFilter).then((res) => setCollaboratorFilterData(res)); filterObjects<FindEmployeesParams>("employees/filter-all", eFilter).then((res) =>
setCollaboratorFilterData(res),
);
const sFilter: FindServicesParams = { companyId: id }; const sFilter: FindServicesParams = { companyId: id };
filterObjects<FindServicesParams>("services/filter-all", sFilter).then((res) => setServiceFilterData(res)); filterObjects<FindServicesParams>("services/filter-all", sFilter).then((res) =>
setServiceFilterData(res),
);
setMethodFilterData(paymentMethods.map((m) => ({ id: m.id, text: m.name, name: m.name, image: "" }))); setMethodFilterData(
paymentMethods.map((m) => ({ id: m.id, text: m.name, name: m.name, image: "" })),
);
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -414,7 +469,7 @@ export default function OrganizationEdit() {
clientData={clientFilterData} clientData={clientFilterData}
setClientIds={setClientIds} setClientIds={setClientIds}
clientIds={clientIds} clientIds={clientIds}
/> />,
); );
filter.setOnFilter(() => {}); filter.setOnFilter(() => {});
}, [collaboratorFilterData, serviceFilterData, methodFilterData, clientFilterData]); }, [collaboratorFilterData, serviceFilterData, methodFilterData, clientFilterData]);
@@ -422,9 +477,22 @@ export default function OrganizationEdit() {
useEffect(() => { useEffect(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP); eventHandler.setEventType(EVENT_TYPES.SLEEP);
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ url: "/admin/org", title: "Organizaciones", icon: null }); location.add({ url: "/admin/org", title: "Organizaciones", icon: null });
location.add({ url: "/admin/org/profile/" + id, title: "Perfil", icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" /> }); location.add({
location.add({ url: "/admin/org/profile/" + id, title: "Pagos", icon: <PaymentsIcon sx={{ fontSize: "32px", mr: "10px" }} /> }); url: "/admin/org/profile/" + id,
title: "Perfil",
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />,
});
location.add({
url: "/admin/org/profile/" + id,
title: "Pagos",
icon: <PaymentsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
});
return () => {
bottomToolbar.clear();
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -444,35 +512,75 @@ export default function OrganizationEdit() {
<Location /> <Location />
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}> <SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
<div style={{ maxWidth: "800px", margin: "0 auto", paddingBottom: "100px", marginTop: "0px" }}> <div
style={{
maxWidth: "800px",
margin: "0 auto",
paddingBottom: "100px",
marginTop: "0px",
}}
>
{/* Sentinel element to detect scroll */} {/* Sentinel element to detect scroll */}
<div ref={sentinelRef} style={{ height: "1px", width: "100%" }}></div> <div ref={sentinelRef} style={{ height: "1px", width: "100%" }}></div>
{/* Top Status Card */} {/* Top Status Card */}
<div style={{ position: "sticky", top: "70px", zIndex: 100, marginBottom: "50px", display: "flex", flexDirection: "column", background: "white", paddingBottom: "10px", paddingTop: "5px", margin: "0 -10px", paddingLeft: "10px", paddingRight: "10px" }}> <div
style={{
position: "sticky",
top: "70px",
zIndex: 100,
marginBottom: "50px",
display: "flex",
flexDirection: "column",
background: "white",
paddingBottom: "10px",
paddingTop: "5px",
margin: "0 -10px",
paddingLeft: "10px",
paddingRight: "10px",
}}
>
{/* Tabs Selector */} {/* Tabs Selector */}
<div style={{ display: "flex", justifyContent: "center", marginBottom: "15px" }}> <div
style={{
display: "flex",
justifyContent: "center",
marginBottom: "15px",
}}
>
<ThemeProvider theme={turnosXpressTheme}> <ThemeProvider theme={turnosXpressTheme}>
<div style={{ <div
style={{
display: "flex", display: "flex",
background: "rgba(255,255,255,0.9)", background: "rgba(255,255,255,0.9)",
backdropFilter: "blur(10px)", backdropFilter: "blur(10px)",
borderRadius: "30px", borderRadius: "30px",
padding: "5px", padding: "5px",
boxShadow: "0 4px 15px rgba(0,0,0,0.05)" boxShadow: "0 4px 15px rgba(0,0,0,0.05)",
}}> }}
>
<ButtonMaterial <ButtonMaterial
variant="contained" variant="contained"
style={{ borderRadius: "25px", boxShadow: "0 2px 8px rgba(0,0,0,0.1)", fontWeight: "bold", padding: "8px 25px" }} style={{
borderRadius: "25px",
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
fontWeight: "bold",
padding: "8px 25px",
}}
> >
Pagados Pagados
</ButtonMaterial> </ButtonMaterial>
<ButtonMaterial <ButtonMaterial
variant="text" variant="text"
onClick={() => goTo(`/admin/org/profile/${id}/payments/not-payed`)} onClick={() =>
style={{ borderRadius: "25px", color: "#666", fontWeight: "bold", padding: "8px 25px" }} goTo(`/admin/org/profile/${id}/payments/not-payed`)
}
style={{
borderRadius: "25px",
color: "#666",
fontWeight: "bold",
padding: "8px 25px",
}}
> >
Adeudados Adeudados
</ButtonMaterial> </ButtonMaterial>
@@ -487,25 +595,33 @@ export default function OrganizationEdit() {
opacity: 1, opacity: 1,
y: 0, y: 0,
padding: isMinimized ? "25px 50px" : "40px 20px", padding: isMinimized ? "25px 50px" : "40px 20px",
borderRadius: isMinimized ? "15px" : "20px" borderRadius: isMinimized ? "15px" : "20px",
}} }}
style={{ style={{
background: "linear-gradient(135deg, #4b6cb7 0%, #182848 100%)", background: "linear-gradient(135deg, #4b6cb7 0%, #182848 100%)",
color: "white", color: "white",
textAlign: "center", textAlign: "center",
boxShadow: isMinimized ? "0 4px 15px rgba(0,0,0,0.3)" : "0 10px 30px rgba(0,0,0,0.15)", boxShadow: isMinimized
? "0 4px 15px rgba(0,0,0,0.3)"
: "0 10px 30px rgba(0,0,0,0.15)",
position: "relative", position: "relative",
display: "flex", display: "flex",
flexDirection: isMinimized ? "row" : "column", flexDirection: isMinimized ? "row" : "column",
alignItems: "center", alignItems: "center",
justifyContent: isMinimized ? "space-between" : "center", justifyContent: isMinimized ? "space-between" : "center",
transition: "box-shadow 0.3s ease" transition: "box-shadow 0.3s ease",
}} }}
> >
<IconButton <IconButton
onClick={() => setForceMinimized(!forceMinimized)} onClick={() => setForceMinimized(!forceMinimized)}
style={{ position: "absolute", top: "10px", left: "10px", color: "rgba(255,255,255,0.7)", zIndex: 10 }} style={{
position: "absolute",
top: "10px",
left: "10px",
color: "rgba(255,255,255,0.7)",
zIndex: 10,
}}
size="small" size="small"
title={forceMinimized ? "Maximizar" : "Minimizar"} title={forceMinimized ? "Maximizar" : "Minimizar"}
> >
@@ -513,28 +629,59 @@ export default function OrganizationEdit() {
</IconButton> </IconButton>
<IconButton <IconButton
onClick={() => filter.toggle()} onClick={() => filter.toggle()}
style={{ position: "absolute", top: "10px", right: "10px", color: "rgba(255,255,255,0.7)", zIndex: 10 }} style={{
position: "absolute",
top: "10px",
right: "10px",
color: "rgba(255,255,255,0.7)",
zIndex: 10,
}}
size="small" size="small"
title="Filtrar" title="Filtrar"
> >
<FilterAltOutlinedIcon /> <FilterAltOutlinedIcon />
</IconButton> </IconButton>
<div style={{ position: "absolute", top: "-50px", right: "-50px", width: "150px", height: "150px", borderRadius: "50%", background: "rgba(255,255,255,0.05)" }}></div> <div
<div style={{ position: "absolute", bottom: "-30px", left: "-30px", width: "100px", height: "100px", borderRadius: "50%", background: "rgba(255,255,255,0.05)" }}></div> style={{
position: "absolute",
top: "-50px",
right: "-50px",
width: "150px",
height: "150px",
borderRadius: "50%",
background: "rgba(255,255,255,0.05)",
}}
></div>
<div
style={{
position: "absolute",
bottom: "-30px",
left: "-30px",
width: "100px",
height: "100px",
borderRadius: "50%",
background: "rgba(255,255,255,0.05)",
}}
></div>
<motion.div <motion.div
animate={{ animate={{
fontSize: isMinimized ? "14px" : "16px", fontSize: isMinimized ? "14px" : "16px",
marginBottom: isMinimized ? "0px" : "10px" marginBottom: isMinimized ? "0px" : "10px",
}}
style={{
fontWeight: "bold",
textTransform: "uppercase",
letterSpacing: "2px",
opacity: 0.9,
}} }}
style={{ fontWeight: "bold", textTransform: "uppercase", letterSpacing: "2px", opacity: 0.9 }}
> >
Total Recaudado Total Recaudado
</motion.div> </motion.div>
<motion.div <motion.div
animate={{ animate={{
fontSize: isMinimized ? "24px" : "56px", fontSize: isMinimized ? "24px" : "56px",
margin: isMinimized ? "0px" : "10px 0" margin: isMinimized ? "0px" : "10px 0",
}} }}
style={{ fontWeight: "900" }} style={{ fontWeight: "900" }}
> >
@@ -547,7 +694,16 @@ export default function OrganizationEdit() {
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
style={{ fontSize: "14px", opacity: 0.8 }} style={{ fontSize: "14px", opacity: 0.8 }}
> >
<div style={{ display: "flex", gap: "10px", alignItems: "center", flexWrap: "wrap", justifyContent: "center", marginTop: "10px" }}> <div
style={{
display: "flex",
gap: "10px",
alignItems: "center",
flexWrap: "wrap",
justifyContent: "center",
marginTop: "10px",
}}
>
<div style={{ width: "250px", maxWidth: "100%" }}> <div style={{ width: "250px", maxWidth: "100%" }}>
<TextRangeDateModern <TextRangeDateModern
label="Período" label="Período"
@@ -567,10 +723,13 @@ export default function OrganizationEdit() {
borderRadius: "20px", borderRadius: "20px",
padding: "8px 20px", padding: "8px 20px",
textTransform: "none", textTransform: "none",
height: "56px" height: "56px",
}} }}
> >
<PictureAsPdfIcon style={{ fontSize: "18px", marginRight: "5px" }} /> PDF <PictureAsPdfIcon
style={{ fontSize: "18px", marginRight: "5px" }}
/>{" "}
PDF
</ButtonMaterial> </ButtonMaterial>
</div> </div>
</motion.div> </motion.div>
@@ -579,10 +738,22 @@ export default function OrganizationEdit() {
</div> </div>
{/* Dynamic Spacer for layout shift */} {/* Dynamic Spacer for layout shift */}
<div style={{ height: isMinimized ? "100px" : "0px", transition: "height 0.3s ease" }}></div> <div
style={{
height: isMinimized ? "100px" : "0px",
transition: "height 0.3s ease",
}}
></div>
{/* Payments List */} {/* Payments List */}
<div style={{ display: "flex", flexDirection: "column", gap: "12px", paddingTop: "15px" }}> <div
style={{
display: "flex",
flexDirection: "column",
gap: "12px",
paddingTop: "15px",
}}
>
{dataList.length > 0 ? ( {dataList.length > 0 ? (
<AnimatePresence> <AnimatePresence>
{dataList.map((payment, index) => ( {dataList.map((payment, index) => (
@@ -602,11 +773,19 @@ export default function OrganizationEdit() {
padding: "16px 20px", padding: "16px 20px",
boxShadow: "0 4px 12px rgba(0,0,0,0.05)", boxShadow: "0 4px 12px rgba(0,0,0,0.05)",
borderLeft: "5px solid #28a745", borderLeft: "5px solid #28a745",
position: "relative" position: "relative",
}} }}
> >
<div style={{ display: "flex", alignItems: "center", gap: "15px", flex: 1 }}> <div
<div style={{ style={{
display: "flex",
alignItems: "center",
gap: "15px",
flex: 1,
}}
>
<div
style={{
width: "50px", width: "50px",
height: "50px", height: "50px",
borderRadius: "12px", borderRadius: "12px",
@@ -614,31 +793,65 @@ export default function OrganizationEdit() {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
color: "#28a745" color: "#28a745",
}}> }}
>
{getPaymentIcon(payment.method)} {getPaymentIcon(payment.method)}
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ fontSize: "16px", fontWeight: "bold", color: "#333", marginBottom: "2px" }}> <div
style={{
fontSize: "16px",
fontWeight: "bold",
color: "#333",
marginBottom: "2px",
}}
>
{payment.clientName} {payment.clientName}
</div> </div>
<div style={{ fontSize: "14px", color: "var(--wine-dark)", fontWeight: "bold", marginBottom: "4px" }}> <div
style={{
fontSize: "14px",
color: "var(--wine-dark)",
fontWeight: "bold",
marginBottom: "4px",
}}
>
{payment.serviceName} {payment.serviceName}
</div> </div>
<div style={{ display: "flex", gap: "10px", fontSize: "12px", color: "#888" }}> <div
<span>{dayjs(payment.createdAt).format("DD MMMM YYYY • HH:mm")}</span> style={{
display: "flex",
gap: "10px",
fontSize: "12px",
color: "#888",
}}
>
<span>
{dayjs(payment.createdAt).format(
"DD MMMM YYYY • HH:mm",
)}
</span>
<span>•</span> <span>•</span>
<span>{payment.employeeName}</span> <span>{payment.employeeName}</span>
</div> </div>
</div> </div>
</div> </div>
<div style={{ display: "flex", alignItems: "center", gap: "15px" }}> <div
<div style={{ style={{
display: "flex",
alignItems: "center",
gap: "15px",
}}
>
<div
style={{
fontSize: "20px", fontSize: "20px",
fontWeight: "900", fontWeight: "900",
color: "#28a745", color: "#28a745",
textAlign: "right" textAlign: "right",
}}> }}
>
+{formatPrice(payment.price)} +{formatPrice(payment.price)}
</div> </div>
<IconButton <IconButton
@@ -646,14 +859,18 @@ export default function OrganizationEdit() {
size="small" size="small"
onClick={() => { onClick={() => {
confirm.show( confirm.show(
"¿Estás seguro que quieres eliminar el pago de " + payment.clientName + "?", "¿Estás seguro que quieres eliminar el pago de " +
payment.clientName +
"?",
() => deletePaymentHandler(payment.id), () => deletePaymentHandler(payment.id),
() => { } () => {},
); );
}} }}
sx={{ sx={{
backgroundColor: "rgba(220, 53, 69, 0.05)", backgroundColor: "rgba(220, 53, 69, 0.05)",
"&:hover": { backgroundColor: "rgba(220, 53, 69, 0.15)" } "&:hover": {
backgroundColor: "rgba(220, 53, 69, 0.15)",
},
}} }}
> >
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
@@ -666,17 +883,29 @@ export default function OrganizationEdit() {
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
style={{ textAlign: "center", padding: "60px 20px", backgroundColor: "#fff", borderRadius: "15px", border: "1px dashed #ccc" }} style={{
textAlign: "center",
padding: "60px 20px",
backgroundColor: "#fff",
borderRadius: "15px",
border: "1px dashed #ccc",
}}
> >
<SearchOffIcon sx={{ fontSize: "64px", color: "#ccc", marginBottom: "15px" }} /> <SearchOffIcon
sx={{ fontSize: "64px", color: "#ccc", marginBottom: "15px" }}
/>
<h2 style={{ margin: 0, color: "#999" }}>Sin Pagos</h2> <h2 style={{ margin: 0, color: "#999" }}>Sin Pagos</h2>
<p style={{ color: "#aaa" }}>No se encontraron pagos registrados para este período.</p> <p style={{ color: "#aaa" }}>
No se encontraron pagos registrados para este período.
</p>
</motion.div> </motion.div>
)} )}
</div> </div>
{payments && payments.pages > 1 && ( {payments && payments.pages > 1 && (
<div style={{ display: "flex", justifyContent: "center", marginTop: "40px" }}> <div
style={{ display: "flex", justifyContent: "center", marginTop: "40px" }}
>
<Pagination <Pagination
count={payments.pages} count={payments.pages}
page={payments.page} page={payments.page}
@@ -17,6 +17,7 @@ import Avatar from "@components/Avatar/Avatar";
import Button from "@core/app/components/Button/Button"; import Button from "@core/app/components/Button/Button";
import { useConfirmStore } from "@core/Store/Confirm.Store"; import { useConfirmStore } from "@core/Store/Confirm.Store";
import { getMondayWeekStart } from "@core/helpers/week"; import { getMondayWeekStart } from "@core/helpers/week";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
dayjs.locale("es"); dayjs.locale("es");
@@ -28,6 +29,7 @@ export default function Schedules() {
const data = useParams<UrlData>(); const data = useParams<UrlData>();
const id = data.id; const id = data.id;
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
const alert = useAlert(); const alert = useAlert();
@@ -38,6 +40,8 @@ export default function Schedules() {
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ location.add({
url: "/admin/org", url: "/admin/org",
title: "Organizaciones", title: "Organizaciones",
@@ -55,6 +59,10 @@ export default function Schedules() {
title: "Horarios Habilitados", title: "Horarios Habilitados",
icon: null, icon: null,
}); });
return () => {
bottomToolbar.clear();
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -118,7 +126,7 @@ export default function Schedules() {
}, },
() => { () => {
return; return;
} },
); );
}; };
@@ -80,7 +80,7 @@ export default function OrganizationService() {
const [serviceDiscountName, setServiceDiscountName] = useState<string | undefined>(undefined); const [serviceDiscountName, setServiceDiscountName] = useState<string | undefined>(undefined);
const [servicePublished, setServicePublished] = useState<SERVICE_PUBLISHED_STATUS | undefined>( const [servicePublished, setServicePublished] = useState<SERVICE_PUBLISHED_STATUS | undefined>(
undefined undefined,
); );
const [savePublishedStatusAvailable, setSavePublishedStatusAvailable] = useState(false); const [savePublishedStatusAvailable, setSavePublishedStatusAvailable] = useState(false);
@@ -149,7 +149,7 @@ export default function OrganizationService() {
> >
Aplicar Descuento Aplicar Descuento
</ButtonMaterial> </ButtonMaterial>
</div> </div>,
); );
}, []); }, []);
@@ -271,8 +271,12 @@ export default function OrganizationService() {
setServiceDiscountType(response.discountType); setServiceDiscountType(response.discountType);
setServiceDiscountValue(response.discountValue); setServiceDiscountValue(response.discountValue);
setServiceDiscountName(response.discountName); setServiceDiscountName(response.discountName);
setShowPublicScores(response.showPublicScores !== undefined ? response.showPublicScores : true); setShowPublicScores(
setShowPublicOpinions(response.showPublicOpinions !== undefined ? response.showPublicOpinions : true); response.showPublicScores !== undefined ? response.showPublicScores : true,
);
setShowPublicOpinions(
response.showPublicOpinions !== undefined ? response.showPublicOpinions : true,
);
}) })
.catch(() => {}) .catch(() => {})
.finally(() => { .finally(() => {
@@ -283,7 +287,7 @@ export default function OrganizationService() {
const formatDiscount = ( const formatDiscount = (
value: number | undefined, value: number | undefined,
type: string | undefined, type: string | undefined,
name: string | undefined name: string | undefined,
): string => { ): string => {
if (value === undefined || value === null || type === undefined || type === null) { if (value === undefined || value === null || type === undefined || type === null) {
return ""; return "";
@@ -464,7 +468,7 @@ export default function OrganizationService() {
setServicePublished( setServicePublished(
checked checked
? SERVICE_PUBLISHED_STATUS.PUBLISHED ? SERVICE_PUBLISHED_STATUS.PUBLISHED
: SERVICE_PUBLISHED_STATUS.PRIVATE : SERVICE_PUBLISHED_STATUS.PRIVATE,
); );
}} }}
/> />
@@ -506,10 +510,15 @@ export default function OrganizationService() {
/> />
<MaterialAlert severity="info" sx={{ marginTop: "20px" }}> <MaterialAlert severity="info" sx={{ marginTop: "20px" }}>
<b>Visibilidad pública:</b> Elegí si querés mostrar puntuaciones y opiniones de este servicio en páginas públicas. Disponible para planes pagos. <b>Visibilidad pública:</b> Elegí si querés mostrar puntuaciones y opiniones de este
servicio en páginas públicas. Disponible para planes pagos.
</MaterialAlert> </MaterialAlert>
<div style={{ display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" }}> <div
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}> style={{ display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" }}
>
<label
style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}
>
<Switch <Switch
checked={showPublicScores} checked={showPublicScores}
disabled={!canEditPublicRatingFlags} disabled={!canEditPublicRatingFlags}
@@ -520,7 +529,9 @@ export default function OrganizationService() {
/> />
Mostrar puntuaciones públicas Mostrar puntuaciones públicas
</label> </label>
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}> <label
style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}
>
<Switch <Switch
checked={showPublicOpinions} checked={showPublicOpinions}
disabled={!canEditPublicRatingFlags} disabled={!canEditPublicRatingFlags}
@@ -567,7 +578,7 @@ export default function OrganizationService() {
() => { () => {
deleteDiscountHandler(); deleteDiscountHandler();
}, },
() => {} () => {},
); );
}} }}
> >
@@ -16,6 +16,7 @@ import { getServiceImage } from "@core/helpers/getServiceImage";
import ServiceRatingSummary from "@components/ServiceRatingSummary/ServiceRatingSummary"; import ServiceRatingSummary from "@components/ServiceRatingSummary/ServiceRatingSummary";
import ShareOutlinedIcon from "@mui/icons-material/ShareOutlined"; import ShareOutlinedIcon from "@mui/icons-material/ShareOutlined";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import Dialog from "@mui/material/Dialog"; import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent"; import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle"; import DialogTitle from "@mui/material/DialogTitle";
@@ -31,8 +32,9 @@ import {
WhatsappIcon as WhatsAppShareIcon, WhatsappIcon as WhatsAppShareIcon,
TwitterIcon, TwitterIcon,
EmailIcon, EmailIcon,
TelegramIcon TelegramIcon,
} from "react-share"; } from "react-share";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = { type UrlData = {
id: string; id: string;
@@ -42,6 +44,7 @@ export default function Org() {
const urlData = useParams<UrlData>(); const urlData = useParams<UrlData>();
const id = urlData.id; const id = urlData.id;
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const alert = useAlert(); const alert = useAlert();
@@ -57,6 +60,8 @@ export default function Org() {
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ location.add({
url: "/admin/org", url: "/admin/org",
title: "Mis organizaciones", title: "Mis organizaciones",
@@ -74,6 +79,10 @@ export default function Org() {
title: "Mis servicios", title: "Mis servicios",
icon: <StorefrontOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />, icon: <StorefrontOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />,
}); });
return () => {
bottomToolbar.clear();
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -104,7 +113,7 @@ export default function Org() {
<div <div
className={style.serviceCard} className={style.serviceCard}
style={{ style={{
borderLeft: `6px solid ${data.color}` borderLeft: `6px solid ${data.color}`,
}} }}
onClick={() => handleServiceClick(data.id)} onClick={() => handleServiceClick(data.id)}
> >
@@ -114,24 +123,40 @@ export default function Org() {
<div className={style.serviceInfo}> <div className={style.serviceInfo}>
<h1>{data.name}</h1> <h1>{data.name}</h1>
<p>{data.description}</p> <p>{data.description}</p>
<ServiceRatingSummary serviceId={data.id} variant="row" showOpinionCount={false} /> <ServiceRatingSummary
serviceId={data.id}
variant="row"
showOpinionCount={false}
/>
</div> </div>
<div className={style.serviceActions}> <div className={style.serviceActions}>
<span className={style.servicePrice}>{formatPrice(data.price)}</span> <span className={style.servicePrice}>{formatPrice(data.price)}</span>
<div className={style.iconsRow}>
<IconButton
onClick={(e) => {
e.stopPropagation();
goTo(`/landing/service/${data.id}`);
}}
size="small"
title="Ver perfil público del servicio"
className={style.iconButton}
>
<OpenInNewIcon fontSize="small" />
</IconButton>
<IconButton <IconButton
className={style.shareButton}
sx={{ position: "absolute", top: "15px", right: "15px" }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setShareService(data); setShareService(data);
}} }}
size="small" size="small"
title="Compartir servicio" title="Compartir servicio"
className={style.iconButton}
> >
<ShareOutlinedIcon fontSize="small" /> <ShareOutlinedIcon fontSize="small" />
</IconButton> </IconButton>
</div> </div>
</div> </div>
</div>
); );
}; };
@@ -146,8 +171,21 @@ export default function Org() {
<div style={{ marginBottom: "20px" }}>&nbsp;</div> <div style={{ marginBottom: "20px" }}>&nbsp;</div>
{shareService && ( {shareService && (
<Dialog open={!!shareService} onClose={() => setShareService(null)} maxWidth="sm" fullWidth> <Dialog
<DialogTitle sx={{ m: 0, p: 2, display: "flex", justifyContent: "space-between", alignItems: "center" }}> open={!!shareService}
onClose={() => setShareService(null)}
maxWidth="sm"
fullWidth
>
<DialogTitle
sx={{
m: 0,
p: 2,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span style={{ fontWeight: "bold" }}>Compartir Servicio</span> <span style={{ fontWeight: "bold" }}>Compartir Servicio</span>
<IconButton <IconButton
aria-label="close" aria-label="close"
@@ -158,23 +196,57 @@ export default function Org() {
</IconButton> </IconButton>
</DialogTitle> </DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<div style={{ textAlign: "center", marginBottom: "30px", fontSize: "16px", color: "var(--black-light)" }}> <div
Compartí tu servicio <strong>{shareService.name}</strong> para que tus clientes puedan reservarlo directamente. style={{
textAlign: "center",
marginBottom: "30px",
fontSize: "16px",
color: "var(--black-light)",
}}
>
Compartí tu servicio <strong>{shareService.name}</strong> para que tus
clientes puedan reservarlo directamente.
</div> </div>
<div style={{ display: "flex", justifyContent: "center", flexDirection: "row", alignItems: "center", gap: "20px", paddingBottom: "20px" }}> <div
<WhatsappShareButton title={"Reserva este servicio: " + shareService.name} separator=":: " url={window.location.origin + "/landing/service/" + shareService.id}> style={{
display: "flex",
justifyContent: "center",
flexDirection: "row",
alignItems: "center",
gap: "20px",
paddingBottom: "20px",
}}
>
<WhatsappShareButton
title={"Reserva este servicio: " + shareService.name}
separator=":: "
url={window.location.origin + "/landing/service/" + shareService.id}
>
<WhatsAppShareIcon size={54} round /> <WhatsAppShareIcon size={54} round />
</WhatsappShareButton> </WhatsappShareButton>
<FacebookShareButton title={"Reserva este servicio: " + shareService.name} url={window.location.origin + "/landing/service/" + shareService.id}> <FacebookShareButton
title={"Reserva este servicio: " + shareService.name}
url={window.location.origin + "/landing/service/" + shareService.id}
>
<FacebookIcon size={54} round /> <FacebookIcon size={54} round />
</FacebookShareButton> </FacebookShareButton>
<EmailShareButton title={"Reserva este servicio: " + shareService.name} separator=":: " url={window.location.origin + "/landing/service/" + shareService.id}> <EmailShareButton
title={"Reserva este servicio: " + shareService.name}
separator=":: "
url={window.location.origin + "/landing/service/" + shareService.id}
>
<EmailIcon size={54} round /> <EmailIcon size={54} round />
</EmailShareButton> </EmailShareButton>
<TelegramShareButton title={"Reserva este servicio: " + shareService.name} url={window.location.origin + "/landing/service/" + shareService.id}> <TelegramShareButton
title={"Reserva este servicio: " + shareService.name}
url={window.location.origin + "/landing/service/" + shareService.id}
>
<TelegramIcon size={54} round /> <TelegramIcon size={54} round />
</TelegramShareButton> </TelegramShareButton>
<TwitterShareButton title={"Reserva este servicio: " + shareService.name} url={window.location.origin + "/landing/service/" + shareService.id}> <TwitterShareButton
title={"Reserva este servicio: " + shareService.name}
url={window.location.origin + "/landing/service/" + shareService.id}
>
<TwitterIcon size={54} round /> <TwitterIcon size={54} round />
</TwitterShareButton> </TwitterShareButton>
</div> </div>
@@ -71,26 +71,29 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: flex-end;
justify-content: flex-end; /* changed from space-between */ justify-content: center;
min-width: 100px; gap: 12px;
min-width: 60px;
} }
.servicePrice { .servicePrice {
font-size: 18px; font-size: 18px;
font-weight: 800; font-weight: 800;
color: #1e293b; color: #1e293b;
margin-bottom: 0; /* removed bottom margin */
} }
.shareButton { .iconsRow {
display: flex;
align-items: center;
gap: 4px;
}
.iconButton {
color: #94a3b8; color: #94a3b8;
transition: color 0.2s ease; transition: color 0.2s ease;
position: absolute;
top: 15px;
right: 15px;
} }
.shareButton:hover { .iconButton:hover {
color: var(--wine-dark); color: var(--wine-dark);
} }
@@ -54,7 +54,7 @@ export default function TemplateCreate() {
const insertAtCursor = ( const insertAtCursor = (
ref: React.RefObject<HTMLInputElement | HTMLTextAreaElement>, ref: React.RefObject<HTMLInputElement | HTMLTextAreaElement>,
insertText: string insertText: string,
) => { ) => {
const el = ref.current; const el = ref.current;
if (!el) return; // todavía no está montado if (!el) return; // todavía no está montado
@@ -126,14 +126,14 @@ export default function TemplateCreate() {
}, },
() => { () => {
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE); eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
} },
); );
}} }}
> >
Eliminar Eliminar
</ButtonMaterial> </ButtonMaterial>
)} )}
</div> </div>,
); );
loadTemplate(); loadTemplate();
@@ -17,6 +17,7 @@ import {
} from "@models/Templates.model"; } from "@models/Templates.model";
import { findTemplatesByOrganization } from "./Templates.Service"; import { findTemplatesByOrganization } from "./Templates.Service";
import { useNavigation } from "@core/app/hooks/goto"; import { useNavigation } from "@core/app/hooks/goto";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = { type UrlData = {
id: string; id: string;
@@ -29,6 +30,7 @@ export default function TemplatesGrid() {
const oid = data.id; const oid = data.id;
const { goTo } = useNavigation(); const { goTo } = useNavigation();
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const formatType = (type: string) => { const formatType = (type: string) => {
@@ -91,8 +93,8 @@ export default function TemplatesGrid() {
parseInt( parseInt(
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE ? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
: "10" : "10",
) ),
); );
const [templates, setTemplates] = useState<PaginateTemplatesResults>(); const [templates, setTemplates] = useState<PaginateTemplatesResults>();
@@ -103,6 +105,8 @@ export default function TemplatesGrid() {
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ location.add({
url: "/admin/org", url: "/admin/org",
title: "Mis organizaciones", title: "Mis organizaciones",
@@ -122,6 +126,10 @@ export default function TemplatesGrid() {
}); });
loadTemplates(1, pageSize); loadTemplates(1, pageSize);
return () => {
bottomToolbar.clear();
};
}, []); }, []);
const loadTemplates = (page: number = 1, pageSize: number = 30) => { const loadTemplates = (page: number = 1, pageSize: number = 30) => {
@@ -26,6 +26,7 @@ import Image from "next/image";
import Switch from "@core/app/components/Switch/Switch"; import Switch from "@core/app/components/Switch/Switch";
import { useConfirmStore } from "@core/Store/Confirm.Store"; import { useConfirmStore } from "@core/Store/Confirm.Store";
import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan"; import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = { type UrlData = {
id: string; id: string;
@@ -35,6 +36,7 @@ export default function OrganizationEdit() {
const data = useParams<UrlData>(); const data = useParams<UrlData>();
const id = data.id; const id = data.id;
const location = useLocationStore(); const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
const alert = useAlert(); const alert = useAlert();
@@ -46,6 +48,8 @@ export default function OrganizationEdit() {
useEffect(() => { useEffect(() => {
location.clear(); location.clear();
bottomToolbar.set(<></>);
location.add({ location.add({
url: "/admin/org", url: "/admin/org",
title: "Organizaciones", title: "Organizaciones",
@@ -63,6 +67,10 @@ export default function OrganizationEdit() {
title: "Whatsapp", title: "Whatsapp",
icon: <OrgConfigIcon sx={{ fontSize: "32px", mr: "10px" }} />, icon: <OrgConfigIcon sx={{ fontSize: "32px", mr: "10px" }} />,
}); });
return () => {
bottomToolbar.clear();
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -420,7 +428,7 @@ export default function OrganizationEdit() {
eventHandler.setEventType(EVENT_TYPES.SLEEP); eventHandler.setEventType(EVENT_TYPES.SLEEP);
}); });
}, },
() => { } () => {},
); );
}} }}
/> />
@@ -206,6 +206,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
const [wapError, setWapError] = useState<string | null>(null); const [wapError, setWapError] = useState<string | null>(null);
// State for ARCA onboarding flow // State for ARCA onboarding flow
const [arcaIsBlocked, setArcaIsBlocked] = useState(false);
const [arcaCuit, setArcaCuit] = useState(""); const [arcaCuit, setArcaCuit] = useState("");
const [arcaLegalName, setArcaLegalName] = useState(""); const [arcaLegalName, setArcaLegalName] = useState("");
const [arcaTaxCondition, setArcaTaxCondition] = useState<"MONOTRIBUTO" | "EXENTO">("MONOTRIBUTO"); const [arcaTaxCondition, setArcaTaxCondition] = useState<"MONOTRIBUTO" | "EXENTO">("MONOTRIBUTO");
@@ -277,6 +278,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
}; };
const loadOrganizationContext = (org: any) => { const loadOrganizationContext = (org: any) => {
setArcaIsBlocked(false);
setCreatedCompanyId(org.id || org._id); setCreatedCompanyId(org.id || org._id);
setSelectedOrganization(org); setSelectedOrganization(org);
setOrgName(org.name || ""); setOrgName(org.name || "");
@@ -499,6 +501,18 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
setWapQr(null); setWapQr(null);
setWapError(null); setWapError(null);
} else if (flowId === "arca-onboarding") { } else if (flowId === "arca-onboarding") {
const orgSub = SessionInfo.organizationSubscriptions[org.id || org._id];
const planSupportsPayments = orgSub ? orgSub.plan?.payments : false;
if (!planSupportsPayments) {
setArcaIsBlocked(true);
setCurrentStepIndex(0);
setArcaError(null);
setArcaLoading(false);
return;
}
setArcaIsBlocked(false);
setCurrentStepIndex(0); setCurrentStepIndex(0);
setArcaError(null); setArcaError(null);
setArcaLoading(false); setArcaLoading(false);
@@ -527,6 +541,19 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
setArcaStatusLabel(getArcaStatusLabel(credential.status)); setArcaStatusLabel(getArcaStatusLabel(credential.status));
}).catch(console.error); }).catch(console.error);
} else if (flowId === "arca-test-connection") { } else if (flowId === "arca-test-connection") {
const orgSub = SessionInfo.organizationSubscriptions[org.id || org._id];
const planSupportsPayments = orgSub ? orgSub.plan?.payments : false;
if (!planSupportsPayments) {
setArcaIsBlocked(true);
setCurrentStepIndex(0);
setArcaError(null);
setArcaLoading(false);
setArcaTestResult(null);
return;
}
setArcaIsBlocked(false);
setCurrentStepIndex(0); setCurrentStepIndex(0);
setArcaError(null); setArcaError(null);
setArcaLoading(false); setArcaLoading(false);
@@ -2415,8 +2442,42 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
if (flowId === "public-link") return renderPublicLinkFlow(); if (flowId === "public-link") return renderPublicLinkFlow();
if (flowId === "notifications") return renderNotificationsFlow(); if (flowId === "notifications") return renderNotificationsFlow();
if (flowId === "whatsapp-bot") return renderWhatsAppBotFlow(); if (flowId === "whatsapp-bot") return renderWhatsAppBotFlow();
if (flowId === "arca-onboarding") return renderArcaOnboardingFlow(); if (flowId === "arca-onboarding") {
if (flowId === "arca-test-connection") return renderArcaTestConnectionFlow(); if (arcaIsBlocked) {
return (
<QuestionCard
isActive
title="Módulo ARCA no disponible"
description="Este flujo requiere un plan con el módulo de pagos habilitado para emitir Factura C."
onNext={() => router.push("/landing/pricing")}
nextLabel="Ver planes"
>
<div style={{ color: "rgba(255,255,255,0.75)", textAlign: "center", lineHeight: 1.6 }}>
La organización seleccionada necesita un plan con el módulo de pagos activo para usar ARCA. Actualizá tu plan o volvé al asistente y elegí otra opción.
</div>
</QuestionCard>
);
}
return renderArcaOnboardingFlow();
}
if (flowId === "arca-test-connection") {
if (arcaIsBlocked) {
return (
<QuestionCard
isActive
title="Test de conexión ARCA no disponible"
description="Este flujo requiere un plan con el módulo de pagos habilitado para probar la conexión con ARCA."
onNext={() => router.push("/landing/pricing")}
nextLabel="Ver planes"
>
<div style={{ color: "rgba(255,255,255,0.75)", textAlign: "center", lineHeight: 1.6 }}>
La organización seleccionada necesita un plan con el módulo de pagos activo para usar ARCA. Actualizá tu plan o volvé al asistente y elegí otra opción.
</div>
</QuestionCard>
);
}
return renderArcaTestConnectionFlow();
}
if (flowId === "disable-schedule") return renderDisableScheduleFlow(); if (flowId === "disable-schedule") return renderDisableScheduleFlow();
if (flowId === "open-reservation-periods") return renderReservationPeriodFlow(); if (flowId === "open-reservation-periods") return renderReservationPeriodFlow();
if (flowId === "override-schedule") return renderOverrideScheduleFlow(); if (flowId === "override-schedule") return renderOverrideScheduleFlow();
@@ -39,13 +39,47 @@ type OnboardingServiceFlowProps = {
}; };
export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps) { export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps) {
const { heatMapFraction, currentStepIndex, handleNext, serviceName, setServiceName, serviceDescription, setServiceDescription, handleKeyDown, serviceLength, setServiceLength, handleCreateService, isCreating, servicePrice, setServicePrice, serviceLimit, setServiceLimit, serviceCreateError, handleUploadServiceImage, serviceImage, setServiceImage, isStandaloneNewService, handleAssignCreatedServiceToMe, handleShowCollaboratorAssignment, handleToggleServiceCollaborator, handleAssignCreatedServiceToCollaborators, handleFinishWithoutAssignment, serviceAssignmentError, serviceAssignmentWarning, serviceAssignmentMode, serviceAssignmentCollaborators, selectedServiceCollaboratorIds, isLoadingServiceCollaborators } = props; const {
heatMapFraction,
currentStepIndex,
handleNext,
serviceName,
setServiceName,
serviceDescription,
setServiceDescription,
handleKeyDown,
serviceLength,
setServiceLength,
handleCreateService,
isCreating,
servicePrice,
setServicePrice,
serviceLimit,
setServiceLimit,
serviceCreateError,
handleUploadServiceImage,
serviceImage,
setServiceImage,
isStandaloneNewService,
handleAssignCreatedServiceToMe,
handleShowCollaboratorAssignment,
handleToggleServiceCollaborator,
handleAssignCreatedServiceToCollaborators,
handleFinishWithoutAssignment,
serviceAssignmentError,
serviceAssignmentWarning,
serviceAssignmentMode,
serviceAssignmentCollaborators,
selectedServiceCollaboratorIds,
isLoadingServiceCollaborators,
} = props;
const [isCustomDuration, setIsCustomDuration] = React.useState(false); const [isCustomDuration, setIsCustomDuration] = React.useState(false);
const [customDurationValue, setCustomDurationValue] = React.useState(""); const [customDurationValue, setCustomDurationValue] = React.useState("");
const fraction = heatMapFraction || 60; const fraction = heatMapFraction || 60;
const durationOptions = [fraction, fraction * 2, fraction * 3, fraction * 4]; const durationOptions = [fraction, fraction * 2, fraction * 3, fraction * 4];
const customDurationMinutes = Number(customDurationValue); const customDurationMinutes = Number(customDurationValue);
const isValidCustomDuration = Number.isInteger(customDurationMinutes) && customDurationMinutes > 0; const isValidCustomDuration =
Number.isInteger(customDurationMinutes) && customDurationMinutes > 0;
const selectDurationOption = (duration: number) => { const selectDurationOption = (duration: number) => {
setIsCustomDuration(false); setIsCustomDuration(false);
@@ -73,7 +107,13 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
onNext={handleNext} onNext={handleNext}
nextLabel="Comenzar" nextLabel="Comenzar"
> >
<div style={{ color: "rgba(255, 255, 255, 0.7)", textAlign: "center", padding: "10px" }}> <div
style={{
color: "rgba(255, 255, 255, 0.7)",
textAlign: "center",
padding: "10px",
}}
>
Vamos a crear el primer servicio de tu negocio. ¡Es muy fácil! Vamos a crear el primer servicio de tu negocio. ¡Es muy fácil!
</div> </div>
</QuestionCard> </QuestionCard>
@@ -85,7 +125,9 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
onNext={handleNext} onNext={handleNext}
disableNext={serviceName.trim().length < 3 || serviceDescription.trim().length < 5} disableNext={serviceName.trim().length < 3 || serviceDescription.trim().length < 5}
> >
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px', width: '100%' }}> <div
style={{ display: "flex", flexDirection: "column", gap: "15px", width: "100%" }}
>
<QuestionInput <QuestionInput
placeholder="Ej. Corte clásico con lavado" placeholder="Ej. Corte clásico con lavado"
value={serviceName} value={serviceName}
@@ -97,7 +139,10 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
value={serviceDescription} value={serviceDescription}
onChange={(e) => setServiceDescription(e.target.value)} onChange={(e) => setServiceDescription(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (serviceName.trim().length >= 3 && serviceDescription.trim().length >= 5) { if (
serviceName.trim().length >= 3 &&
serviceDescription.trim().length >= 5
) {
handleKeyDown(e, handleNext); handleKeyDown(e, handleNext);
} }
}} }}
@@ -112,12 +157,21 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
onNext={handleNext} onNext={handleNext}
disableNext={!serviceLength || (isCustomDuration && !isValidCustomDuration)} disableNext={!serviceLength || (isCustomDuration && !isValidCustomDuration)}
> >
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap', width: '100%', justifyContent: 'center' }}> <div
{durationOptions.map(val => { style={{
display: "flex",
gap: "10px",
flexWrap: "wrap",
width: "100%",
justifyContent: "center",
}}
>
{durationOptions.map((val) => {
const hours = Math.floor(val / 60); const hours = Math.floor(val / 60);
const minutes = val % 60; const minutes = val % 60;
const label = hours > 0 const label =
? `${hours}h ${minutes > 0 ? minutes + 'm' : ''}`.trim() hours > 0
? `${hours}h ${minutes > 0 ? minutes + "m" : ""}`.trim()
: `${minutes} min`; : `${minutes} min`;
return ( return (
@@ -125,15 +179,21 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
key={val} key={val}
onClick={() => selectDurationOption(val)} onClick={() => selectDurationOption(val)}
style={{ style={{
padding: '12px 24px', padding: "12px 24px",
borderRadius: '12px', borderRadius: "12px",
cursor: 'pointer', cursor: "pointer",
background: !isCustomDuration && serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', background:
border: `2px solid ${!isCustomDuration && serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`, !isCustomDuration && serviceLength === val
color: 'white', ? "var(--wine-red)"
fontWeight: !isCustomDuration && serviceLength === val ? 'bold' : 'normal', : "rgba(255,255,255,0.05)",
transition: 'all 0.2s ease', border: `2px solid ${!isCustomDuration && serviceLength === val ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`,
fontSize: '16px' color: "white",
fontWeight:
!isCustomDuration && serviceLength === val
? "bold"
: "normal",
transition: "all 0.2s ease",
fontSize: "16px",
}} }}
> >
{label} {label}
@@ -143,22 +203,24 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
<div <div
onClick={selectCustomDuration} onClick={selectCustomDuration}
style={{ style={{
padding: '12px 24px', padding: "12px 24px",
borderRadius: '12px', borderRadius: "12px",
cursor: 'pointer', cursor: "pointer",
background: isCustomDuration ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', background: isCustomDuration
border: `2px solid ${isCustomDuration ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`, ? "var(--wine-red)"
color: 'white', : "rgba(255,255,255,0.05)",
fontWeight: isCustomDuration ? 'bold' : 'normal', border: `2px solid ${isCustomDuration ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`,
transition: 'all 0.2s ease', color: "white",
fontSize: '16px' fontWeight: isCustomDuration ? "bold" : "normal",
transition: "all 0.2s ease",
fontSize: "16px",
}} }}
> >
Otro Otro
</div> </div>
</div> </div>
{isCustomDuration && ( {isCustomDuration && (
<div style={{ width: '100%', marginTop: '16px' }}> <div style={{ width: "100%", marginTop: "16px" }}>
<QuestionInput <QuestionInput
type="number" type="number"
min="1" min="1"
@@ -173,7 +235,14 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
}} }}
autoFocus autoFocus
/> />
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: '13px', marginTop: '8px', textAlign: 'center' }}> <div
style={{
color: "rgba(255,255,255,0.6)",
fontSize: "13px",
marginTop: "8px",
textAlign: "center",
}}
>
Ingresá la duración total del servicio expresada en minutos. Ingresá la duración total del servicio expresada en minutos.
</div> </div>
</div> </div>
@@ -183,16 +252,31 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
<QuestionCard <QuestionCard
isActive={currentStepIndex === 3} isActive={currentStepIndex === 3}
title="3. Precio y Cupos" title="3. Precio y Cupos"
description="¿Cuánto cuesta y a cuántas personas puedes atender a la vez en este servicio?" description="¿Cuánto cuesta y a cuántas personas puede atender cada profesional a la vez en este servicio?"
onNext={handleCreateService} onNext={handleCreateService}
disableNext={isCreating} disableNext={isCreating}
nextLabel={isCreating ? "Guardando..." : "Crear Servicio"} nextLabel={isCreating ? "Guardando..." : "Crear Servicio"}
> >
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', width: '100%' }}> <div
style={{ display: "flex", flexDirection: "column", gap: "20px", width: "100%" }}
>
<div> <div>
<div style={{ color: 'white', marginBottom: '10px', fontSize: '14px' }}>Precio Original</div> <div style={{ color: "white", marginBottom: "10px", fontSize: "14px" }}>
<div style={{ position: 'relative' }}> Precio Original
<span style={{ position: 'absolute', left: '15px', top: '50%', transform: 'translateY(-50%)', color: 'white', fontSize: '18px' }}>$</span> </div>
<div style={{ position: "relative" }}>
<span
style={{
position: "absolute",
left: "15px",
top: "50%",
transform: "translateY(-50%)",
color: "white",
fontSize: "18px",
}}
>
$
</span>
<input <input
type="text" type="text"
inputMode="decimal" inputMode="decimal"
@@ -200,35 +284,40 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
value={servicePrice} value={servicePrice}
onChange={(e) => setServicePrice(e.target.value)} onChange={(e) => setServicePrice(e.target.value)}
style={{ style={{
width: '100%', width: "100%",
padding: '15px 15px 15px 35px', padding: "15px 15px 15px 35px",
borderRadius: '12px', borderRadius: "12px",
border: '1px solid rgba(255,255,255,0.2)', border: "1px solid rgba(255,255,255,0.2)",
background: 'rgba(255,255,255,0.05)', background: "rgba(255,255,255,0.05)",
color: 'white', color: "white",
fontSize: '18px', fontSize: "18px",
outline: 'none' outline: "none",
}} }}
/> />
</div> </div>
</div> </div>
<div> <div>
<div style={{ color: 'white', marginBottom: '10px', fontSize: '14px' }}>Cupos Simultáneos (Por defecto 1)</div> <div style={{ color: "white", marginBottom: "10px", fontSize: "14px" }}>
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}> Cupos Simultáneos (Por defecto 1)
{[1, 2, 3, 5, 10].map(val => ( </div>
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
{[1, 2, 3, 5, 10].map((val) => (
<div <div
key={val} key={val}
onClick={() => setServiceLimit(val)} onClick={() => setServiceLimit(val)}
style={{ style={{
padding: '8px 16px', padding: "8px 16px",
borderRadius: '8px', borderRadius: "8px",
cursor: 'pointer', cursor: "pointer",
background: serviceLimit === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)', background:
border: `1px solid ${serviceLimit === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.2)'}`, serviceLimit === val
color: 'white', ? "var(--wine-red)"
fontWeight: serviceLimit === val ? 'bold' : 'normal', : "rgba(255,255,255,0.1)",
transition: 'all 0.2s ease' border: `1px solid ${serviceLimit === val ? "var(--wine-red)" : "rgba(255,255,255,0.2)"}`,
color: "white",
fontWeight: serviceLimit === val ? "bold" : "normal",
transition: "all 0.2s ease",
}} }}
> >
{val} {val}
@@ -237,16 +326,18 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
</div> </div>
</div> </div>
{serviceCreateError && ( {serviceCreateError && (
<div style={{ <div
padding: '14px 16px', style={{
borderRadius: '12px', padding: "14px 16px",
background: 'rgba(255, 99, 132, 0.12)', borderRadius: "12px",
border: '1px solid rgba(255, 99, 132, 0.35)', background: "rgba(255, 99, 132, 0.12)",
color: '#fecdd3', border: "1px solid rgba(255, 99, 132, 0.35)",
fontSize: '14px', color: "#fecdd3",
fontSize: "14px",
lineHeight: 1.5, lineHeight: 1.5,
textAlign: 'center' textAlign: "center",
}}> }}
>
{serviceCreateError} {serviceCreateError}
</div> </div>
)} )}
@@ -259,30 +350,50 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
description="Sube una foto representativa para que los clientes sepan qué ofreces. (Opcional)" description="Sube una foto representativa para que los clientes sepan qué ofreces. (Opcional)"
onNext={handleUploadServiceImage} onNext={handleUploadServiceImage}
disableNext={isCreating} disableNext={isCreating}
nextLabel={isCreating ? "Subiendo..." : (serviceImage ? (isStandaloneNewService ? "Subir y continuar" : "Subir y Finalizar") : "Omitir por ahora")} nextLabel={
isCreating
? "Subiendo..."
: serviceImage
? isStandaloneNewService
? "Subir y continuar"
: "Subir y Finalizar"
: "Omitir por ahora"
}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "20px",
width: "100%",
alignItems: "center",
}}
>
<div
style={{
width: "100%",
height: "150px",
border: "2px dashed rgba(255,255,255,0.3)",
borderRadius: "12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
overflow: "hidden",
cursor: "pointer",
background: serviceImage ? "transparent" : "rgba(255,255,255,0.05)",
}}
> >
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', width: '100%', alignItems: 'center' }}>
<div style={{
width: '100%',
height: '150px',
border: '2px dashed rgba(255,255,255,0.3)',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
overflow: 'hidden',
cursor: 'pointer',
background: serviceImage ? 'transparent' : 'rgba(255,255,255,0.05)'
}}>
{serviceImage ? ( {serviceImage ? (
<img <img
src={URL.createObjectURL(serviceImage)} src={URL.createObjectURL(serviceImage)}
alt="Preview" alt="Preview"
style={{ width: '100%', height: '100%', objectFit: 'cover' }} style={{ width: "100%", height: "100%", objectFit: "cover" }}
/> />
) : ( ) : (
<span style={{ color: 'rgba(255,255,255,0.6)' }}>Toca para seleccionar imagen</span> <span style={{ color: "rgba(255,255,255,0.6)" }}>
Toca para seleccionar imagen
</span>
)} )}
<input <input
type="file" type="file"
@@ -293,27 +404,29 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
} }
}} }}
style={{ style={{
position: 'absolute', position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
width: '100%', width: "100%",
height: '100%', height: "100%",
opacity: 0, opacity: 0,
cursor: 'pointer' cursor: "pointer",
}} }}
/> />
</div> </div>
{serviceCreateError && ( {serviceCreateError && (
<div style={{ <div
padding: '14px 16px', style={{
borderRadius: '12px', padding: "14px 16px",
background: 'rgba(255, 99, 132, 0.12)', borderRadius: "12px",
border: '1px solid rgba(255, 99, 132, 0.35)', background: "rgba(255, 99, 132, 0.12)",
color: '#fecdd3', border: "1px solid rgba(255, 99, 132, 0.35)",
fontSize: '14px', color: "#fecdd3",
fontSize: "14px",
lineHeight: 1.5, lineHeight: 1.5,
textAlign: 'center' textAlign: "center",
}}> }}
>
{serviceCreateError} {serviceCreateError}
</div> </div>
)} )}
@@ -326,21 +439,28 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
title="¿Quién brinda este servicio?" title="¿Quién brinda este servicio?"
description="Asigná este servicio a un colaborador para que pueda recibir reservas. Si todavía no querés hacerlo, podés configurarlo después." description="Asigná este servicio a un colaborador para que pueda recibir reservas. Si todavía no querés hacerlo, podés configurarlo después."
> >
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px', width: '100%' }}> <div
style={{
display: "flex",
flexDirection: "column",
gap: "14px",
width: "100%",
}}
>
<button <button
type="button" type="button"
onClick={handleAssignCreatedServiceToMe} onClick={handleAssignCreatedServiceToMe}
disabled={isCreating} disabled={isCreating}
style={{ style={{
padding: '14px 18px', padding: "14px 18px",
borderRadius: '12px', borderRadius: "12px",
border: '1px solid var(--wine-red)', border: "1px solid var(--wine-red)",
background: 'var(--wine-red)', background: "var(--wine-red)",
color: 'white', color: "white",
fontSize: '16px', fontSize: "16px",
fontWeight: 'bold', fontWeight: "bold",
cursor: isCreating ? 'not-allowed' : 'pointer', cursor: isCreating ? "not-allowed" : "pointer",
opacity: isCreating ? 0.7 : 1 opacity: isCreating ? 0.7 : 1,
}} }}
> >
{isCreating ? "Asignando..." : "Yo"} {isCreating ? "Asignando..." : "Yo"}
@@ -350,23 +470,49 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
onClick={handleShowCollaboratorAssignment} onClick={handleShowCollaboratorAssignment}
disabled={isCreating || isLoadingServiceCollaborators} disabled={isCreating || isLoadingServiceCollaborators}
style={{ style={{
padding: '14px 18px', padding: "14px 18px",
borderRadius: '12px', borderRadius: "12px",
border: serviceAssignmentMode === "collaborators" ? '1px solid var(--wine-red)' : '1px solid rgba(255,255,255,0.2)', border:
background: serviceAssignmentMode === "collaborators" ? 'rgba(128, 0, 64, 0.35)' : 'rgba(255,255,255,0.05)', serviceAssignmentMode === "collaborators"
color: 'white', ? "1px solid var(--wine-red)"
fontSize: '16px', : "1px solid rgba(255,255,255,0.2)",
cursor: isCreating || isLoadingServiceCollaborators ? 'not-allowed' : 'pointer', background:
opacity: isCreating || isLoadingServiceCollaborators ? 0.7 : 1 serviceAssignmentMode === "collaborators"
? "rgba(128, 0, 64, 0.35)"
: "rgba(255,255,255,0.05)",
color: "white",
fontSize: "16px",
cursor:
isCreating || isLoadingServiceCollaborators
? "not-allowed"
: "pointer",
opacity: isCreating || isLoadingServiceCollaborators ? 0.7 : 1,
}} }}
> >
{isLoadingServiceCollaborators ? "Cargando colaboradores..." : "Uno o más colaboradores"} {isLoadingServiceCollaborators
? "Cargando colaboradores..."
: "Uno o más colaboradores"}
</button> </button>
{serviceAssignmentMode === "collaborators" && ( {serviceAssignmentMode === "collaborators" && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', width: '100%' }}> <div
{serviceAssignmentCollaborators.length === 0 && !isLoadingServiceCollaborators && ( style={{
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', textAlign: 'center' }}> display: "flex",
No encontramos colaboradores activos para esta organización. flexDirection: "column",
gap: "10px",
width: "100%",
}}
>
{serviceAssignmentCollaborators.length === 0 &&
!isLoadingServiceCollaborators && (
<div
style={{
color: "rgba(255,255,255,0.7)",
fontSize: "14px",
textAlign: "center",
}}
>
No encontramos colaboradores activos para esta
organización.
</div> </div>
)} )}
<CollaboratorPicker <CollaboratorPicker
@@ -380,17 +526,28 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
<button <button
type="button" type="button"
onClick={handleAssignCreatedServiceToCollaborators} onClick={handleAssignCreatedServiceToCollaborators}
disabled={isCreating || selectedServiceCollaboratorIds.length === 0} disabled={
isCreating ||
selectedServiceCollaboratorIds.length === 0
}
style={{ style={{
padding: '14px 18px', padding: "14px 18px",
borderRadius: '12px', borderRadius: "12px",
border: '1px solid var(--wine-red)', border: "1px solid var(--wine-red)",
background: 'var(--wine-red)', background: "var(--wine-red)",
color: 'white', color: "white",
fontSize: '16px', fontSize: "16px",
fontWeight: 'bold', fontWeight: "bold",
cursor: isCreating || selectedServiceCollaboratorIds.length === 0 ? 'not-allowed' : 'pointer', cursor:
opacity: isCreating || selectedServiceCollaboratorIds.length === 0 ? 0.7 : 1 isCreating ||
selectedServiceCollaboratorIds.length === 0
? "not-allowed"
: "pointer",
opacity:
isCreating ||
selectedServiceCollaboratorIds.length === 0
? 0.7
: 1,
}} }}
> >
{isCreating ? "Asignando..." : "Asignar servicio"} {isCreating ? "Asignando..." : "Asignar servicio"}
@@ -405,43 +562,49 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
onClick={handleFinishWithoutAssignment} onClick={handleFinishWithoutAssignment}
disabled={isCreating} disabled={isCreating}
style={{ style={{
padding: '14px 18px', padding: "14px 18px",
borderRadius: '12px', borderRadius: "12px",
border: '1px solid rgba(255,255,255,0.2)', border: "1px solid rgba(255,255,255,0.2)",
background: 'rgba(255,255,255,0.05)', background: "rgba(255,255,255,0.05)",
color: 'white', color: "white",
fontSize: '16px', fontSize: "16px",
cursor: isCreating ? 'not-allowed' : 'pointer', cursor: isCreating ? "not-allowed" : "pointer",
opacity: isCreating ? 0.7 : 1 opacity: isCreating ? 0.7 : 1,
}} }}
> >
Lo configuro después Lo configuro después
</button> </button>
<div style={{ <div
padding: '12px 14px', style={{
borderRadius: '12px', padding: "12px 14px",
background: 'rgba(245, 158, 11, 0.12)', borderRadius: "12px",
border: '1px solid rgba(245, 158, 11, 0.35)', background: "rgba(245, 158, 11, 0.12)",
color: '#fde68a', border: "1px solid rgba(245, 158, 11, 0.35)",
fontSize: '14px', color: "#fde68a",
fontSize: "14px",
lineHeight: 1.5, lineHeight: 1.5,
textAlign: 'center' textAlign: "center",
}}> }}
Si lo configurás después, el servicio no va a estar disponible para reservas hasta que lo asignes a un colaborador con horarios cargados. >
Si lo configurás después, el servicio no va a estar disponible
para reservas hasta que lo asignes a un colaborador con horarios
cargados.
</div> </div>
</> </>
)} )}
{serviceAssignmentError && ( {serviceAssignmentError && (
<div style={{ <div
padding: '12px 14px', style={{
borderRadius: '12px', padding: "12px 14px",
background: 'rgba(255, 99, 132, 0.12)', borderRadius: "12px",
border: '1px solid rgba(255, 99, 132, 0.35)', background: "rgba(255, 99, 132, 0.12)",
color: '#fecdd3', border: "1px solid rgba(255, 99, 132, 0.35)",
fontSize: '14px', color: "#fecdd3",
fontSize: "14px",
lineHeight: 1.5, lineHeight: 1.5,
textAlign: 'center' textAlign: "center",
}}> }}
>
{serviceAssignmentError} {serviceAssignmentError}
</div> </div>
)} )}
@@ -450,5 +613,4 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
)} )}
</> </>
); );
} }
@@ -12,6 +12,7 @@ export interface CalendarRowViewProps {
type CalendarRowViewData = { type CalendarRowViewData = {
name: string; name: string;
startTime: number;
events: TXCalendarEvent[]; events: TXCalendarEvent[];
}; };
@@ -167,11 +168,13 @@ export default function CalendarRowView(props: CalendarRowViewProps) {
} else { } else {
auxData.push({ auxData.push({
name: time, name: time,
startTime: item.startTime,
events: [item], events: [item],
}); });
} }
} }
setData(auxData.sort((a, b) => a.name.localeCompare(b.name))); // Sort by numeric hour, never by the formatted label: as strings "11:00 PM" < "5:00 PM".
setData(auxData.sort((a, b) => a.startTime - b.startTime));
}, [daySchedules]); }, [daySchedules]);
return ( return (
@@ -1,34 +0,0 @@
.buttonFilter {
position: absolute;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
top: 5px;
right: -10px;
background-color: transparent;
color: var(--gray-dark);
border-radius: 50%;
cursor: pointer;
transition: width 0.25s ease-in-out, height 0.25s ease-in-out;
width: 52px;
height: 52px;
}
.buttonFilter > svg {
display: flex;
opacity: 1;
background: transparent;
animation: buttonFilterPulseShadow 1.5s ease-in-out infinite;
}
@keyframes buttonFilterPulseShadow {
0%,
100% {
filter: drop-shadow(0px 2px 1px var(--gray-light));
}
50% {
filter: drop-shadow(0px 2px 4px var(--gray-dark));
}
}
@@ -1,27 +0,0 @@
"use client";
import { ReactNode } from "react";
import style from "./Filterable.module.css";
import FilterAltOutlinedIcon from "@mui/icons-material/FilterAltOutlined";
import { useFilterStore } from "@core/Store/Filter.Store";
interface FilterableProps {
children?: ReactNode;
}
export default function Filterable(props: FilterableProps): React.ReactElement {
const filter = useFilterStore();
return (
<>
{props.children}
<div
className={style.buttonFilter}
onClick={() => {
filter.toggle();
}}
>
<FilterAltOutlinedIcon sx={{ fontSize: "32px" }} />
</div>
</>
);
}
@@ -27,15 +27,28 @@
padding: 0; padding: 0;
border: 0; border: 0;
background: transparent; background: transparent;
cursor: pointer;
text-align: left; text-align: left;
} }
.headerToggle {
display: block;
width: 100%;
padding: 0;
border: 0;
background: transparent;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
}
.title { .title {
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
color: var(--green-darkestX1); color: var(--green-darkestX1);
margin: 0; margin: 0;
flex: 1;
min-width: 0;
} }
.toggleIcon { .toggleIcon {
@@ -45,13 +58,16 @@
flex: 0 0 auto; flex: 0 0 auto;
width: 32px; width: 32px;
height: 32px; height: 32px;
padding: 0;
border-radius: 999px; border-radius: 999px;
background: var(--white); background: var(--white);
color: var(--green-darkestX1); color: var(--green-darkestX1);
border: 1px solid var(--green-darkestX1); border: 1px solid var(--green-darkestX1);
font-family: inherit;
font-size: 22px; font-size: 22px;
font-weight: 800; font-weight: 800;
line-height: 1; line-height: 1;
cursor: pointer;
} }
.filters { .filters {
@@ -1,13 +1,17 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { LineChart } from "@mui/x-charts/LineChart"; import { LineChart } from "@mui/x-charts/LineChart";
import { MyOranizationsView } from "@core/Models/Company.model"; import {
import { DashboardFinancialsResult, getDashboardFinancials } from "../../landing/dashboard/Dashboard.Service"; DashboardFinancialsResult,
getDashboardFinancials,
} from "../../landing/dashboard/Dashboard.Service";
import { loadCollaboratorsByCompanyId } from "../../admin/(organization-profile)/org/profile/[id]/collaborators/Collaborators.Service"; import { loadCollaboratorsByCompanyId } from "../../admin/(organization-profile)/org/profile/[id]/collaborators/Collaborators.Service";
import { CompanyEmployeesView } from "@models/Collaborators.model"; import { CompanyEmployeesView } from "@models/Collaborators.model";
import UnpaidIncomeCard from "../UnpaidIncomeCard/UnpaidIncomeCard"; import UnpaidIncomeCard from "../UnpaidIncomeCard/UnpaidIncomeCard";
import styles from "./FinancialWidget.module.css"; import styles from "./FinancialWidget.module.css";
import dayjs from "dayjs"; import dayjs from "dayjs";
import OpenInNewOutlinedIcon from "@mui/icons-material/OpenInNewOutlined";
import { useNavigation } from "@core/app/hooks/goto";
export interface FinancialWidgetProps { export interface FinancialWidgetProps {
sessionUser: string; sessionUser: string;
@@ -15,7 +19,12 @@ export interface FinancialWidgetProps {
organizationName?: string; organizationName?: string;
} }
export default function FinancialWidget({ sessionUser, selectedOrgId, organizationName }: FinancialWidgetProps) { export default function FinancialWidget({
sessionUser,
selectedOrgId,
organizationName,
}: FinancialWidgetProps) {
const { goTo } = useNavigation();
const [selectedTimeframe, setSelectedTimeframe] = useState<"week" | "month" | "year">("month"); const [selectedTimeframe, setSelectedTimeframe] = useState<"week" | "month" | "year">("month");
const [selectedEmployeeId, setSelectedEmployeeId] = useState<string>(""); const [selectedEmployeeId, setSelectedEmployeeId] = useState<string>("");
const [collaborators, setCollaborators] = useState<CompanyEmployeesView[]>([]); const [collaborators, setCollaborators] = useState<CompanyEmployeesView[]>([]);
@@ -27,10 +36,12 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
useEffect(() => { useEffect(() => {
if (selectedOrgId) { if (selectedOrgId) {
loadCollaboratorsByCompanyId({ companyId: selectedOrgId }).then((res) => { loadCollaboratorsByCompanyId({ companyId: selectedOrgId })
.then((res) => {
setCollaborators(res); setCollaborators(res);
setSelectedEmployeeId(""); // reset employee filter when org changes setSelectedEmployeeId(""); // reset employee filter when org changes
}).catch(console.error); })
.catch(console.error);
} }
}, [selectedOrgId, sessionUser]); }, [selectedOrgId, sessionUser]);
@@ -43,14 +54,17 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
sessionUser, sessionUser,
companyId: selectedOrgId, companyId: selectedOrgId,
timeframe: selectedTimeframe, timeframe: selectedTimeframe,
employeeId: selectedEmployeeId || undefined employeeId: selectedEmployeeId || undefined,
}).then((res) => { })
.then((res) => {
setFinancials(res); setFinancials(res);
}).catch((err) => { })
.catch((err) => {
const errMsg = err.format ? err.format() : err.message; const errMsg = err.format ? err.format() : err.message;
console.warn("Acceso denegado a métricas financieras: ", errMsg); console.warn("Acceso denegado a métricas financieras: ", errMsg);
setError("No tienes permisos de administrador para esta organización."); setError("No tienes permisos de administrador para esta organización.");
}).finally(() => setLoading(false)); })
.finally(() => setLoading(false));
} }
}, [selectedOrgId, selectedTimeframe, selectedEmployeeId, sessionUser]); }, [selectedOrgId, selectedTimeframe, selectedEmployeeId, sessionUser]);
@@ -64,32 +78,87 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
const previous = financials.previousPeriodTotal; const previous = financials.previousPeriodTotal;
if (previous === 0) { if (previous === 0) {
if (current > 0) return <span className={styles.positive}>+100% vs periodo anterior</span>; if (current > 0)
return <span className={styles.positive}>+100% vs periodo anterior</span>;
return <span className={styles.neutral}>Sin datos previos</span>; return <span className={styles.neutral}>Sin datos previos</span>;
} }
const diff = current - previous; const diff = current - previous;
const percent = (diff / previous) * 100; const percent = (diff / previous) * 100;
if (percent > 0) return <span className={styles.positive}>+{percent.toFixed(1)}% vs periodo anterior</span>; if (percent > 0)
if (percent < 0) return <span className={styles.negative}>{percent.toFixed(1)}% vs periodo anterior</span>; return (
<span className={styles.positive}>+{percent.toFixed(1)}% vs periodo anterior</span>
);
if (percent < 0)
return (
<span className={styles.negative}>{percent.toFixed(1)}% vs periodo anterior</span>
);
return <span className={styles.neutral}>0% vs periodo anterior</span>; return <span className={styles.neutral}>0% vs periodo anterior</span>;
}; };
if (!selectedOrgId) return null; if (!selectedOrgId) return null;
const header = (
<div className={styles.headerButton}>
<h3 className={styles.title}>
<button
type="button"
className={styles.headerToggle}
onClick={() => setIsOpen((value) => !value)}
aria-expanded={isOpen}
>
Rendimiento Financiero {organizationName ? `- ${organizationName}` : ""}
</button>
</h3>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<button
type="button"
onClick={() => goTo(`/landing/org/${selectedOrgId}`)}
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
borderRadius: "999px",
color: "var(--green-darkestX1)",
border: "1px solid var(--green-darkestX1)",
background: "var(--white)",
textDecoration: "none",
fontSize: "14px",
fontWeight: 600,
cursor: "pointer",
transition: "all 0.2s ease",
}}
title="Ver perfil público de la organización"
>
<OpenInNewOutlinedIcon fontSize="small" />
Ver perfil
</button>
<button
type="button"
className={styles.toggleIcon}
onClick={() => setIsOpen((value) => !value)}
aria-expanded={isOpen}
aria-label={
isOpen ? "Colapsar métricas financieras" : "Expandir métricas financieras"
}
>
{isOpen ? "−" : "+"}
</button>
</div>
</div>
);
if (error) { if (error) {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<button className={styles.headerButton} type="button" onClick={() => setIsOpen((value) => !value)}> {header}
<h3 className={styles.title}>
Rendimiento Financiero {organizationName ? `- ${organizationName}` : ''}
</h3>
<span className={styles.toggleIcon}>{isOpen ? "−" : "+"}</span>
</button>
{isOpen && ( {isOpen && (
<div style={{ padding: '40px 20px', textAlign: 'center', color: '#6b7280' }}> <div style={{ padding: "40px 20px", textAlign: "center", color: "#6b7280" }}>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#374151' }}>Acceso Restringido</p> <p style={{ fontWeight: "600", marginBottom: "8px", color: "#374151" }}>
Acceso Restringido
</p>
<p>{error}</p> <p>{error}</p>
</div> </div>
)} )}
@@ -98,22 +167,17 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
} }
const chartData = financials?.currentPeriodData || []; const chartData = financials?.currentPeriodData || [];
const xAxisData = chartData.map(d => { const xAxisData = chartData.map((d) => {
// format label nicer depending on timeframe // format label nicer depending on timeframe
if (selectedTimeframe === "year") return dayjs(d.date).format("MMM YYYY"); if (selectedTimeframe === "year") return dayjs(d.date).format("MMM YYYY");
return dayjs(d.date).format("DD/MM"); return dayjs(d.date).format("DD/MM");
}); });
const seriesData = chartData.map(d => d.amount); const seriesData = chartData.map((d) => d.amount);
const hasChartData = chartData.some(d => d.amount > 0); const hasChartData = chartData.some((d) => d.amount > 0);
return ( return (
<div className={styles.container}> <div className={styles.container}>
<button className={styles.headerButton} type="button" onClick={() => setIsOpen((value) => !value)}> {header}
<h3 className={styles.title}>
Rendimiento Financiero {organizationName ? `- ${organizationName}` : ''}
</h3>
<span className={styles.toggleIcon}>{isOpen ? "−" : "+"}</span>
</button>
{isOpen && ( {isOpen && (
<> <>
@@ -121,7 +185,9 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
<select <select
className={styles.select} className={styles.select}
value={selectedTimeframe} value={selectedTimeframe}
onChange={e => setSelectedTimeframe(e.target.value as "week" | "month" | "year")} onChange={(e) =>
setSelectedTimeframe(e.target.value as "week" | "month" | "year")
}
> >
<option value="week">Última Semana</option> <option value="week">Última Semana</option>
<option value="month">Último Mes</option> <option value="month">Último Mes</option>
@@ -131,11 +197,13 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
<select <select
className={styles.select} className={styles.select}
value={selectedEmployeeId} value={selectedEmployeeId}
onChange={e => setSelectedEmployeeId(e.target.value)} onChange={(e) => setSelectedEmployeeId(e.target.value)}
> >
<option value="">Todos los Profesionales</option> <option value="">Todos los Profesionales</option>
{collaborators.map(c => ( {collaborators.map((c) => (
<option key={c.id} value={c.id}>{c.fullName}</option> <option key={c.id} value={c.id}>
{c.fullName}
</option>
))} ))}
</select> </select>
</div> </div>
@@ -144,21 +212,29 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
<div className={styles.metricBox}> <div className={styles.metricBox}>
<span className={styles.metricLabel}>Ingresos del Periodo</span> <span className={styles.metricLabel}>Ingresos del Periodo</span>
<span className={styles.metricValue}> <span className={styles.metricValue}>
{loading ? "..." : formatCurrency(financials?.currentPeriodTotal || 0)} {loading
? "..."
: formatCurrency(financials?.currentPeriodTotal || 0)}
</span> </span>
<div className={styles.metricComparison}> <div className={styles.metricComparison}>
{!loading && renderComparison()} {!loading && renderComparison()}
</div> </div>
</div> </div>
{financials && ( {financials && <UnpaidIncomeCard value={financials.unpaidToday} />}
<UnpaidIncomeCard value={financials.unpaidToday} />
)}
</div> </div>
{loading && ( {loading && (
<div className={styles.chartContainer}> <div className={styles.chartContainer}>
<div style={{display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', color: '#9ca3af'}}> <div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "#9ca3af",
}}
>
Cargando datos... Cargando datos...
</div> </div>
</div> </div>
@@ -168,57 +244,68 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
<div className={styles.chartContainer}> <div className={styles.chartContainer}>
<LineChart <LineChart
height={350} height={350}
xAxis={[{ xAxis={[
scaleType: 'point', {
scaleType: "point",
data: xAxisData, data: xAxisData,
}]} },
]}
series={[ series={[
{ {
data: seriesData, data: seriesData,
color: 'rgb(36, 169, 53)', color: "rgb(36, 169, 53)",
area: true, area: true,
showMark: true, showMark: true,
curve: 'monotoneX', curve: "monotoneX",
valueFormatter: (val: number | null) => val ? formatCurrency(val) : "0", valueFormatter: (val: number | null) =>
val ? formatCurrency(val) : "0",
}, },
]} ]}
grid={{ horizontal: true }} grid={{ horizontal: true }}
margin={{ top: 20, bottom: 40, left: 70, right: 20 }} margin={{ top: 20, bottom: 40, left: 70, right: 20 }}
sx={{ sx={{
'.MuiLineElement-root': { ".MuiLineElement-root": {
strokeWidth: 3, strokeWidth: 3,
}, },
'.MuiAreaElement-root': { ".MuiAreaElement-root": {
fill: 'url(#gradient)', fill: "url(#gradient)",
}, },
'.MuiChartsGrid-line': { ".MuiChartsGrid-line": {
strokeDasharray: '5 5', strokeDasharray: "5 5",
stroke: '#e5e7eb', stroke: "#e5e7eb",
}, },
'.MuiChartsAxis-line': { ".MuiChartsAxis-line": {
stroke: 'transparent', stroke: "transparent",
}, },
'.MuiChartsAxis-tick': { ".MuiChartsAxis-tick": {
stroke: 'transparent', stroke: "transparent",
}, },
'.MuiChartsAxis-tickLabel': { ".MuiChartsAxis-tickLabel": {
fill: '#9ca3af', fill: "#9ca3af",
fontFamily: 'inherit', fontFamily: "inherit",
fontSize: '12px', fontSize: "12px",
fontWeight: 500, fontWeight: 500,
}, },
'.MuiMarkElement-root': { ".MuiMarkElement-root": {
stroke: 'rgb(36, 169, 53)', stroke: "rgb(36, 169, 53)",
strokeWidth: 2, strokeWidth: 2,
fill: '#ffffff', fill: "#ffffff",
scale: '1.2', scale: "1.2",
} },
}} }}
> >
<defs> <defs>
<linearGradient id="gradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="gradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="rgb(36, 169, 53)" stopOpacity={0.3}/> <stop
<stop offset="95%" stopColor="rgb(36, 169, 53)" stopOpacity={0.0}/> offset="5%"
stopColor="rgb(36, 169, 53)"
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor="rgb(36, 169, 53)"
stopOpacity={0.0}
/>
</linearGradient> </linearGradient>
</defs> </defs>
</LineChart> </LineChart>
@@ -24,6 +24,32 @@
font-size: 24px; font-size: 24px;
} }
.locationTitleFilter {
padding-right: 64px;
}
.filterButton {
position: absolute;
top: 50%;
right: 12px;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
padding: 0;
border: none;
border-radius: 50%;
background-color: transparent;
color: var(--gray-dark);
cursor: pointer;
}
.filterButton:hover {
color: var(--black);
}
@media (max-width: 600px) { @media (max-width: 600px) {
.locationContainer { .locationContainer {
left: 6px; left: 6px;
@@ -2,18 +2,22 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import style from "./Location.module.css"; import style from "./Location.module.css";
import { useLocationStore } from "@store/Location.Store"; import { useLocationStore } from "@store/Location.Store";
import { useFilterStore } from "@store/Filter.Store";
import Breadcrumbs from "@mui/material/Breadcrumbs"; import Breadcrumbs from "@mui/material/Breadcrumbs";
import Link from "@mui/material/Link"; import Link from "@mui/material/Link";
import HomeIcon from "@mui/icons-material/Home"; import HomeIcon from "@mui/icons-material/Home";
import FilterAltOutlinedIcon from "@mui/icons-material/FilterAltOutlined";
export interface LocationProps { export interface LocationProps {
bottomSeperation?: string; bottomSeperation?: string;
showFilter?: boolean;
} }
export default function Location(props: LocationProps) { export default function Location(props: LocationProps) {
const { bottomSeperation = "55px" } = props; const { bottomSeperation = "55px", showFilter = false } = props;
const router = useRouter(); const router = useRouter();
const location = useLocationStore(); const location = useLocationStore();
const filter = useFilterStore();
const goTo = (path: string) => { const goTo = (path: string) => {
router.push(path); router.push(path);
@@ -48,10 +52,26 @@ export default function Location(props: LocationProps) {
))} ))}
</Breadcrumbs> </Breadcrumbs>
</div> </div>
<div className={style.locationTitle}> <div
className={
showFilter
? `${style.locationTitle} ${style.locationTitleFilter}`
: style.locationTitle
}
>
{location.last()?.icon} {location.last()?.icon}
<h1>{location.last()?.title}</h1> <h1>{location.last()?.title}</h1>
</div> </div>
{showFilter && (
<button
className={style.filterButton}
onClick={() => filter.toggle()}
aria-label="Filtrar"
title="Filtrar"
>
<FilterAltOutlinedIcon sx={{ fontSize: "32px" }} />
</button>
)}
</div> </div>
<div <div
style={{ style={{
@@ -17,6 +17,7 @@ import classNames from "classnames";
import { useNavigation } from "@core/app/hooks/goto"; import { useNavigation } from "@core/app/hooks/goto";
import ShareOutlinedIcon from "@mui/icons-material/ShareOutlined"; import ShareOutlinedIcon from "@mui/icons-material/ShareOutlined";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import Dialog from "@mui/material/Dialog"; import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent"; import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle"; import DialogTitle from "@mui/material/DialogTitle";
@@ -69,6 +70,14 @@ export default function OrganizationHeader(props: OrganizationHeaderParams): Rea
icon: <ShareOutlinedIcon />, icon: <ShareOutlinedIcon />,
}); });
menu.add({
text: "Ver perfil",
onClick: () => {
goTo(`/landing/org/${props.data.id}`);
},
icon: <OpenInNewIcon />,
});
menu.add({ menu.add({
text: "Notificaciones", text: "Notificaciones",
onClick: () => { onClick: () => {
@@ -0,0 +1,187 @@
"use client";
import { useState, useEffect } from "react";
import { Grid2, IconButton, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import turnosXpressTheme from "@core/app/theme/turnosXpress";
import Button from "@mui/material/Button";
import { ThemeProvider } from "@emotion/react";
import { PopoverPicker } from "@components/ColorPicker/ColorPicker";
import ImagePicker from "@components/ImagePicker/ImagePicker";
import { useAlert } from "@core/Store/Alert.Store";
import { useSessionStore } from "@core/Store/Sesion.Store";
import { useOrganizationHeaderStore } from "@core/Store/OrganizationHeader.Store";
import { updateOrganizationColors, uploadHeaderImage } from "@services/Header.Edit.Service";
import { UpdateCompanyParams } from "@models/Company.model";
interface HeaderEditPopupProps {
open: boolean;
onClose: () => void;
companyId: string;
headerColor: string;
headerFontColor: string;
headerFontShadowColor: string;
headerImage: string;
onSaved: () => void;
onRefresh?: () => void;
}
export default function HeaderEditPopup(props: HeaderEditPopupProps) {
const { open, onClose, companyId, headerColor, headerFontColor, headerFontShadowColor, headerImage, onSaved, onRefresh } = props;
const alert = useAlert();
const SessionInfo = useSessionStore();
const organizationHeader = useOrganizationHeaderStore();
const [headerColorState, setHeaderColorState] = useState("");
const [headerFontColorState, setHeaderFontColorState] = useState("");
const [headerFontShadowColorState, setHeaderFontShadowColorState] = useState("");
const [loading, setLoading] = useState(false);
// Sync local state when values change externally
useEffect(() => {
if (open) {
setHeaderColorState(headerColor || "");
setHeaderFontColorState(headerFontColor || "");
setHeaderFontShadowColorState(headerFontShadowColor || "");
setLoading(false);
}
}, [open, headerColor, headerFontColor, headerFontShadowColor]);
const handleSaveColors = async () => {
setLoading(true);
try {
const updData: UpdateCompanyParams = {
id: companyId,
headerColor: headerColorState,
headerFontColor: headerFontColorState,
headerFontShadowColor: headerFontShadowColorState,
sessionUser: SessionInfo.userId,
};
await updateOrganizationColors(updData);
organizationHeader.setHeaderColor(headerColorState);
organizationHeader.setHeaderFontColor(headerFontColorState);
organizationHeader.setHeaderFontShadowColor(headerFontShadowColorState);
alert.showSuccess("Colores actualizados");
onSaved();
onRefresh?.();
} catch (error) {
const formatted = error instanceof Error ? error.message : "Error al guardar los colores";
alert.showError(formatted || "Error al guardar los colores");
} finally {
setLoading(false);
}
};
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setLoading(true);
try {
const formData = new FormData();
formData.append("companyId", companyId);
formData.append("sessionUser", SessionInfo.userId);
formData.append("file", file);
uploadHeaderImage(formData).then(() => {
alert.showSuccess("Imagen actualizada");
onSaved();
onRefresh?.();
}).catch((error) => {
const formatted = error instanceof Error ? error.message : "Error al guardar la imagen";
alert.showError(formatted || "Error al guardar la imagen");
}).finally(() => {
setLoading(false);
});
} catch (error) {
const formatted = error instanceof Error ? error.message : "Error al guardar la imagen";
alert.showError(formatted || "Error al guardar la imagen");
setLoading(false);
}
};
return (
<ThemeProvider theme={turnosXpressTheme}>
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
borderRadius: 2,
height: 500,
},
}}
>
<DialogTitle sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
Personalizar
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers sx={{ p: 3 }}>
{/* Colores */}
<Grid2 container spacing={1} mb={3}>
<Grid2 size={4}>
<PopoverPicker
placeholder="Fondo"
color={headerColorState}
onChange={(color: string) => {
setHeaderColorState(color);
}}
/>
</Grid2>
<Grid2 size={4}>
<PopoverPicker
placeholder="Fuente"
color={headerFontColorState}
onChange={(color: string) => {
setHeaderFontColorState(color);
}}
/>
</Grid2>
<Grid2 size={4}>
<PopoverPicker
placeholder="Sombra"
color={headerFontShadowColorState}
onChange={(color: string) => {
setHeaderFontShadowColorState(color);
}}
/>
</Grid2>
</Grid2>
{/* Imagen */}
<ImagePicker
src={headerImage}
alt="Cabecera"
style={{ width: "100%", height: 120, borderRadius: 8 }}
readOnly={false}
onChange={handleImageChange}
imageSizeSrc="/org-header-size.webp"
mbLimit={5}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onClose}>Cancelar</Button>
<Button
onClick={handleSaveColors}
autoFocus
color="success"
variant="contained"
disabled={loading}
>
Guardar
</Button>
</DialogActions>
</Dialog>
</ThemeProvider>
);
}
@@ -0,0 +1,116 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import HeaderEditPopup from "./HeaderEditPopup";
import { useSessionStore } from "@core/Store/Sesion.Store";
import { checkOrgAdmin } from "@services/Header.Edit.Service";
interface OrganizationHeaderEditProps {
companyId: string;
headerColor?: string;
headerFontColor?: string;
headerFontShadowColor?: string;
headerImage?: string;
}
export default function OrganizationHeaderEdit({
companyId,
headerColor = "",
headerFontColor = "",
headerFontShadowColor = "",
headerImage = "",
}: OrganizationHeaderEditProps) {
const router = useRouter();
const SessionInfo = useSessionStore();
const [isOrgAdmin, setIsOrgAdmin] = useState(false);
const [showEditPopup, setShowEditPopup] = useState(false);
useEffect(() => {
if (!SessionInfo.userId || !companyId) {
setIsOrgAdmin(false);
const existing = document.getElementById("edit-header-btn");
if (existing) existing.remove();
return;
}
let cancelled = false;
checkOrgAdmin(companyId, SessionInfo.userId)
.then((isAdmin) => {
if (cancelled) return;
setIsOrgAdmin(isAdmin);
const wrapper = document.getElementById("header-wrapper");
if (!wrapper) return;
if (isAdmin) {
const existingBtn = document.getElementById("edit-header-btn");
if (existingBtn) existingBtn.remove();
const btn = document.createElement("div");
btn.id = "edit-header-btn";
btn.innerHTML = `
<button style="
position: absolute;
top: 12px;
right: 12px;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(0,0,0,0.45);
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
transition: background 0.2s;
" onmouseover="this.style.background='rgba(0,0,0,0.7)'" onmouseout="this.style.background='rgba(0,0,0,0.45)'">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
`;
btn.querySelector("button")!.addEventListener("click", () =>
setShowEditPopup(true),
);
wrapper.appendChild(btn);
} else {
const existingBtn = document.getElementById("edit-header-btn");
if (existingBtn) existingBtn.remove();
}
})
.catch(() => {
if (cancelled) return;
setIsOrgAdmin(false);
const existingBtn = document.getElementById("edit-header-btn");
if (existingBtn) existingBtn.remove();
});
return () => {
cancelled = true;
const existing = document.getElementById("edit-header-btn");
if (existing) existing.remove();
};
}, [SessionInfo.userId, companyId]);
return (
<>
{isOrgAdmin && (
<HeaderEditPopup
open={showEditPopup}
onClose={() => setShowEditPopup(false)}
companyId={companyId}
headerColor={headerColor}
headerFontColor={headerFontColor}
headerFontShadowColor={headerFontShadowColor}
headerImage={headerImage}
onSaved={() => {
setShowEditPopup(false);
}}
onRefresh={() => router.refresh()}
/>
)}
</>
);
}
@@ -9,10 +9,11 @@ export interface PaginationControlProps {
current: number; current: number;
size: number; size: number;
max: number; max: number;
inheritColor?: boolean;
} }
export default function PaginationControls(props: PaginationControlProps) { export default function PaginationControls(props: PaginationControlProps) {
const { current, size, max, onChange = () => {} } = props; const { current, size, max, onChange = () => {}, inheritColor = false } = props;
useEffect(() => {}, [current]); useEffect(() => {}, [current]);
@@ -36,14 +37,20 @@ export default function PaginationControls(props: PaginationControlProps) {
}; };
return ( return (
<div className={style.paginationControls}> <div
<IconButton onClick={handlePrevious}> className={style.paginationControls}
style={inheritColor ? { color: "var(--white)" } : undefined}
>
<IconButton color={inheritColor ? "inherit" : undefined} onClick={handlePrevious}>
<ArrowBackwardIcon sx={{ fontSize: 24 }} /> <ArrowBackwardIcon sx={{ fontSize: 24 }} />
</IconButton> </IconButton>
<span className={style.paginationLabel}> <span
className={style.paginationLabel}
style={inheritColor ? { color: "var(--white)" } : undefined}
>
{current} / {max} {current} / {max}
</span> </span>
<IconButton onClick={handleNext}> <IconButton color={inheritColor ? "inherit" : undefined} onClick={handleNext}>
<ArrowForwardIcon sx={{ fontSize: 24 }} /> <ArrowForwardIcon sx={{ fontSize: 24 }} />
</IconButton> </IconButton>
</div> </div>
@@ -6,6 +6,7 @@ import { PublicServiceView } from "@models/Service.model";
import style from "./style.module.css"; import style from "./style.module.css";
import { getServiceImage } from "@core/helpers/getServiceImage"; import { getServiceImage } from "@core/helpers/getServiceImage";
import ServiceRatingSummary from "@components/ServiceRatingSummary/ServiceRatingSummary"; import ServiceRatingSummary from "@components/ServiceRatingSummary/ServiceRatingSummary";
import EditIcon from "@mui/icons-material/Edit";
export type FindResultsViewMode = "grid" | "card"; export type FindResultsViewMode = "grid" | "card";
@@ -14,6 +15,8 @@ export interface ProductItemProps {
width?: number | string; width?: number | string;
viewMode?: FindResultsViewMode; viewMode?: FindResultsViewMode;
dragging?: boolean; dragging?: boolean;
isAdmin?: boolean;
onImageEdit?: () => void;
} }
export default function ProductItem(props: ProductItemProps) { export default function ProductItem(props: ProductItemProps) {
@@ -21,6 +24,8 @@ export default function ProductItem(props: ProductItemProps) {
const { product } = props; const { product } = props;
const { viewMode = "card" } = props; const { viewMode = "card" } = props;
const { dragging = false } = props; const { dragging = false } = props;
const { isAdmin = false } = props;
const { onImageEdit } = props;
const { goTo } = useNavigation(); const { goTo } = useNavigation();
return ( return (
@@ -38,7 +43,7 @@ export default function ProductItem(props: ProductItemProps) {
textAlign: "center", textAlign: "center",
padding: "10px", padding: "10px",
border: "solid 1px rgba(0, 0, 0, 0.1)", border: "solid 1px rgba(0, 0, 0, 0.1)",
cursor: "pointer", cursor: isAdmin ? "default" : "pointer",
position: "relative", position: "relative",
}} }}
onClick={() => { onClick={() => {
@@ -58,6 +63,11 @@ export default function ProductItem(props: ProductItemProps) {
display: "block", display: "block",
}} }}
/> />
{isAdmin && (
<span className={style.adminEditButton} onClick={(e) => { e.stopPropagation(); onImageEdit?.(); }}>
<EditIcon fontSize="small" />
</span>
)}
<ServiceRatingSummary <ServiceRatingSummary
serviceId={product.id} serviceId={product.id}
variant="card" variant="card"
@@ -8,6 +8,32 @@
bottom: 8px; bottom: 8px;
} }
.adminEditButton {
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.9);
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
z-index: 10;
}
.adminEditButton:hover {
background: var(--wine-red);
transform: scale(1.1);
}
.adminEditButton:hover svg {
color: white !important;
}
.cardHeader { .cardHeader {
margin: 10px 0; margin: 10px 0;
text-align: center; text-align: center;
@@ -0,0 +1,22 @@
import { Metadata } from "next";
import OfferContent from "../offer-appointments/OfferContent";
export const metadata: Metadata = {
title: "Agenda Online para Profesionales | Reservas Automáticas - TurnosXpress",
description: "Agendá citas online y eliminá el ida y vuelta de mensajes. Una agenda online automática, con recordatorios y reservas 24/7 para tu negocio.",
keywords: ["agenda online", "agenda digital", "agenda de citas online", "reservas online", "agenda para profesionales"],
openGraph: {
title: "Agenda Online para Profesionales | Reservas - TurnosXpress",
description: "Tu agenda online con reservas automáticas, recordatorios y gestión simple. ¡Empezá gratis!",
type: "website",
}
};
export default function AgendaOnlinePage() {
return (
<OfferContent
heroTitle="Tu agenda online trabaja mientras vos trabajás"
heroSubtitle="Una agenda online que recibe reservas automáticamente, avisa a tus clientes y respeta tus horarios. Sin papeles ni mensajes a deshora."
/>
);
}
@@ -1,9 +1,39 @@
import { Metadata } from "next";
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer"; import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
import CategoriesExplorer from "@core/app/components/Home/CategoriesExplorer/page"; import CategoriesExplorer from "@core/app/components/Home/CategoriesExplorer/page";
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction"; import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
import { ScrollToTop } from "@core/app/components/ScrollTop"; import { ScrollToTop } from "@core/app/components/ScrollTop";
import style from "./style.module.css"; import style from "./style.module.css";
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar";
export const metadata: Metadata = {
title: "Explorá categorías y encontrá tu próximo turno | TurnosXpress",
description:
"Categorías de servicios por rubro en TurnosXpress. Elegí el tipo de servicio que necesitás y filtrá por ubicación para encontrar opciones cercanas, rápidas y organizadas.",
alternates: {
canonical: `${baseUrl}/landing/categories`,
},
openGraph: {
type: "website",
title: "Explorá categorías y encontrá tu próximo turno | TurnosXpress",
description:
"Categorías de servicios por rubro en TurnosXpress. Elegí el tipo de servicio que necesitás y filtrá por ubicación.",
url: `${baseUrl}/landing/categories`,
siteName: "TurnosXpress",
locale: "es_AR",
},
twitter: {
card: "summary",
title: "Explorá categorías y encontrá tu próximo turno | TurnosXpress",
description: "Categorías de servicios por rubro en TurnosXpress.",
},
robots: {
index: true,
follow: true,
},
};
export default function CategoriesPage() { export default function CategoriesPage() {
return ( return (
<AnimatedContainer <AnimatedContainer
@@ -0,0 +1,44 @@
"use client";
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
import ExploreCategories from "@core/app/components/ExploreCategories/ExploreCategories";
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
import useContainerHeight from "@core/app/hooks/ContainerHeight";
import { ScrollToTop } from "@core/app/components/ScrollTop";
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
export default function GetAppointmentsContent() {
const containerHeight = useContainerHeight();
return (
<AnimatedContainer
color="#aeaeae"
minSize={150}
maxSize={500}
items={10}
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
}}
>
<ScrollToTop />
<HeaderConfProvider />
<DefaultLoginAction />
<div className="homeCentered">
<h1 className="homeTitleHeader">Quiero reservar turno</h1>
<p className="homeResumeHeader">
Reservar un turno es muy fácil. Solo tenés que explorar nuestras categorías y
buscar el servicio más adecuado para vos. Una vez que ingreses al servicio,
podrás realizar la reserva. Tené en cuenta que, para poder reservar, debés estar
registrado en nuestro sitio web.
</p>
</div>
<div className="homeCentered">
<ExploreCategories containerHeight={containerHeight} />
<div style={{ height: "100px" }}>&nbsp;</div>
</div>
</AnimatedContainer>
);
}
@@ -1,44 +1,35 @@
"use client"; import { Metadata } from "next";
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer"; import GetAppointmentsContent from "./GetAppointmentsContent";
import ExploreCategories from "@core/app/components/ExploreCategories/ExploreCategories";
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider"; const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar";
import useContainerHeight from "@core/app/hooks/ContainerHeight";
import { ScrollToTop } from "@core/app/components/ScrollTop"; export const metadata: Metadata = {
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction"; title: "Quiero reservar turno | TurnosXpress",
description:
"Reservá tu turno en TurnosXpress: explorá categorías y buscá el servicio más adecuado para vos. Registrate y reservá en minutos.",
alternates: {
canonical: `${baseUrl}/landing/get-appointments`,
},
openGraph: {
type: "website",
title: "Quiero reservar turno | TurnosXpress",
description:
"Explorá categorías, buscá el servicio más adecuado para vos y reservá tu turno en TurnosXpress.",
url: `${baseUrl}/landing/get-appointments`,
siteName: "TurnosXpress",
locale: "es_AR",
},
twitter: {
card: "summary",
title: "Quiero reservar turno | TurnosXpress",
description: "Explorá categorías y reservá tu turno en TurnosXpress.",
},
robots: {
index: true,
follow: true,
},
};
export default function GetAppointmentsPage() { export default function GetAppointmentsPage() {
const containerHeight = useContainerHeight(); return <GetAppointmentsContent />;
return (
<AnimatedContainer
color="#aeaeae"
minSize={150}
maxSize={500}
items={10}
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
}}
>
<ScrollToTop />
<HeaderConfProvider />
<DefaultLoginAction />
<div className="homeCentered">
<h1 className="homeTitleHeader">Quiero reservar turno</h1>
<p className="homeResumeHeader">
Reservar un turno es muy fácil. Solo tenés que explorar nuestras categorías y
buscar el servicio más adecuado para vos. Una vez que ingreses al servicio,
podrás realizar la reserva. Tené en cuenta que, para poder reservar, debés estar
registrado en nuestro sitio web.
</p>
</div>
<div className="homeCentered">
<ExploreCategories containerHeight={containerHeight} />
<div style={{ height: "100px" }}>&nbsp;</div>
</div>
</AnimatedContainer>
);
} }
@@ -0,0 +1,335 @@
"use client";
import { useState, useEffect } from "react";
import style from "./help.module.css";
import { videoCategories, Video, getTotalVideoCount } from "./videoData";
import VideoModal from "./components/VideoModal";
import AnimatedContainer from "@core/app/components/AnimatedConainer/AnimatedContainer";
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
import { ScrollToTop } from "@core/app/components/ScrollTop";
import Link from "next/link";
export default function HelpContent() {
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [activeCategory, setActiveCategory] = useState<string>("all");
const [watchedVideos, setWatchedVideos] = useState<Set<string>>(new Set());
const totalVideos = getTotalVideoCount();
useEffect(() => {
const watched = localStorage.getItem("tx-help-watched");
if (watched) {
setWatchedVideos(new Set(JSON.parse(watched)));
}
}, []);
const handleVideoClick = (video: Video) => {
setSelectedVideo(video);
setIsModalOpen(true);
const newWatched = new Set(watchedVideos);
newWatched.add(video.id);
setWatchedVideos(newWatched);
localStorage.setItem("tx-help-watched", JSON.stringify([...newWatched]));
};
const handleVideoNavigate = (video: Video) => {
// Mark as watched without opening the modal, then let the Link navigate
const newWatched = new Set(watchedVideos);
newWatched.add(video.id);
setWatchedVideos(newWatched);
localStorage.setItem("tx-help-watched", JSON.stringify([...newWatched]));
};
const handleCloseModal = () => {
setIsModalOpen(false);
setTimeout(() => setSelectedVideo(null), 200);
};
const handleCategoryClick = (categoryId: string) => {
setActiveCategory(categoryId);
if (categoryId !== "all") {
const element = document.getElementById(`category-${categoryId}`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
};
const progressPercent = Math.round((watchedVideos.size / totalVideos) * 100);
return (
<AnimatedContainer
color="#aeaeae"
minSize={150}
maxSize={500}
items={10}
style={{
display: "block",
width: "100%",
background: "var(--white)",
}}
>
<ScrollToTop />
<HeaderConfProvider />
<DefaultLoginAction />
<div className={style.container}>
{/* Hero Section */}
<div className={style.hero}>
<div className={style.heroIcon}>
<svg
width="36"
height="36"
viewBox="0 0 24 24"
fill="none"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</div>
<h1 className={style.heroTitle}>Centro de Ayuda</h1>
<p className={style.heroSubtitle}>
Aprende a usar TurnosXpress paso a paso con nuestros tutoriales en video.
Desde la configuración inicial hasta las funciones más avanzadas,
tenemos todo lo que necesitas para aprovechar al máximo la plataforma.
</p>
<div className={style.heroStats}>
<div className={style.statItem}>
<span className={style.statNumber}>{totalVideos}</span>
<span className={style.statLabel}>Video tutoriales</span>
</div>
<div className={style.statItem}>
<span className={style.statNumber}>{videoCategories.length}</span>
<span className={style.statLabel}>Categorías</span>
</div>
<div className={style.statItem}>
<span className={style.statNumber}>{watchedVideos.size}</span>
<span className={style.statLabel}>Vistos</span>
</div>
</div>
</div>
{/* Progress Section */}
{watchedVideos.size > 0 && (
<div className={style.progressSection}>
<h2 className={style.progressTitle}>
Tu progreso de aprendizaje
</h2>
<p className={style.progressText}>
Has visto {watchedVideos.size} de {totalVideos} tutoriales.
{progressPercent === 100
? " ¡Felicidades! Has completado todos los tutoriales."
: ` ¡Sigue así, te faltan ${totalVideos - watchedVideos.size}!`}
</p>
<div className={style.progressBar}>
<div
className={style.progressFill}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
)}
{/* Category Navigation */}
<div className={style.categoryNav}>
<button
className={
activeCategory === "all"
? style.categoryBtnActive
: style.categoryBtn
}
onClick={() => {
setActiveCategory("all");
window.scrollTo({ top: 0, behavior: "smooth" });
}}
>
Todos
</button>
{videoCategories.map((cat) => (
<button
key={cat.id}
className={
activeCategory === cat.id
? style.categoryBtnActive
: style.categoryBtn
}
onClick={() => handleCategoryClick(cat.id)}
>
{cat.icon} {cat.name}
</button>
))}
</div>
{/* Learning Path */}
<div className={style.learningPath}>
<span className={style.learningPathIcon}>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
</span>
<span className={style.learningPathText}>
<strong>Recomendación:</strong> Sigue el orden de las categorías para
un aprendizaje progresivo. Comienza con &quot;Primeros Pasos&quot; y
avanza según tus necesidades.
</span>
</div>
{/* Video Categories */}
{videoCategories.map((category) => (
<div
key={category.id}
id={`category-${category.id}`}
className={style.categorySection}
>
<div className={style.categoryHeader}>
<div
className={style.categoryIcon}
style={{ background: category.color }}
>
<span style={{ fontSize: "1.5rem" }}>{category.icon}</span>
</div>
<div>
<h2 className={style.categoryTitle}>{category.name}</h2>
<p className={style.categoryDescription}>
{category.description}
</p>
</div>
<span className={style.categoryCount}>
{category.videos.length} videos
</span>
</div>
<div className={style.videoGrid}>
{category.videos.map((video, idx) => (
<div
key={video.id}
className={style.videoCard}
onClick={() => handleVideoClick(video)}
>
<div className={style.videoThumbnail}>
<img
src={video.thumbnail}
alt={video.title}
loading="lazy"
/>
<div className={style.playOverlay}>
<div className={style.playButton}>
<div className={style.playIcon} />
</div>
</div>
{watchedVideos.has(video.id) && (
<div
style={{
position: "absolute",
top: "8px",
left: "8px",
background:
"linear-gradient(135deg, #2aaa2a, #008011)",
color: "white",
padding: "3px 8px",
borderRadius: "4px",
fontSize: "0.7rem",
fontWeight: 700,
display: "flex",
alignItems: "center",
gap: "3px",
}}
>
✓ Visto
</div>
)}
</div>
<div className={style.videoContent}>
<h3 className={style.videoTitle}>{video.title}</h3>
<p className={style.videoDescription}>
{video.description}
</p>
<div className={style.videoMeta}>
<div className={style.videoStep}>
<span className={style.stepNumber}>
{idx + 1}
</span>
Paso {idx + 1} de {category.videos.length}
</div>
<Link
href={`/landing/help/video/${video.id}`}
className={style.videoPageLink}
onClick={(e) => {
e.stopPropagation();
handleVideoNavigate(video);
}}
>
Ver tutorial completo
</Link>
</div>
</div>
</div>
))}
</div>
</div>
))}
{/* CTA Section */}
<div className={style.ctaSection}>
<h2 className={style.ctaTitle}>¿Necesitas más ayuda?</h2>
<p className={style.ctaText}>
Si no encontrás lo que buscás, nuestro equipo de soporte está
listo para ayudarte. También podés visitar nuestro canal de YouTube
para más contenido.
</p>
<div style={{ display: "flex", gap: "1rem", justifyContent: "center", flexWrap: "wrap" }}>
<a
href="mailto:info@turnosxpress.com.ar"
className={style.ctaButton}
>
✉ Escribinos
</a>
<a
href="https://www.youtube.com/@turnosxpress"
target="_blank"
rel="noopener noreferrer"
className={style.ctaButton}
style={{
background: "var(--white)",
color: "var(--wine-darkest)",
boxShadow: "0 4px 15px rgba(0,0,0,0.1)",
}}
>
▶ Canal de YouTube
</a>
</div>
</div>
</div>
{/* Video Modal */}
<VideoModal
video={selectedVideo}
isOpen={isModalOpen}
onClose={handleCloseModal}
onSelectVideo={(video) => {
setSelectedVideo(video);
const newWatched = new Set(watchedVideos);
newWatched.add(video.id);
setWatchedVideos(newWatched);
localStorage.setItem("tx-help-watched", JSON.stringify([...newWatched]));
}}
/>
</AnimatedContainer>
);
}
@@ -320,6 +320,20 @@
font-weight: 600; font-weight: 600;
} }
.videoPageLink {
margin-left: auto;
font-size: 0.8rem;
font-weight: 600;
color: var(--wine-dark);
text-decoration: none;
transition: color 0.2s ease;
}
.videoPageLink:hover {
color: var(--wine-red);
text-decoration: underline;
}
.stepNumber { .stepNumber {
width: 22px; width: 22px;
height: 22px; height: 22px;
+32 -313
View File
@@ -1,317 +1,36 @@
"use client"; import { Metadata } from "next";
import HelpContent from "./HelpContent";
import { useState, useEffect } from "react"; const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar";
import style from "./help.module.css";
import { videoCategories, Video, getTotalVideoCount } from "./videoData"; export const metadata: Metadata = {
import VideoModal from "./components/VideoModal"; title: "Centro de Ayuda y Tutoriales | TurnosXpress",
import AnimatedContainer from "@core/app/components/AnimatedConainer/AnimatedContainer"; description:
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider"; "Aprende a usar TurnosXpress paso a paso con tutoriales en video. Desde la configuración inicial hasta las funciones más avanzadas: servicios, horarios, reservas, pagos y más.",
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction"; alternates: {
import { ScrollToTop } from "@core/app/components/ScrollTop"; canonical: `${baseUrl}/landing/help`,
import Link from "next/link"; },
openGraph: {
type: "website",
title: "Centro de Ayuda y Tutoriales | TurnosXpress",
description:
"Aprende a usar TurnosXpress paso a paso con tutoriales en video. Desde la configuración inicial hasta las funciones más avanzadas: servicios, horarios, reservas, pagos y más.",
url: `${baseUrl}/landing/help`,
siteName: "TurnosXpress",
locale: "es_AR",
},
twitter: {
card: "summary",
title: "Centro de Ayuda y Tutoriales | TurnosXpress",
description:
"Aprende a usar TurnosXpress paso a paso con tutoriales en video.",
},
robots: {
index: true,
follow: true,
},
};
export default function HelpPage() { export default function HelpPage() {
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null); return <HelpContent />;
const [isModalOpen, setIsModalOpen] = useState(false);
const [activeCategory, setActiveCategory] = useState<string>("all");
const [watchedVideos, setWatchedVideos] = useState<Set<string>>(new Set());
const totalVideos = getTotalVideoCount();
useEffect(() => {
const watched = localStorage.getItem("tx-help-watched");
if (watched) {
setWatchedVideos(new Set(JSON.parse(watched)));
}
}, []);
const handleVideoClick = (video: Video) => {
setSelectedVideo(video);
setIsModalOpen(true);
const newWatched = new Set(watchedVideos);
newWatched.add(video.id);
setWatchedVideos(newWatched);
localStorage.setItem("tx-help-watched", JSON.stringify([...newWatched]));
};
const handleCloseModal = () => {
setIsModalOpen(false);
setTimeout(() => setSelectedVideo(null), 200);
};
const handleCategoryClick = (categoryId: string) => {
setActiveCategory(categoryId);
if (categoryId !== "all") {
const element = document.getElementById(`category-${categoryId}`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
};
const progressPercent = Math.round((watchedVideos.size / totalVideos) * 100);
return (
<AnimatedContainer
color="#aeaeae"
minSize={150}
maxSize={500}
items={10}
style={{
display: "block",
width: "100%",
background: "var(--white)",
}}
>
<ScrollToTop />
<HeaderConfProvider />
<DefaultLoginAction />
<div className={style.container}>
{/* Hero Section */}
<div className={style.hero}>
<div className={style.heroIcon}>
<svg
width="36"
height="36"
viewBox="0 0 24 24"
fill="none"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</div>
<h1 className={style.heroTitle}>Centro de Ayuda</h1>
<p className={style.heroSubtitle}>
Aprende a usar TurnosXpress paso a paso con nuestros tutoriales en video.
Desde la configuración inicial hasta las funciones más avanzadas,
tenemos todo lo que necesitas para aprovechar al máximo la plataforma.
</p>
<div className={style.heroStats}>
<div className={style.statItem}>
<span className={style.statNumber}>{totalVideos}</span>
<span className={style.statLabel}>Video tutoriales</span>
</div>
<div className={style.statItem}>
<span className={style.statNumber}>{videoCategories.length}</span>
<span className={style.statLabel}>Categorías</span>
</div>
<div className={style.statItem}>
<span className={style.statNumber}>{watchedVideos.size}</span>
<span className={style.statLabel}>Vistos</span>
</div>
</div>
</div>
{/* Progress Section */}
{watchedVideos.size > 0 && (
<div className={style.progressSection}>
<h2 className={style.progressTitle}>
Tu progreso de aprendizaje
</h2>
<p className={style.progressText}>
Has visto {watchedVideos.size} de {totalVideos} tutoriales.
{progressPercent === 100
? " ¡Felicidades! Has completado todos los tutoriales."
: ` ¡Sigue así, te faltan ${totalVideos - watchedVideos.size}!`}
</p>
<div className={style.progressBar}>
<div
className={style.progressFill}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
)}
{/* Category Navigation */}
<div className={style.categoryNav}>
<button
className={
activeCategory === "all"
? style.categoryBtnActive
: style.categoryBtn
}
onClick={() => {
setActiveCategory("all");
window.scrollTo({ top: 0, behavior: "smooth" });
}}
>
Todos
</button>
{videoCategories.map((cat) => (
<button
key={cat.id}
className={
activeCategory === cat.id
? style.categoryBtnActive
: style.categoryBtn
}
onClick={() => handleCategoryClick(cat.id)}
>
{cat.icon} {cat.name}
</button>
))}
</div>
{/* Learning Path */}
<div className={style.learningPath}>
<span className={style.learningPathIcon}>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
</span>
<span className={style.learningPathText}>
<strong>Recomendación:</strong> Sigue el orden de las categorías para
un aprendizaje progresivo. Comienza con &quot;Primeros Pasos&quot; y
avanza según tus necesidades.
</span>
</div>
{/* Video Categories */}
{videoCategories.map((category) => (
<div
key={category.id}
id={`category-${category.id}`}
className={style.categorySection}
>
<div className={style.categoryHeader}>
<div
className={style.categoryIcon}
style={{ background: category.color }}
>
<span style={{ fontSize: "1.5rem" }}>{category.icon}</span>
</div>
<div>
<h2 className={style.categoryTitle}>{category.name}</h2>
<p className={style.categoryDescription}>
{category.description}
</p>
</div>
<span className={style.categoryCount}>
{category.videos.length} videos
</span>
</div>
<div className={style.videoGrid}>
{category.videos.map((video, idx) => (
<div
key={video.id}
className={style.videoCard}
onClick={() => handleVideoClick(video)}
>
<div className={style.videoThumbnail}>
<img
src={video.thumbnail}
alt={video.title}
loading="lazy"
/>
<div className={style.playOverlay}>
<div className={style.playButton}>
<div className={style.playIcon} />
</div>
</div>
{watchedVideos.has(video.id) && (
<div
style={{
position: "absolute",
top: "8px",
left: "8px",
background:
"linear-gradient(135deg, #2aaa2a, #008011)",
color: "white",
padding: "3px 8px",
borderRadius: "4px",
fontSize: "0.7rem",
fontWeight: 700,
display: "flex",
alignItems: "center",
gap: "3px",
}}
>
✓ Visto
</div>
)}
</div>
<div className={style.videoContent}>
<h3 className={style.videoTitle}>{video.title}</h3>
<p className={style.videoDescription}>
{video.description}
</p>
<div className={style.videoMeta}>
<div className={style.videoStep}>
<span className={style.stepNumber}>
{idx + 1}
</span>
Paso {idx + 1} de {category.videos.length}
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
{/* CTA Section */}
<div className={style.ctaSection}>
<h2 className={style.ctaTitle}>¿Necesitas más ayuda?</h2>
<p className={style.ctaText}>
Si no encontrás lo que buscás, nuestro equipo de soporte está
listo para ayudarte. También podés visitar nuestro canal de YouTube
para más contenido.
</p>
<div style={{ display: "flex", gap: "1rem", justifyContent: "center", flexWrap: "wrap" }}>
<a
href="mailto:info@turnosxpress.com.ar"
className={style.ctaButton}
>
✉ Escribinos
</a>
<a
href="https://www.youtube.com/@turnosxpress"
target="_blank"
rel="noopener noreferrer"
className={style.ctaButton}
style={{
background: "var(--white)",
color: "var(--wine-darkest)",
boxShadow: "0 4px 15px rgba(0,0,0,0.1)",
}}
>
▶ Canal de YouTube
</a>
</div>
</div>
</div>
{/* Video Modal */}
<VideoModal
video={selectedVideo}
isOpen={isModalOpen}
onClose={handleCloseModal}
onSelectVideo={(video) => {
setSelectedVideo(video);
const newWatched = new Set(watchedVideos);
newWatched.add(video.id);
setWatchedVideos(newWatched);
localStorage.setItem("tx-help-watched", JSON.stringify([...newWatched]));
}}
/>
</AnimatedContainer>
);
} }
@@ -0,0 +1,272 @@
import { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Video, VideoCategory } from "../../videoData";
import { videoCategories, getAllVideos } from "../../videoData";
// Static generation: Next.js builds a page for every video at build time
export function generateStaticParams() {
return getAllVideos().map((video) => ({ videoId: video.id }));
}
// SEO metadata per video
export async function generateMetadata({
params,
}: {
params: Promise<{ videoId: string }>;
}): Promise<Metadata> {
const { videoId } = await params;
const video = getAllVideos().find((v) => v.id === videoId);
if (!video) {
return {};
}
const category = videoCategories.find((c) => c.videos.some((v) => v.id === videoId));
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar";
const url = `${baseUrl}/landing/help/video/${videoId}`;
return {
title: `${video.title} | TurnosXpress`,
description: video.description,
alternates: {
canonical: url,
},
openGraph: {
type: "video.other",
title: video.title,
description: video.description,
images: [{ url: video.thumbnail, width: 480, height: 360, alt: video.title }],
siteName: "TurnosXpress",
locale: "es_AR",
},
twitter: {
card: "summary_large_image",
title: video.title,
description: video.description,
images: [video.thumbnail],
},
robots: {
index: true,
follow: true,
},
};
}
const allVideos = getAllVideos();
// Build flat array with category context for each video
interface VideoWithContext {
video: Video;
category: VideoCategory;
index: number;
}
function getVideoWithContext(videoId: string): VideoWithContext | null {
let flatIndex = 0;
for (const cat of videoCategories) {
const idx = cat.videos.findIndex((v) => v.id === videoId);
if (idx !== -1) {
return {
video: cat.videos[idx],
category: cat,
index: flatIndex,
};
}
flatIndex += cat.videos.length;
}
return null;
}
export default async function VideoPage({
params,
}: {
params: Promise<{ videoId: string }>;
}) {
const { videoId } = await params;
const ctx = getVideoWithContext(videoId);
if (!ctx) {
notFound();
}
const { video, category, index } = ctx;
const prevVideo = index > 0 ? allVideos[index - 1] : null;
const nextVideo = index < allVideos.length - 1 ? allVideos[index + 1] : null;
// JSON-LD VideoObject schema for Google rich results
const jsonLd = {
"@context": "https://schema.org",
"@type": "VideoObject",
name: video.title,
description: video.description,
thumbnailUrl: video.thumbnail,
contentUrl: `https://www.youtube.com/watch?v=${video.id}`,
embedUrl: `https://www.youtube.com/embed/${video.id}`,
uploadDate: video.uploadDate,
publisher: {
"@type": "Organization",
name: "TurnosXpress",
logo: {
"@type": "ImageObject",
url: `${process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar"}/favicon.ico`,
},
},
category: category.name,
};
return (
<>
{/* JSON-LD structured data */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<div style={{ maxWidth: 900, margin: "0 auto", padding: "2rem 1.5rem" }}>
{/* Breadcrumb nav */}
<nav
style={{
marginBottom: "2rem",
fontSize: "0.875rem",
color: "var(--gray-dark)",
}}
>
<Link
href="/landing/help"
style={{ color: "var(--wine-dark)", textDecoration: "none" }}
>
Centro de Ayuda
</Link>
{" / "}
<span style={{ color: "var(--black)" }}>{video.title}</span>
</nav>
{/* Category badge */}
<div
style={{
display: "inline-block",
padding: "0.35rem 1rem",
borderRadius: 50,
background: category.color,
color: "white",
fontSize: "0.8rem",
fontWeight: 700,
marginBottom: "1.25rem",
}}
>
{category.icon} {category.name}
</div>
{/* Title */}
<h1
style={{
fontSize: "2rem",
fontWeight: 800,
color: "var(--wine-superdark)",
marginBottom: "0.75rem",
lineHeight: 1.3,
}}
>
{video.title}
</h1>
{/* Description */}
<p
style={{
fontSize: "1.05rem",
color: "var(--gray-dark)",
lineHeight: 1.6,
marginBottom: "2rem",
}}
>
{video.description}
</p>
{/* YouTube embed */}
<div style={{ position: "relative", paddingBottom: "56.25%", height: 0, overflow: "hidden", borderRadius: 16, boxShadow: "0 8px 30px rgba(0,0,0,0.12)", marginBottom: "2rem" }}>
<iframe
src={`https://www.youtube.com/embed/${video.id}?rel=0&modestbranding=1`}
title={video.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", border: 0 }}
/>
</div>
{/* Prev/Next navigation */}
{(prevVideo || nextVideo) && (
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}>
{prevVideo && (
<Link
href={`/landing/help/video/${prevVideo.id}`}
style={{
flex: 1,
minWidth: 200,
padding: "1rem 1.25rem",
borderRadius: 12,
border: "1px solid var(--white-darkest)",
background: "var(--white)",
textDecoration: "none",
transition: "all 0.2s ease",
}}
>
<div style={{ fontSize: "0.8rem", color: "var(--gray-dark)", marginBottom: "0.25rem" }}>
{"← Anterior"}
</div>
<div style={{ fontSize: "0.95rem", fontWeight: 700, color: "var(--wine-superdark)" }}>
{prevVideo.title}
</div>
</Link>
)}
{nextVideo && (
<Link
href={`/landing/help/video/${nextVideo.id}`}
style={{
flex: 1,
minWidth: 200,
padding: "1rem 1.25rem",
borderRadius: 12,
border: "1px solid var(--white-darkest)",
background: "var(--white)",
textDecoration: "none",
textAlign: "right",
transition: "all 0.2s ease",
}}
>
<div style={{ fontSize: "0.8rem", color: "var(--gray-dark)", marginBottom: "0.25rem" }}>
{"Siguiente →"}
</div>
<div style={{ fontSize: "0.95rem", fontWeight: 700, color: "var(--wine-superdark)" }}>
{nextVideo.title}
</div>
</Link>
)}
</div>
)}
{/* Back to help CTA */}
<div style={{ textAlign: "center", marginTop: "3rem", paddingTop: "2rem", borderTop: "1px solid var(--white-darkest)" }}>
<p style={{ color: "var(--gray-dark)", marginBottom: "1rem" }}>
Querés ver más tutoriales?
</p>
<Link
href="/landing/help"
style={{
display: "inline-block",
padding: "0.75rem 2rem",
background: "linear-gradient(135deg, var(--wine-red), var(--wine-dark))",
color: "white",
borderRadius: 50,
textDecoration: "none",
fontWeight: 700,
fontSize: "1rem",
}}
>
Ver todos los tutoriales
</Link>
</div>
</div>
</>
);
}
@@ -5,6 +5,8 @@ export interface Video {
thumbnail: string; thumbnail: string;
category: string; category: string;
order: number; order: number;
/** Date the tutorial was published on turnosxpress.com.ar (ISO yyyy-mm-dd). Defensible: matches git history of videoData.ts when the Help Center was added. */
uploadDate: string;
} }
export interface VideoCategory { export interface VideoCategory {
@@ -31,6 +33,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i2.ytimg.com/vi/QE5pEw_af5g/hqdefault.jpg", thumbnail: "https://i2.ytimg.com/vi/QE5pEw_af5g/hqdefault.jpg",
category: "getting-started", category: "getting-started",
order: 1, order: 1,
uploadDate: "2026-09-02",
}, },
{ {
id: "Aufl00f4U0Y", id: "Aufl00f4U0Y",
@@ -39,6 +42,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i2.ytimg.com/vi/Aufl00f4U0Y/hqdefault.jpg", thumbnail: "https://i2.ytimg.com/vi/Aufl00f4U0Y/hqdefault.jpg",
category: "getting-started", category: "getting-started",
order: 2, order: 2,
uploadDate: "2026-09-02",
}, },
], ],
}, },
@@ -56,6 +60,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i1.ytimg.com/vi/hgs9w9Q7w2k/hqdefault.jpg", thumbnail: "https://i1.ytimg.com/vi/hgs9w9Q7w2k/hqdefault.jpg",
category: "services", category: "services",
order: 1, order: 1,
uploadDate: "2026-09-02",
}, },
{ {
id: "UthPmnzR7fg", id: "UthPmnzR7fg",
@@ -64,6 +69,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i2.ytimg.com/vi/UthPmnzR7fg/hqdefault.jpg", thumbnail: "https://i2.ytimg.com/vi/UthPmnzR7fg/hqdefault.jpg",
category: "services", category: "services",
order: 2, order: 2,
uploadDate: "2026-09-02",
}, },
{ {
id: "p6_ax-B2vDA", id: "p6_ax-B2vDA",
@@ -72,6 +78,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i1.ytimg.com/vi/p6_ax-B2vDA/hqdefault.jpg", thumbnail: "https://i1.ytimg.com/vi/p6_ax-B2vDA/hqdefault.jpg",
category: "services", category: "services",
order: 3, order: 3,
uploadDate: "2026-09-02",
}, },
{ {
id: "tzVF2MeW37I", id: "tzVF2MeW37I",
@@ -80,6 +87,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i.ytimg.com/vi/tzVF2MeW37I/hqdefault.jpg", thumbnail: "https://i.ytimg.com/vi/tzVF2MeW37I/hqdefault.jpg",
category: "services", category: "services",
order: 4, order: 4,
uploadDate: "2026-09-02",
}, },
], ],
}, },
@@ -97,6 +105,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i2.ytimg.com/vi/enu2ny1k6XU/hqdefault.jpg", thumbnail: "https://i2.ytimg.com/vi/enu2ny1k6XU/hqdefault.jpg",
category: "schedules", category: "schedules",
order: 1, order: 1,
uploadDate: "2026-09-02",
}, },
{ {
id: "8kBxSYoisSM", id: "8kBxSYoisSM",
@@ -105,6 +114,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i1.ytimg.com/vi/8kBxSYoisSM/hqdefault.jpg", thumbnail: "https://i1.ytimg.com/vi/8kBxSYoisSM/hqdefault.jpg",
category: "schedules", category: "schedules",
order: 2, order: 2,
uploadDate: "2026-09-02",
}, },
], ],
}, },
@@ -122,6 +132,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i3.ytimg.com/vi/BcHrqxW_PTs/hqdefault.jpg", thumbnail: "https://i3.ytimg.com/vi/BcHrqxW_PTs/hqdefault.jpg",
category: "clients-bookings", category: "clients-bookings",
order: 1, order: 1,
uploadDate: "2026-09-02",
}, },
{ {
id: "MVpCGA-w7c8", id: "MVpCGA-w7c8",
@@ -130,6 +141,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i2.ytimg.com/vi/MVpCGA-w7c8/hqdefault.jpg", thumbnail: "https://i2.ytimg.com/vi/MVpCGA-w7c8/hqdefault.jpg",
category: "clients-bookings", category: "clients-bookings",
order: 2, order: 2,
uploadDate: "2026-09-02",
}, },
], ],
}, },
@@ -147,6 +159,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i4.ytimg.com/vi/CpGNWAJo6MM/hqdefault.jpg", thumbnail: "https://i4.ytimg.com/vi/CpGNWAJo6MM/hqdefault.jpg",
category: "payments-discounts", category: "payments-discounts",
order: 1, order: 1,
uploadDate: "2026-09-02",
}, },
{ {
id: "UAh7HkOcvMQ", id: "UAh7HkOcvMQ",
@@ -155,6 +168,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i2.ytimg.com/vi/UAh7HkOcvMQ/hqdefault.jpg", thumbnail: "https://i2.ytimg.com/vi/UAh7HkOcvMQ/hqdefault.jpg",
category: "payments-discounts", category: "payments-discounts",
order: 2, order: 2,
uploadDate: "2026-09-02",
}, },
], ],
}, },
@@ -172,6 +186,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i2.ytimg.com/vi/iu7sGYW1iGk/hqdefault.jpg", thumbnail: "https://i2.ytimg.com/vi/iu7sGYW1iGk/hqdefault.jpg",
category: "customization", category: "customization",
order: 1, order: 1,
uploadDate: "2026-09-02",
}, },
{ {
id: "nZkBIpmLRyI", id: "nZkBIpmLRyI",
@@ -180,6 +195,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i.ytimg.com/vi/nZkBIpmLRyI/hqdefault.jpg", thumbnail: "https://i.ytimg.com/vi/nZkBIpmLRyI/hqdefault.jpg",
category: "customization", category: "customization",
order: 2, order: 2,
uploadDate: "2026-09-02",
}, },
{ {
id: "oJuvDdewG1I", id: "oJuvDdewG1I",
@@ -188,6 +204,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i4.ytimg.com/vi/oJuvDdewG1I/hqdefault.jpg", thumbnail: "https://i4.ytimg.com/vi/oJuvDdewG1I/hqdefault.jpg",
category: "customization", category: "customization",
order: 3, order: 3,
uploadDate: "2026-09-02",
}, },
{ {
id: "wjJfoky6VD0", id: "wjJfoky6VD0",
@@ -196,6 +213,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i4.ytimg.com/vi/wjJfoky6VD0/hqdefault.jpg", thumbnail: "https://i4.ytimg.com/vi/wjJfoky6VD0/hqdefault.jpg",
category: "customization", category: "customization",
order: 4, order: 4,
uploadDate: "2026-09-02",
}, },
], ],
}, },
@@ -0,0 +1,121 @@
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
import style from "./style.module.css";
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
import { ScrollToTop } from "@core/app/components/ScrollTop";
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
import CTABtn from "./CTABtn";
export interface OfferContentProps {
heroTitle: string;
heroSubtitle: string;
}
export default function OfferContent({ heroTitle, heroSubtitle }: OfferContentProps) {
return (
<AnimatedContainer
color="#aeaeae"
minSize={150}
maxSize={500}
items={10}
style={{
display: "block",
width: "100%",
background: "var(--white)",
}}
>
<ScrollToTop />
<HeaderConfProvider />
<DefaultLoginAction />
{/* Hero Section */}
<div className={style.heroSection}>
<h1 className={style.heroTitle}>{heroTitle}</h1>
<p className={style.heroSubtitle}>{heroSubtitle}</p>
<div className={style.btnGroup}>
<CTABtn
className={style.primaryBtn}
secondaryClassName={style.secondaryBtn}
signedOutText="¡Empezá Gratis Hoy!"
signedInText="Ver planes"
showSecondaryWhenSignedOut
/>
</div>
</div>
{/* Features Section */}
<div className={style.featuresSection}>
<h2 className={style.sectionTitle}>Todo lo que necesitás para crecer</h2>
<div className={style.featuresGrid}>
<div className={style.featureCard}>
<div className={style.featureIcon}>📅</div>
<h3>Agenda Inteligente</h3>
<p>Visualizá tus citas por día, semana o mes. Bloqueá horarios y manejá tu disponibilidad con un par de clics.</p>
</div>
<div className={style.featureCard}>
<div className={style.featureIcon}>🔔</div>
<h3>Recordatorios Automáticos</h3>
<p>Reducí el ausentismo hasta un 80%. El sistema envía recordatorios automáticos a tus clientes antes de cada turno.</p>
</div>
<div className={style.featureCard}>
<div className={style.featureIcon}>👥</div>
<h3>Gestión de Colaboradores</h3>
<p>Agregá a tu equipo de trabajo. Asignale a cada colaborador sus propios servicios, horarios y disponibilidad de forma individual.</p>
</div>
<div className={style.featureCard}>
<div className={style.featureIcon}>🌐</div>
<h3>Portal de Reservas Público</h3>
<p>Mayor exposición. Tus servicios aparecerán en nuestro buscador público para que nuevos clientes te encuentren y reserven al instante.</p>
</div>
</div>
</div>
{/* Steps Section */}
<div className={style.stepsSection}>
<h2 className={style.sectionTitle}>Recibí turnos en 4 simples pasos</h2>
<div className={style.stepItem}>
<div className={style.stepNumber}>1</div>
<div className={style.stepContent}>
<h3>Creá tu cuenta gratis</h3>
<p>Registrate en segundos. Podés probar la plataforma completa sin compromisos ni tarjetas de crédito.</p>
</div>
</div>
<div className={style.stepItem}>
<div className={style.stepNumber}>2</div>
<div className={style.stepContent}>
<h3>Configurá tu perfil</h3>
<p>Personalizá tu espacio. Agregá tu logo, ubicación y una descripción atractiva para que los clientes te elijan al instante.</p>
</div>
</div>
<div className={style.stepItem}>
<div className={style.stepNumber}>3</div>
<div className={style.stepContent}>
<h3>Definí servicios y horarios</h3>
<p>¿Qué ofrecés y cuándo estás disponible? Configurá la duración de tus turnos, pausas entre clientes y tus días laborales.</p>
</div>
</div>
<div className={style.stepItem}>
<div className={style.stepNumber}>4</div>
<div className={style.stepContent}>
<h3>¡Compartí tu enlace!</h3>
<p>Te daremos un link único. Ponelo en tu biografía de Instagram, en WhatsApp o en tu web, y dejá que los turnos se agenden solos.</p>
</div>
</div>
</div>
{/* CTA Section */}
<div className={style.ctaSection}>
<h2 className={style.ctaTitle}>¿Listo para modernizar tu negocio?</h2>
<p className={style.ctaSubtitle}>Unite a los profesionales que ya automatizaron su agenda y ganan más tiempo libre.</p>
<CTABtn />
<p style={{ marginTop: '20px', fontSize: '1rem', color: 'rgba(255, 255, 255, 0.85)' }}>
¿Necesitás más información? Contactanos a <a href="mailto:info@turnosxpress.com.ar" style={{ color: '#fff', textDecoration: 'underline', fontWeight: '600' }}>info@turnosxpress.com.ar</a>
</p>
</div>
</AnimatedContainer>
);
}
@@ -1,10 +1,5 @@
import { Metadata } from "next"; import { Metadata } from "next";
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer"; import OfferContent from "./OfferContent";
import style from "./style.module.css";
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
import { ScrollToTop } from "@core/app/components/ScrollTop";
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
import CTABtn from "./CTABtn";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Ofrecé Turnos Online | Sistema de Gestión y Reservas - TurnosXpress", title: "Ofrecé Turnos Online | Sistema de Gestión y Reservas - TurnosXpress",
@@ -19,112 +14,9 @@ export const metadata: Metadata = {
export default function OfferAppointmentsPage() { export default function OfferAppointmentsPage() {
return ( return (
<AnimatedContainer <OfferContent
color="#aeaeae" heroTitle="Multiplicá tus clientes, simplificá tus turnos"
minSize={150} heroSubtitle="Olvidate del teléfono y los mensajes a deshora. Con nuestra plataforma, tus clientes pueden reservar turnos 24/7 de forma automática. Optimizá tu tiempo, reducí ausencias y llevá tu negocio al siguiente nivel."
maxSize={500}
items={10}
style={{
display: "block",
width: "100%",
background: "var(--white)",
}}
>
<ScrollToTop />
<HeaderConfProvider />
<DefaultLoginAction />
{/* Hero Section */}
<div className={style.heroSection}>
<h1 className={style.heroTitle}>Multiplicá tus clientes, simplificá tus turnos</h1>
<p className={style.heroSubtitle}>
Olvidate del teléfono y los mensajes a deshora. Con nuestra plataforma, tus clientes pueden reservar turnos 24/7 de forma automática. Optimizá tu tiempo, reducí ausencias y llevá tu negocio al siguiente nivel.
</p>
<div className={style.btnGroup}>
<CTABtn
className={style.primaryBtn}
secondaryClassName={style.secondaryBtn}
signedOutText="¡Empezá Gratis Hoy!"
signedInText="Ver planes"
showSecondaryWhenSignedOut
/> />
</div>
</div>
{/* Features Section */}
<div className={style.featuresSection}>
<h2 className={style.sectionTitle}>Todo lo que necesitás para crecer</h2>
<div className={style.featuresGrid}>
<div className={style.featureCard}>
<div className={style.featureIcon}>📅</div>
<h3>Agenda Inteligente</h3>
<p>Visualizá tus citas por día, semana o mes. Bloqueá horarios y manejá tu disponibilidad con un par de clics.</p>
</div>
<div className={style.featureCard}>
<div className={style.featureIcon}>🔔</div>
<h3>Recordatorios Automáticos</h3>
<p>Reducí el ausentismo hasta un 80%. El sistema envía recordatorios automáticos a tus clientes antes de cada turno.</p>
</div>
<div className={style.featureCard}>
<div className={style.featureIcon}>👥</div>
<h3>Gestión de Colaboradores</h3>
<p>Agregá a tu equipo de trabajo. Asignale a cada colaborador sus propios servicios, horarios y disponibilidad de forma individual.</p>
</div>
<div className={style.featureCard}>
<div className={style.featureIcon}>🌐</div>
<h3>Portal de Reservas Público</h3>
<p>Mayor exposición. Tus servicios aparecerán en nuestro buscador público para que nuevos clientes te encuentren y reserven al instante.</p>
</div>
</div>
</div>
{/* Steps Section */}
<div className={style.stepsSection}>
<h2 className={style.sectionTitle}>Recibí turnos en 4 simples pasos</h2>
<div className={style.stepItem}>
<div className={style.stepNumber}>1</div>
<div className={style.stepContent}>
<h3>Creá tu cuenta gratis</h3>
<p>Registrate en segundos. Podés probar la plataforma completa sin compromisos ni tarjetas de crédito.</p>
</div>
</div>
<div className={style.stepItem}>
<div className={style.stepNumber}>2</div>
<div className={style.stepContent}>
<h3>Configurá tu perfil</h3>
<p>Personalizá tu espacio. Agregá tu logo, ubicación y una descripción atractiva para que los clientes te elijan al instante.</p>
</div>
</div>
<div className={style.stepItem}>
<div className={style.stepNumber}>3</div>
<div className={style.stepContent}>
<h3>Definí servicios y horarios</h3>
<p>¿Qué ofrecés y cuándo estás disponible? Configurá la duración de tus turnos, pausas entre clientes y tus días laborales.</p>
</div>
</div>
<div className={style.stepItem}>
<div className={style.stepNumber}>4</div>
<div className={style.stepContent}>
<h3>¡Compartí tu enlace!</h3>
<p>Te daremos un link único. Ponelo en tu biografía de Instagram, en WhatsApp o en tu web, y dejá que los turnos se agenden solos.</p>
</div>
</div>
</div>
{/* CTA Section */}
<div className={style.ctaSection}>
<h2 className={style.ctaTitle}>¿Listo para modernizar tu negocio?</h2>
<p className={style.ctaSubtitle}>Unite a los profesionales que ya automatizaron su agenda y ganan más tiempo libre.</p>
<CTABtn />
<p style={{ marginTop: '20px', fontSize: '1rem', color: 'rgba(255, 255, 255, 0.85)' }}>
¿Necesitás más información? Contactanos a <a href="mailto:info@turnosxpress.com.ar" style={{ color: '#fff', textDecoration: 'underline', fontWeight: '600' }}>info@turnosxpress.com.ar</a>
</p>
</div>
</AnimatedContainer>
); );
} }
@@ -10,7 +10,7 @@ import ImagePicker from "@components/ImagePicker/ImagePicker";
import { useAlert } from "@core/Store/Alert.Store"; import { useAlert } from "@core/Store/Alert.Store";
import { useSessionStore } from "@core/Store/Sesion.Store"; import { useSessionStore } from "@core/Store/Sesion.Store";
import { useOrganizationHeaderStore } from "@core/Store/OrganizationHeader.Store"; import { useOrganizationHeaderStore } from "@core/Store/OrganizationHeader.Store";
import { updateOrganizationsById, uploadHeaderImage } from "./Org.Service"; import { updateOrganizationColors, uploadHeaderImage } from "@services/Header.Edit.Service";
import { UpdateCompanyParams } from "@models/Company.model"; import { UpdateCompanyParams } from "@models/Company.model";
interface HeaderEditPopupProps { interface HeaderEditPopupProps {
@@ -22,10 +22,11 @@ interface HeaderEditPopupProps {
headerFontShadowColor: string; headerFontShadowColor: string;
headerImage: string; headerImage: string;
onSaved: () => void; onSaved: () => void;
onRefresh?: () => void;
} }
export default function HeaderEditPopup(props: HeaderEditPopupProps) { export default function HeaderEditPopup(props: HeaderEditPopupProps) {
const { open, onClose, companyId, headerColor, headerFontColor, headerFontShadowColor, headerImage, onSaved } = props; const { open, onClose, companyId, headerColor, headerFontColor, headerFontShadowColor, headerImage, onSaved, onRefresh } = props;
const alert = useAlert(); const alert = useAlert();
const SessionInfo = useSessionStore(); const SessionInfo = useSessionStore();
const organizationHeader = useOrganizationHeaderStore(); const organizationHeader = useOrganizationHeaderStore();
@@ -57,13 +58,15 @@ export default function HeaderEditPopup(props: HeaderEditPopupProps) {
sessionUser: SessionInfo.userId, sessionUser: SessionInfo.userId,
}; };
await updateOrganizationsById(updData); await updateOrganizationColors(updData);
organizationHeader.setHeaderColor(headerColorState); organizationHeader.setHeaderColor(headerColorState);
organizationHeader.setHeaderFontColor(headerFontColorState); organizationHeader.setHeaderFontColor(headerFontColorState);
organizationHeader.setHeaderFontShadowColor(headerFontShadowColorState); organizationHeader.setHeaderFontShadowColor(headerFontShadowColorState);
alert.showSuccess("Colores actualizados"); alert.showSuccess("Colores actualizados");
onSaved();
onRefresh?.();
} catch (error) { } catch (error) {
const formatted = error instanceof Error ? error.message : "Error al guardar los colores"; const formatted = error instanceof Error ? error.message : "Error al guardar los colores";
alert.showError(formatted || "Error al guardar los colores"); alert.showError(formatted || "Error al guardar los colores");
@@ -87,6 +90,7 @@ export default function HeaderEditPopup(props: HeaderEditPopupProps) {
uploadHeaderImage(formData).then(() => { uploadHeaderImage(formData).then(() => {
alert.showSuccess("Imagen actualizada"); alert.showSuccess("Imagen actualizada");
onSaved(); onSaved();
onRefresh?.();
}).catch((error) => { }).catch((error) => {
const formatted = error instanceof Error ? error.message : "Error al guardar la imagen"; const formatted = error instanceof Error ? error.message : "Error al guardar la imagen";
alert.showError(formatted || "Error al guardar la imagen"); alert.showError(formatted || "Error al guardar la imagen");
@@ -110,6 +114,7 @@ export default function HeaderEditPopup(props: HeaderEditPopupProps) {
PaperProps={{ PaperProps={{
sx: { sx: {
borderRadius: 2, borderRadius: 2,
height: 500,
}, },
}} }}
> >
@@ -3,6 +3,7 @@ import ApiServerService from "@services/Api.Server.Service";
import { PublicOrganizationView } from "@core/Models/Company.model"; import { PublicOrganizationView } from "@core/Models/Company.model";
import OrganizationHeader from "@core/app/components/Home/OrganizationHeader/OrganizationHeader"; import OrganizationHeader from "@core/app/components/Home/OrganizationHeader/OrganizationHeader";
import OrganizationDataConnector from "./components/OrganizationData"; import OrganizationDataConnector from "./components/OrganizationData";
import type { Metadata } from "next";
const GetOrganizationData = async (companyId: string): Promise<PublicOrganizationView> => { const GetOrganizationData = async (companyId: string): Promise<PublicOrganizationView> => {
try { try {
@@ -28,10 +29,67 @@ type Props = {
children: React.ReactNode; children: React.ReactNode;
}; };
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
const { oid } = await params;
const orgData = await GetOrganizationData(oid);
if (!orgData || !orgData.id) {
return {
robots: { index: false, follow: false },
};
}
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar";
const canonicalUrl = `${baseUrl}/landing/org/${oid}`;
const description =
orgData.description && orgData.description.trim().length > 0
? orgData.description
: `${orgData.name} - TurnosXpress`;
return {
title: `${orgData.name} | TurnosXpress`,
description,
alternates: {
canonical: canonicalUrl,
},
openGraph: {
type: "website",
title: `${orgData.name} | TurnosXpress`,
description,
url: canonicalUrl,
siteName: "TurnosXpress",
locale: "es_AR",
},
twitter: {
card: "summary",
title: `${orgData.name} | TurnosXpress`,
description,
},
robots: {
index: true,
follow: true,
},
};
}
export default async function OrganizationPublicProfile({ params, children }: Props) { export default async function OrganizationPublicProfile({ params, children }: Props) {
const { oid } = await params; const { oid } = await params;
const orgData = await GetOrganizationData(oid); const orgData = await GetOrganizationData(oid);
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar";
const canonicalUrl = `${baseUrl}/landing/org/${oid}`;
const jsonLd = {
"@context": "https://schema.org",
"@type": "Organization",
name: orgData.name,
url: canonicalUrl,
description:
orgData.description && orgData.description.trim().length > 0
? orgData.description
: `${orgData.name} - TurnosXpress`,
};
return ( return (
<AnimatedContainer <AnimatedContainer
color={orgData.headerColor} color={orgData.headerColor}
@@ -45,6 +103,10 @@ export default async function OrganizationPublicProfile({ params, children }: Pr
<div className="homeCentered"> <div className="homeCentered">
{orgData.id && ( {orgData.id && (
<> <>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<div style={{ position: "relative" }} id="header-wrapper"> <div style={{ position: "relative" }} id="header-wrapper">
<OrganizationHeader organization={orgData} /> <OrganizationHeader organization={orgData} />
</div> </div>
+90 -38
View File
@@ -43,7 +43,10 @@ import { useSessionStore } from "@core/Store/Sesion.Store";
import { EVENT_TYPES, useEventHandlerStore } from "@core/Store/EventHandler.Store"; import { EVENT_TYPES, useEventHandlerStore } from "@core/Store/EventHandler.Store";
import { useAlert } from "@core/Store/Alert.Store"; import { useAlert } from "@core/Store/Alert.Store";
import { IPost } from "@core/Models/Posts.model"; import { IPost } from "@core/Models/Posts.model";
import { preprocessQuillHtml, transformQuillContentForDisplay } from "@core/app/helpers/htmlSainitize"; import {
preprocessQuillHtml,
transformQuillContentForDisplay,
} from "@core/app/helpers/htmlSainitize";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { ThemeProvider } from "@emotion/react"; import { ThemeProvider } from "@emotion/react";
@@ -55,7 +58,7 @@ import DownIcon from "@mui/icons-material/ArrowDownwardRounded";
import PinIcon from "@mui/icons-material/PushPin"; import PinIcon from "@mui/icons-material/PushPin";
import EditIcon from "@mui/icons-material/AutoFixHigh"; import EditIcon from "@mui/icons-material/AutoFixHigh";
import turnosXpressTheme from "@core/app/theme/turnosXpress"; import turnosXpressTheme from "@core/app/theme/turnosXpress";
import { useParams } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useConfirmStore } from "@core/Store/Confirm.Store"; import { useConfirmStore } from "@core/Store/Confirm.Store";
import ReviewsCarousel from "@core/app/components/ReviewsCarousel/ReviewsCarousel"; import ReviewsCarousel from "@core/app/components/ReviewsCarousel/ReviewsCarousel";
@@ -63,7 +66,6 @@ import OrganizationProfessionals from "@core/app/components/OrganizationProfessi
import { RatingTargetType } from "@core/Models/Ratings.model"; import { RatingTargetType } from "@core/Models/Ratings.model";
import HeaderEditPopup from "./HeaderEditPopup"; import HeaderEditPopup from "./HeaderEditPopup";
import { checkOrgAdmin } from "./post.service"; import { checkOrgAdmin } from "./post.service";
import { IconButton as MuiIconButton } from "@mui/material";
enum VIEW { enum VIEW {
POSTS = "posts", POSTS = "posts",
@@ -79,6 +81,7 @@ type UrlData = {
export default function OrganizationPublicProfile() { export default function OrganizationPublicProfile() {
const data = useParams<UrlData>(); const data = useParams<UrlData>();
const oid = data.oid; const oid = data.oid;
const router = useRouter();
const OrganizationData = useOrgDataStore(); const OrganizationData = useOrgDataStore();
const [position, setPosition] = useState<[number, number]>([0, 0]); const [position, setPosition] = useState<[number, number]>([0, 0]);
const [orgData, setOrgData] = useState<PublicOrganizationView>(); const [orgData, setOrgData] = useState<PublicOrganizationView>();
@@ -101,14 +104,33 @@ export default function OrganizationPublicProfile() {
const [currentPage, setCurrentPage] = useState<number>(1); const [currentPage, setCurrentPage] = useState<number>(1);
const [isOrgAdmin, setIsOrgAdmin] = useState<boolean>(false); const [isOrgAdmin, setIsOrgAdmin] = useState<boolean>(false);
const [showEditPopup, setShowEditPopup] = useState<boolean>(false); const [showEditPopup, setShowEditPopup] = useState<boolean>(false);
const SessionInfo = useSessionStore();
// Append edit button to header wrapper when admin // Unified effect: check admin status and manage edit button DOM element
useEffect(() => { useEffect(() => {
if (!isOrgAdmin) return; if (!SessionInfo.userId || !oid) {
setIsOrgAdmin(false);
const existing = document.getElementById("edit-header-btn");
if (existing) existing.remove();
return;
}
let cancelled = false;
checkOrgAdmin(oid, SessionInfo.userId)
.then((isAdmin) => {
if (cancelled) return;
console.log("isAdmin:", isAdmin);
setIsOrgAdmin(isAdmin);
// Append or remove button based on the result
const wrapper = document.getElementById("header-wrapper"); const wrapper = document.getElementById("header-wrapper");
if (!wrapper) return; if (!wrapper) return;
if (isAdmin) {
const existingBtn = document.getElementById("edit-header-btn");
if (existingBtn) existingBtn.remove();
const btn = document.createElement("div"); const btn = document.createElement("div");
btn.id = "edit-header-btn"; btn.id = "edit-header-btn";
btn.innerHTML = ` btn.innerHTML = `
@@ -132,16 +154,33 @@ export default function OrganizationPublicProfile() {
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button> </button>
`; `;
btn.querySelector("button")!.addEventListener("click", () => setShowEditPopup(true)); btn.querySelector("button")!.addEventListener("click", () =>
setShowEditPopup(true),
);
wrapper.appendChild(btn); wrapper.appendChild(btn);
} else {
// Remove button for non-admin users
const existingBtn = document.getElementById("edit-header-btn");
if (existingBtn) existingBtn.remove();
}
})
.catch(() => {
if (cancelled) return;
setIsOrgAdmin(false);
// Ensure button is removed on error
const existingBtn = document.getElementById("edit-header-btn");
if (existingBtn) existingBtn.remove();
});
// Cleanup: mark as cancelled and remove button
return () => { return () => {
cancelled = true;
const existing = document.getElementById("edit-header-btn"); const existing = document.getElementById("edit-header-btn");
if (existing) existing.remove(); if (existing) existing.remove();
}; };
}, [isOrgAdmin]); }, [SessionInfo.userId, oid]);
const SessionInfo = useSessionStore();
const eventHandler = useEventHandlerStore(); const eventHandler = useEventHandlerStore();
const alert = useAlert(); const alert = useAlert();
const confirm = useConfirmStore(); const confirm = useConfirmStore();
@@ -183,22 +222,6 @@ export default function OrganizationPublicProfile() {
}); });
}, [SessionInfo, authorizedUsers]); }, [SessionInfo, authorizedUsers]);
// Check if the logged-in user is admin of this organization
useEffect(() => {
if (!SessionInfo.userId || !oid) {
setIsOrgAdmin(false);
return;
}
checkOrgAdmin(oid, SessionInfo.userId)
.then((isAdmin) => {
setIsOrgAdmin(isAdmin);
})
.catch(() => {
setIsOrgAdmin(false);
});
}, [SessionInfo.userId, oid]);
useEffect(() => { useEffect(() => {
if (!OrganizationData.orgData) return; if (!OrganizationData.orgData) return;
if (!oid) return; //if (!OrganizationData.orgData.id) return; if (!oid) return; //if (!OrganizationData.orgData.id) return;
@@ -398,7 +421,8 @@ export default function OrganizationPublicProfile() {
return response.data; return response.data;
} }
const newPosts = response.data.filter( const newPosts = response.data.filter(
(newPost) => !prevPosts.some((prevPost) => prevPost._id === newPost._id), (newPost) =>
!prevPosts.some((prevPost) => prevPost._id === newPost._id),
); );
return [...prevPosts, ...newPosts]; return [...prevPosts, ...newPosts];
@@ -568,7 +592,11 @@ export default function OrganizationPublicProfile() {
gap: "10px", gap: "10px",
}} }}
> >
<WhatsappShareButton title={shareTitle} separator=":: " url={window.location.href}> <WhatsappShareButton
title={shareTitle}
separator=":: "
url={window.location.href}
>
<WhatsAppShareIcon size={32} round /> <WhatsAppShareIcon size={32} round />
</WhatsappShareButton> </WhatsappShareButton>
<FacebookShareButton title={shareTitle} url={window.location.href}> <FacebookShareButton title={shareTitle} url={window.location.href}>
@@ -603,6 +631,7 @@ export default function OrganizationPublicProfile() {
onSaved={() => { onSaved={() => {
setShowEditPopup(false); setShowEditPopup(false);
}} }}
onRefresh={() => router.refresh()}
/> />
)} )}
{orgData && ( {orgData && (
@@ -730,7 +759,9 @@ export default function OrganizationPublicProfile() {
)} )}
</div> </div>
{view === VIEW.SERVICES && ( {view === VIEW.SERVICES && (
<div style={{ display: "flex", justifyContent: "flex-end" }}> <div
style={{ display: "flex", justifyContent: "flex-end" }}
>
<ButtonGroup <ButtonGroup
variant="outlined" variant="outlined"
aria-label="Basic button group" aria-label="Basic button group"
@@ -785,7 +816,8 @@ export default function OrganizationPublicProfile() {
<div <div
style={{ style={{
textAlign: "center", textAlign: "center",
backgroundImage: "url(/no-conversation.webp)", backgroundImage:
"url(/no-conversation.webp)",
backgroundRepeat: "no-repeat", backgroundRepeat: "no-repeat",
backgroundPosition: "center center", backgroundPosition: "center center",
backgroundSize: "50% auto", backgroundSize: "50% auto",
@@ -800,7 +832,8 @@ export default function OrganizationPublicProfile() {
<div <div
key={post._id} key={post._id}
style={{ style={{
borderBottom: "1px solid var(--gray-light)", borderBottom:
"1px solid var(--gray-light)",
paddingTop: "5px", paddingTop: "5px",
paddingBottom: "10px", paddingBottom: "10px",
paddingLeft: "30px", paddingLeft: "30px",
@@ -814,7 +847,11 @@ export default function OrganizationPublicProfile() {
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<div style={{ color: "var(--gray-darkest)" }}> <div
style={{
color: "var(--gray-darkest)",
}}
>
{dayjs(post.createdAt).format( {dayjs(post.createdAt).format(
"DD MMMM YYYY HH:mm", "DD MMMM YYYY HH:mm",
)} )}
@@ -854,22 +891,30 @@ export default function OrganizationPublicProfile() {
color: "var(--gray-darkest)", color: "var(--gray-darkest)",
}} }}
> >
<ThemeProvider theme={turnosXpressTheme}> <ThemeProvider
theme={turnosXpressTheme}
>
<MaterialButton <MaterialButton
color="inherit" color="inherit"
startIcon={<ClipIcon />} startIcon={<ClipIcon />}
onClick={() => { onClick={() => {
fijarPost(`${post._id}`); fijarPost(
`${post._id}`,
);
}} }}
> >
{post.fixed ? "Liberar" : "Fijar"} {post.fixed
? "Liberar"
: "Fijar"}
</MaterialButton> </MaterialButton>
{post.fixed && ( {post.fixed && (
<> <>
<MaterialButton <MaterialButton
color="inherit" color="inherit"
startIcon={<UpIcon />} startIcon={
<UpIcon />
}
onClick={() => { onClick={() => {
moverPost( moverPost(
`${post._id}`, `${post._id}`,
@@ -882,7 +927,9 @@ export default function OrganizationPublicProfile() {
<MaterialButton <MaterialButton
color="inherit" color="inherit"
startIcon={<DownIcon />} startIcon={
<DownIcon />
}
onClick={() => { onClick={() => {
moverPost( moverPost(
`${post._id}`, `${post._id}`,
@@ -898,7 +945,9 @@ export default function OrganizationPublicProfile() {
<MaterialButton <MaterialButton
color="inherit" color="inherit"
onClick={() => { onClick={() => {
editarPost(`${post._id}`); editarPost(
`${post._id}`,
);
}} }}
> >
<EditIcon /> <EditIcon />
@@ -907,7 +956,9 @@ export default function OrganizationPublicProfile() {
<MaterialButton <MaterialButton
color="inherit" color="inherit"
onClick={() => { onClick={() => {
eliminarPost(`${post._id}`); eliminarPost(
`${post._id}`,
);
}} }}
> >
<DeleteIcon /> <DeleteIcon />
@@ -932,7 +983,8 @@ export default function OrganizationPublicProfile() {
style={{ style={{
padding: "0px 0px 40px 0px", padding: "0px 0px 40px 0px",
display: "flex", display: "flex",
flexDirection: viewMode === "card" ? "row" : "column", flexDirection:
viewMode === "card" ? "row" : "column",
justifyContent: "start", justifyContent: "start",
gap: "20px", gap: "20px",
flexWrap: "wrap", flexWrap: "wrap",
@@ -0,0 +1,10 @@
import ApiRequest from "@services/Api.Service";
import { ApiVoidResult } from "@models/Api.VoidResult.type";
export const uploadServiceImage = async (data: FormData): Promise<ApiVoidResult> => {
return ApiRequest.postFile<ApiVoidResult>("services/upload-image", data);
};
export const checkOrgAdmin = async (companyId: string, sessionUser: string): Promise<boolean> => {
return ApiRequest.post<boolean>("companies/check-admin", { companyId, sessionUser });
};
@@ -0,0 +1,59 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import ProductItem from "@core/app/components/ProductItem/ProductItem";
import { PublicServiceView } from "@models/Service.model";
import { PublicOrganizationView } from "@models/Company.model";
import ServiceImageEditPopup from "./ServiceImageEditPopup";
import { useSessionStore } from "@core/Store/Sesion.Store";
import { checkOrgAdmin } from "@services/Header.Edit.Service";
interface ServiceDetailCardProps {
service: PublicServiceView;
organization: PublicOrganizationView;
}
export default function ServiceDetailCard({ service, organization }: ServiceDetailCardProps) {
const router = useRouter();
const SessionInfo = useSessionStore();
const [isAdmin, setIsAdmin] = useState(false);
const [showEditPopup, setShowEditPopup] = useState(false);
useEffect(() => {
if (!SessionInfo.userId || !organization.id || !service.id) return;
checkOrgAdmin(organization.id, SessionInfo.userId)
.then((result) => {
setIsAdmin(result);
})
.catch(() => {
setIsAdmin(false);
});
}, [SessionInfo.userId, organization.id, service.id]);
const handleImageSaved = () => {
setShowEditPopup(false);
router.refresh();
};
return (
<>
<ProductItem
product={service}
width="100%"
isAdmin={isAdmin}
onImageEdit={() => setShowEditPopup(true)}
/>
{isAdmin && (
<ServiceImageEditPopup
open={showEditPopup}
onClose={() => setShowEditPopup(false)}
serviceId={service.id}
companyId={organization.id}
currentImage={service.image}
onSaved={handleImageSaved}
/>
)}
</>
);
}
@@ -0,0 +1,105 @@
"use client";
import { useState } from "react";
import { IconButton, Dialog, DialogActions, DialogContent, DialogTitle, Button } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import turnosXpressTheme from "@core/app/theme/turnosXpress";
import { ThemeProvider } from "@emotion/react";
import ImagePicker from "@components/ImagePicker/ImagePicker";
import { useAlert } from "@core/Store/Alert.Store";
import { useSessionStore } from "@core/Store/Sesion.Store";
import { uploadServiceImage } from "@services/Header.Edit.Service";
interface ServiceImageEditPopupProps {
open: boolean;
onClose: () => void;
serviceId: string;
companyId: string;
currentImage: string;
onSaved: () => void;
}
export default function ServiceImageEditPopup(props: ServiceImageEditPopupProps) {
const { open, onClose, serviceId, companyId, currentImage, onSaved } = props;
const alert = useAlert();
const SessionInfo = useSessionStore();
const [loading, setLoading] = useState(false);
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setLoading(true);
try {
const formData = new FormData();
formData.append("companyId", companyId);
formData.append("serviceId", serviceId);
formData.append("sessionUser", SessionInfo.userId);
formData.append("file", file);
uploadServiceImage(formData)
.then(() => {
alert.showSuccess("Imagen actualizada");
onSaved();
})
.catch((error) => {
const formatted = error instanceof Error ? error.message : "Error al guardar la imagen";
alert.showError(formatted || "Error al guardar la imagen");
})
.finally(() => {
setLoading(false);
});
} catch (error) {
const formatted = error instanceof Error ? error.message : "Error al guardar la imagen";
alert.showError(formatted || "Error al guardar la imagen");
setLoading(false);
}
};
return (
<ThemeProvider theme={turnosXpressTheme}>
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
borderRadius: 2,
width: 'auto',
height: 'auto',
},
}}
>
<DialogTitle sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", py: 2 }}>
Editar imagen del servicio
<IconButton onClick={onClose} size="small">
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers sx={{ p: 3, display: "flex", justifyContent: "center" }}>
<ImagePicker
src={currentImage}
alt="Imagen del servicio"
style={{ width: 400, height: 400, borderRadius: 8 }}
readOnly={false}
onChange={handleImageChange}
imageSizeSrc="/service-image-size.webp"
mbLimit={5}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, pt: 1 }}>
<Button onClick={onClose}>Cancelar</Button>
<Button
onClick={() => {}}
disabled={loading}
>
Guardar
</Button>
</DialogActions>
</Dialog>
</ThemeProvider>
);
}
+158 -4
View File
@@ -1,8 +1,9 @@
import type { Metadata } from "next";
import { cache } from "react";
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer"; import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
import style from "./profile.module.css"; import style from "./profile.module.css";
import ApiServerService from "@services/Api.Server.Service"; import ApiServerService from "@services/Api.Server.Service";
import { PublicOrganizationServiceView } from "@core/Models/Service.model"; import { PublicOrganizationServiceView } from "@core/Models/Service.model";
import ProductItem from "@core/app/components/ProductItem/ProductItem";
import Appointment from "./components/Appointment/Appointment"; import Appointment from "./components/Appointment/Appointment";
import OrganizationHeader from "@components/Home/OrganizationHeader/OrganizationHeader"; import OrganizationHeader from "@components/Home/OrganizationHeader/OrganizationHeader";
import ProductGallery from "@core/app/components/Home/ProductsGallery/ProductGallery"; import ProductGallery from "@core/app/components/Home/ProductsGallery/ProductGallery";
@@ -12,8 +13,11 @@ import ServicePrivate from "@core/app/components/ServicePrivate/ServicePrivate";
import EvangelizeBanner from "@components/EvangelizeBanner/EvangelizeBanner"; import EvangelizeBanner from "@components/EvangelizeBanner/EvangelizeBanner";
import ReviewsCarousel from "@components/ReviewsCarousel/ReviewsCarousel"; import ReviewsCarousel from "@components/ReviewsCarousel/ReviewsCarousel";
import { RatingTargetType } from "@core/Models/Ratings.model"; import { RatingTargetType } from "@core/Models/Ratings.model";
import ServiceDetailCard from "./ServiceDetailCard";
import OrganizationHeaderEdit from "@components/OrganizationHeaderEdit/OrganizationHeaderEdit";
const GetServiceData = async (serviceId: string): Promise<PublicOrganizationServiceView> => { // `cache()` makes generateMetadata and the page component share one API call per request.
const GetServiceData = cache(async (serviceId: string): Promise<PublicOrganizationServiceView> => {
try { try {
const organizationServiceData = await ApiServerService.post<PublicOrganizationServiceView>( const organizationServiceData = await ApiServerService.post<PublicOrganizationServiceView>(
"views/service", "views/service",
@@ -26,7 +30,7 @@ const GetServiceData = async (serviceId: string): Promise<PublicOrganizationServ
} catch { } catch {
return {} as PublicOrganizationServiceView; return {} as PublicOrganizationServiceView;
} }
}; });
type Params = { type Params = {
sid: string; sid: string;
@@ -36,12 +40,153 @@ type Props = {
params: Promise<Params>; params: Promise<Params>;
}; };
type Service = PublicOrganizationServiceView["service"];
type Organization = PublicOrganizationServiceView["organization"];
const getBaseUrl = (): string => process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar";
const getServiceUrl = (sid: string): string => `${getBaseUrl()}/landing/service/${sid}`;
// Meta tags must be plain text: collapse whitespace and drop any markup.
const toPlainText = (value?: string): string =>
(value || "")
.replace(/<[^>]*>/g, " ")
.replace(/\s+/g, " ")
.trim();
// Cut on a word boundary so snippets never end mid-word.
const truncate = (value: string, max: number): string => {
if (value.length <= max) return value;
const cut = value.slice(0, max - 1);
const lastSpace = cut.lastIndexOf(" ");
return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
};
// `image` can be an absolute external URL or a path served from this origin.
const resolveImage = (src: string, baseUrl: string): string | undefined => {
if (!src) return undefined;
if (/^https?:\/\//i.test(src)) return src;
return `${baseUrl}${src.startsWith("/") ? "" : "/"}${src}`;
};
const buildTitle = (service: Service, org: Organization): string => {
const MAX = 70;
const full = `${service.name} | ${org.name} | TurnosXpress`;
// Drop the organisation rather than truncating the brand out of the title.
return full.length <= MAX ? full : truncate(`${service.name} | TurnosXpress`, MAX);
};
const buildDescription = (service: Service, org: Organization): string => {
const MAX = 160;
const cta = `Reservá online con ${org.name} en TurnosXpress.`;
const body = toPlainText(service.description);
if (!body) return truncate(`${service.name}: ${cta}`, MAX);
const room = MAX - cta.length - 1;
// Keep the call to action intact; it is what is cut first when space runs out.
return room >= 60 ? `${truncate(body, room)} ${cta}` : truncate(`${body} ${cta}`, MAX);
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { sid } = await params;
const organizationService = await GetServiceData(sid);
const service = organizationService?.service;
const org = organizationService?.organization;
if (!service?.id || !org?.id) {
return {
robots: { index: false, follow: false },
};
}
const baseUrl = getBaseUrl();
const url = getServiceUrl(sid);
const title = buildTitle(service, org);
const description = buildDescription(service, org);
const image = resolveImage(service.image, baseUrl) ?? resolveImage(org.logoFile, baseUrl);
const ogImage = image ? [{ url: image, alt: service.name }] : undefined;
return {
title,
description,
alternates: {
canonical: url,
},
openGraph: {
type: "website",
title,
description,
url,
siteName: "TurnosXpress",
locale: "es_AR",
images: ogImage,
},
twitter: {
card: image ? "summary_large_image" : "summary",
title,
description,
images: image ? [image] : undefined,
},
robots: {
index: true,
follow: true,
},
};
}
const buildServiceJsonLd = (service: Service, org: Organization, url: string) => {
const baseUrl = getBaseUrl();
const orgUrl = org.slug
? `${baseUrl}/landing/org/${org.slug}`
: `${baseUrl}/landing/org/${org.id}`;
return {
"@context": "https://schema.org",
"@type": "Service",
"@id": url,
name: service.name,
description: toPlainText(service.description) || `${service.name} ofrecido por ${org.name}.`,
url,
image: resolveImage(service.image, baseUrl) ?? resolveImage(org.logoFile, baseUrl),
provider: {
"@type": "Organization",
"@id": orgUrl,
name: org.name,
url: orgUrl,
},
offers: {
"@type": "Offer",
price: service.price,
priceCurrency: "ARS",
availability: "https://schema.org/InStock",
url,
},
};
};
export default async function OrganizationPublicProfile({ params }: Props) { export default async function OrganizationPublicProfile({ params }: Props) {
const { sid } = await params; const { sid } = await params;
const organizationService = await GetServiceData(sid); const organizationService = await GetServiceData(sid);
return ( return (
<> <>
{/* AnimatedContainer returns null during SSR, so structured data has to sit
outside it to reach the static HTML that crawlers read first. */}
{organizationService.service && organizationService.organization && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(
buildServiceJsonLd(
organizationService.service,
organizationService.organization,
getServiceUrl(sid),
),
),
}}
/>
)}
{!organizationService.organization && !organizationService.service && <ServicePrivate />} {!organizationService.organization && !organizationService.service && <ServicePrivate />}
{organizationService.organization && organizationService.service && ( {organizationService.organization && organizationService.service && (
<AnimatedContainer <AnimatedContainer
@@ -56,11 +201,20 @@ export default async function OrganizationPublicProfile({ params }: Props) {
<ScrollToTop /> <ScrollToTop />
<HeaderConfProvider /> <HeaderConfProvider />
<div className="homeCentered"> <div className="homeCentered">
<div id="header-wrapper" style={{ position: "relative" }}>
<OrganizationHeader organization={organizationService.organization} /> <OrganizationHeader organization={organizationService.organization} />
</div>
<OrganizationHeaderEdit
companyId={organizationService.organization.id}
headerColor={organizationService.organization.headerColor}
headerFontColor={organizationService.organization.headerFontColor}
headerFontShadowColor={organizationService.organization.headerFontShadowColor}
headerImage={organizationService.organization.headerFile}
/>
<div className={style.organizationContent}> <div className={style.organizationContent}>
<div className={style.serviceSide}> <div className={style.serviceSide}>
<div className={style.stickyWrapper}> <div className={style.stickyWrapper}>
<ProductItem product={organizationService.service} width="100%" /> <ServiceDetailCard service={organizationService.service} organization={organizationService.organization} />
</div> </div>
</div> </div>
<div className={style.bookingSide}> <div className={style.bookingSide}>
@@ -0,0 +1,22 @@
import { Metadata } from "next";
import OfferContent from "../offer-appointments/OfferContent";
export const metadata: Metadata = {
title: "Sistema de Turnos Online | Gestión de Reservas y Agenda - TurnosXpress",
description: "Un sistema de turnos online para gestionar reservas, reducir ausencias y organizar tu agenda. Automatizá tus turnos y probá TurnosXpress gratis.",
keywords: ["sistema de turnos", "gestión de turnos", "sistema de turnos online", "software de turnos", "reservas online"],
openGraph: {
title: "Sistema de Turnos Online | Gestión - TurnosXpress",
description: "Automatizá reservas y reducí ausencias con un sistema de turnos online. ¡Empezá gratis!",
type: "website",
}
};
export default function SistemaDeTurnosPage() {
return (
<OfferContent
heroTitle="Un sistema de turnos online que simplifica tu gestión"
heroSubtitle="Dejá que tu negocio reciba reservas las 24 horas sin atender el teléfono. Con nuestro sistema de turnos gestionás citas, horarios y recordatorios en un solo lugar."
/>
);
}
@@ -0,0 +1,22 @@
import { Metadata } from "next";
import OfferContent from "../offer-appointments/OfferContent";
export const metadata: Metadata = {
title: "Turnos Online | Reservas Automáticas para tu Negocio - TurnosXpress",
description: "Ofrecé turnos online a tus clientes y dejá de coordinar por mensaje. Reservas automáticas, recordatorios y gestión simple con TurnosXpress.",
keywords: ["turnos online", "sacar turno online", "reservas de turnos", "turnos web", "turnos para profesionales"],
openGraph: {
title: "Turnos Online | Reservas Automáticas - TurnosXpress",
description: "Ofrecé turnos online con reservas 24/7 y recordatorios automáticos. ¡Empezá gratis!",
type: "website",
}
};
export default function TurnosOnlinePage() {
return (
<OfferContent
heroTitle="Ofrecé turnos online y ganá horas de tu día"
heroSubtitle="Tus clientes reservan turnos online desde cualquier dispositivo, a cualquier hora. Vos ganás tiempo, ellos ganan comodidad."
/>
);
}
+8 -3
View File
@@ -8,9 +8,14 @@ export default function robots(): MetadataRoute.Robots {
allow: "/", allow: "/",
disallow: [ disallow: [
"/admin/", "/admin/",
"/dashboard/", "/user/",
"/my-appointments/", "/messenger/",
"/offer-appointments/", "/landing/dashboard/",
"/landing/my-appointments/",
"/landing/current-plan/",
"/landing/upgrade-plan/",
"/landing/pending-ratings/",
"/landing/subscription/",
"/landing/recover-account/", "/landing/recover-account/",
], ],
}, },
+135 -17
View File
@@ -2,6 +2,18 @@ import { NextResponse } from "next/server";
import ApiServerService from "@core/Services/Api.Server.Service"; import ApiServerService from "@core/Services/Api.Server.Service";
import { PublicServicesResults } from "@models/Service.model"; import { PublicServicesResults } from "@models/Service.model";
import { sliderFrames } from "@core/app/Services/SliderFrames"; import { sliderFrames } from "@core/app/Services/SliderFrames";
import { getAllVideos } from "@core/app/landing/help/videoData";
// Escape text content so the sitemap is always well-formed XML
// (URLs with query strings, service names/descriptions, etc.).
function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
export async function GET() { export async function GET() {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar"; const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar";
@@ -37,6 +49,25 @@ export async function GET() {
changeFrequency: "daily", changeFrequency: "daily",
priority: 0.8, priority: 0.8,
}, },
{
url: `${baseUrl}/landing/help`,
changeFrequency: "weekly",
priority: 0.8,
},
];
// Dynamic video routes (one per tutorial)
// Note: video thumbnails are hosted on YouTube's CDN (i*.ytimg.com); Google's image
// sitemap extension targets images on your own domain, so they are omitted here.
const allVideos = getAllVideos();
const videoRoutes: SitemapRoute[] = allVideos.map((video) => ({
url: `${baseUrl}/landing/help/video/${video.id}`,
changeFrequency: "monthly",
priority: 0.8,
}));
// Login and signup routes (lower priority)
const authRoutes: SitemapRoute[] = [
{ {
url: `${baseUrl}/landing/login`, url: `${baseUrl}/landing/login`,
changeFrequency: "monthly", changeFrequency: "monthly",
@@ -47,6 +78,10 @@ export async function GET() {
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.5, priority: 0.5,
}, },
];
// Marketing pages
const marketingRoutes: SitemapRoute[] = [
{ {
url: `${baseUrl}/landing/offer-appointments`, url: `${baseUrl}/landing/offer-appointments`,
changeFrequency: "weekly", changeFrequency: "weekly",
@@ -57,6 +92,56 @@ export async function GET() {
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 0.9, priority: 0.9,
}, },
{
url: `${baseUrl}/landing/sistema-de-turnos`,
changeFrequency: "weekly",
priority: 0.9,
},
{
url: `${baseUrl}/landing/agenda-online`,
changeFrequency: "weekly",
priority: 0.9,
},
{
url: `${baseUrl}/landing/turnos-online`,
changeFrequency: "weekly",
priority: 0.9,
},
];
// Category exploration pages
// /landing/reviews is intentionally omitted: it renders rating lists driven by
// query params (?targetType=&targetId=) — parametric thin pages, not stable URLs.
const explorationRoutes: SitemapRoute[] = [
{
url: `${baseUrl}/landing/categories`,
changeFrequency: "weekly",
priority: 0.8,
},
{
url: `${baseUrl}/landing/get-appointments`,
changeFrequency: "weekly",
priority: 0.6,
},
];
// Legal pages (rarely change, low priority)
const legalRoutes: SitemapRoute[] = [
{
url: `${baseUrl}/landing/terms`,
changeFrequency: "yearly",
priority: 0.3,
},
{
url: `${baseUrl}/landing/privacy`,
changeFrequency: "yearly",
priority: 0.3,
},
{
url: `${baseUrl}/landing/cookies`,
changeFrequency: "yearly",
priority: 0.3,
},
]; ];
let serviceRoutes: SitemapRoute[] = []; let serviceRoutes: SitemapRoute[] = [];
@@ -75,19 +160,12 @@ export async function GET() {
if (servicesResult && servicesResult.data) { if (servicesResult && servicesResult.data) {
totalPages = servicesResult.pages || 1; totalPages = servicesResult.pages || 1;
// Image sitemap entries are omitted: service images may be hosted
// on arbitrary external domains, which the image extension can't verify.
const newRoutes = servicesResult.data.map((service) => ({ const newRoutes = servicesResult.data.map((service) => ({
url: `${baseUrl}/landing/service/${service.id}`, url: `${baseUrl}/landing/service/${service.id}`,
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 0.8, priority: 0.8,
images: [
{
url: service.image.startsWith("http")
? service.image
: `${process.env.NEXT_PUBLIC_API_URL}${service.image}`,
title: service.name,
caption: service.description,
},
],
})); }));
serviceRoutes = [...serviceRoutes, ...newRoutes]; serviceRoutes = [...serviceRoutes, ...newRoutes];
@@ -102,7 +180,44 @@ export async function GET() {
console.error("Error generating sitemap:", error); console.error("Error generating sitemap:", error);
} }
const allRoutes = [...staticRoutes, ...serviceRoutes]; let orgRoutes: SitemapRoute[] = [];
try {
let currentPage = 1;
let totalPages = 1;
do {
const orgsResult = await ApiServerService.post<{
data: { slug: string; name: string; description: string; creationDate?: string }[];
page: number;
pages: number;
}>("views/public-organizations", {
page: currentPage,
limit: 1000,
});
if (orgsResult && orgsResult.data) {
totalPages = orgsResult.pages || 1;
const newRoutes = orgsResult.data.map((org) => ({
url: `${baseUrl}/landing/org/${org.slug}`,
changeFrequency: "weekly" as const,
priority: 0.8,
}));
orgRoutes = [...orgRoutes, ...newRoutes];
} else {
break;
}
currentPage++;
} while (currentPage <= totalPages);
} catch (error) {
console.error("Error generating org sitemap:", error);
}
const allRoutes = [...staticRoutes, ...videoRoutes, ...authRoutes, ...marketingRoutes, ...orgRoutes, ...explorationRoutes, ...legalRoutes, ...serviceRoutes];
const sitemapXml = `<?xml version="1.0" encoding="UTF-8"?> const sitemapXml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
@@ -111,18 +226,18 @@ export async function GET() {
.map((route) => { .map((route) => {
return ` return `
<url> <url>
<loc>${route.url}</loc> <loc>${escapeXml(route.url)}</loc>
${route.lastModified ? `<lastmod>${route.lastModified}</lastmod>` : ""} ${route.lastModified ? `<lastmod>${escapeXml(route.lastModified)}</lastmod>` : ""}
<changefreq>${route.changeFrequency}</changefreq> <changefreq>${escapeXml(route.changeFrequency)}</changefreq>
<priority>${route.priority}</priority> <priority>${route.priority}</priority>
${route.images ${route.images
? route.images ? route.images
.map( .map(
(img) => ` (img) => `
<image:image> <image:image>
<image:loc>${img.url}</image:loc> <image:loc>${escapeXml(img.url)}</image:loc>
<image:title><![CDATA[${img.title}]]></image:title> <image:title>${escapeXml(img.title)}</image:title>
<image:caption><![CDATA[${img.caption}]]></image:caption> <image:caption>${escapeXml(img.caption)}</image:caption>
</image:image>` </image:image>`
) )
.join("") .join("")
@@ -135,7 +250,10 @@ export async function GET() {
return new NextResponse(sitemapXml, { return new NextResponse(sitemapXml, {
headers: { headers: {
"Content-Type": "application/xml", "Content-Type": "application/xml; charset=utf-8",
// Regenerated on every request (fetch no-store): cache at the edge/CDN
// so crawlers and browsers don't re-hit the services API each time.
"Cache-Control": "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
}, },
}); });
} }