45 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
horacio f200f91f93 Implement code changes to enhance functionality and improve performance 2026-09-14 13:43:23 -03:00
horacio 06aac5b126 fix: popup centrado con estilo Confirm (botones sin iconos, titulo 'Personalizar')
- Dialog centrado (no bottom-sheet) con ThemeProvider
- Botones Cancelar/Guardar en texto plano (sin iconos), mismo patrón que Confirm.tsx
- Recibe valores actuales por props para no depender de store global
- Titulo dice 'Personalizar' solamente
- Cierre se hace con icono X en el title bar
2026-09-14 13:01:36 -03:00
horacio 80d9f5d428 feat: permitir a admins personalizar header desde landing pública
- Nuevo endpoint POST /companies/check-admin para verificar rol de admin
- Componente HeaderEditPopup con color pickers e image picker
- Botón flotante de editar sobre el header cuando el usuario es admin
- Popup tipo drawer inferior con formulario de personalización
- Reutiliza APIs existentes: companies/update y companies/upload-header
2026-09-14 12:34:59 -03:00
horacio ad44e775e2 feat: agregar habilidad para sincronizar el proyecto Engram con la nube 2026-09-14 12:02:34 -03:00
horacio cebb34c88a feat: actualizar URL de webhook de Mercado Pago y eliminar conteo de citas en la creación de ciclo de uso de plan 2026-09-14 11:37:42 -03:00
horacio 0095bd884a feat: actualizar la validación del campo de comentarios en el esquema de actualización de citas 2026-09-11 15:03:26 -03:00
horacio c238f28179 feat: ajustar el relleno superior en la página de movimientos y actualizar el z-index en el componente de ubicación 2026-09-11 14:53:32 -03:00
109 changed files with 7196 additions and 4304 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 -->
@@ -0,0 +1,33 @@
---
name: engram-cloud-sync
description: "Trigger: sync engram cloud, sincronizar memoria, push engram, engram cloud sync. Synchronize local Engram project to remote cloud server."
license: Apache-2.0
metadata:
author: "horacio"
version: "1.0"
---
## Activation Contract
Load this skill whenever the user explicitly asks to synchronize, push, or sync their Engram memory with the cloud.
## Hard Rules
- **Never skip the sync** when requested. Execute `engram sync --cloud --project turnosxpress` every time.
- **Always verify** the cloud server is reachable and the project is enrolled before syncing.
- **Report results to the user**: sessions, observations, prompts, mutations exported.
- **Fail fast** on 403/forbidden errors — that means the project needs enrollment first.
## Execution Steps
1. **Check cloud status:** `engram cloud status --cwd <repo>` (default: current project directory). If `Auth status: ready` and `Sync readiness: pending`, continue.
2. **If project is not enrolled** (`403: forbidden: project is not allowed` or similar), run `engram cloud enroll turnosxpress --cwd <repo>` then re-check status.
3. **Execute sync:** `engram sync --cloud --project turnosxpress --cwd <repo>`.
4. **Report results** — sessions, observations, prompts, mutations count. If sync fails, surface the exact error and suggest running `engram serve` for autosync mode.
## Output Contract
Return:
- Cloud server URL and enrollment status.
- Export counts: sessions, observations, prompts, mutations.
- Confirmation: "Cloud sync complete" or actionable error if it failed.
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] } } }
])
+1 -1
View File
@@ -45,7 +45,7 @@ MP_SUBSCRIPTION_FIND_URL = https://api.mercadopago.com/preapproval/
MP_ACCESS_TOKEN = APP_USR-8753424836448507-121412-731599daea1ffa0b278b5dcc78aa1ec8-1611134648
MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard
MP_CHECK_PAYMENT_MINUTES = 1
MP_WEBHOOK_URL = https://30e1-186-132-147-13.ngrok-free.app/mercadopago/webhook
MP_WEBHOOK_URL = https://5f09-186-58-32-217.ngrok-free.app/mercadopago/webhook
DEFAULT_BOT_ADMIN_PORT = 3005
+12 -2
View File
@@ -1131,7 +1131,12 @@ class AppointmentManager implements IAppointmentsManager {
serviceId: String(appointment.serviceId),
employeeId: String(appointment.employeeId),
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),
avatar: clientAvatar,
color: isNull<string>(employee.calendarColor, "#ff9900"),
@@ -1257,16 +1262,21 @@ class AppointmentManager implements IAppointmentsManager {
//get min startTime hour from returnData
let minStartTime = 24;
let maxEndTime = 24;
for (const event of returnData) {
if (event.startTime < minStartTime) {
minStartTime = event.startTime;
}
if (event.endTime > maxEndTime) {
maxEndTime = event.endTime;
}
}
return {
events: returnData,
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 forge from "node-forge";
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 {
ARCA_WSAA_ENVIRONMENT,
@@ -123,6 +126,23 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
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(
companyId: string,
sessionUser: string
@@ -730,6 +750,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
public async generateCsr(data: GenerateArcaCredentialCsrParams): Promise<ArcaCredentialView> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
await this.requirePaymentsFeature(data.companyId);
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
const keys = forge.pki.rsa.generateKeyPair(2048);
@@ -778,6 +799,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
public async uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise<ArcaCredentialView> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
await this.requirePaymentsFeature(data.companyId);
if (!data.certificatePem || !data.certificatePem.trim()) {
throw new Error("Se requiere el certificado ARCA");
@@ -836,6 +858,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
public async testWsaaLogin(data: TestArcaWsaaLoginParams): Promise<ArcaWsaaLoginTestResult> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
await this.requirePaymentsFeature(data.companyId);
const service = ARCA_WSAA_SERVICE.WSFE;
const environment = this.getArcaEnvironment();
@@ -853,6 +876,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
public async getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise<ArcaWsfeLastVoucherResult> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
await this.requirePaymentsFeature(data.companyId);
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
const loginTicket = await this.getWsaaLoginTicket(data.companyId);
const environment = this.getArcaEnvironment();
@@ -886,6 +910,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
public async issueFacturaC(data: ArcaWsfeIssueFacturaCParams): Promise<ArcaWsfeIssueFacturaCResult> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
await this.requirePaymentsFeature(data.companyId);
if (data.amount <= 0) {
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> {
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
await this.requirePaymentsFeature(data.companyId);
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
const loginTicket = await this.getWsaaLoginTicket(data.companyId);
const environment = this.getArcaEnvironment();
@@ -1054,8 +1080,11 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
await validateSessionUser({ sessionUser: data.sessionUser });
let companyId: string | undefined;
if (data.companyId) {
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
companyId = data.companyId;
} else if (data.id) {
const credential = await this.arcaCredentials.findOne(data);
@@ -1067,12 +1096,13 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
companyId: String(credential.companyId),
sessionUser: data.sessionUser,
});
return [this.sanitizeCredential(credential)];
companyId = String(credential.companyId);
} else {
throw new Error("Se requiere una organización para buscar credenciales ARCA");
}
await this.requirePaymentsFeature(companyId);
const credentials = await this.arcaCredentials.find(data);
return credentials.map((credential) => this.sanitizeCredential(credential));
}
@@ -99,7 +99,9 @@ export class CashFlowAdapterMongoose implements ICashFlowAdapter {
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;
}
@@ -45,7 +45,8 @@ export class CashFlowBalanceAdapterMongoose implements ICashFlowBalanceAdapter {
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;
}
@@ -231,7 +231,10 @@ class CashFlowBalanceManager implements ICashFlowBalanceManager {
const userSession = await this.validateSessionUser(data.sessionUser);
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 companyId = data.companyId;
@@ -32,6 +32,14 @@ export class ClientsAdapterMongoose implements IClientsAdapter {
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);
}
+51 -11
View File
@@ -266,6 +266,27 @@ class ClientManager implements IClientsManager {
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);
if (!canAdd) {
@@ -274,17 +295,36 @@ class ClientManager implements IClientsManager {
);
}
const newClient = await this.clients.create({
...data,
...{
email: userCheck.email,
firstName: userCheck.firstName,
lastName: userCheck.lastName,
phoneAreaCode: userCheck.phoneAreaCode,
phoneCountryCode: userCheck.phoneCountryCode,
phoneNumber: userCheck.phoneNumber,
},
});
let newClient: IClientDocument;
try {
newClient = await this.clients.create({
...data,
...{
email: userCheck.email,
firstName: userCheck.firstName,
lastName: userCheck.lastName,
phoneAreaCode: userCheck.phoneAreaCode,
phoneCountryCode: userCheck.phoneCountryCode,
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({
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 },
});
this.schema.index({ published: 1, banned: 1 });
this.companyList = model<ICompanyDocument>("Company", this.schema);
}
@@ -380,4 +380,5 @@ export interface ICompaniesManager {
sysAdminUpdate(data: SysAdminUpdateCompanyParams): Promise<void>;
sysAdminSetBanned(data: SysAdminSetCompanyBannedParams): Promise<void>;
sysAdminGetInsights(data: SysAdminCompanyInsightsParams): Promise<SysAdminCompanyInsightsResult>;
checkCompanyAdmin(data: { companyId: string; sessionUser: string }): Promise<boolean>;
}
+13
View File
@@ -1221,6 +1221,19 @@ class CompaniesManager implements ICompaniesManager {
},
};
}
public async checkCompanyAdmin(data: { companyId: string; sessionUser: string }): Promise<boolean> {
const filterData =
data.companyId.indexOf("-") >= 0 ? { slug: data.companyId } : { _id: data.companyId };
const company = await this.companies.findOne(filterData as any);
if (!company) {
return false;
}
return await EmployeesList.checkPermission(company, data.sessionUser, EmployeeRoles.ADMIN);
}
}
const CompaniesList = new CompaniesManager();
@@ -5,9 +5,11 @@ import {
import { NotificationJobsAdapterMongoose } from "../NotificationJobs/NotificationJobs.Adapter.Mongoose";
import { PolicyResolver, ResolvedPolicy } from "./PolicyResolver";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
dayjs.extend(utc);
dayjs.extend(timezone);
export interface CreateJobServiceParams {
companyId: string;
@@ -66,12 +68,17 @@ export class NotificationJobService {
);
for (const rule of enabledReminderRules) {
const reminderScheduledAt = this.adjustForQuietHours(
dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(),
const originalReminderScheduledAt = dayjs(params.appointmentStart)
.subtract(rule.offset, "minute")
.toDate();
const reminderScheduledAt = this.selectAutomaticReminderScheduledAt(
originalReminderScheduledAt,
now,
params.appointmentStart,
resolvedPolicy
);
if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) {
if (reminderScheduledAt === null) {
continue;
}
@@ -169,12 +176,17 @@ export class NotificationJobService {
);
for (const rule of enabledReminderRules) {
const reminderScheduledAt = this.adjustForQuietHours(
dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(),
const originalReminderScheduledAt = dayjs(params.appointmentStart)
.subtract(rule.offset, "minute")
.toDate();
const reminderScheduledAt = this.selectAutomaticReminderScheduledAt(
originalReminderScheduledAt,
now,
params.appointmentStart,
resolvedPolicy
);
if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) {
if (reminderScheduledAt === null) {
continue;
}
@@ -239,6 +251,78 @@ export class NotificationJobService {
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[] {
const channels: NotificationChannel[] = [];
@@ -180,28 +180,35 @@ describe("NotificationJobService.createJob", () => {
expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0);
});
it("skips reminder jobs adjusted by quiet hours after appointment start", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T20:00:00.000Z"));
it("keeps UTC adjustment for the createJob base job and uses local candidates for reminders", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T23:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system", "email"],
channels: ["system"],
timezone: "America/Argentina/Buenos_Aires",
quietHours: { from: "22:00", to: "07:00" },
reminderRules: [{ offset: 15, enabled: true }],
reminderRules: [{ offset: 30, enabled: true }],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
const baseScheduledAt = new Date("2026-07-22T02:00:00.000Z");
await service.createJob({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "creation",
appointmentStart: new Date("2026-07-21T23:30:00.000Z"),
type: "update",
scheduledAt: baseScheduledAt,
appointmentStart: new Date("2026-07-22T03:00:00.000Z"),
});
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
expect(createCalls.filter((job) => job.type === "creation")).toHaveLength(2);
expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0);
expect(createCalls).toHaveLength(2);
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 () => {
@@ -330,77 +337,208 @@ describe("NotificationJobService.createJob", () => {
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 = {
channels: ["system"],
timezone: "America/Argentina/Buenos_Aires",
quietHours: { from: "22:00", to: "07:00" },
timezone: "UTC",
quietHours: { from: "08:00", to: "10:00" },
reminderRules: [{ offset: 270, enabled: true }],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
// scheduledAt at 23:30 UTC — should be adjusted to 07:00 next day UTC
const scheduledAt = new Date("2026-07-21T23:30:00.000Z");
await service.createJob({
const result = await service.createReminderJobs({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "creation",
scheduledAt,
type: "reminder",
appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
});
const createCall = mockJobCreate.mock.calls[0][0];
// 23:30 is inside quiet hours (22:00-07:00), adjusted to 07:00 next day
expect(createCall.scheduledAt.getUTCHours()).toBe(7);
expect(createCall.scheduledAt.getUTCDate()).toBe(22); // next day
expect(result).toHaveLength(1);
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
"2026-07-21T07:30:00.123Z"
);
});
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 = {
channels: ["system"],
timezone: "America/Argentina/Buenos_Aires",
quietHours: { from: "22:00", to: "07:00" },
timezone: "UTC",
quietHours: { from: "01:00", to: "06:00" },
reminderRules: [{ offset: 150, enabled: true }],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
await service.createJob({
const result = await service.createReminderJobs({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "creation",
scheduledAt: new Date("2026-07-21T05:00:00.000Z"),
type: "reminder",
appointmentStart: new Date("2026-07-21T08:00:00.123Z"),
});
const createCall = mockJobCreate.mock.calls[0][0];
expect(createCall.scheduledAt.toISOString()).toBe("2026-07-21T07:00:00.000Z");
expect(result).toHaveLength(1);
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 = {
channels: ["system"],
timezone: "America/Argentina/Buenos_Aires",
quietHours: { from: "22:00", to: "07:00" },
reminderRules: [{ offset: 510, enabled: true }],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
// scheduledAt at 14:00 UTC — outside quiet hours (22:00-07:00)
const scheduledAt = new Date("2026-07-21T14:00:00.000Z");
await service.createJob({
const result = await service.createReminderJobs({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "creation",
scheduledAt,
type: "reminder",
appointmentStart: new Date("2026-07-22T12:00:00.123Z"),
});
const createCall = mockJobCreate.mock.calls[0][0];
// 14:00 is outside quiet hours, should remain unchanged
expect(createCall.scheduledAt.getUTCHours()).toBe(14);
expect(createCall.scheduledAt.getUTCDate()).toBe(21);
expect(result).toHaveLength(1);
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
"2026-07-22T00:59:00.123Z"
);
});
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 () => {
@@ -428,21 +566,24 @@ describe("NotificationJobService.createJob", () => {
expect(createCall.clientId).toBe("client-1");
});
it("creates immediate jobs without reminder offsets", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z"));
it("does not apply quiet-hours adjustment to immediate reminder jobs", async () => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T09:00:00.000Z"));
const resolvedPolicy: ResolvedPolicy = {
channels: ["system", "email"],
timezone: "America/Argentina/Buenos_Aires",
timezone: "UTC",
quietHours: { from: "08:00", to: "10:00" },
reminderRules: [{ offset: 30, enabled: true }],
};
mockResolverResolve.mockResolvedValue(resolvedPolicy);
mockJobCreate.mockResolvedValue({ id: "job-1" });
const scheduledAt = new Date("2026-07-21T09:30:00.123Z");
const result = await service.createImmediateJob({
companyId: "company-1",
clientId: "client-1",
appointmentId: "appt-1",
type: "reminder",
scheduledAt,
appointmentStart: new Date("2026-07-21T16:00:00.000Z"),
});
@@ -453,12 +594,12 @@ describe("NotificationJobService.createJob", () => {
expect.objectContaining({
channel: "system",
type: "reminder",
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
scheduledAt,
}),
expect.objectContaining({
channel: "email",
type: "reminder",
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
scheduledAt,
}),
]);
});
@@ -1,4 +1,7 @@
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 { OrganizationFiscalProfilesAdapterMongoose } from "./OrganizationFiscalProfiles.Adapter.Mongoose";
import {
@@ -70,6 +73,21 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa
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);
const existingProfile = await this.organizationFiscalProfiles.findOne({
@@ -140,6 +158,18 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa
companyId: data.companyId,
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) {
const profile = await this.organizationFiscalProfiles.findOne(data);
@@ -152,6 +182,18 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa
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];
} else {
throw new Error("Se requiere una organización para buscar perfiles fiscales");
@@ -112,7 +112,6 @@ export class PlanUsageCycleAdapterMongoose implements IPlanUsageCycleAdapter {
planId: data.planId,
cycleStart: data.cycleStart,
cycleEnd: data.cycleEnd,
appointmentsCount: 0,
creationDate: now,
},
$inc: { appointmentsCount: data.quantity },
+10 -1
View File
@@ -63,6 +63,7 @@ import ClientsList from "../Clients/Clients";
import RepeatsList from "../Repeats/Repeats";
import { OAuth2Client } from "google-auth-library";
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
import dayjs from "dayjs";
const cleanEmail = (email: string): string => {
return email.toLowerCase().trim();
@@ -1087,10 +1088,18 @@ class UsersManager implements IUsersManager {
await this.users.update(data);
const fecha = dayjs().format("DD/MM/YYYY");
const hora = dayjs().format("HH:mm");
await NotificationsManager.sendEmail({
email: cleanEmail(sessionUser.email),
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 CompaniesList from "../Companies/Companies";
import { CompaniesAdapterMongoose } from "../Companies/Companies.Adapter.Mongoose";
import ServiceList from "../Services/Service";
import {
PublicServicesResults,
@@ -15,6 +16,24 @@ import { BotEventParams, BotView } from "../WapServer/WapServer.Interface";
import WapServerList from "../WapServer/WapServer";
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 = {
education: PublicServicesResults;
health: PublicServicesResults;
@@ -39,6 +58,7 @@ export type PublicOrganizationViewParams = {
export type PublicOrganizationView = {
id: string;
slug: string;
headerFile: string;
headerColor: string;
headerFontColor: string;
@@ -143,6 +163,7 @@ class Views {
return {
id: isNull<string>(company.id, ""),
slug: isNull<string>(company.slug, ""),
name: isNull<string>(company.name, ""),
description: isNull<string>(company.description, ""),
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> {
return await WapServerList.botView(data);
}
@@ -38,7 +38,7 @@ export class ToggleCompanyFixedPostController extends Controller {
@SuccessResponse(200, "Done")
@Post()
public async toggleFixedCompanyPost(
@Body() requestBody: FixCompanyPostParams
@Body() requestBody: FixCompanyPostParams,
): Promise<ApiVoidResult | ApiValidationError> {
try {
await new CompaniesService().toggleFixedPost(requestBody);
@@ -62,7 +62,7 @@ export class MoveCompanyFixedPostController extends Controller {
@SuccessResponse(200, "Done")
@Post()
public async moveFixedCompanyPost(
@Body() requestBody: MoveCompanyFixedPostParams
@Body() requestBody: MoveCompanyFixedPostParams,
): Promise<ApiVoidResult | ApiValidationError> {
try {
await new CompaniesService().moveFixedPost(requestBody);
@@ -86,7 +86,7 @@ export class SetNotificationsStatusCompanyController extends Controller {
@SuccessResponse(200, "Done")
@Post()
public async setNotificationAuto(
@Body() requestBody: SetNotificationAutoParams
@Body() requestBody: SetNotificationAutoParams,
): Promise<ApiVoidResult | ApiValidationError> {
try {
await new CompaniesService().setNotificationAuto(requestBody);
@@ -110,7 +110,7 @@ export class SetPublishedStatusCompanyController extends Controller {
@SuccessResponse(200, "Done")
@Post()
public async setPublishedStatus(
@Body() requestBody: SetOrganizationPublishedStatusParams
@Body() requestBody: SetOrganizationPublishedStatusParams,
): Promise<ApiVoidResult | ApiValidationError> {
try {
await new CompaniesService().setPublishedStatus(requestBody);
@@ -134,7 +134,7 @@ export class CreateCompanyController extends Controller {
@SuccessResponse(200, "La compañia fue creada con exito")
@Post()
public async createCompany(
@Body() requestBody: CreateCompanyParams
@Body() requestBody: CreateCompanyParams,
): Promise<ICompany | ApiValidationError> {
try {
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")
@Post()
public async deleteCompany(
@Body() requestBody: DeleteCompanyParams
@Body() requestBody: DeleteCompanyParams,
): Promise<ApiVoidResult | ApiValidationError> {
try {
await new CompaniesService().deleteCompany(requestBody);
@@ -179,7 +179,7 @@ export class UpdateCompanyController extends Controller {
@SuccessResponse(200, "La compañia fue modificada con exito")
@Post()
public async updateCompany(
@Body() requestBody: UpdateCompanyParams
@Body() requestBody: UpdateCompanyParams,
): Promise<ApiVoidResult | ApiValidationError> {
try {
await new CompaniesService().updateCompany(requestBody);
@@ -202,7 +202,7 @@ export class GetCompanyByIdController extends Controller {
@SuccessResponse(200, "Done")
@Post()
public async getById(
@Body() requestBody: OranizationsViewByIdParams
@Body() requestBody: OranizationsViewByIdParams,
): Promise<MyOranizationsView | ApiValidationError> {
try {
const companies = await new CompaniesService().getById(requestBody);
@@ -222,7 +222,7 @@ export class GetCompanyByUserController extends Controller {
@SuccessResponse(200, "Done")
@Post()
public async getByUserId(
@Body() requestBody: MyOranizationsViewParams
@Body() requestBody: MyOranizationsViewParams,
): Promise<MyOranizationsView[] | ApiValidationError> {
try {
const companies = await new CompaniesService().getByUserId(requestBody);
@@ -242,7 +242,7 @@ export class PaginateCompanyController extends Controller {
@SuccessResponse(200, "Done")
@Post()
public async paginateUsers(
@Body() requestBody: PaginateCompaniesParams
@Body() requestBody: PaginateCompaniesParams,
): Promise<PaginateCompaniesResults | ApiValidationError> {
try {
const users = await new CompaniesService().paginateCompanies(requestBody);
@@ -265,7 +265,7 @@ export class UploadCompanyHeaderController extends Controller {
public async uploadHeader(
@FormField() companyId: string,
@FormField() sessionUser: string,
@UploadedFile() file: Express.Multer.File
@UploadedFile() file: Express.Multer.File,
): Promise<ApiVoidResult | ApiValidationError> {
try {
await new CompaniesService().setCompanyHeader({
@@ -295,7 +295,7 @@ export class UploadCompanyIconController extends Controller {
public async uploadIcon(
@FormField() companyId: string,
@FormField() sessionUser: string,
@UploadedFile() file: Express.Multer.File
@UploadedFile() file: Express.Multer.File,
): Promise<ApiVoidResult | ApiValidationError> {
try {
await new CompaniesService().setCompanyIcon({
@@ -325,7 +325,7 @@ export class DeleteCompanyHeaderController extends Controller {
@SuccessResponse(200, "El archivo fue cargado con exito")
@Post()
public async deleteHeader(
@Body() data: DeleteCompanyFileParams
@Body() data: DeleteCompanyFileParams,
): Promise<ApiVoidResult | ApiValidationError> {
try {
await new CompaniesService().deleteCompanyHeader(data);
@@ -349,7 +349,7 @@ export class DeleteCompanyIconController extends Controller {
@SuccessResponse(200, "El archivo fue cargado con exito")
@Post()
public async deleteIcon(
@Body() data: DeleteCompanyFileParams
@Body() data: DeleteCompanyFileParams,
): Promise<ApiVoidResult | ApiValidationError> {
try {
await new CompaniesService().deleteCompanyIcon(data);
@@ -372,7 +372,7 @@ export class filterAllOrganizationsController extends Controller {
@SuccessResponse(200, "Done")
@Post()
public async filterAllOrganizations(
@Body() requestBody: PaginateCompaniesParams
@Body() requestBody: PaginateCompaniesParams,
): Promise<TextObjectFilterResult[] | ApiValidationError> {
try {
const clients = await new CompaniesService().filterAll(requestBody);
@@ -392,7 +392,7 @@ export class CheckCompanyNameController extends Controller {
@SuccessResponse(200, "Done")
@Post()
public async checkName(
@Body() requestBody: { name: string }
@Body() requestBody: { name: string },
): Promise<{ valid: boolean } | ApiValidationError> {
try {
const valid = await new CompaniesService().checkNameExists(requestBody.name);
@@ -405,3 +405,24 @@ export class CheckCompanyNameController extends Controller {
}
}
}
@Route("companies/check-admin")
@Middlewares(authenticateMiddleware)
export class CheckCompanyAdminController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async checkAdmin(
@Body() requestBody: { companyId: string; sessionUser: string },
): Promise<boolean | ApiValidationError> {
try {
const isAdmin = await new CompaniesService().checkCompanyAdmin(requestBody);
this.setStatus(200);
return isAdmin;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -112,4 +112,9 @@ export class CompaniesService {
const isValid = await CompaniestList.isValidName({ name });
return isValid;
}
public async checkCompanyAdmin(data: { companyId: string; sessionUser: string }): Promise<boolean> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await CompaniestList.checkCompanyAdmin(data);
}
}
+22
View File
@@ -9,6 +9,8 @@ import {
PublicOrganizationServiceView,
UserAppointmentViewParams,
UserAppointmentView,
PublicOrganizationsParams,
PublicOrganizationsResult,
} from "../../Models/Views/Views";
import { ViewsService } from "./Views.Service";
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,
UserAppointmentViewParams,
UserAppointmentView,
PublicOrganizationsParams,
PublicOrganizationsResult,
} from "../../Models/Views/Views";
import { BotView, BotEventParams } from "../../Models/WapServer/WapServer.Interface";
@@ -45,4 +47,12 @@ export class ViewsService {
const view = await views.wapView(data);
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: 37 KiB

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/*
node_modules
.env
auth/
*_sessions
*tokens
+1391 -2344
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -2,8 +2,9 @@
"name": "txbot",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"scripts": {
"dev": "ts-node src/server.ts",
"dev": "tsx src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
},
@@ -12,14 +13,14 @@
"license": "ISC",
"description": "",
"dependencies": {
"@whiskeysockets/baileys": "6.17.16",
"@whiskeysockets/baileys": "7.0.0-rc14",
"express": "^5.1.0",
"qrcode": "^1.5.4"
},
"devDependencies": {
"@types/express": "^5.0.2",
"@types/qrcode": "^1.5.5",
"ts-node": "^10.9.2",
"tsx": "^4.20.3",
"typescript": "^5.8.3"
}
}
+40 -3
View File
@@ -9,6 +9,35 @@ import { Boom } from "@hapi/boom";
let sock: ReturnType<typeof makeWASocket>;
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() {
const { state, saveCreds } = await useMultiFileAuthState("./auth");
@@ -17,7 +46,6 @@ async function startWhatsApp() {
sock = makeWASocket({
version,
auth: state,
printQRInTerminal: false,
});
sock.ev.on("creds.update", saveCreds);
@@ -29,10 +57,16 @@ async function startWhatsApp() {
if (connection === "close") {
const reason = (lastDisconnect?.error as Boom)?.output?.statusCode;
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") {
console.log("Connected to WhatsApp");
latestQR = null;
reconnecting = false;
}
});
}
@@ -85,4 +119,7 @@ async function startServer() {
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. */
/* 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. */
// "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. */
@@ -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 SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan";
import { motion } from "motion/react";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = {
oid: string;
@@ -36,13 +37,14 @@ export default function ClientAccount() {
const alert = useAlert();
const SessionInfo = useSessionStore();
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore();
const { goTo } = useNavigation();
const [view, setView] = useState<ClientAccountView | null>(null);
const [isScrolled, setIsScrolled] = useState(false);
const [forceMinimized, setForceMinimized] = useState(false);
const isMinimized = forceMinimized || isScrolled;
useEffect(() => {
@@ -68,6 +70,8 @@ export default function ClientAccount() {
useEffect(() => {
location.clear();
bottomToolbar.set(<></>);
location.add({
url: "/admin/org/profile/" + id + "/clients",
title: "Clientes",
@@ -85,6 +89,10 @@ export default function ClientAccount() {
title: "Cuenta Corriente",
icon: <AccountBalanceOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />,
});
return () => {
bottomToolbar.clear();
};
}, []);
useEffect(() => {
@@ -127,149 +135,273 @@ export default function ClientAccount() {
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
<div style={{ maxWidth: "800px", margin: "0 auto", paddingBottom: "100px" }}>
{/* 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" }}>
{view && (
<motion.div
layout
initial={{ opacity: 0, y: -20 }}
animate={{
opacity: 1,
y: 0,
padding: isMinimized ? "25px 50px" : "40px 20px",
borderRadius: isMinimized ? "15px" : "20px"
}}
<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 && (
<motion.div
layout
initial={{ opacity: 0, y: -20 }}
animate={{
opacity: 1,
y: 0,
padding: isMinimized ? "25px 50px" : "40px 20px",
borderRadius: isMinimized ? "15px" : "20px",
}}
style={{
display: "flex",
flexDirection: isMinimized ? "row" : "column",
alignItems: "center",
justifyContent: isMinimized ? "space-between" : "center",
background:
view.amount >= 0
? "linear-gradient(135deg, #1d976c 0%, #93f9b9 100%)"
: "linear-gradient(135deg, #cb2d3e 0%, #ef473a 100%)",
color: "white",
boxShadow: isMinimized
? "0 4px 15px rgba(0,0,0,0.3)"
: "0 10px 30px rgba(0,0,0,0.15)",
position: "relative",
overflow: "hidden",
transition: "box-shadow 0.3s ease",
}}
>
<IconButton
onClick={() => setForceMinimized(!forceMinimized)}
style={{
position: "absolute",
top: "10px",
left: "10px",
color: "rgba(255,255,255,0.7)",
zIndex: 10,
}}
size="small"
title={forceMinimized ? "Maximizar" : "Minimizar"}
>
{forceMinimized ? <UnfoldMoreIcon /> : <UnfoldLessIcon />}
</IconButton>
{/* 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
style={{
position: "absolute",
bottom: "-30px",
left: "-30px",
width: "100px",
height: "100px",
borderRadius: "50%",
background: "rgba(255,255,255,0.1)",
}}
></div>
<motion.div
animate={{
fontSize: isMinimized ? "14px" : "16px",
marginBottom: isMinimized ? "0px" : "0px",
marginLeft: isMinimized ? "30px" : "0px",
}}
style={{
fontWeight: "bold",
opacity: 0.9,
textTransform: "uppercase",
letterSpacing: "1px",
}}
>
Saldo Actual
</motion.div>
<motion.div
animate={{
fontSize: isMinimized ? "24px" : "56px",
margin: isMinimized ? "0px" : "10px 0 30px 0",
}}
style={{
fontWeight: "900",
textShadow: "0 2px 10px rgba(0,0,0,0.1)",
}}
>
{formatPrice(view.amount)}
</motion.div>
{!isMinimized && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{
display: "flex",
gap: "15px",
flexDirection: "row",
zIndex: 1,
}}
>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<IconButton
sx={{
backgroundColor: "rgba(255,255,255,0.2)",
color: "white",
display: "flex",
alignItems: "center",
flexDirection: "column",
width: "110px",
height: "90px",
borderRadius: "15px",
"&:hover": {
backgroundColor: "rgba(255,255,255,0.3)",
},
}}
onClick={() =>
add(CLIENT_ACCOUNT_MOVEMENT_TYPES.DEPOSIT)
}
>
<DepositarIcon
sx={{ fontSize: "32px", marginBottom: "5px" }}
/>
<span
style={{ fontSize: "14px", fontWeight: "bold" }}
>
Ingresar
</span>
</IconButton>
</motion.div>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<IconButton
sx={{
backgroundColor: "rgba(255,255,255,0.2)",
color: "white",
display: "flex",
alignItems: "center",
flexDirection: "column",
width: "110px",
height: "90px",
borderRadius: "15px",
"&:hover": {
backgroundColor: "rgba(255,255,255,0.3)",
},
}}
onClick={() =>
add(CLIENT_ACCOUNT_MOVEMENT_TYPES.WITHDRAWAL)
}
>
<RetirarIcon
sx={{ fontSize: "32px", marginBottom: "5px" }}
/>
<span
style={{ fontSize: "14px", fontWeight: "bold" }}
>
Retirar
</span>
</IconButton>
</motion.div>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<IconButton
sx={{
backgroundColor: "white",
color: view.amount >= 0 ? "#1d976c" : "#cb2d3e",
display: "flex",
alignItems: "center",
flexDirection: "column",
width: "110px",
height: "90px",
borderRadius: "15px",
boxShadow: "0 4px 15px rgba(0,0,0,0.1)",
"&:hover": { backgroundColor: "#f8f9fa" },
}}
onClick={() =>
goTo(
"/admin/org/" +
id +
"/client/" +
clientId +
"/account/movements",
)
}
>
<MovimientosIcon
sx={{ fontSize: "32px", marginBottom: "5px" }}
/>
<span
style={{ fontSize: "14px", fontWeight: "bold" }}
>
Historial
</span>
</IconButton>
</motion.div>
</motion.div>
)}
</motion.div>
)}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
<div
style={{
display: "flex",
flexDirection: isMinimized ? "row" : "column",
justifyContent: "space-between",
alignItems: "center",
justifyContent: isMinimized ? "space-between" : "center",
background: view.amount >= 0
? "linear-gradient(135deg, #1d976c 0%, #93f9b9 100%)"
: "linear-gradient(135deg, #cb2d3e 0%, #ef473a 100%)",
color: "white",
boxShadow: isMinimized ? "0 4px 15px rgba(0,0,0,0.3)" : "0 10px 30px rgba(0,0,0,0.15)",
position: "relative",
overflow: "hidden",
transition: "box-shadow 0.3s ease"
marginBottom: "15px",
padding: "0 10px",
}}
>
<IconButton
onClick={() => setForceMinimized(!forceMinimized)}
style={{ position: "absolute", top: "10px", left: "10px", color: "rgba(255,255,255,0.7)", zIndex: 10 }}
size="small"
title={forceMinimized ? "Maximizar" : "Minimizar"}
>
{forceMinimized ? <UnfoldMoreIcon /> : <UnfoldLessIcon />}
</IconButton>
{/* 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 style={{ position: "absolute", bottom: "-30px", left: "-30px", width: "100px", height: "100px", borderRadius: "50%", background: "rgba(255,255,255,0.1)" }}></div>
<motion.div
animate={{
fontSize: isMinimized ? "14px" : "16px",
marginBottom: isMinimized ? "0px" : "0px",
marginLeft: isMinimized ? "30px" : "0px"
<div
style={{
fontSize: "20px",
fontWeight: "bold",
color: "var(--black-light)",
}}
style={{ fontWeight: "bold", opacity: 0.9, textTransform: "uppercase", letterSpacing: "1px" }}
>
Saldo Actual
</motion.div>
<motion.div
animate={{
fontSize: isMinimized ? "24px" : "56px",
margin: isMinimized ? "0px" : "10px 0 30px 0"
}}
style={{ fontWeight: "900", textShadow: "0 2px 10px rgba(0,0,0,0.1)" }}
>
{formatPrice(view.amount)}
</motion.div>
{!isMinimized && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ display: "flex", gap: "15px", flexDirection: "row", zIndex: 1 }}
>
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<IconButton
sx={{
backgroundColor: "rgba(255,255,255,0.2)",
color: "white",
display: "flex",
alignItems: "center",
flexDirection: "column",
width: "110px",
height: "90px",
borderRadius: "15px",
"&:hover": { backgroundColor: "rgba(255,255,255,0.3)" }
}}
onClick={() => add(CLIENT_ACCOUNT_MOVEMENT_TYPES.DEPOSIT)}
>
<DepositarIcon sx={{ fontSize: "32px", marginBottom: "5px" }} />
<span style={{ fontSize: "14px", fontWeight: "bold" }}>Ingresar</span>
</IconButton>
</motion.div>
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<IconButton
sx={{
backgroundColor: "rgba(255,255,255,0.2)",
color: "white",
display: "flex",
alignItems: "center",
flexDirection: "column",
width: "110px",
height: "90px",
borderRadius: "15px",
"&:hover": { backgroundColor: "rgba(255,255,255,0.3)" }
}}
onClick={() => add(CLIENT_ACCOUNT_MOVEMENT_TYPES.WITHDRAWAL)}
>
<RetirarIcon sx={{ fontSize: "32px", marginBottom: "5px" }} />
<span style={{ fontSize: "14px", fontWeight: "bold" }}>Retirar</span>
</IconButton>
</motion.div>
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<IconButton
sx={{
backgroundColor: "white",
color: view.amount >= 0 ? "#1d976c" : "#cb2d3e",
display: "flex",
alignItems: "center",
flexDirection: "column",
width: "110px",
height: "90px",
borderRadius: "15px",
boxShadow: "0 4px 15px rgba(0,0,0,0.1)",
"&:hover": { backgroundColor: "#f8f9fa" }
}}
onClick={() => goTo("/admin/org/" + id + "/client/" + clientId + "/account/movements")}
>
<MovimientosIcon sx={{ fontSize: "32px", marginBottom: "5px" }} />
<span style={{ fontSize: "14px", fontWeight: "bold" }}>Historial</span>
</IconButton>
</motion.div>
</motion.div>
)}
</motion.div>
)}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
<div 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
</div>
<div
style={{ fontSize: "14px", color: "var(--wine-dark)", cursor: "pointer", fontWeight: "bold" }}
onClick={() => goTo("/admin/org/" + id + "/client/" + clientId + "/account/movements")}
<div
style={{
fontSize: "14px",
color: "var(--wine-dark)",
cursor: "pointer",
fontWeight: "bold",
}}
onClick={() =>
goTo(
"/admin/org/" +
id +
"/client/" +
clientId +
"/account/movements",
)
}
>
Ver todos
</div>
@@ -294,42 +426,79 @@ export default function ClientAccount() {
boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "15px" }}>
<div style={{
width: "45px",
height: "45px",
borderRadius: "12px",
backgroundColor: movement.amount > 0 ? "rgba(40, 167, 69, 0.1)" : "rgba(220, 53, 69, 0.1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: movement.amount > 0 ? "#28a745" : "#dc3545"
}}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "15px",
}}
>
<div
style={{
width: "45px",
height: "45px",
borderRadius: "12px",
backgroundColor:
movement.amount > 0
? "rgba(40, 167, 69, 0.1)"
: "rgba(220, 53, 69, 0.1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color:
movement.amount > 0 ? "#28a745" : "#dc3545",
}}
>
{getPaymentIcon(movement.paymentMethod)}
</div>
<div>
<div style={{ fontSize: "16px", fontWeight: "bold", color: "#333", marginBottom: "3px" }}>
<div
style={{
fontSize: "16px",
fontWeight: "bold",
color: "#333",
marginBottom: "3px",
}}
>
{movement.description}
</div>
<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 style={{
fontSize: "18px",
fontWeight: "900",
color: movement.amount > 0 ? "#28a745" : "#dc3545",
}}>
{movement.amount > 0 ? "+" : ""}{formatPrice(movement.amount)}
<div
style={{
fontSize: "18px",
fontWeight: "900",
color: movement.amount > 0 ? "#28a745" : "#dc3545",
}}
>
{movement.amount > 0 ? "+" : ""}
{formatPrice(movement.amount)}
</div>
</motion.div>
))}
</div>
) : (
<div style={{ 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" }}>
<div
style={{
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
</div>
<div style={{ fontSize: "14px" }}>
@@ -37,7 +37,7 @@ const schemaAppointmentUpdate = Yup.object().shape({
start: Yup.string().required("Debe seleccionar una fecha."),
price: Yup.number().positive().required("Debe introducir un precio valido."),
length: Yup.number().positive().required("Debe introducir una duración."),
comments: Yup.string().required("Debe introducir un comentario."),
comments: Yup.string().notRequired(),
sessionUser: Yup.string().required("Para crear un turno debe iniciar sesión."),
});
@@ -1,9 +1,6 @@
"use client";
import { useAlert } from "@core/Store/Alert.Store";
import {
useEventHandlerStore,
EVENT_TYPES,
} from "@core/Store/EventHandler.Store";
import { useEventHandlerStore, EVENT_TYPES } from "@core/Store/EventHandler.Store";
import { useLocationStore } from "@core/Store/Location.Store";
import { useParams } from "next/navigation";
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 { useNavigation } from "@core/app/hooks/goto";
import { useMenuStore } from "@core/Store/Menu.Store";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = {
oid: string;
@@ -26,14 +24,14 @@ export default function ClientAppointments() {
const clientId = data.clientId;
const alert = useAlert();
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore();
const menu = useMenuStore();
const { goTo } = useNavigation();
const [appintments, setAppointments] =
useState<AppointmentAdminByClientView>({
events: [],
});
const [appintments, setAppointments] = useState<AppointmentAdminByClientView>({
events: [],
});
useEffect(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
@@ -44,6 +42,8 @@ export default function ClientAppointments() {
useEffect(() => {
location.clear();
bottomToolbar.set(<></>);
location.add({
url: "/admin/org/profile/" + id + "/clients",
title: "Clientes",
@@ -61,6 +61,10 @@ export default function ClientAppointments() {
title: "Turnos reservados",
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
});
return () => {
bottomToolbar.clear();
};
}, []);
useEffect(() => {
@@ -89,15 +93,17 @@ export default function ClientAppointments() {
}}
>
{appintments.events.length === 0 ? (
<div style={{
gridColumn: "1 / -1",
textAlign: "center",
padding: "40px 20px",
background: "#fff",
borderRadius: "12px",
boxShadow: "0 4px 15px rgba(0,0,0,0.02)",
color: "#7f8c8d"
}}>
<div
style={{
gridColumn: "1 / -1",
textAlign: "center",
padding: "40px 20px",
background: "#fff",
borderRadius: "12px",
boxShadow: "0 4px 15px rgba(0,0,0,0.02)",
color: "#7f8c8d",
}}
>
No hay turnos registrados para este cliente.
</div>
) : (
@@ -113,7 +119,7 @@ export default function ClientAppointments() {
"/client/" +
clientId +
"/appointments/" +
event.id
event.id,
);
}}
/>
@@ -15,6 +15,7 @@ import { validatePhone } from "@core/helpers/validatePhone";
import { useConfirmStore } from "@core/Store/Confirm.Store";
import Switch from "@core/app/components/Switch/Switch";
import { Box, Typography } from "@mui/material";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = {
oid: string;
@@ -29,6 +30,7 @@ export default function ClientCreate() {
const confirm = useConfirmStore();
const SessionInfo = useSessionStore();
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
@@ -46,6 +48,8 @@ export default function ClientCreate() {
useEffect(() => {
location.clear();
bottomToolbar.set(<></>);
location.add({
url: "/admin/org/profile/" + id + "/clients",
title: "Clientes",
@@ -63,6 +67,10 @@ export default function ClientCreate() {
title: "Editar",
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
});
return () => {
bottomToolbar.clear();
};
}, []);
useEffect(() => {
@@ -92,7 +100,7 @@ export default function ClientCreate() {
confirm.show(
validationPhoneResult.message,
() => {},
() => {}
() => {},
);
eventHandler.setEventType(EVENT_TYPES.SLEEP);
return;
@@ -112,7 +120,7 @@ export default function ClientCreate() {
})
.then(() => {
alert.showSuccess(
`Información del cliente ${firstName} ${lastName} actualizada`
`Información del cliente ${firstName} ${lastName} actualizada`,
);
setTimeout(() => {
goTo("/admin/org/" + id + "/client/" + clientId + "/profile");
@@ -180,7 +188,9 @@ export default function ClientCreate() {
/>
<div style={{ marginTop: "15px" }}></div>
<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
checked={banned}
onChange={(checked) => {
@@ -18,6 +18,7 @@ import { ClientMergeParams, OrganizationClientView } from "@core/Models/Clients.
import Button from "@core/app/components/Button/Button";
import MaterialAlert from "@mui/material/Alert";
import { useConfirmStore } from "@core/Store/Confirm.Store";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = {
oid: string;
@@ -32,6 +33,7 @@ export default function ClientCreate() {
const confirm = useConfirmStore();
const SessionInfo = useSessionStore();
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore();
const { goTo } = useNavigation();
@@ -53,7 +55,7 @@ export default function ClientCreate() {
});
const [clientMergeFilterData, setClientMergeFilterData] = useState<TextObjectFilterResult[]>(
[]
[],
);
const [clientMergeId, setClientMergeId] = useState<string>("");
@@ -94,7 +96,7 @@ export default function ClientCreate() {
() => {
mergeClients();
},
() => {}
() => {},
);
};
@@ -105,6 +107,8 @@ export default function ClientCreate() {
useEffect(() => {
location.clear();
bottomToolbar.set(<></>);
location.add({
url: "/admin/org/profile/" + id + "/clients",
title: "Clientes",
@@ -122,6 +126,10 @@ export default function ClientCreate() {
title: "Fusionar clientes",
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
});
return () => {
bottomToolbar.clear();
};
}, []);
useEffect(() => {
@@ -11,6 +11,7 @@ import ClientsIcon from "@mui/icons-material/AccountBoxOutlined";
import NotificationsIcon from "@mui/icons-material/NotificationsOutlined";
import MaterialAlert from "@mui/material/Alert";
import styles from "./NotificationsPage.module.css";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = {
oid: string;
@@ -20,7 +21,7 @@ type UrlData = {
const CHANNELS: { key: NotificationChannel; label: string }[] = [
{ key: "whatsapp", label: "WhatsApp" },
{ key: "email", label: "Email" },
{ key: "system", label: "Sistema" }
{ key: "system", label: "Sistema" },
];
type ChannelPreference = "default" | "preferred" | "muted";
@@ -30,34 +31,41 @@ export default function NotificationsPage() {
const oid = data.oid;
const clientId = data.clientId;
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const store = useNotificationPreferencesStore();
useEffect(() => {
location.clear();
bottomToolbar.set(<></>);
location.add({
url: "/admin/org",
title: "Organizaciones",
icon: null
icon: null,
});
location.add({
url: "/admin/org/profile/" + oid,
title: "Organización",
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />,
});
location.add({
url: "/admin/org/" + oid + "/client/" + clientId + "/profile",
title: "Cliente",
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
});
location.add({
url: "",
title: "Notificaciones",
icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} />
icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
});
return () => {
bottomToolbar.clear();
};
}, [oid, clientId]);
const getChannelPreference = (channel: NotificationChannel): ChannelPreference => {
@@ -81,7 +89,7 @@ export default function NotificationsPage() {
mutedChannels:
preference === "muted"
? [...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}>
<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>
<div className={styles.channels}>
@@ -102,24 +112,32 @@ export default function NotificationsPage() {
<div>
<div className={styles.channelTitle}>{channel.label}</div>
<div className={styles.channelDescription}>
{selectedPreference === "preferred" && "Priorizar este canal para el cliente."}
{selectedPreference === "muted" && "No enviar notificaciones por este canal."}
{selectedPreference === "default" && "Usar la configuración general de la organización."}
{selectedPreference === "preferred" &&
"Priorizar este canal para el cliente."}
{selectedPreference === "muted" &&
"No enviar notificaciones por este canal."}
{selectedPreference === "default" &&
"Usar la configuración general de la organización."}
</div>
</div>
<div className={styles.options}>
{[
{ 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) => {
const isSelected = selectedPreference === option.key;
return (
<button
key={option.key}
type="button"
onClick={() => selectPreference(channel.key, option.key)}
onClick={() =>
selectPreference(channel.key, option.key)
}
className={`${styles.optionButton} ${isSelected ? styles.optionButtonActive : ""}`}
>
{option.label}
@@ -8,6 +8,7 @@ import { useLocationStore } from "@store/Location.Store";
import { useNavigation } from "@hooks/goto";
import { useParams } from "next/navigation";
import { useEffect } from "react";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = {
oid: string;
@@ -19,11 +20,14 @@ export default function ClientProfile() {
const clientId = data.clientId;
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const { goTo } = useNavigation();
useEffect(() => {
location.clear();
bottomToolbar.set(<></>);
location.add({
url: "/admin/org",
title: "Mis organizaciones",
@@ -33,9 +37,7 @@ export default function ClientProfile() {
location.add({
url: "/admin/org/profile/" + oid,
title: "Organizacion",
icon: (
<StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />
),
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />,
});
location.add({
@@ -43,6 +45,10 @@ export default function ClientProfile() {
title: "Perfil del cliente",
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
});
return () => {
bottomToolbar.clear();
};
}, []);
return (
@@ -59,79 +65,35 @@ export default function ClientProfile() {
<ButtonOption
text="Editar"
icon="/profile-icon.svg"
onClick={() =>
goTo(
"/admin/org/" +
oid +
"/client/" +
clientId +
"/edit"
)
}
onClick={() => goTo("/admin/org/" + oid + "/client/" + clientId + "/edit")}
/>
<ButtonOption
text="Turnos"
icon="/profile-icon.svg"
onClick={() =>
goTo(
"/admin/org/" +
oid +
"/client/" +
clientId +
"/appointments"
)
goTo("/admin/org/" + oid + "/client/" + clientId + "/appointments")
}
/>
<ButtonOption
text="Fusionar Duplicados"
icon="/client-merge-icon.svg"
onClick={() =>
goTo(
"/admin/org/" +
oid +
"/client/" +
clientId +
"/merge"
)
}
onClick={() => goTo("/admin/org/" + oid + "/client/" + clientId + "/merge")}
/>
<ButtonOption
text="Repeticiones"
icon="/repeat-icon.svg"
onClick={() =>
goTo(
"/admin/org/" +
oid +
"/client/" +
clientId +
"/repeats"
)
}
onClick={() => goTo("/admin/org/" + oid + "/client/" + clientId + "/repeats")}
/>
<ButtonOption
text="Cuenta"
icon="/account-icon.svg"
onClick={() =>
goTo(
"/admin/org/" +
oid +
"/client/" +
clientId +
"/account"
)
}
onClick={() => goTo("/admin/org/" + oid + "/client/" + clientId + "/account")}
/>
<ButtonOption
text="Notificaciones"
icon="/notifications-icon.svg"
onClick={() =>
goTo(
"/admin/org/" +
oid +
"/client/" +
clientId +
"/notifications"
)
goTo("/admin/org/" + oid + "/client/" + clientId + "/notifications")
}
/>
</div>
@@ -17,6 +17,7 @@ import { deleteRepeat, getRepeatsByClient } from "../Client.Service";
import { DeleteRepeatsParams, FindRepeatsParams, RepeatView } from "@core/Models/Repeats.model";
import style from "./repeats.module.css";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = {
oid: string;
@@ -31,6 +32,7 @@ export default function ClientCreate() {
const confirm = useConfirmStore();
const SessionInfo = useSessionStore();
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore();
const [repeats, setRepeats] = useState<RepeatView[]>([]);
@@ -52,6 +54,8 @@ export default function ClientCreate() {
useEffect(() => {
location.clear();
bottomToolbar.set(<></>);
location.add({
url: "/admin/org/profile/" + id + "/clients",
title: "Clientes",
@@ -69,6 +73,10 @@ export default function ClientCreate() {
title: "Repeticiones",
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
});
return () => {
bottomToolbar.clear();
};
}, []);
useEffect(() => {
@@ -162,7 +170,7 @@ export default function ClientCreate() {
() => {
handleDelete(repeat.id);
},
() => {}
() => {},
);
}}
>
@@ -27,6 +27,7 @@ import TextObjectFilter from "@components/TextObjectFilter/TextObjectFilter";
import SelectLocationMap, {
LocationCoordinates,
} from "@core/app/components/SelectLocationMap/SelectLocationMap";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = {
id: string;
@@ -36,6 +37,7 @@ export default function OrganizationEdit() {
const data = useParams<UrlData>();
const id = data.id;
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore();
const SessionInfo = useSessionStore();
const alert = useAlert();
@@ -140,7 +142,7 @@ export default function OrganizationEdit() {
});
const [localidadesFilterData, setLocalidadesFilterData] = useState<TextObjectFilterResult[]>(
[]
[],
);
const [selectedLocalidad, setSelectedLocalidad] = useState<string>("");
@@ -188,6 +190,8 @@ export default function OrganizationEdit() {
useEffect(() => {
location.clear();
bottomToolbar.set(<></>);
location.add({
url: "/admin/org",
title: "Organizaciones",
@@ -205,6 +209,10 @@ export default function OrganizationEdit() {
title: "Ubicación",
icon: <OrgConfigIcon sx={{ fontSize: "32px", mr: "10px" }} />,
});
return () => {
bottomToolbar.clear();
};
}, []);
useEffect(() => {
@@ -14,6 +14,7 @@ import Button from "@core/app/components/Button/Button";
import { useConfirmStore } from "@core/Store/Confirm.Store";
import { deleteOrganization } from "./advanced.Service";
import { useNavigation } from "@core/app/hooks/goto";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = {
id: string;
@@ -23,6 +24,7 @@ export default function OrganizationAdvancedEdit() {
const data = useParams<UrlData>();
const id = data.id;
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore();
const SessionInfo = useSessionStore();
const alert = useAlert();
@@ -33,6 +35,8 @@ export default function OrganizationAdvancedEdit() {
useEffect(() => {
location.clear();
bottomToolbar.set(<></>);
location.add({
url: "/admin/org",
title: "Organizaciones",
@@ -50,6 +54,10 @@ export default function OrganizationAdvancedEdit() {
title: "Avanzado",
icon: <OrgConfigIcon sx={{ fontSize: "32px", mr: "10px" }} />,
});
return () => {
bottomToolbar.clear();
};
}, []);
const eliminarHandler = () => {
@@ -73,7 +81,7 @@ export default function OrganizationAdvancedEdit() {
},
() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
}
},
);
} else {
alert.showError("La confirmación no es correcta");
@@ -23,6 +23,7 @@ import { useNavigation } from "@hooks/goto";
import Switch from "@mui/material/Switch";
import { ThemeProvider } from "@emotion/react";
import turnosXpressTheme from "@core/app/theme/turnosXpress";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
dayjs.locale("es");
@@ -40,6 +41,7 @@ export default function ClientCreate() {
const { goTo } = useNavigation();
const location = useLocationStore();
const eventHandler = useEventHandlerStore();
const bottomToolbar = useBottomToolbarStore();
const [validation, setValidation] = useState<boolean>(true);
const [notification, setNotification] = useState<boolean>(true);
@@ -148,6 +150,8 @@ export default function ClientCreate() {
useEffect(() => {
location.clear();
bottomToolbar.set(<></>);
location.add({
url: "/admin/org/profile/" + id,
title: "Origanizacion",
@@ -169,6 +173,10 @@ export default function ClientCreate() {
title: "Crear nuevo turno",
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
});
return () => {
bottomToolbar.clear();
};
}, []);
useEffect(() => {
@@ -31,7 +31,6 @@ import filterObjects from "@components/TextObjectFilter/Filter.Service";
import dayjs, { ManipulateType } from "dayjs";
import "dayjs/locale/es";
import Filterable from "@core/app/components/Filterable/Filterable";
import { useFilterStore } from "@core/Store/Filter.Store";
import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model";
import { FindEmployeesParams } from "@core/Models/Collaborators.model";
@@ -496,8 +495,7 @@ export default function OrganizationProfile() {
return (
<>
<Addable onClick={handleAdd}>
<Filterable />
<Location />
<Location showFilter />
<div className={style.appointmentsTitle}>
<h4>{dayjs(currentDate).format("dddd DD [de] MMMM, YYYY")}</h4>
</div>
@@ -614,7 +612,27 @@ export default function OrganizationProfile() {
</div>
<div
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 />
</div>
@@ -12,6 +12,7 @@ import { UserAssistanceInfo } from "@core/Models/User.model";
import Link from "next/link";
import { getAssistanceUserInfo } from "./Assistance.Service";
import AssistanceIcon from "@mui/icons-material/HeadsetMicOutlined";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = {
id: string;
@@ -23,6 +24,7 @@ export default function CollaboratorsAdd() {
const alert = useAlert();
const eventHandler = useEventHandlerStore();
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const SessionInfo = useSessionStore();
const [user, setUser] = useState<UserAssistanceInfo | null>(null);
const [findEmail, setFindEmail] = useState<string>("");
@@ -34,6 +36,8 @@ export default function CollaboratorsAdd() {
useEffect(() => {
location.clear();
bottomToolbar.set(<></>);
location.add({
url: "/admin/org/profile/" + id,
title: "Organizacion",
@@ -45,6 +49,10 @@ export default function CollaboratorsAdd() {
title: "Asistencia",
icon: <AssistanceIcon sx={{ fontSize: "32px", mr: "10px" }} />,
});
return () => {
bottomToolbar.clear();
};
}, []);
const findUser = async () => {
@@ -188,12 +188,6 @@
text-align: center;
}
.paginationWrapper {
display: flex;
justify-content: center;
margin-top: 18px;
}
.invoiceModalBackdrop {
align-items: flex-end;
background: rgba(15, 23, 42, 0.48);
@@ -21,7 +21,6 @@ import {
} from "@core/Models/CashFlow.model";
import { cashFlowPaginate, createInvoiceFromCashMovement, findInvoices, findOrganizationFiscalProfiles, Invoice, OrganizationFiscalProfile } from "../Cash.Service";
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 { TextObjectModalContent } from "@core/Store/TextObjectModal.Store";
import TextObjectFilterItem from "@core/app/components/TextObjectFilterItem/TextObjectFilterItem";
@@ -39,8 +38,9 @@ import {
CLIENT_ACCOUNT_MOVEMENT_TYPES,
CLIENT_ACCOUNT_PAYMENT_METHODS,
} from "@core/Models/ClientAccountMovements.model";
import PaginationControls from "@components/PaginationControls/PaginationControls";
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";
type UrlData = {
@@ -437,6 +437,7 @@ export default function CashFlowMovementsList() {
const eventHandler = useEventHandlerStore();
const alert = useAlert();
const filter = useFilterStore();
const bottomToolbar = useBottomToolbarStore();
const [clientFilterData, setClientFilterData] = useState<TextObjectFilterResult[]>([]);
const [clientId, setClientId] = useState<string>("");
@@ -600,6 +601,28 @@ export default function CashFlowMovementsList() {
});
}, [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 details = [
movement.paymentMethod ? getPaymentMethodName(movement.paymentMethod) : null,
@@ -862,8 +885,7 @@ export default function CashFlowMovementsList() {
return (
<>
<Location />
<Filterable />
<Location showFilter />
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
<>
@@ -948,21 +970,8 @@ export default function CashFlowMovementsList() {
);
})}
</section>
{movements && movements.pages > 1 && (
<div className={style.paginationWrapper}>
<PaginationControls
current={movements.page}
size={pageSize}
max={movements.pages}
onChange={loadMovements}
/>
</div>
)}
</div>
<div style={{ marginTop: "15px" }}></div>
{invoiceDraft && (
<div className={style.invoiceModalBackdrop} role="presentation">
<section
@@ -15,6 +15,7 @@ import { PaginateCashFlowParams, PaginateCashFlowResults } from "@core/Models/Ca
import { useSessionStore } from "@core/Store/Sesion.Store";
import { ArcaWsfeLastVoucherResult, cashFlowPaginate, getArcaWsfeLastVoucher } from "./Cash.Service";
import { useAlert } from "@core/Store/Alert.Store";
import { useBottomToolbarStore } from "@store/BottomToolbar.Store";
import { IconButton } from "@mui/material";
import RetirarIcon from "@mui/icons-material/CallMadeOutlined";
import DepositarIcon from "@mui/icons-material/SouthWestOutlined";
@@ -38,6 +39,7 @@ export default function OrganizationCashFlowDetail() {
const eventHandler = useEventHandlerStore();
const SessionInfo = useSessionStore();
const { goTo } = useNavigation();
const bottomToolbar = useBottomToolbarStore();
const [dateFrom, setDateFrom] = useState<Dayjs>(dayjs().startOf("day"));
const [dateTo, setDateTo] = useState<Dayjs>(dayjs().endOf("day"));
@@ -49,6 +51,11 @@ export default function OrganizationCashFlowDetail() {
useEffect(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
bottomToolbar.set(<></>);
return () => {
bottomToolbar.clear();
};
}, []);
useEffect(() => {
@@ -149,7 +149,9 @@ export default function OrganizationCashFlowResume() {
cashFlowCalculateBalance({
companyId: id,
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,
})
.then(() => {
@@ -12,6 +12,7 @@ import { useNavigation } from "@hooks/goto";
import TextPhone from "@core/app/components/TextPhone/TextPhone";
import MaterialAlert from "@mui/material/Alert";
import { createClient } from "../Clients.Service";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = {
id: string;
@@ -23,6 +24,7 @@ export default function ClientCreate() {
const alert = useAlert();
const SessionInfo = useSessionStore();
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
@@ -39,6 +41,8 @@ export default function ClientCreate() {
useEffect(() => {
location.clear();
bottomToolbar.set(<></>);
location.add({
url: "/admin/org/profile/" + id,
title: "Origanizacion",
@@ -56,6 +60,10 @@ export default function ClientCreate() {
title: "Crear",
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
});
return () => {
bottomToolbar.clear();
};
}, []);
useEffect(() => {
@@ -72,7 +80,7 @@ export default function ClientCreate() {
})
.then((newClient) => {
alert.showSuccess(
`El cliente ${newClient.firstName} ${newClient.lastName} ha sido creado`
`El cliente ${newClient.firstName} ${newClient.lastName} ha sido creado`,
);
setTimeout(() => {
goTo("/admin/org/profile/" + id + "/clients");
@@ -2,7 +2,7 @@
display: flex;
flex-direction: column;
position: absolute;
top: 60px; /* Below Location/Filterable */
top: 60px; /* Below Location */
left: 0;
right: 0;
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 {
grid-column: 1 / -1;
text-align: center;
@@ -10,6 +10,7 @@ import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined";
import PhoneOutlinedIcon from "@mui/icons-material/PhoneOutlined";
import Avatar from "@components/Avatar/Avatar";
import PaginationControls from "@components/PaginationControls/PaginationControls";
import { useBottomToolbarStore } from "@store/BottomToolbar.Store";
import pageStyle from "./page.module.css";
import { findClientsByOrganization } from "./Clients.Service";
import {
@@ -20,7 +21,6 @@ import {
} from "@core/Models/Clients.model";
import { useNavigation } from "@hooks/goto";
import { FilterData, useFilterStore } from "@core/Store/Filter.Store";
import Filterable from "@core/app/components/Filterable/Filterable";
import Textbox from "@core/app/components/Textbox/Textbox";
type UrlData = {
@@ -107,6 +107,7 @@ export default function ClientsGrid() {
const eventHandler = useEventHandlerStore();
const filter = useFilterStore();
const { goTo } = useNavigation();
const bottomToolbar = useBottomToolbarStore();
const [pageSize] = useState<number>(
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) => {
goTo("/admin/org/" + oid + "/client/" + client.id + "/profile");
};
return (
<>
<Filterable />
<Location />
<Location showFilter />
<div className={pageStyle.pageContainer}>
{clients?.data && clients.data.length > 0 ? (
<div className={pageStyle.clientsGrid}>
@@ -229,17 +251,6 @@ export default function ClientsGrid() {
No se encontraron clientes con los filtros aplicados.
</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>
</>
);
@@ -10,6 +10,7 @@ import { CompanyEmployeesView } from "@models/Collaborators.model";
import { loadCollaboratorsByCompanyId } from "./Collaborators.Service";
import { useAlert } from "@store/Alert.Store";
import CollaboratorItem from "@core/app/components/CollaboratorItem/CollaboratorItem";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = {
id: string;
@@ -19,6 +20,7 @@ export default function OrganizationProfile() {
const data = useParams<UrlData>();
const id = data.id;
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore();
const alert = useAlert();
const { goTo } = useNavigation();
@@ -31,6 +33,8 @@ export default function OrganizationProfile() {
useEffect(() => {
location.clear();
bottomToolbar.set(<></>);
location.add({
url: "/admin/org",
title: "Mis organizaciones",
@@ -60,6 +64,10 @@ export default function OrganizationProfile() {
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.NEED_ADD);
});
return () => {
bottomToolbar.clear();
};
}, []);
const handleCollaboratorClick = (collaboratorId: string) => {
@@ -29,13 +29,13 @@ import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import { useConfirmStore } from "@core/Store/Confirm.Store";
import { useAlert } from "@core/Store/Alert.Store";
import Textbox from "@components/Textbox/Textbox";
import Filterable from "@core/app/components/Filterable/Filterable";
import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model";
import { TextObjectModalContent } from "@core/Store/TextObjectModal.Store";
import TextObjectFilterItem from "@core/app/components/TextObjectFilterItem/TextObjectFilterItem";
import TextObjectFilter from "@core/app/components/TextObjectFilter/TextObjectFilter";
import { useFilterStore } from "@core/Store/Filter.Store";
import filterObjects from "@components/TextObjectFilter/Filter.Service";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = {
id: string;
@@ -113,6 +113,7 @@ export default function DiscountsEmailGrid() {
const data = useParams<UrlData>();
const oid = data.id;
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const SessionInfo = useSessionStore();
const eventHandler = useEventHandlerStore();
const alert = useAlert();
@@ -152,7 +153,9 @@ export default function DiscountsEmailGrid() {
render: (value: DiscountEmailRow) => (
<div style={{ paddingBottom: "10px" }}>
<div>
<div style={{ paddingLeft: "10px", paddingTop: "10px", fontWeight: "bold" }}>
<div
style={{ paddingLeft: "10px", paddingTop: "10px", fontWeight: "bold" }}
>
{value.discountName + " (" + value.discountCode + ")"}
</div>
<div style={{ paddingLeft: "10px", paddingTop: "0px", fontWeight: "bold" }}>
@@ -207,8 +210,10 @@ export default function DiscountsEmailGrid() {
const [pageSize] = useState<number>(
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 discountsRef = useRef(discounts);
@@ -220,6 +225,8 @@ export default function DiscountsEmailGrid() {
useEffect(() => {
location.clear();
bottomToolbar.set(<></>);
location.add({
url: "/admin/org",
title: "Mis organizaciones",
@@ -239,6 +246,10 @@ export default function DiscountsEmailGrid() {
});
loadDiscountsEmail(1, pageSize);
return () => {
bottomToolbar.clear();
};
}, []);
useEffect(() => {
@@ -268,7 +279,7 @@ export default function DiscountsEmailGrid() {
setDiscountId={setDiscountId}
filterEmail={filterEmail}
setFilterEmail={setFilterEmail}
/>
/>,
);
filter.setOnFilter(() => {
setSearchByFilters(true);
@@ -328,14 +339,13 @@ export default function DiscountsEmailGrid() {
alert.showError(err.format());
});
},
() => {}
() => {},
);
};
return (
<>
<Location />
<Filterable />
<Location showFilter />
<div style={{ display: "flex", justifyContent: "center", marginTop: "15px" }}>
<ThemeProvider theme={turnosXpressTheme}>
@@ -22,6 +22,7 @@ import turnosXpressTheme from "@core/app/theme/turnosXpress";
import { ButtonGroup } from "@mui/material";
import ButtonMaterial from "@mui/material/Button";
import { useNavigation } from "@core/app/hooks/goto";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = {
id: string;
@@ -33,6 +34,7 @@ export default function DiscountsGrid() {
const data = useParams<UrlData>();
const oid = data.id;
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore();
const { goTo } = useNavigation();
@@ -67,7 +69,9 @@ export default function DiscountsGrid() {
color: "var(--green-darkestX2)",
}}
>
{value.type === DiscountType.FIXED ? formatPrice(value.value) : value.value + "%"}
{value.type === DiscountType.FIXED
? formatPrice(value.value)
: value.value + "%"}
</div>
</div>
),
@@ -76,8 +80,10 @@ export default function DiscountsGrid() {
const [pageSize] = useState<number>(
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>();
@@ -88,6 +94,8 @@ export default function DiscountsGrid() {
useEffect(() => {
location.clear();
bottomToolbar.set(<></>);
location.add({
url: "/admin/org",
title: "Mis organizaciones",
@@ -107,6 +115,10 @@ export default function DiscountsGrid() {
});
loadDiscounts(1, pageSize);
return () => {
bottomToolbar.clear();
};
}, []);
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 filterObjects from "@core/app/components/TextObjectFilter/Filter.Service";
import { CompanyServiceView } from "@models/Service.model";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = {
id: string;
@@ -47,6 +48,7 @@ export default function OrganizationEdit() {
const data = useParams<UrlData>();
const id = data.id;
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore();
const SessionInfo = useSessionStore();
const alert = useAlert();
@@ -66,11 +68,12 @@ export default function OrganizationEdit() {
const [servicesShowPublicScores, setServicesShowPublicScores] = useState(true);
const [servicesShowPublicOpinions, setServicesShowPublicOpinions] = useState(true);
const [originalServicesShowPublicScores, setOriginalServicesShowPublicScores] = useState(true);
const [originalServicesShowPublicOpinions, setOriginalServicesShowPublicOpinions] = useState(true);
const [originalServicesShowPublicOpinions, setOriginalServicesShowPublicOpinions] =
useState(true);
const [services, setServices] = useState<CompanyServiceView[]>([]);
const [orgPublished, setOrgPublished] = useState<COMPANY_PUBLISHED_STATUS | undefined>(
undefined
undefined,
);
const [savePublishedStatusAvailable, setSavePublishedStatusAvailable] = useState(false);
@@ -199,6 +202,8 @@ export default function OrganizationEdit() {
useEffect(() => {
location.clear();
bottomToolbar.set(<></>);
location.add({
url: "/admin/org",
title: "Organizaciones",
@@ -218,6 +223,10 @@ export default function OrganizationEdit() {
});
loadCategories();
return () => {
bottomToolbar.clear();
};
}, []);
useEffect(() => {
@@ -241,25 +250,33 @@ export default function OrganizationEdit() {
setCategoryId(organization.categoryId.toString());
setOrgSlug(getSlug(organization.name));
setOrgPublished(organization.published);
setShowPublicScores(organization.showPublicScores !== undefined ? organization.showPublicScores : true);
setShowPublicOpinions(organization.showPublicOpinions !== undefined ? organization.showPublicOpinions : true);
setShowPublicScores(
organization.showPublicScores !== undefined
? organization.showPublicScores
: true,
);
setShowPublicOpinions(
organization.showPublicOpinions !== undefined
? organization.showPublicOpinions
: true,
);
setShowPublicProfessionals(
organization.showPublicProfessionals !== undefined
? organization.showPublicProfessionals
: true
: true,
);
setServices(loadedServices);
setServicesShowPublicScores(
loadedServices.every((service) => service.showPublicScores !== false)
loadedServices.every((service) => service.showPublicScores !== false),
);
setServicesShowPublicOpinions(
loadedServices.every((service) => service.showPublicOpinions !== false)
loadedServices.every((service) => service.showPublicOpinions !== false),
);
setOriginalServicesShowPublicScores(
loadedServices.every((service) => service.showPublicScores !== false)
loadedServices.every((service) => service.showPublicScores !== false),
);
setOriginalServicesShowPublicOpinions(
loadedServices.every((service) => service.showPublicOpinions !== false)
loadedServices.every((service) => service.showPublicOpinions !== false),
);
requestAnimationFrame(() => {
@@ -287,7 +304,7 @@ export default function OrganizationEdit() {
confirm.show(
validationPhoneResult.message,
() => {},
() => {}
() => {},
);
eventHandler.setEventType(EVENT_TYPES.SLEEP);
return;
@@ -307,7 +324,8 @@ export default function OrganizationEdit() {
phoneNumber: phone,
templateWapNotifId: templateWapNotifId === "" ? undefined : templateWapNotifId,
templateWapAltaId: templateWapAltaId === "" ? undefined : templateWapAltaId,
templateWapCancellationId: templateWapCancellationId === "" ? undefined : templateWapCancellationId,
templateWapCancellationId:
templateWapCancellationId === "" ? undefined : templateWapCancellationId,
...(canEditPublicVisibilityFlags
? {
showPublicScores,
@@ -321,31 +339,31 @@ export default function OrganizationEdit() {
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."),
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(
"Debe seleccionar una categoría para la organización."
"Debe seleccionar una categoría para la organización.",
),
phoneAreaCode: Yup.number()
.typeError("Código de área debe ser un número.")
.transform((value, originalValue) =>
originalValue.toString().trim() === "" ? null : value
originalValue.toString().trim() === "" ? null : value,
)
.nullable(),
phoneNumber: Yup.number()
.typeError("El teléfono debe ser un número.")
.transform((value, originalValue) =>
originalValue.toString().trim() === "" ? null : value
originalValue.toString().trim() === "" ? null : value,
)
.nullable(),
appointmentAlert: Yup.string().optional().default(""),
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()
.typeError("Fracción de tiempo debe ser un número.")
.transform((value, originalValue) =>
originalValue.toString().trim() === "" ? null : value
originalValue.toString().trim() === "" ? null : value,
)
.nullable()
.integer("Fracción de tiempo debe ser un número entero en minutos.")
@@ -354,7 +372,7 @@ export default function OrganizationEdit() {
appointmentTime: Yup.number()
.typeError("El Plazo Mínimo de Reserva debe ser un número.")
.transform((value, originalValue) =>
originalValue.toString().trim() === "" ? null : value
originalValue.toString().trim() === "" ? null : value,
)
.nullable()
.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()
.typeError("El Plazo Mínimo de Cancelación debe ser un número.")
.transform((value, originalValue) =>
originalValue.toString().trim() === "" ? null : value
originalValue.toString().trim() === "" ? null : value,
)
.nullable()
.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;
const changedServices =
canEditPublicVisibilityFlags &&
(servicesShowPublicScoresChanged || servicesShowPublicOpinionsChanged)
(servicesShowPublicScoresChanged ||
servicesShowPublicOpinionsChanged)
? services
: [];
@@ -414,8 +433,8 @@ export default function OrganizationEdit() {
: service.showPublicOpinions !== undefined
? service.showPublicOpinions
: true,
})
)
}),
),
).then(() => {
const updatedServices = services.map((service) => ({
...service,
@@ -434,7 +453,9 @@ export default function OrganizationEdit() {
});
})
.catch((error: ApiError | Error) => {
alert.showError(error instanceof ApiError ? error.format() : error.message);
alert.showError(
error instanceof ApiError ? error.format() : error.message,
);
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
@@ -453,7 +474,7 @@ export default function OrganizationEdit() {
id: category.id.toString(),
name: category.name,
description: category.description,
}))
})),
);
};
@@ -533,7 +554,7 @@ export default function OrganizationEdit() {
setOrgPublished(
checked
? 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" }}>
<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>
{!canEditPublicVisibilityFlags && (
<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>
)}
<div style={{ display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" }}>
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
<div
style={{ display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" }}
>
<label
style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}
>
<Checkbox
checked={showPublicScores}
disabled={!canEditPublicVisibilityFlags}
@@ -600,7 +629,9 @@ export default function OrganizationEdit() {
/>
Mostrar puntuaciones públicas
</label>
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
<label
style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}
>
<Checkbox
checked={showPublicOpinions}
disabled={!canEditPublicVisibilityFlags}
@@ -611,7 +642,9 @@ export default function OrganizationEdit() {
/>
Mostrar opiniones públicas
</label>
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
<label
style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}
>
<Checkbox
checked={showPublicProfessionals}
disabled={!canEditPublicVisibilityFlags}
@@ -632,7 +665,9 @@ export default function OrganizationEdit() {
{services.length > 0 && (
<>
<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>
<div
style={{
@@ -642,7 +677,14 @@ export default function OrganizationEdit() {
marginTop: "10px",
}}
>
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
<label
style={{
display: "flex",
alignItems: "center",
gap: "10px",
fontWeight: 600,
}}
>
<Checkbox
checked={servicesShowPublicScores}
disabled={!canEditPublicVisibilityFlags}
@@ -653,7 +695,14 @@ export default function OrganizationEdit() {
/>
Mostrar puntuaciones públicas de los servicios
</label>
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
<label
style={{
display: "flex",
alignItems: "center",
gap: "10px",
fontWeight: 600,
}}
>
<Checkbox
checked={servicesShowPublicOpinions}
disabled={!canEditPublicVisibilityFlags}
@@ -666,7 +715,8 @@ export default function OrganizationEdit() {
</label>
{!canEditPublicVisibilityFlags && (
<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>
)}
</div>
@@ -17,12 +17,14 @@ import ReceiptLongOutlinedIcon from "@mui/icons-material/ReceiptLongOutlined";
import { Grid2 } from "@mui/material";
import { ApiError } from "@models/Server.Error.model";
import style from "./page.module.css";
import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan";
import {
findArcaActivities,
findOrganizationFiscalProfiles,
OrganizationFiscalProfileTaxCondition,
upsertOrganizationFiscalProfile,
} from "./Fiscal.Service";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = {
id: string;
@@ -32,13 +34,15 @@ export default function OrganizationFiscalProfileEdit() {
const data = useParams<UrlData>();
const id = data.id;
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore();
const SessionInfo = useSessionStore();
const alert = useAlert();
const [cuit, setCuit] = useState("");
const [legalName, setLegalName] = useState("");
const [taxCondition, setTaxCondition] = useState<OrganizationFiscalProfileTaxCondition>("MONOTRIBUTO");
const [taxCondition, setTaxCondition] =
useState<OrganizationFiscalProfileTaxCondition>("MONOTRIBUTO");
const [pointOfSale, setPointOfSale] = useState("1");
const [activityCode, setActivityCode] = useState("");
const [activityDescription, setActivityDescription] = useState("");
@@ -79,6 +83,8 @@ export default function OrganizationFiscalProfileEdit() {
useEffect(() => {
location.clear();
bottomToolbar.clear();
location.add({
url: "/admin/org",
title: "Organizaciones",
@@ -96,6 +102,10 @@ export default function OrganizationFiscalProfileEdit() {
title: "Datos fiscales",
icon: <ReceiptLongOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />,
});
return () => {
bottomToolbar.clear();
};
}, []);
useEffect(() => {
@@ -117,7 +127,9 @@ export default function OrganizationFiscalProfileEdit() {
setLegalName(profile.legalName || "");
setTaxCondition(profile.taxCondition || "MONOTRIBUTO");
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 || "");
setActivityDescription(profile.activityDescription || profileActivity?.name || "");
})
@@ -151,7 +163,10 @@ export default function OrganizationFiscalProfileEdit() {
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.");
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
return;
@@ -159,7 +174,9 @@ export default function OrganizationFiscalProfileEdit() {
eventHandler.setEventType(EVENT_TYPES.LOADING);
const selectedActivity = arcaActivities.find((activity) => activity.id === activityCode.trim());
const selectedActivity = arcaActivities.find(
(activity) => activity.id === activityCode.trim(),
);
upsertOrganizationFiscalProfile({
companyId: id,
@@ -172,7 +189,9 @@ export default function OrganizationFiscalProfileEdit() {
sessionUser: SessionInfo.userId,
})
.then((profile) => {
const savedActivity = arcaActivities.find((activity) => activity.id === profile.activityCode);
const savedActivity = arcaActivities.find(
(activity) => activity.id === profile.activityCode,
);
setActivityCode(profile.activityCode || "");
setActivityDescription(profile.activityDescription || savedActivity?.name || "");
alert.showSuccess("Datos fiscales actualizados.");
@@ -188,123 +207,132 @@ export default function OrganizationFiscalProfileEdit() {
return (
<>
<Location />
<div className={style.pageContainer}>
<section className={style.heroCard}>
<div className={style.heroIcon}>
<ReceiptLongOutlinedIcon />
</div>
<div>
<h2>Configuración fiscal</h2>
<p>
Estos datos se usan para emitir Factura C desde los movimientos de caja y para precargar el asistente ARCA.
</p>
</div>
</section>
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
<div className={style.pageContainer}>
<section className={style.heroCard}>
<div className={style.heroIcon}>
<ReceiptLongOutlinedIcon />
</div>
<div>
<h2>Configuración fiscal</h2>
<p>
Estos datos se usan para emitir Factura C desde los movimientos de
caja y para precargar el asistente ARCA.
</p>
</div>
</section>
<section className={style.formSection}>
<h3>Datos del emisor</h3>
<Grid2 container spacing={2}>
<Grid2 size={{ xs: 12, sm: 6 }}>
<Textbox
name="cuit"
placeholder="CUIT"
type="text"
value={cuit}
width="100%"
onChange={(event) => {
setCuit(event.target.value.replace(/\D/g, ""));
markNeedsSave();
}}
/>
</Grid2>
<Grid2 size={{ xs: 12, sm: 6 }}>
<Textbox
name="legalName"
placeholder="Razón social"
type="text"
value={legalName}
width="100%"
onChange={(event) => {
setLegalName(event.target.value);
markNeedsSave();
}}
/>
</Grid2>
</Grid2>
</section>
<section className={style.formSection}>
<div className={style.sectionHeader}>
<h3>Condición fiscal</h3>
<p>Seleccioná la condición declarada para esta organización.</p>
</div>
<div className={style.optionGroup} role="radiogroup" aria-label="Condición fiscal">
{fiscalConditionOptions.map((option) => {
const selected = taxCondition === option.id;
return (
<button
key={option.id}
type="button"
className={`${style.optionCard} ${selected ? style.optionCardSelected : ""}`}
onClick={() => {
setTaxCondition(option.id);
<section className={style.formSection}>
<h3>Datos del emisor</h3>
<Grid2 container spacing={2}>
<Grid2 size={{ xs: 12, sm: 6 }}>
<Textbox
name="cuit"
placeholder="CUIT"
type="text"
value={cuit}
width="100%"
onChange={(event) => {
setCuit(event.target.value.replace(/\D/g, ""));
markNeedsSave();
}}
aria-pressed={selected}
>
<span className={style.optionTitle}>{option.title}</span>
<span className={style.optionDescription}>{option.description}</span>
</button>
);
})}
</div>
</section>
/>
</Grid2>
<Grid2 size={{ xs: 12, sm: 6 }}>
<Textbox
name="legalName"
placeholder="Razón social"
type="text"
value={legalName}
width="100%"
onChange={(event) => {
setLegalName(event.target.value);
markNeedsSave();
}}
/>
</Grid2>
</Grid2>
</section>
<section className={style.formSection}>
<h3>Facturación ARCA</h3>
<Grid2 container spacing={2}>
<Grid2 size={{ xs: 12, sm: 6 }}>
<Textbox
name="pointOfSale"
placeholder="Punto de venta"
type="text"
value={pointOfSale}
width="100%"
onChange={(event) => {
setPointOfSale(event.target.value.replace(/\D/g, ""));
markNeedsSave();
}}
/>
</Grid2>
</Grid2>
<section className={style.formSection}>
<div className={style.sectionHeader}>
<h3>Condición fiscal</h3>
<p>Seleccioná la condición declarada para esta organización.</p>
</div>
<div
className={style.optionGroup}
role="radiogroup"
aria-label="Condición fiscal"
>
{fiscalConditionOptions.map((option) => {
const selected = taxCondition === option.id;
{fiscalDataLoaded && (
<TextObjectFilter
key={`${activityCode}-${arcaActivities.length}`}
placeholder="Actividad ARCA/CLAE"
content={activityFilterContent}
data={arcaActivities}
selectedId={activityCode}
onChange={(selectedActivityCode) => {
const selectedActivity = arcaActivities.find(
(activity) => activity.id === selectedActivityCode
return (
<button
key={option.id}
type="button"
className={`${style.optionCard} ${selected ? style.optionCardSelected : ""}`}
onClick={() => {
setTaxCondition(option.id);
markNeedsSave();
}}
aria-pressed={selected}
>
<span className={style.optionTitle}>{option.title}</span>
<span className={style.optionDescription}>
{option.description}
</span>
</button>
);
})}
</div>
</section>
setActivityCode(selectedActivity?.id || "");
setActivityDescription(selectedActivity?.name || "");
markNeedsSave();
}}
/>
)}
<section className={style.formSection}>
<h3>Facturación ARCA</h3>
<Grid2 container spacing={2}>
<Grid2 size={{ xs: 12, sm: 6 }}>
<Textbox
name="pointOfSale"
placeholder="Punto de venta"
type="text"
value={pointOfSale}
width="100%"
onChange={(event) => {
setPointOfSale(event.target.value.replace(/\D/g, ""));
markNeedsSave();
}}
/>
</Grid2>
</Grid2>
<p className={style.helpText}>
{activityCode && activityDescription
? `${activityCode} - ${activityDescription}`
: "Seleccioná la actividad principal declarada en ARCA."}
</p>
</section>
</div>
{fiscalDataLoaded && (
<TextObjectFilter
key={`${activityCode}-${arcaActivities.length}`}
placeholder="Actividad ARCA/CLAE"
content={activityFilterContent}
data={arcaActivities}
selectedId={activityCode}
onChange={(selectedActivityCode) => {
const selectedActivity = arcaActivities.find(
(activity) => activity.id === selectedActivityCode,
);
setActivityCode(selectedActivity?.id || "");
setActivityDescription(selectedActivity?.name || "");
markNeedsSave();
}}
/>
)}
<p className={style.helpText}>
{activityCode && activityDescription
? `${activityCode} - ${activityDescription}`
: "Seleccioná la actividad principal declarada en ARCA."}
</p>
</section>
</div>
</SecureByPlan>
</>
);
}
@@ -13,6 +13,7 @@ import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined";
import NotificationsIcon from "@mui/icons-material/NotificationsOutlined";
import MaterialAlert from "@mui/material/Alert";
import dayjs from "dayjs";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = {
id: string;
@@ -21,7 +22,7 @@ type UrlData = {
const CHANNELS: { key: NotificationChannel; label: string }[] = [
{ key: "whatsapp", label: "WhatsApp" },
{ key: "email", label: "Email" },
{ key: "system", label: "Sistema" }
{ key: "system", label: "Sistema" },
];
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: 720, label: "12 horas 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() {
const data = useParams<UrlData>();
const id = data.id;
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const store = useNotificationPreferencesStore();
const eventHandler = useEventHandlerStore();
useEffect(() => {
location.clear();
bottomToolbar.set(<></>);
location.add({
url: "/admin/org",
title: "Organizaciones",
icon: null
icon: null,
});
location.add({
url: "/admin/org/profile/" + id,
title: "Perfil",
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />,
});
location.add({
url: "/admin/org/profile/" + id + "/notifications",
title: "Notificaciones",
icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} />
icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
});
return () => {
bottomToolbar.clear();
};
}, [id]);
const toggleChannel = (channel: NotificationChannel) => {
@@ -73,18 +81,22 @@ export default function NotificationsPage() {
const updateQuietHours = (field: "from" | "to", value: string) => {
const current = store.formData.quietHours || { from: "00:00", to: "00:00" };
store.updateFormData({
quietHours: { ...current, [field]: value }
quietHours: { ...current, [field]: value },
});
};
const quietHourValue = (value: string | undefined, fallback: string) => {
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 = () => {
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 });
} else {
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" }}>
{/* Channel Toggles Section */}
<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>
<div style={{ display: "flex", flexDirection: "column", gap: "8px", marginBottom: "30px" }}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "8px",
marginBottom: "30px",
}}
>
{CHANNELS.map((channel) => (
<div
key={channel.key}
@@ -121,10 +144,12 @@ export default function NotificationsPage() {
padding: "12px 16px",
borderRadius: "4px",
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
checked={store.formData.defaultChannels.includes(channel.key)}
onChange={() => toggleChannel(channel.key)}
@@ -135,7 +160,8 @@ export default function NotificationsPage() {
{/* Quiet Hours Section */}
<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>
<div
@@ -147,7 +173,7 @@ export default function NotificationsPage() {
borderRadius: "8px",
border: "1px solid var(--border-color)",
backgroundColor: "var(--bg-primary)",
marginBottom: "16px"
marginBottom: "16px",
}}
>
<span style={{ color: "var(--text-primary)", fontWeight: "500" }}>
@@ -160,13 +186,22 @@ export default function NotificationsPage() {
</div>
{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 }}>
<TextTime
placeholder="Desde"
width="100%"
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 style={{ flex: 1 }}>
@@ -182,12 +217,15 @@ export default function NotificationsPage() {
{/* Reminder Rules Section */}
<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>
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px" }}>
{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 (
<button
key={preset.minutes}
@@ -198,11 +236,15 @@ export default function NotificationsPage() {
padding: "10px 16px",
borderRadius: "999px",
border: `1px solid ${isActive ? "var(--primary-color)" : "var(--border-color)"}`,
backgroundColor: isActive ? "rgba(25, 118, 210, 0.12)" : "var(--bg-primary)",
color: isActive ? "var(--primary-color)" : "var(--text-secondary)",
backgroundColor: isActive
? "rgba(25, 118, 210, 0.12)"
: "var(--bg-primary)",
color: isActive
? "var(--primary-color)"
: "var(--text-secondary)",
fontWeight: isActive ? "600" : "500",
fontSize: "14px",
transition: "all 0.2s ease"
transition: "all 0.2s ease",
}}
>
{preset.label}
@@ -248,7 +248,7 @@ export default function OrganizationNotPayedAppointments() {
const [dialogPayVisible, setDialogPayVisible] = useState<boolean>(false);
const [dialogPayOneVisible, setDialogPayOneVisible] = useState<boolean>(false);
const [createPaymentData, setCreatePaymentData] = useState<PaginatePaymentsView | undefined>(
undefined
undefined,
);
const [paymentMethodFilterContent] = useState<TextObjectModalContent>({
@@ -284,7 +284,7 @@ export default function OrganizationNotPayedAppointments() {
TextObjectFilterResult[]
>([]);
const [paymentMethodId, setPaymentMethodId] = useState<CLIENT_ACCOUNT_PAYMENT_METHODS>(
CLIENT_ACCOUNT_PAYMENT_METHODS.CASH
CLIENT_ACCOUNT_PAYMENT_METHODS.CASH,
);
useEffect(() => {
@@ -326,7 +326,7 @@ export default function OrganizationNotPayedAppointments() {
TextObjectFilterResult[]
>([]);
const [paymentProcessDateId, setPaymentProcessDateId] = useState<PAYMENT_PROCCESS_DATE>(
PAYMENT_PROCCESS_DATE.NOW
PAYMENT_PROCCESS_DATE.NOW,
);
useEffect(() => {
@@ -356,7 +356,7 @@ export default function OrganizationNotPayedAppointments() {
id: category.id.toString(),
name: category.name,
description: category.description,
}))
})),
);
};
@@ -370,7 +370,7 @@ export default function OrganizationNotPayedAppointments() {
const [clientIds, setClientIds] = useState<string[]>([]);
const [collaboratorFilterData, setCollaboratorFilterData] = useState<TextObjectFilterResult[]>(
[]
[],
);
const [collaboratorId, setCollaboratorId] = useState<string>("");
@@ -484,8 +484,8 @@ export default function OrganizationNotPayedAppointments() {
parseInt(
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
: "10"
)
: "10",
),
);
const [payments, setPayments] = useState<PaginateNotPayedAppointmentsResult>();
const paymentsRef = useRef(payments);
@@ -508,7 +508,7 @@ export default function OrganizationNotPayedAppointments() {
};
window.addEventListener("scroll", handleScroll, { capture: true, passive: true });
return () => {
window.removeEventListener("scroll", handleScroll, { capture: true });
};
@@ -595,7 +595,6 @@ export default function OrganizationNotPayedAppointments() {
});
};
const generarReporte = async () => {
eventHandler.setEventType(EVENT_TYPES.LOADING);
try {
@@ -611,11 +610,11 @@ export default function OrganizationNotPayedAppointments() {
if (methodId) filterData.method = methodId as any;
const report = await GetNotPayedReport(filterData);
const pdfMake = await import("pdfmake/build/pdfmake");
const pdfFonts = (await import("pdfmake/build/vfs_fonts")) as any;
pdfMake.vfs = pdfFonts.pdfMake ? pdfFonts.pdfMake.vfs : pdfFonts.vfs;
let logoDataUrl = "";
try {
const response = await fetch("/favicon512.png");
@@ -625,27 +624,27 @@ export default function OrganizationNotPayedAppointments() {
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
} catch(e) {}
} catch (e) {}
const tableBody: any[] = [
[
{ text: "Cliente", 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([
item.clientName || "-",
{
{
text: [
{ 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([
{ 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 = {
content: [
{
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: [
{ 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: "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: {
headerRows: 1,
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) => {
(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 {
pdfMake.createPdf(docDefinition as any).download(`reporte-adeudados.pdf`);
}
} 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 {
eventHandler.setEventType(EVENT_TYPES.CLEAR);
}
@@ -832,14 +860,24 @@ export default function OrganizationNotPayedAppointments() {
setMethodId={setMethodId}
clientData={clientFilterData}
setClientIds={setClientIds}
/>
/>,
);
filter.setOnFilter(() => { });
filter.setOnFilter(() => {});
return () => {
bottomToolbar.clear();
};
}, [paymentsTotal, collaboratorFilterData, serviceFilterData, methodFilterData, clientFilterData, clientIds, collaboratorId, serviceId, methodId]);
}, [
paymentsTotal,
collaboratorFilterData,
serviceFilterData,
methodFilterData,
clientFilterData,
clientIds,
collaboratorId,
serviceId,
methodId,
]);
useEffect(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
@@ -881,72 +919,116 @@ export default function OrganizationNotPayedAppointments() {
loadNotPayedAppointmentsTotal();
}, [clientIds, collaboratorId, serviceId, methodId, dateFrom, dateTo, reloadPayments]);
return (
<>
<Location />
<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 */}
<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 */}
<div style={{ display: "flex", justifyContent: "center", marginBottom: "15px" }}>
<ThemeProvider theme={turnosXpressTheme}>
<div style={{
display: "flex",
background: "rgba(255,255,255,0.9)",
backdropFilter: "blur(10px)",
borderRadius: "30px",
padding: "5px",
boxShadow: "0 4px 15px rgba(0,0,0,0.05)"
}}>
<ButtonMaterial
variant="text"
onClick={() => goTo(`/admin/org/profile/${id}/payments`)}
style={{ borderRadius: "25px", color: "#666", fontWeight: "bold", padding: "8px 25px" }}
<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 */}
<div
style={{
display: "flex",
justifyContent: "center",
marginBottom: "15px",
}}
>
<ThemeProvider theme={turnosXpressTheme}>
<div
style={{
display: "flex",
background: "rgba(255,255,255,0.9)",
backdropFilter: "blur(10px)",
borderRadius: "30px",
padding: "5px",
boxShadow: "0 4px 15px rgba(0,0,0,0.05)",
}}
>
Pagados
</ButtonMaterial>
<ButtonMaterial
variant="contained"
style={{ borderRadius: "25px", boxShadow: "0 2px 8px rgba(0,0,0,0.1)", fontWeight: "bold", padding: "8px 25px" }}
>
Adeudados
</ButtonMaterial>
</div>
</ThemeProvider>
</div>
<ButtonMaterial
variant="text"
onClick={() => goTo(`/admin/org/profile/${id}/payments`)}
style={{
borderRadius: "25px",
color: "#666",
fontWeight: "bold",
padding: "8px 25px",
}}
>
Pagados
</ButtonMaterial>
<ButtonMaterial
variant="contained"
style={{
borderRadius: "25px",
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
fontWeight: "bold",
padding: "8px 25px",
}}
>
Adeudados
</ButtonMaterial>
</div>
</ThemeProvider>
</div>
<motion.div
layout
initial={{ opacity: 0, y: -20 }}
animate={{
opacity: 1,
animate={{
opacity: 1,
y: 0,
padding: isMinimized ? "25px 50px" : "40px 20px",
borderRadius: isMinimized ? "15px" : "20px"
borderRadius: isMinimized ? "15px" : "20px",
}}
style={{
background: "linear-gradient(135deg, #FF416C 0%, #FF4B2B 100%)", // Red gradient for debts
color: "white",
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",
display: "flex",
flexDirection: isMinimized ? "row" : "column",
alignItems: "center",
justifyContent: isMinimized ? "space-between" : "center",
transition: "box-shadow 0.3s ease"
transition: "box-shadow 0.3s ease",
}}
>
<IconButton
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"
title={forceMinimized ? "Maximizar" : "Minimizar"}
>
@@ -954,51 +1036,95 @@ export default function OrganizationNotPayedAppointments() {
</IconButton>
<IconButton
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"
title="Filtrar"
>
<FilterAltOutlinedIcon />
</IconButton>
<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
animate={{
fontSize: isMinimized ? "14px" : "16px",
marginBottom: isMinimized ? "0px" : "10px"
<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
animate={{
fontSize: isMinimized ? "14px" : "16px",
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
</motion.div>
<motion.div
animate={{
<motion.div
animate={{
fontSize: isMinimized ? "24px" : "56px",
margin: isMinimized ? "0px" : "10px 0"
margin: isMinimized ? "0px" : "10px 0",
}}
style={{ fontWeight: "900" }}
>
{formatPrice(paymentsTotal)}
</motion.div>
{!isMinimized && (
<motion.div
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{ marginTop: "15px" }}
>
<div style={{ marginBottom: "15px", width: "250px", maxWidth: "100%" }}>
<div
style={{
marginBottom: "15px",
width: "250px",
maxWidth: "100%",
}}
>
<TextRangeDateModern
label="Período"
dateFrom={dateFrom}
dateTo={dateTo}
setDateFrom={setDateFrom}
setDateTo={setDateTo}
/>
label="Período"
dateFrom={dateFrom}
dateTo={dateTo}
setDateFrom={setDateFrom}
setDateTo={setDateTo}
/>
</div>
<div style={{ display: "flex", gap: "10px", alignItems: "center", flexWrap: "wrap" }}>
<div
style={{
display: "flex",
gap: "10px",
alignItems: "center",
flexWrap: "wrap",
}}
>
<ButtonMaterial
variant="contained"
startIcon={<PayIcon />}
@@ -1009,7 +1135,7 @@ export default function OrganizationNotPayedAppointments() {
fontWeight: "bold",
borderRadius: "20px",
padding: "8px 20px",
textTransform: "none"
textTransform: "none",
}}
>
Pagar Todos
@@ -1023,10 +1149,13 @@ export default function OrganizationNotPayedAppointments() {
fontWeight: "bold",
borderRadius: "20px",
padding: "8px 20px",
textTransform: "none"
textTransform: "none",
}}
>
<PictureAsPdfIcon style={{ fontSize: "18px", marginRight: "5px" }} /> PDF
<PictureAsPdfIcon
style={{ fontSize: "18px", marginRight: "5px" }}
/>{" "}
PDF
</ButtonMaterial>
</div>
</motion.div>
@@ -1035,10 +1164,22 @@ export default function OrganizationNotPayedAppointments() {
</div>
{/* 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 */}
<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>
{payments?.data.map((payment, index) => (
<motion.div
@@ -1054,35 +1195,76 @@ export default function OrganizationNotPayedAppointments() {
alignItems: "center",
justifyContent: "space-between",
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 style={{
background: "rgba(255, 75, 43, 0.1)",
padding: "10px",
borderRadius: "12px",
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--red)"
}}>
gap: "15px",
}}
>
<div
style={{
background: "rgba(255, 75, 43, 0.1)",
padding: "10px",
borderRadius: "12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--red)",
}}
>
<CreditCardIcon />
</div>
<div>
<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={{
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" }}>
{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 style={{ display: "flex", alignItems: "center", gap: "15px" }}>
<div style={{ fontWeight: "900", fontSize: "16px", color: "var(--red)" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "15px",
}}
>
<div
style={{
fontWeight: "900",
fontSize: "16px",
color: "var(--red)",
}}
>
{formatPrice(payment.price)}
</div>
<IconButton
color="success"
<IconButton
color="success"
onClick={() => {
setCreatePaymentData(payment);
setDialogPayOneVisible(true);
@@ -1094,12 +1276,24 @@ export default function OrganizationNotPayedAppointments() {
</div>
</motion.div>
))}
{(!payments?.data || payments.data.length === 0) && (
<div style={{ textAlign: "center", padding: "50px", color: "#aaa" }}>
<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
style={{ textAlign: "center", padding: "50px", color: "#aaa" }}
>
<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>
)}
</AnimatePresence>
@@ -1107,7 +1301,9 @@ export default function OrganizationNotPayedAppointments() {
{/* Pagination */}
{payments && payments.pages > 1 && (
<div style={{ display: "flex", justifyContent: "center", marginTop: "40px" }}>
<div
style={{ display: "flex", justifyContent: "center", marginTop: "40px" }}
>
<Pagination
count={payments.pages}
page={payments.page}
@@ -1134,7 +1330,7 @@ export default function OrganizationNotPayedAppointments() {
data={paymentMethhodFilterData}
onChange={(id) => {
setPaymentMethodId(
id as CLIENT_ACCOUNT_PAYMENT_METHODS
id as CLIENT_ACCOUNT_PAYMENT_METHODS,
);
}}
/>
@@ -1185,7 +1381,7 @@ export default function OrganizationNotPayedAppointments() {
data={paymentMethhodFilterData}
onChange={(id) => {
setPaymentMethodId(
id as CLIENT_ACCOUNT_PAYMENT_METHODS
id as CLIENT_ACCOUNT_PAYMENT_METHODS,
);
}}
/>
@@ -1219,7 +1415,6 @@ export default function OrganizationNotPayedAppointments() {
</DialogActions>
</>
</DialogWindow>
</div>
</SecureByPlan>
</>
@@ -47,6 +47,7 @@ import SearchOffIcon from "@mui/icons-material/SearchOff";
import UnfoldLessIcon from "@mui/icons-material/UnfoldLess";
import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore";
import FilterAltOutlinedIcon from "@mui/icons-material/FilterAltOutlined";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = {
id: string;
@@ -68,8 +69,12 @@ const FilterContent = (props: FilterContentProps) => {
const [clientFilterContent] = useState<TextObjectModalContent>({
header: (
<div>
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>Seleccionar Cliente:</div>
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>Ingrese el nombre del cliente para buscarlo.</p>
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
Seleccionar Cliente:
</div>
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>
Ingrese el nombre del cliente para buscarlo.
</p>
</div>
),
bottom: <div>&nbsp;</div>,
@@ -81,8 +86,12 @@ const FilterContent = (props: FilterContentProps) => {
const [collaboratorFilterContent] = useState<TextObjectModalContent>({
header: (
<div>
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>Seleccionar Colaborador:</div>
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>Ingrese el nombre del colaborador para buscarlo.</p>
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
Seleccionar Colaborador:
</div>
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>
Ingrese el nombre del colaborador para buscarlo.
</p>
</div>
),
bottom: <div>&nbsp;</div>,
@@ -94,8 +103,12 @@ const FilterContent = (props: FilterContentProps) => {
const [serviceFilterContent] = useState<TextObjectModalContent>({
header: (
<div>
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>Seleccionar Servicio:</div>
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>Ingrese el nombre del servicio para buscarlo.</p>
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
Seleccionar Servicio:
</div>
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>
Ingrese el nombre del servicio para buscarlo.
</p>
</div>
),
bottom: <div>&nbsp;</div>,
@@ -107,8 +120,12 @@ const FilterContent = (props: FilterContentProps) => {
const [methodFilterContent] = useState<TextObjectModalContent>({
header: (
<div>
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>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 style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
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>
),
bottom: <div>&nbsp;</div>,
@@ -152,6 +169,7 @@ export default function OrganizationEdit() {
const data = useParams<UrlData>();
const id = data.id;
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore();
const SessionInfo = useSessionStore();
const filter = useFilterStore();
@@ -168,7 +186,9 @@ export default function OrganizationEdit() {
const [clientFilterData, setClientFilterData] = useState<TextObjectFilterResult[]>([]);
const [clientIds, setClientIds] = useState<string[]>([]);
const [collaboratorFilterData, setCollaboratorFilterData] = useState<TextObjectFilterResult[]>([]);
const [collaboratorFilterData, setCollaboratorFilterData] = useState<TextObjectFilterResult[]>(
[],
);
const [collaboratorId, setCollaboratorId] = useState<string>("");
const [serviceFilterData, setServiceFilterData] = useState<TextObjectFilterResult[]>([]);
@@ -188,7 +208,6 @@ export default function OrganizationEdit() {
const generarReporte = async () => {
try {
const pdfMake = await import("pdfmake/build/pdfmake");
const pdfFonts = (await import("pdfmake/build/vfs_fonts")) as any;
pdfMake.vfs = pdfFonts.pdfMake ? pdfFonts.pdfMake.vfs : pdfFonts.vfs;
@@ -201,7 +220,7 @@ export default function OrganizationEdit() {
serviceId: serviceId ? serviceId : undefined,
employeeId: collaboratorId ? collaboratorId : undefined,
method: methodId ? (methodId as any) : undefined,
sessionUser: SessionInfo.userId
sessionUser: SessionInfo.userId,
});
if (!report.items || report.items.length === 0) {
@@ -218,86 +237,114 @@ export default function OrganizationEdit() {
reader.onloadend = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
} catch(e) {}
} catch (e) {}
const tableBody: any[] = [
[
{ text: "Cliente", 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([
item.clientName || "-",
{
{
text: [
{ 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([
{ 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 = {
content: [
{
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: [
{ 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: "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: {
headerRows: 1,
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) => {
(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 {
pdfMake.createPdf(docDefinition as any).download(`reporte-procesados.pdf`);
}
} catch (error: unknown) {
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(() => {
const handleScroll = (e: Event) => {
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement;
// Check if it's a DOM element scrolling
if (target && target.scrollTop !== undefined) {
@@ -313,7 +360,7 @@ export default function OrganizationEdit() {
// Use capture: true to catch all scroll events in the app regardless of where they happen
window.addEventListener("scroll", handleScroll, { capture: true, passive: true });
return () => {
window.removeEventListener("scroll", handleScroll, { capture: true });
};
@@ -389,15 +436,23 @@ export default function OrganizationEdit() {
useEffect(() => {
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 };
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 };
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(() => {
@@ -413,18 +468,31 @@ export default function OrganizationEdit() {
setMethodId={setMethodId}
clientData={clientFilterData}
setClientIds={setClientIds}
clientIds={clientIds}
/>
clientIds={clientIds}
/>,
);
filter.setOnFilter(() => { });
filter.setOnFilter(() => {});
}, [collaboratorFilterData, serviceFilterData, methodFilterData, clientFilterData]);
useEffect(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
location.clear();
bottomToolbar.set(<></>);
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({ url: "/admin/org/profile/" + id, title: "Pagos", icon: <PaymentsIcon sx={{ fontSize: "32px", mr: "10px" }} /> });
location.add({
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(() => {
@@ -444,68 +512,116 @@ export default function OrganizationEdit() {
<Location />
<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 */}
<div ref={sentinelRef} style={{ height: "1px", width: "100%" }}></div>
{/* 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" }}>
{/* Tabs Selector */}
<div style={{ display: "flex", justifyContent: "center", marginBottom: "15px" }}>
<ThemeProvider theme={turnosXpressTheme}>
<div style={{
display: "flex",
background: "rgba(255,255,255,0.9)",
backdropFilter: "blur(10px)",
borderRadius: "30px",
padding: "5px",
boxShadow: "0 4px 15px rgba(0,0,0,0.05)"
}}>
<ButtonMaterial
variant="contained"
style={{ borderRadius: "25px", boxShadow: "0 2px 8px rgba(0,0,0,0.1)", fontWeight: "bold", padding: "8px 25px" }}
<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 */}
<div
style={{
display: "flex",
justifyContent: "center",
marginBottom: "15px",
}}
>
<ThemeProvider theme={turnosXpressTheme}>
<div
style={{
display: "flex",
background: "rgba(255,255,255,0.9)",
backdropFilter: "blur(10px)",
borderRadius: "30px",
padding: "5px",
boxShadow: "0 4px 15px rgba(0,0,0,0.05)",
}}
>
Pagados
</ButtonMaterial>
<ButtonMaterial
variant="text"
onClick={() => goTo(`/admin/org/profile/${id}/payments/not-payed`)}
style={{ borderRadius: "25px", color: "#666", fontWeight: "bold", padding: "8px 25px" }}
>
Adeudados
</ButtonMaterial>
</div>
</ThemeProvider>
</div>
<ButtonMaterial
variant="contained"
style={{
borderRadius: "25px",
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
fontWeight: "bold",
padding: "8px 25px",
}}
>
Pagados
</ButtonMaterial>
<ButtonMaterial
variant="text"
onClick={() =>
goTo(`/admin/org/profile/${id}/payments/not-payed`)
}
style={{
borderRadius: "25px",
color: "#666",
fontWeight: "bold",
padding: "8px 25px",
}}
>
Adeudados
</ButtonMaterial>
</div>
</ThemeProvider>
</div>
<motion.div
layout
initial={{ opacity: 0, y: -20 }}
animate={{
opacity: 1,
animate={{
opacity: 1,
y: 0,
padding: isMinimized ? "25px 50px" : "40px 20px",
borderRadius: isMinimized ? "15px" : "20px"
borderRadius: isMinimized ? "15px" : "20px",
}}
style={{
background: "linear-gradient(135deg, #4b6cb7 0%, #182848 100%)",
color: "white",
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",
display: "flex",
flexDirection: isMinimized ? "row" : "column",
alignItems: "center",
justifyContent: isMinimized ? "space-between" : "center",
transition: "box-shadow 0.3s ease"
transition: "box-shadow 0.3s ease",
}}
>
<IconButton
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"
title={forceMinimized ? "Maximizar" : "Minimizar"}
>
@@ -513,41 +629,81 @@ export default function OrganizationEdit() {
</IconButton>
<IconButton
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"
title="Filtrar"
>
<FilterAltOutlinedIcon />
</IconButton>
<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
animate={{
fontSize: isMinimized ? "14px" : "16px",
marginBottom: isMinimized ? "0px" : "10px"
<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
animate={{
fontSize: isMinimized ? "14px" : "16px",
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
</motion.div>
<motion.div
animate={{
<motion.div
animate={{
fontSize: isMinimized ? "24px" : "56px",
margin: isMinimized ? "0px" : "10px 0"
margin: isMinimized ? "0px" : "10px 0",
}}
style={{ fontWeight: "900" }}
>
{formatPrice(paymentsTotal)}
</motion.div>
{!isMinimized && (
<motion.div
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
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%" }}>
<TextRangeDateModern
label="Período"
@@ -567,10 +723,13 @@ export default function OrganizationEdit() {
borderRadius: "20px",
padding: "8px 20px",
textTransform: "none",
height: "56px"
height: "56px",
}}
>
<PictureAsPdfIcon style={{ fontSize: "18px", marginRight: "5px" }} /> PDF
<PictureAsPdfIcon
style={{ fontSize: "18px", marginRight: "5px" }}
/>{" "}
PDF
</ButtonMaterial>
</div>
</motion.div>
@@ -579,10 +738,22 @@ export default function OrganizationEdit() {
</div>
{/* 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 */}
<div style={{ display: "flex", flexDirection: "column", gap: "12px", paddingTop: "15px" }}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "12px",
paddingTop: "15px",
}}
>
{dataList.length > 0 ? (
<AnimatePresence>
{dataList.map((payment, index) => (
@@ -602,43 +773,85 @@ export default function OrganizationEdit() {
padding: "16px 20px",
boxShadow: "0 4px 12px rgba(0,0,0,0.05)",
borderLeft: "5px solid #28a745",
position: "relative"
position: "relative",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "15px", flex: 1 }}>
<div style={{
width: "50px",
height: "50px",
borderRadius: "12px",
backgroundColor: "rgba(40, 167, 69, 0.1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#28a745"
}}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "15px",
flex: 1,
}}
>
<div
style={{
width: "50px",
height: "50px",
borderRadius: "12px",
backgroundColor: "rgba(40, 167, 69, 0.1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#28a745",
}}
>
{getPaymentIcon(payment.method)}
</div>
<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}
</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}
</div>
<div style={{ display: "flex", gap: "10px", fontSize: "12px", color: "#888" }}>
<span>{dayjs(payment.createdAt).format("DD MMMM YYYY • HH:mm")}</span>
<div
style={{
display: "flex",
gap: "10px",
fontSize: "12px",
color: "#888",
}}
>
<span>
{dayjs(payment.createdAt).format(
"DD MMMM YYYY • HH:mm",
)}
</span>
<span>•</span>
<span>{payment.employeeName}</span>
</div>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "15px" }}>
<div style={{
fontSize: "20px",
fontWeight: "900",
color: "#28a745",
textAlign: "right"
}}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "15px",
}}
>
<div
style={{
fontSize: "20px",
fontWeight: "900",
color: "#28a745",
textAlign: "right",
}}
>
+{formatPrice(payment.price)}
</div>
<IconButton
@@ -646,14 +859,18 @@ export default function OrganizationEdit() {
size="small"
onClick={() => {
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),
() => { }
() => {},
);
}}
sx={{
sx={{
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" />
@@ -663,23 +880,35 @@ export default function OrganizationEdit() {
))}
</AnimatePresence>
) : (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
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>
<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>
)}
</div>
{payments && payments.pages > 1 && (
<div style={{ display: "flex", justifyContent: "center", marginTop: "40px" }}>
<Pagination
count={payments.pages}
page={payments.page}
<div
style={{ display: "flex", justifyContent: "center", marginTop: "40px" }}
>
<Pagination
count={payments.pages}
page={payments.page}
onChange={(e, value) => loadPayments(value, pageSize)}
color="primary"
/>
@@ -17,6 +17,7 @@ import Avatar from "@components/Avatar/Avatar";
import Button from "@core/app/components/Button/Button";
import { useConfirmStore } from "@core/Store/Confirm.Store";
import { getMondayWeekStart } from "@core/helpers/week";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
dayjs.locale("es");
@@ -28,6 +29,7 @@ export default function Schedules() {
const data = useParams<UrlData>();
const id = data.id;
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore();
const SessionInfo = useSessionStore();
const alert = useAlert();
@@ -38,6 +40,8 @@ export default function Schedules() {
useEffect(() => {
location.clear();
bottomToolbar.set(<></>);
location.add({
url: "/admin/org",
title: "Organizaciones",
@@ -55,6 +59,10 @@ export default function Schedules() {
title: "Horarios Habilitados",
icon: null,
});
return () => {
bottomToolbar.clear();
};
}, []);
useEffect(() => {
@@ -118,7 +126,7 @@ export default function Schedules() {
},
() => {
return;
}
},
);
};
@@ -80,7 +80,7 @@ export default function OrganizationService() {
const [serviceDiscountName, setServiceDiscountName] = useState<string | undefined>(undefined);
const [servicePublished, setServicePublished] = useState<SERVICE_PUBLISHED_STATUS | undefined>(
undefined
undefined,
);
const [savePublishedStatusAvailable, setSavePublishedStatusAvailable] = useState(false);
@@ -149,7 +149,7 @@ export default function OrganizationService() {
>
Aplicar Descuento
</ButtonMaterial>
</div>
</div>,
);
}, []);
@@ -271,8 +271,12 @@ export default function OrganizationService() {
setServiceDiscountType(response.discountType);
setServiceDiscountValue(response.discountValue);
setServiceDiscountName(response.discountName);
setShowPublicScores(response.showPublicScores !== undefined ? response.showPublicScores : true);
setShowPublicOpinions(response.showPublicOpinions !== undefined ? response.showPublicOpinions : true);
setShowPublicScores(
response.showPublicScores !== undefined ? response.showPublicScores : true,
);
setShowPublicOpinions(
response.showPublicOpinions !== undefined ? response.showPublicOpinions : true,
);
})
.catch(() => {})
.finally(() => {
@@ -283,7 +287,7 @@ export default function OrganizationService() {
const formatDiscount = (
value: number | undefined,
type: string | undefined,
name: string | undefined
name: string | undefined,
): string => {
if (value === undefined || value === null || type === undefined || type === null) {
return "";
@@ -464,7 +468,7 @@ export default function OrganizationService() {
setServicePublished(
checked
? 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" }}>
<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>
<div style={{ display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" }}>
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
<div
style={{ display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" }}
>
<label
style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}
>
<Switch
checked={showPublicScores}
disabled={!canEditPublicRatingFlags}
@@ -520,7 +529,9 @@ export default function OrganizationService() {
/>
Mostrar puntuaciones públicas
</label>
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
<label
style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}
>
<Switch
checked={showPublicOpinions}
disabled={!canEditPublicRatingFlags}
@@ -567,7 +578,7 @@ export default function OrganizationService() {
() => {
deleteDiscountHandler();
},
() => {}
() => {},
);
}}
>
@@ -16,6 +16,7 @@ import { getServiceImage } from "@core/helpers/getServiceImage";
import ServiceRatingSummary from "@components/ServiceRatingSummary/ServiceRatingSummary";
import ShareOutlinedIcon from "@mui/icons-material/ShareOutlined";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
@@ -31,8 +32,9 @@ import {
WhatsappIcon as WhatsAppShareIcon,
TwitterIcon,
EmailIcon,
TelegramIcon
TelegramIcon,
} from "react-share";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = {
id: string;
@@ -42,6 +44,7 @@ export default function Org() {
const urlData = useParams<UrlData>();
const id = urlData.id;
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore();
const alert = useAlert();
@@ -57,6 +60,8 @@ export default function Org() {
useEffect(() => {
location.clear();
bottomToolbar.set(<></>);
location.add({
url: "/admin/org",
title: "Mis organizaciones",
@@ -74,6 +79,10 @@ export default function Org() {
title: "Mis servicios",
icon: <StorefrontOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />,
});
return () => {
bottomToolbar.clear();
};
}, []);
useEffect(() => {
@@ -104,7 +113,7 @@ export default function Org() {
<div
className={style.serviceCard}
style={{
borderLeft: `6px solid ${data.color}`
borderLeft: `6px solid ${data.color}`,
}}
onClick={() => handleServiceClick(data.id)}
>
@@ -114,22 +123,38 @@ export default function Org() {
<div className={style.serviceInfo}>
<h1>{data.name}</h1>
<p>{data.description}</p>
<ServiceRatingSummary serviceId={data.id} variant="row" showOpinionCount={false} />
<ServiceRatingSummary
serviceId={data.id}
variant="row"
showOpinionCount={false}
/>
</div>
<div className={style.serviceActions}>
<span className={style.servicePrice}>{formatPrice(data.price)}</span>
<IconButton
className={style.shareButton}
sx={{ position: "absolute", top: "15px", right: "15px" }}
onClick={(e) => {
e.stopPropagation();
setShareService(data);
}}
size="small"
title="Compartir servicio"
>
<ShareOutlinedIcon fontSize="small" />
</IconButton>
<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
onClick={(e) => {
e.stopPropagation();
setShareService(data);
}}
size="small"
title="Compartir servicio"
className={style.iconButton}
>
<ShareOutlinedIcon fontSize="small" />
</IconButton>
</div>
</div>
</div>
);
@@ -146,8 +171,21 @@ export default function Org() {
<div style={{ marginBottom: "20px" }}>&nbsp;</div>
{shareService && (
<Dialog open={!!shareService} onClose={() => setShareService(null)} maxWidth="sm" fullWidth>
<DialogTitle sx={{ m: 0, p: 2, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Dialog
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>
<IconButton
aria-label="close"
@@ -158,23 +196,57 @@ export default function Org() {
</IconButton>
</DialogTitle>
<DialogContent dividers>
<div 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
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 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}>
<div
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 />
</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 />
</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 />
</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 />
</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 />
</TwitterShareButton>
</div>
@@ -71,26 +71,29 @@
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: flex-end; /* changed from space-between */
min-width: 100px;
justify-content: center;
gap: 12px;
min-width: 60px;
}
.servicePrice {
font-size: 18px;
font-weight: 800;
color: #1e293b;
margin-bottom: 0; /* removed bottom margin */
}
.shareButton {
.iconsRow {
display: flex;
align-items: center;
gap: 4px;
}
.iconButton {
color: #94a3b8;
transition: color 0.2s ease;
position: absolute;
top: 15px;
right: 15px;
}
.shareButton:hover {
.iconButton:hover {
color: var(--wine-dark);
}
@@ -54,7 +54,7 @@ export default function TemplateCreate() {
const insertAtCursor = (
ref: React.RefObject<HTMLInputElement | HTMLTextAreaElement>,
insertText: string
insertText: string,
) => {
const el = ref.current;
if (!el) return; // todavía no está montado
@@ -126,14 +126,14 @@ export default function TemplateCreate() {
},
() => {
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
}
},
);
}}
>
Eliminar
</ButtonMaterial>
)}
</div>
</div>,
);
loadTemplate();
@@ -17,6 +17,7 @@ import {
} from "@models/Templates.model";
import { findTemplatesByOrganization } from "./Templates.Service";
import { useNavigation } from "@core/app/hooks/goto";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = {
id: string;
@@ -29,6 +30,7 @@ export default function TemplatesGrid() {
const oid = data.id;
const { goTo } = useNavigation();
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore();
const formatType = (type: string) => {
@@ -91,8 +93,8 @@ export default function TemplatesGrid() {
parseInt(
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
: "10"
)
: "10",
),
);
const [templates, setTemplates] = useState<PaginateTemplatesResults>();
@@ -103,6 +105,8 @@ export default function TemplatesGrid() {
useEffect(() => {
location.clear();
bottomToolbar.set(<></>);
location.add({
url: "/admin/org",
title: "Mis organizaciones",
@@ -122,6 +126,10 @@ export default function TemplatesGrid() {
});
loadTemplates(1, pageSize);
return () => {
bottomToolbar.clear();
};
}, []);
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 { useConfirmStore } from "@core/Store/Confirm.Store";
import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan";
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
type UrlData = {
id: string;
@@ -35,6 +36,7 @@ export default function OrganizationEdit() {
const data = useParams<UrlData>();
const id = data.id;
const location = useLocationStore();
const bottomToolbar = useBottomToolbarStore();
const eventHandler = useEventHandlerStore();
const SessionInfo = useSessionStore();
const alert = useAlert();
@@ -46,6 +48,8 @@ export default function OrganizationEdit() {
useEffect(() => {
location.clear();
bottomToolbar.set(<></>);
location.add({
url: "/admin/org",
title: "Organizaciones",
@@ -63,6 +67,10 @@ export default function OrganizationEdit() {
title: "Whatsapp",
icon: <OrgConfigIcon sx={{ fontSize: "32px", mr: "10px" }} />,
});
return () => {
bottomToolbar.clear();
};
}, []);
useEffect(() => {
@@ -420,7 +428,7 @@ export default function OrganizationEdit() {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
},
() => { }
() => {},
);
}}
/>
@@ -206,6 +206,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
const [wapError, setWapError] = useState<string | null>(null);
// State for ARCA onboarding flow
const [arcaIsBlocked, setArcaIsBlocked] = useState(false);
const [arcaCuit, setArcaCuit] = useState("");
const [arcaLegalName, setArcaLegalName] = useState("");
const [arcaTaxCondition, setArcaTaxCondition] = useState<"MONOTRIBUTO" | "EXENTO">("MONOTRIBUTO");
@@ -277,6 +278,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
};
const loadOrganizationContext = (org: any) => {
setArcaIsBlocked(false);
setCreatedCompanyId(org.id || org._id);
setSelectedOrganization(org);
setOrgName(org.name || "");
@@ -499,6 +501,18 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
setWapQr(null);
setWapError(null);
} 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);
setArcaError(null);
setArcaLoading(false);
@@ -527,6 +541,19 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
setArcaStatusLabel(getArcaStatusLabel(credential.status));
}).catch(console.error);
} 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);
setArcaError(null);
setArcaLoading(false);
@@ -2415,8 +2442,42 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
if (flowId === "public-link") return renderPublicLinkFlow();
if (flowId === "notifications") return renderNotificationsFlow();
if (flowId === "whatsapp-bot") return renderWhatsAppBotFlow();
if (flowId === "arca-onboarding") return renderArcaOnboardingFlow();
if (flowId === "arca-test-connection") return renderArcaTestConnectionFlow();
if (flowId === "arca-onboarding") {
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 === "open-reservation-periods") return renderReservationPeriodFlow();
if (flowId === "override-schedule") return renderOverrideScheduleFlow();
@@ -39,13 +39,47 @@ type 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 [customDurationValue, setCustomDurationValue] = React.useState("");
const fraction = heatMapFraction || 60;
const durationOptions = [fraction, fraction * 2, fraction * 3, fraction * 4];
const customDurationMinutes = Number(customDurationValue);
const isValidCustomDuration = Number.isInteger(customDurationMinutes) && customDurationMinutes > 0;
const isValidCustomDuration =
Number.isInteger(customDurationMinutes) && customDurationMinutes > 0;
const selectDurationOption = (duration: number) => {
setIsCustomDuration(false);
@@ -65,390 +99,518 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
};
return (
<>
<QuestionCard
isActive={currentStepIndex === 0}
title="Configura tus Servicios"
description="Ya creaste tu negocio. Ahora necesitas configurar al menos un servicio para que tus clientes puedan empezar a reservar."
onNext={handleNext}
nextLabel="Comenzar"
<>
<QuestionCard
isActive={currentStepIndex === 0}
title="Configura tus Servicios"
description="Ya creaste tu negocio. Ahora necesitas configurar al menos un servicio para que tus clientes puedan empezar a reservar."
onNext={handleNext}
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!
</div>
</QuestionCard>
Vamos a crear el primer servicio de tu negocio. ¡Es muy fácil!
</div>
</QuestionCard>
<QuestionCard
isActive={currentStepIndex === 1}
title="1. Nombre y Descripción"
description="Elige un nombre atractivo y describe de qué trata el servicio."
onNext={handleNext}
disableNext={serviceName.trim().length < 3 || serviceDescription.trim().length < 5}
<QuestionCard
isActive={currentStepIndex === 1}
title="1. Nombre y Descripción"
description="Elige un nombre atractivo y describe de qué trata el servicio."
onNext={handleNext}
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
placeholder="Ej. Corte clásico con lavado"
value={serviceName}
onChange={(e) => setServiceName(e.target.value)}
autoFocus
/>
<QuestionInput
placeholder="Breve descripción del servicio..."
value={serviceDescription}
onChange={(e) => setServiceDescription(e.target.value)}
<QuestionInput
placeholder="Ej. Corte clásico con lavado"
value={serviceName}
onChange={(e) => setServiceName(e.target.value)}
autoFocus
/>
<QuestionInput
placeholder="Breve descripción del servicio..."
value={serviceDescription}
onChange={(e) => setServiceDescription(e.target.value)}
onKeyDown={(e) => {
if (
serviceName.trim().length >= 3 &&
serviceDescription.trim().length >= 5
) {
handleKeyDown(e, handleNext);
}
}}
/>
</div>
</QuestionCard>
<QuestionCard
isActive={currentStepIndex === 2}
title="2. Duración del Servicio"
description={`Elige cuánto tiempo tomará. Las opciones están basadas en tu fracción de agenda (${fraction} min).`}
onNext={handleNext}
disableNext={!serviceLength || (isCustomDuration && !isValidCustomDuration)}
>
<div
style={{
display: "flex",
gap: "10px",
flexWrap: "wrap",
width: "100%",
justifyContent: "center",
}}
>
{durationOptions.map((val) => {
const hours = Math.floor(val / 60);
const minutes = val % 60;
const label =
hours > 0
? `${hours}h ${minutes > 0 ? minutes + "m" : ""}`.trim()
: `${minutes} min`;
return (
<div
key={val}
onClick={() => selectDurationOption(val)}
style={{
padding: "12px 24px",
borderRadius: "12px",
cursor: "pointer",
background:
!isCustomDuration && serviceLength === val
? "var(--wine-red)"
: "rgba(255,255,255,0.05)",
border: `2px solid ${!isCustomDuration && serviceLength === val ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`,
color: "white",
fontWeight:
!isCustomDuration && serviceLength === val
? "bold"
: "normal",
transition: "all 0.2s ease",
fontSize: "16px",
}}
>
{label}
</div>
);
})}
<div
onClick={selectCustomDuration}
style={{
padding: "12px 24px",
borderRadius: "12px",
cursor: "pointer",
background: isCustomDuration
? "var(--wine-red)"
: "rgba(255,255,255,0.05)",
border: `2px solid ${isCustomDuration ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`,
color: "white",
fontWeight: isCustomDuration ? "bold" : "normal",
transition: "all 0.2s ease",
fontSize: "16px",
}}
>
Otro
</div>
</div>
{isCustomDuration && (
<div style={{ width: "100%", marginTop: "16px" }}>
<QuestionInput
type="number"
min="1"
step="1"
placeholder="Duración en minutos"
value={customDurationValue}
onChange={(e) => updateCustomDuration(e.target.value)}
onKeyDown={(e) => {
if (serviceName.trim().length >= 3 && serviceDescription.trim().length >= 5) {
if (isValidCustomDuration) {
handleKeyDown(e, handleNext);
}
}}
autoFocus
/>
</div>
</QuestionCard>
<QuestionCard
isActive={currentStepIndex === 2}
title="2. Duración del Servicio"
description={`Elige cuánto tiempo tomará. Las opciones están basadas en tu fracción de agenda (${fraction} min).`}
onNext={handleNext}
disableNext={!serviceLength || (isCustomDuration && !isValidCustomDuration)}
>
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap', width: '100%', justifyContent: 'center' }}>
{durationOptions.map(val => {
const hours = Math.floor(val / 60);
const minutes = val % 60;
const label = hours > 0
? `${hours}h ${minutes > 0 ? minutes + 'm' : ''}`.trim()
: `${minutes} min`;
return (
<div
key={val}
onClick={() => selectDurationOption(val)}
style={{
padding: '12px 24px',
borderRadius: '12px',
cursor: 'pointer',
background: !isCustomDuration && serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)',
border: `2px solid ${!isCustomDuration && serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`,
color: 'white',
fontWeight: !isCustomDuration && serviceLength === val ? 'bold' : 'normal',
transition: 'all 0.2s ease',
fontSize: '16px'
}}
>
{label}
</div>
);
})}
<div
onClick={selectCustomDuration}
style={{
padding: '12px 24px',
borderRadius: '12px',
cursor: 'pointer',
background: isCustomDuration ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)',
border: `2px solid ${isCustomDuration ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`,
color: 'white',
fontWeight: isCustomDuration ? 'bold' : 'normal',
transition: 'all 0.2s ease',
fontSize: '16px'
<div
style={{
color: "rgba(255,255,255,0.6)",
fontSize: "13px",
marginTop: "8px",
textAlign: "center",
}}
>
Otro
Ingresá la duración total del servicio expresada en minutos.
</div>
</div>
{isCustomDuration && (
<div style={{ width: '100%', marginTop: '16px' }}>
<QuestionInput
type="number"
min="1"
step="1"
placeholder="Duración en minutos"
value={customDurationValue}
onChange={(e) => updateCustomDuration(e.target.value)}
onKeyDown={(e) => {
if (isValidCustomDuration) {
handleKeyDown(e, handleNext);
}
)}
</QuestionCard>
<QuestionCard
isActive={currentStepIndex === 3}
title="3. Precio y Cupos"
description="¿Cuánto cuesta y a cuántas personas puede atender cada profesional a la vez en este servicio?"
onNext={handleCreateService}
disableNext={isCreating}
nextLabel={isCreating ? "Guardando..." : "Crear Servicio"}
>
<div
style={{ display: "flex", flexDirection: "column", gap: "20px", width: "100%" }}
>
<div>
<div style={{ color: "white", marginBottom: "10px", fontSize: "14px" }}>
Precio Original
</div>
<div style={{ position: "relative" }}>
<span
style={{
position: "absolute",
left: "15px",
top: "50%",
transform: "translateY(-50%)",
color: "white",
fontSize: "18px",
}}
>
$
</span>
<input
type="text"
inputMode="decimal"
placeholder="0.00"
value={servicePrice}
onChange={(e) => setServicePrice(e.target.value)}
style={{
width: "100%",
padding: "15px 15px 15px 35px",
borderRadius: "12px",
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.05)",
color: "white",
fontSize: "18px",
outline: "none",
}}
autoFocus
/>
<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.
</div>
</div>
</div>
<div>
<div style={{ color: "white", marginBottom: "10px", fontSize: "14px" }}>
Cupos Simultáneos (Por defecto 1)
</div>
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
{[1, 2, 3, 5, 10].map((val) => (
<div
key={val}
onClick={() => setServiceLimit(val)}
style={{
padding: "8px 16px",
borderRadius: "8px",
cursor: "pointer",
background:
serviceLimit === val
? "var(--wine-red)"
: "rgba(255,255,255,0.1)",
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}
</div>
))}
</div>
</div>
{serviceCreateError && (
<div
style={{
padding: "14px 16px",
borderRadius: "12px",
background: "rgba(255, 99, 132, 0.12)",
border: "1px solid rgba(255, 99, 132, 0.35)",
color: "#fecdd3",
fontSize: "14px",
lineHeight: 1.5,
textAlign: "center",
}}
>
{serviceCreateError}
</div>
)}
</QuestionCard>
</div>
</QuestionCard>
<QuestionCard
isActive={currentStepIndex === 3}
title="3. Precio y Cupos"
description="¿Cuánto cuesta y a cuántas personas puedes atender a la vez en este servicio?"
onNext={handleCreateService}
disableNext={isCreating}
nextLabel={isCreating ? "Guardando..." : "Crear Servicio"}
<QuestionCard
isActive={currentStepIndex === 4}
title="4. Imagen del Servicio"
description="Sube una foto representativa para que los clientes sepan qué ofreces. (Opcional)"
onNext={handleUploadServiceImage}
disableNext={isCreating}
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={{ display: 'flex', flexDirection: 'column', gap: '20px', width: '100%' }}>
<div>
<div style={{ color: 'white', marginBottom: '10px', fontSize: '14px' }}>Precio Original</div>
<div style={{ position: 'relative' }}>
<span style={{ position: 'absolute', left: '15px', top: '50%', transform: 'translateY(-50%)', color: 'white', fontSize: '18px' }}>$</span>
<input
type="text"
inputMode="decimal"
placeholder="0.00"
value={servicePrice}
onChange={(e) => setServicePrice(e.target.value)}
style={{
width: '100%',
padding: '15px 15px 15px 35px',
borderRadius: '12px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: 'white',
fontSize: '18px',
outline: 'none'
}}
/>
</div>
</div>
<div>
<div style={{ color: 'white', marginBottom: '10px', fontSize: '14px' }}>Cupos Simultáneos (Por defecto 1)</div>
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
{[1, 2, 3, 5, 10].map(val => (
<div
key={val}
onClick={() => setServiceLimit(val)}
style={{
padding: '8px 16px',
borderRadius: '8px',
cursor: 'pointer',
background: serviceLimit === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)',
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}
</div>
))}
</div>
</div>
{serviceCreateError && (
<div style={{
padding: '14px 16px',
borderRadius: '12px',
background: 'rgba(255, 99, 132, 0.12)',
border: '1px solid rgba(255, 99, 132, 0.35)',
color: '#fecdd3',
fontSize: '14px',
lineHeight: 1.5,
textAlign: 'center'
}}>
{serviceCreateError}
</div>
)}
</div>
</QuestionCard>
<QuestionCard
isActive={currentStepIndex === 4}
title="4. Imagen del Servicio"
description="Sube una foto representativa para que los clientes sepan qué ofreces. (Opcional)"
onNext={handleUploadServiceImage}
disableNext={isCreating}
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)'
}}>
{serviceImage ? (
<img
src={URL.createObjectURL(serviceImage)}
alt="Preview"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<span style={{ color: 'rgba(255,255,255,0.6)' }}>Toca para seleccionar imagen</span>
)}
<input
type="file"
accept="image/*"
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) {
setServiceImage(e.target.files[0]);
}
}}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
opacity: 0,
cursor: 'pointer'
}}
/>
</div>
{serviceCreateError && (
<div style={{
padding: '14px 16px',
borderRadius: '12px',
background: 'rgba(255, 99, 132, 0.12)',
border: '1px solid rgba(255, 99, 132, 0.35)',
color: '#fecdd3',
fontSize: '14px',
lineHeight: 1.5,
textAlign: 'center'
}}>
{serviceCreateError}
</div>
)}
</div>
</QuestionCard>
{isStandaloneNewService && (
<QuestionCard
isActive={currentStepIndex === 5}
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."
<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: '14px', width: '100%' }}>
<button
type="button"
onClick={handleAssignCreatedServiceToMe}
disabled={isCreating}
{serviceImage ? (
<img
src={URL.createObjectURL(serviceImage)}
alt="Preview"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<span style={{ color: "rgba(255,255,255,0.6)" }}>
Toca para seleccionar imagen
</span>
)}
<input
type="file"
accept="image/*"
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) {
setServiceImage(e.target.files[0]);
}
}}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
opacity: 0,
cursor: "pointer",
}}
/>
</div>
{serviceCreateError && (
<div
style={{
padding: "14px 16px",
borderRadius: "12px",
background: "rgba(255, 99, 132, 0.12)",
border: "1px solid rgba(255, 99, 132, 0.35)",
color: "#fecdd3",
fontSize: "14px",
lineHeight: 1.5,
textAlign: "center",
}}
>
{serviceCreateError}
</div>
)}
</div>
</QuestionCard>
{isStandaloneNewService && (
<QuestionCard
isActive={currentStepIndex === 5}
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."
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "14px",
width: "100%",
}}
>
<button
type="button"
onClick={handleAssignCreatedServiceToMe}
disabled={isCreating}
style={{
padding: "14px 18px",
borderRadius: "12px",
border: "1px solid var(--wine-red)",
background: "var(--wine-red)",
color: "white",
fontSize: "16px",
fontWeight: "bold",
cursor: isCreating ? "not-allowed" : "pointer",
opacity: isCreating ? 0.7 : 1,
}}
>
{isCreating ? "Asignando..." : "Yo"}
</button>
<button
type="button"
onClick={handleShowCollaboratorAssignment}
disabled={isCreating || isLoadingServiceCollaborators}
style={{
padding: "14px 18px",
borderRadius: "12px",
border:
serviceAssignmentMode === "collaborators"
? "1px solid var(--wine-red)"
: "1px solid rgba(255,255,255,0.2)",
background:
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"}
</button>
{serviceAssignmentMode === "collaborators" && (
<div
style={{
padding: '14px 18px',
borderRadius: '12px',
border: '1px solid var(--wine-red)',
background: 'var(--wine-red)',
color: 'white',
fontSize: '16px',
fontWeight: 'bold',
cursor: isCreating ? 'not-allowed' : 'pointer',
opacity: isCreating ? 0.7 : 1
display: "flex",
flexDirection: "column",
gap: "10px",
width: "100%",
}}
>
{isCreating ? "Asignando..." : "Yo"}
</button>
<button
type="button"
onClick={handleShowCollaboratorAssignment}
disabled={isCreating || isLoadingServiceCollaborators}
style={{
padding: '14px 18px',
borderRadius: '12px',
border: serviceAssignmentMode === "collaborators" ? '1px solid var(--wine-red)' : '1px solid rgba(255,255,255,0.2)',
background: 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"}
</button>
{serviceAssignmentMode === "collaborators" && (
<div style={{ display: 'flex', 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>
)}
<CollaboratorPicker
employees={serviceAssignmentCollaborators}
selectedIds={selectedServiceCollaboratorIds}
multiple
disabled={isCreating}
onToggle={handleToggleServiceCollaborator}
/>
{serviceAssignmentCollaborators.length > 0 && (
<button
type="button"
onClick={handleAssignCreatedServiceToCollaborators}
disabled={isCreating || selectedServiceCollaboratorIds.length === 0}
style={{
padding: '14px 18px',
borderRadius: '12px',
border: '1px solid var(--wine-red)',
background: 'var(--wine-red)',
color: 'white',
fontSize: '16px',
fontWeight: 'bold',
cursor: isCreating || selectedServiceCollaboratorIds.length === 0 ? 'not-allowed' : 'pointer',
opacity: isCreating || selectedServiceCollaboratorIds.length === 0 ? 0.7 : 1
}}
>
{isCreating ? "Asignando..." : "Asignar servicio"}
</button>
)}
</div>
)}
{!serviceAssignmentWarning && (
<>
>
{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>
)}
<CollaboratorPicker
employees={serviceAssignmentCollaborators}
selectedIds={selectedServiceCollaboratorIds}
multiple
disabled={isCreating}
onToggle={handleToggleServiceCollaborator}
/>
{serviceAssignmentCollaborators.length > 0 && (
<button
type="button"
onClick={handleFinishWithoutAssignment}
disabled={isCreating}
onClick={handleAssignCreatedServiceToCollaborators}
disabled={
isCreating ||
selectedServiceCollaboratorIds.length === 0
}
style={{
padding: '14px 18px',
borderRadius: '12px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: 'white',
fontSize: '16px',
cursor: isCreating ? 'not-allowed' : 'pointer',
opacity: isCreating ? 0.7 : 1
padding: "14px 18px",
borderRadius: "12px",
border: "1px solid var(--wine-red)",
background: "var(--wine-red)",
color: "white",
fontSize: "16px",
fontWeight: "bold",
cursor:
isCreating ||
selectedServiceCollaboratorIds.length === 0
? "not-allowed"
: "pointer",
opacity:
isCreating ||
selectedServiceCollaboratorIds.length === 0
? 0.7
: 1,
}}
>
Lo configuro después
{isCreating ? "Asignando..." : "Asignar servicio"}
</button>
<div style={{
padding: '12px 14px',
borderRadius: '12px',
background: 'rgba(245, 158, 11, 0.12)',
border: '1px solid rgba(245, 158, 11, 0.35)',
color: '#fde68a',
fontSize: '14px',
)}
</div>
)}
{!serviceAssignmentWarning && (
<>
<button
type="button"
onClick={handleFinishWithoutAssignment}
disabled={isCreating}
style={{
padding: "14px 18px",
borderRadius: "12px",
border: "1px solid rgba(255,255,255,0.2)",
background: "rgba(255,255,255,0.05)",
color: "white",
fontSize: "16px",
cursor: isCreating ? "not-allowed" : "pointer",
opacity: isCreating ? 0.7 : 1,
}}
>
Lo configuro después
</button>
<div
style={{
padding: "12px 14px",
borderRadius: "12px",
background: "rgba(245, 158, 11, 0.12)",
border: "1px solid rgba(245, 158, 11, 0.35)",
color: "#fde68a",
fontSize: "14px",
lineHeight: 1.5,
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.
</div>
</>
)}
{serviceAssignmentError && (
<div style={{
padding: '12px 14px',
borderRadius: '12px',
background: 'rgba(255, 99, 132, 0.12)',
border: '1px solid rgba(255, 99, 132, 0.35)',
color: '#fecdd3',
fontSize: '14px',
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.
</div>
</>
)}
{serviceAssignmentError && (
<div
style={{
padding: "12px 14px",
borderRadius: "12px",
background: "rgba(255, 99, 132, 0.12)",
border: "1px solid rgba(255, 99, 132, 0.35)",
color: "#fecdd3",
fontSize: "14px",
lineHeight: 1.5,
textAlign: 'center'
}}>
{serviceAssignmentError}
</div>
)}
</div>
</QuestionCard>
)}
</>
);
textAlign: "center",
}}
>
{serviceAssignmentError}
</div>
)}
</div>
</QuestionCard>
)}
</>
);
}
@@ -12,6 +12,7 @@ export interface CalendarRowViewProps {
type CalendarRowViewData = {
name: string;
startTime: number;
events: TXCalendarEvent[];
};
@@ -167,11 +168,13 @@ export default function CalendarRowView(props: CalendarRowViewProps) {
} else {
auxData.push({
name: time,
startTime: item.startTime,
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]);
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;
border: 0;
background: transparent;
cursor: pointer;
text-align: left;
}
.headerToggle {
display: block;
width: 100%;
padding: 0;
border: 0;
background: transparent;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
}
.title {
font-size: 20px;
font-weight: 700;
color: var(--green-darkestX1);
margin: 0;
flex: 1;
min-width: 0;
}
.toggleIcon {
@@ -45,13 +58,16 @@
flex: 0 0 auto;
width: 32px;
height: 32px;
padding: 0;
border-radius: 999px;
background: var(--white);
color: var(--green-darkestX1);
border: 1px solid var(--green-darkestX1);
font-family: inherit;
font-size: 22px;
font-weight: 800;
line-height: 1;
cursor: pointer;
}
.filters {
@@ -1,13 +1,17 @@
"use client";
import React, { useEffect, useState } from "react";
import { LineChart } from "@mui/x-charts/LineChart";
import { MyOranizationsView } from "@core/Models/Company.model";
import { DashboardFinancialsResult, getDashboardFinancials } from "../../landing/dashboard/Dashboard.Service";
import {
DashboardFinancialsResult,
getDashboardFinancials,
} from "../../landing/dashboard/Dashboard.Service";
import { loadCollaboratorsByCompanyId } from "../../admin/(organization-profile)/org/profile/[id]/collaborators/Collaborators.Service";
import { CompanyEmployeesView } from "@models/Collaborators.model";
import UnpaidIncomeCard from "../UnpaidIncomeCard/UnpaidIncomeCard";
import styles from "./FinancialWidget.module.css";
import dayjs from "dayjs";
import OpenInNewOutlinedIcon from "@mui/icons-material/OpenInNewOutlined";
import { useNavigation } from "@core/app/hooks/goto";
export interface FinancialWidgetProps {
sessionUser: string;
@@ -15,11 +19,16 @@ export interface FinancialWidgetProps {
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 [selectedEmployeeId, setSelectedEmployeeId] = useState<string>("");
const [collaborators, setCollaborators] = useState<CompanyEmployeesView[]>([]);
const [financials, setFinancials] = useState<DashboardFinancialsResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -27,10 +36,12 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
useEffect(() => {
if (selectedOrgId) {
loadCollaboratorsByCompanyId({ companyId: selectedOrgId }).then((res) => {
setCollaborators(res);
setSelectedEmployeeId(""); // reset employee filter when org changes
}).catch(console.error);
loadCollaboratorsByCompanyId({ companyId: selectedOrgId })
.then((res) => {
setCollaborators(res);
setSelectedEmployeeId(""); // reset employee filter when org changes
})
.catch(console.error);
}
}, [selectedOrgId, sessionUser]);
@@ -43,14 +54,17 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
sessionUser,
companyId: selectedOrgId,
timeframe: selectedTimeframe,
employeeId: selectedEmployeeId || undefined
}).then((res) => {
setFinancials(res);
}).catch((err) => {
const errMsg = err.format ? err.format() : err.message;
console.warn("Acceso denegado a métricas financieras: ", errMsg);
setError("No tienes permisos de administrador para esta organización.");
}).finally(() => setLoading(false));
employeeId: selectedEmployeeId || undefined,
})
.then((res) => {
setFinancials(res);
})
.catch((err) => {
const errMsg = err.format ? err.format() : err.message;
console.warn("Acceso denegado a métricas financieras: ", errMsg);
setError("No tienes permisos de administrador para esta organización.");
})
.finally(() => setLoading(false));
}
}, [selectedOrgId, selectedTimeframe, selectedEmployeeId, sessionUser]);
@@ -62,34 +76,89 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
if (!financials) return null;
const current = financials.currentPeriodTotal;
const previous = financials.previousPeriodTotal;
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>;
}
const diff = current - previous;
const percent = (diff / previous) * 100;
if (percent > 0) 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>;
if (percent > 0)
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>;
};
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) {
return (
<div className={styles.container}>
<button className={styles.headerButton} type="button" onClick={() => setIsOpen((value) => !value)}>
<h3 className={styles.title}>
Rendimiento Financiero {organizationName ? `- ${organizationName}` : ''}
</h3>
<span className={styles.toggleIcon}>{isOpen ? "−" : "+"}</span>
</button>
{header}
{isOpen && (
<div style={{ padding: '40px 20px', textAlign: 'center', color: '#6b7280' }}>
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#374151' }}>Acceso Restringido</p>
<div style={{ padding: "40px 20px", textAlign: "center", color: "#6b7280" }}>
<p style={{ fontWeight: "600", marginBottom: "8px", color: "#374151" }}>
Acceso Restringido
</p>
<p>{error}</p>
</div>
)}
@@ -98,67 +167,74 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
}
const chartData = financials?.currentPeriodData || [];
const xAxisData = chartData.map(d => {
const xAxisData = chartData.map((d) => {
// format label nicer depending on timeframe
if (selectedTimeframe === "year") return dayjs(d.date).format("MMM YYYY");
return dayjs(d.date).format("DD/MM");
});
const seriesData = chartData.map(d => d.amount);
const hasChartData = chartData.some(d => d.amount > 0);
const seriesData = chartData.map((d) => d.amount);
const hasChartData = chartData.some((d) => d.amount > 0);
return (
<div className={styles.container}>
<button className={styles.headerButton} type="button" onClick={() => setIsOpen((value) => !value)}>
<h3 className={styles.title}>
Rendimiento Financiero {organizationName ? `- ${organizationName}` : ''}
</h3>
<span className={styles.toggleIcon}>{isOpen ? "−" : "+"}</span>
</button>
{header}
{isOpen && (
<>
<div className={styles.filters}>
<select
className={styles.select}
value={selectedTimeframe}
onChange={e => setSelectedTimeframe(e.target.value as "week" | "month" | "year")}
>
<option value="week">Última Semana</option>
<option value="month">Último Mes</option>
<option value="year">Último Año</option>
</select>
<select
className={styles.select}
value={selectedTimeframe}
onChange={(e) =>
setSelectedTimeframe(e.target.value as "week" | "month" | "year")
}
>
<option value="week">Última Semana</option>
<option value="month">Último Mes</option>
<option value="year">Último Año</option>
</select>
<select
className={styles.select}
value={selectedEmployeeId}
onChange={e => setSelectedEmployeeId(e.target.value)}
>
<option value="">Todos los Profesionales</option>
{collaborators.map(c => (
<option key={c.id} value={c.id}>{c.fullName}</option>
))}
</select>
<select
className={styles.select}
value={selectedEmployeeId}
onChange={(e) => setSelectedEmployeeId(e.target.value)}
>
<option value="">Todos los Profesionales</option>
{collaborators.map((c) => (
<option key={c.id} value={c.id}>
{c.fullName}
</option>
))}
</select>
</div>
<div className={styles.metricsRow}>
<div className={styles.metricBox}>
<span className={styles.metricLabel}>Ingresos del Periodo</span>
<span className={styles.metricValue}>
{loading ? "..." : formatCurrency(financials?.currentPeriodTotal || 0)}
</span>
<div className={styles.metricComparison}>
{!loading && renderComparison()}
</div>
</div>
{financials && (
<UnpaidIncomeCard value={financials.unpaidToday} />
)}
<div className={styles.metricBox}>
<span className={styles.metricLabel}>Ingresos del Periodo</span>
<span className={styles.metricValue}>
{loading
? "..."
: formatCurrency(financials?.currentPeriodTotal || 0)}
</span>
<div className={styles.metricComparison}>
{!loading && renderComparison()}
</div>
</div>
{financials && <UnpaidIncomeCard value={financials.unpaidToday} />}
</div>
{loading && (
<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...
</div>
</div>
@@ -168,57 +244,68 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
<div className={styles.chartContainer}>
<LineChart
height={350}
xAxis={[{
scaleType: 'point',
data: xAxisData,
}]}
xAxis={[
{
scaleType: "point",
data: xAxisData,
},
]}
series={[
{
data: seriesData,
color: 'rgb(36, 169, 53)',
color: "rgb(36, 169, 53)",
area: true,
showMark: true,
curve: 'monotoneX',
valueFormatter: (val: number | null) => val ? formatCurrency(val) : "0",
curve: "monotoneX",
valueFormatter: (val: number | null) =>
val ? formatCurrency(val) : "0",
},
]}
grid={{ horizontal: true }}
margin={{ top: 20, bottom: 40, left: 70, right: 20 }}
sx={{
'.MuiLineElement-root': {
".MuiLineElement-root": {
strokeWidth: 3,
},
'.MuiAreaElement-root': {
fill: 'url(#gradient)',
".MuiAreaElement-root": {
fill: "url(#gradient)",
},
'.MuiChartsGrid-line': {
strokeDasharray: '5 5',
stroke: '#e5e7eb',
".MuiChartsGrid-line": {
strokeDasharray: "5 5",
stroke: "#e5e7eb",
},
'.MuiChartsAxis-line': {
stroke: 'transparent',
".MuiChartsAxis-line": {
stroke: "transparent",
},
'.MuiChartsAxis-tick': {
stroke: 'transparent',
".MuiChartsAxis-tick": {
stroke: "transparent",
},
'.MuiChartsAxis-tickLabel': {
fill: '#9ca3af',
fontFamily: 'inherit',
fontSize: '12px',
".MuiChartsAxis-tickLabel": {
fill: "#9ca3af",
fontFamily: "inherit",
fontSize: "12px",
fontWeight: 500,
},
'.MuiMarkElement-root': {
stroke: 'rgb(36, 169, 53)',
".MuiMarkElement-root": {
stroke: "rgb(36, 169, 53)",
strokeWidth: 2,
fill: '#ffffff',
scale: '1.2',
}
fill: "#ffffff",
scale: "1.2",
},
}}
>
<defs>
<linearGradient id="gradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="rgb(36, 169, 53)" stopOpacity={0.3}/>
<stop offset="95%" stopColor="rgb(36, 169, 53)" stopOpacity={0.0}/>
<stop
offset="5%"
stopColor="rgb(36, 169, 53)"
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor="rgb(36, 169, 53)"
stopOpacity={0.0}
/>
</linearGradient>
</defs>
</LineChart>
@@ -5,7 +5,7 @@
right: 0px;
height: 70px;
overflow: hidden;
z-index: 1;
z-index: 100;
pointer-events: none;
}
@@ -24,8 +24,34 @@
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) {
.locationContainer {
left: 6px;
}
}
}
@@ -2,18 +2,22 @@
import { useRouter } from "next/navigation";
import style from "./Location.module.css";
import { useLocationStore } from "@store/Location.Store";
import { useFilterStore } from "@store/Filter.Store";
import Breadcrumbs from "@mui/material/Breadcrumbs";
import Link from "@mui/material/Link";
import HomeIcon from "@mui/icons-material/Home";
import FilterAltOutlinedIcon from "@mui/icons-material/FilterAltOutlined";
export interface LocationProps {
bottomSeperation?: string;
showFilter?: boolean;
}
export default function Location(props: LocationProps) {
const { bottomSeperation = "55px" } = props;
const { bottomSeperation = "55px", showFilter = false } = props;
const router = useRouter();
const location = useLocationStore();
const filter = useFilterStore();
const goTo = (path: string) => {
router.push(path);
@@ -48,10 +52,26 @@ export default function Location(props: LocationProps) {
))}
</Breadcrumbs>
</div>
<div className={style.locationTitle}>
<div
className={
showFilter
? `${style.locationTitle} ${style.locationTitleFilter}`
: style.locationTitle
}
>
{location.last()?.icon}
<h1>{location.last()?.title}</h1>
</div>
{showFilter && (
<button
className={style.filterButton}
onClick={() => filter.toggle()}
aria-label="Filtrar"
title="Filtrar"
>
<FilterAltOutlinedIcon sx={{ fontSize: "32px" }} />
</button>
)}
</div>
<div
style={{
@@ -63,4 +83,4 @@ export default function Location(props: LocationProps) {
</div>
</>
);
}
}
@@ -17,6 +17,7 @@ import classNames from "classnames";
import { useNavigation } from "@core/app/hooks/goto";
import ShareOutlinedIcon from "@mui/icons-material/ShareOutlined";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
@@ -69,6 +70,14 @@ export default function OrganizationHeader(props: OrganizationHeaderParams): Rea
icon: <ShareOutlinedIcon />,
});
menu.add({
text: "Ver perfil",
onClick: () => {
goTo(`/landing/org/${props.data.id}`);
},
icon: <OpenInNewIcon />,
});
menu.add({
text: "Notificaciones",
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;
size: number;
max: number;
inheritColor?: boolean;
}
export default function PaginationControls(props: PaginationControlProps) {
const { current, size, max, onChange = () => {} } = props;
const { current, size, max, onChange = () => {}, inheritColor = false } = props;
useEffect(() => {}, [current]);
@@ -36,14 +37,20 @@ export default function PaginationControls(props: PaginationControlProps) {
};
return (
<div className={style.paginationControls}>
<IconButton onClick={handlePrevious}>
<div
className={style.paginationControls}
style={inheritColor ? { color: "var(--white)" } : undefined}
>
<IconButton color={inheritColor ? "inherit" : undefined} onClick={handlePrevious}>
<ArrowBackwardIcon sx={{ fontSize: 24 }} />
</IconButton>
<span className={style.paginationLabel}>
<span
className={style.paginationLabel}
style={inheritColor ? { color: "var(--white)" } : undefined}
>
{current} / {max}
</span>
<IconButton onClick={handleNext}>
<IconButton color={inheritColor ? "inherit" : undefined} onClick={handleNext}>
<ArrowForwardIcon sx={{ fontSize: 24 }} />
</IconButton>
</div>
@@ -6,6 +6,7 @@ import { PublicServiceView } from "@models/Service.model";
import style from "./style.module.css";
import { getServiceImage } from "@core/helpers/getServiceImage";
import ServiceRatingSummary from "@components/ServiceRatingSummary/ServiceRatingSummary";
import EditIcon from "@mui/icons-material/Edit";
export type FindResultsViewMode = "grid" | "card";
@@ -14,6 +15,8 @@ export interface ProductItemProps {
width?: number | string;
viewMode?: FindResultsViewMode;
dragging?: boolean;
isAdmin?: boolean;
onImageEdit?: () => void;
}
export default function ProductItem(props: ProductItemProps) {
@@ -21,6 +24,8 @@ export default function ProductItem(props: ProductItemProps) {
const { product } = props;
const { viewMode = "card" } = props;
const { dragging = false } = props;
const { isAdmin = false } = props;
const { onImageEdit } = props;
const { goTo } = useNavigation();
return (
@@ -38,7 +43,7 @@ export default function ProductItem(props: ProductItemProps) {
textAlign: "center",
padding: "10px",
border: "solid 1px rgba(0, 0, 0, 0.1)",
cursor: "pointer",
cursor: isAdmin ? "default" : "pointer",
position: "relative",
}}
onClick={() => {
@@ -58,6 +63,11 @@ export default function ProductItem(props: ProductItemProps) {
display: "block",
}}
/>
{isAdmin && (
<span className={style.adminEditButton} onClick={(e) => { e.stopPropagation(); onImageEdit?.(); }}>
<EditIcon fontSize="small" />
</span>
)}
<ServiceRatingSummary
serviceId={product.id}
variant="card"
@@ -8,6 +8,32 @@
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 {
margin: 10px 0;
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 CategoriesExplorer from "@core/app/components/Home/CategoriesExplorer/page";
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
import { ScrollToTop } from "@core/app/components/ScrollTop";
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() {
return (
<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 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";
import { Metadata } from "next";
import GetAppointmentsContent from "./GetAppointmentsContent";
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar";
export const metadata: Metadata = {
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() {
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>
);
}
return <GetAppointmentsContent />;
}
@@ -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;
}
.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 {
width: 22px;
height: 22px;
+33 -314
View File
@@ -1,317 +1,36 @@
"use client";
import { Metadata } from "next";
import HelpContent from "./HelpContent";
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";
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar";
export const metadata: Metadata = {
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.",
alternates: {
canonical: `${baseUrl}/landing/help`,
},
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() {
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 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>
);
}
return <HelpContent />;
}
@@ -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>
</>
);
}
+19 -1
View File
@@ -5,6 +5,8 @@ export interface Video {
thumbnail: string;
category: string;
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 {
@@ -31,6 +33,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i2.ytimg.com/vi/QE5pEw_af5g/hqdefault.jpg",
category: "getting-started",
order: 1,
uploadDate: "2026-09-02",
},
{
id: "Aufl00f4U0Y",
@@ -39,6 +42,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i2.ytimg.com/vi/Aufl00f4U0Y/hqdefault.jpg",
category: "getting-started",
order: 2,
uploadDate: "2026-09-02",
},
],
},
@@ -56,6 +60,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i1.ytimg.com/vi/hgs9w9Q7w2k/hqdefault.jpg",
category: "services",
order: 1,
uploadDate: "2026-09-02",
},
{
id: "UthPmnzR7fg",
@@ -64,6 +69,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i2.ytimg.com/vi/UthPmnzR7fg/hqdefault.jpg",
category: "services",
order: 2,
uploadDate: "2026-09-02",
},
{
id: "p6_ax-B2vDA",
@@ -72,6 +78,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i1.ytimg.com/vi/p6_ax-B2vDA/hqdefault.jpg",
category: "services",
order: 3,
uploadDate: "2026-09-02",
},
{
id: "tzVF2MeW37I",
@@ -80,6 +87,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i.ytimg.com/vi/tzVF2MeW37I/hqdefault.jpg",
category: "services",
order: 4,
uploadDate: "2026-09-02",
},
],
},
@@ -97,6 +105,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i2.ytimg.com/vi/enu2ny1k6XU/hqdefault.jpg",
category: "schedules",
order: 1,
uploadDate: "2026-09-02",
},
{
id: "8kBxSYoisSM",
@@ -105,6 +114,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i1.ytimg.com/vi/8kBxSYoisSM/hqdefault.jpg",
category: "schedules",
order: 2,
uploadDate: "2026-09-02",
},
],
},
@@ -122,6 +132,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i3.ytimg.com/vi/BcHrqxW_PTs/hqdefault.jpg",
category: "clients-bookings",
order: 1,
uploadDate: "2026-09-02",
},
{
id: "MVpCGA-w7c8",
@@ -130,6 +141,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i2.ytimg.com/vi/MVpCGA-w7c8/hqdefault.jpg",
category: "clients-bookings",
order: 2,
uploadDate: "2026-09-02",
},
],
},
@@ -147,6 +159,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i4.ytimg.com/vi/CpGNWAJo6MM/hqdefault.jpg",
category: "payments-discounts",
order: 1,
uploadDate: "2026-09-02",
},
{
id: "UAh7HkOcvMQ",
@@ -155,6 +168,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i2.ytimg.com/vi/UAh7HkOcvMQ/hqdefault.jpg",
category: "payments-discounts",
order: 2,
uploadDate: "2026-09-02",
},
],
},
@@ -172,6 +186,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i2.ytimg.com/vi/iu7sGYW1iGk/hqdefault.jpg",
category: "customization",
order: 1,
uploadDate: "2026-09-02",
},
{
id: "nZkBIpmLRyI",
@@ -180,6 +195,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i.ytimg.com/vi/nZkBIpmLRyI/hqdefault.jpg",
category: "customization",
order: 2,
uploadDate: "2026-09-02",
},
{
id: "oJuvDdewG1I",
@@ -188,6 +204,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i4.ytimg.com/vi/oJuvDdewG1I/hqdefault.jpg",
category: "customization",
order: 3,
uploadDate: "2026-09-02",
},
{
id: "wjJfoky6VD0",
@@ -196,6 +213,7 @@ export const videoCategories: VideoCategory[] = [
thumbnail: "https://i4.ytimg.com/vi/wjJfoky6VD0/hqdefault.jpg",
category: "customization",
order: 4,
uploadDate: "2026-09-02",
},
],
},
@@ -207,4 +225,4 @@ export const getAllVideos = (): Video[] => {
export const getTotalVideoCount = (): number => {
return getAllVideos().length;
};
};
@@ -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 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";
import OfferContent from "./OfferContent";
export const metadata: Metadata = {
title: "Ofrecé Turnos Online | Sistema de Gestión y Reservas - TurnosXpress",
@@ -19,112 +14,9 @@ export const metadata: Metadata = {
export default function OfferAppointmentsPage() {
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}>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>
<OfferContent
heroTitle="Multiplicá tus clientes, simplificá tus turnos"
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."
/>
);
}
@@ -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,13 @@
import ApiRequest from "@services/Api.Service";
import { UpdateCompanyParams } from "@models/Company.model";
import { ApiVoidResult } from "@models/Api.VoidResult.type";
export const updateOrganizationsById = 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);
};
+65 -1
View File
@@ -3,6 +3,7 @@ import ApiServerService from "@services/Api.Server.Service";
import { PublicOrganizationView } from "@core/Models/Company.model";
import OrganizationHeader from "@core/app/components/Home/OrganizationHeader/OrganizationHeader";
import OrganizationDataConnector from "./components/OrganizationData";
import type { Metadata } from "next";
const GetOrganizationData = async (companyId: string): Promise<PublicOrganizationView> => {
try {
@@ -28,10 +29,67 @@ type Props = {
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) {
const { oid } = await params;
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 (
<AnimatedContainer
color={orgData.headerColor}
@@ -45,7 +103,13 @@ export default async function OrganizationPublicProfile({ params, children }: Pr
<div className="homeCentered">
{orgData.id && (
<>
<OrganizationHeader organization={orgData} />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<div style={{ position: "relative" }} id="header-wrapper">
<OrganizationHeader organization={orgData} />
</div>
<OrganizationDataConnector orgData={orgData} />
<div>{children}</div>
</>
+143 -17
View File
@@ -43,7 +43,10 @@ import { useSessionStore } from "@core/Store/Sesion.Store";
import { EVENT_TYPES, useEventHandlerStore } from "@core/Store/EventHandler.Store";
import { useAlert } from "@core/Store/Alert.Store";
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 { ThemeProvider } from "@emotion/react";
@@ -55,12 +58,14 @@ import DownIcon from "@mui/icons-material/ArrowDownwardRounded";
import PinIcon from "@mui/icons-material/PushPin";
import EditIcon from "@mui/icons-material/AutoFixHigh";
import turnosXpressTheme from "@core/app/theme/turnosXpress";
import { useParams } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import dayjs from "dayjs";
import { useConfirmStore } from "@core/Store/Confirm.Store";
import ReviewsCarousel from "@core/app/components/ReviewsCarousel/ReviewsCarousel";
import OrganizationProfessionals from "@core/app/components/OrganizationProfessionals/OrganizationProfessionals";
import { RatingTargetType } from "@core/Models/Ratings.model";
import HeaderEditPopup from "./HeaderEditPopup";
import { checkOrgAdmin } from "./post.service";
enum VIEW {
POSTS = "posts",
@@ -76,6 +81,7 @@ type UrlData = {
export default function OrganizationPublicProfile() {
const data = useParams<UrlData>();
const oid = data.oid;
const router = useRouter();
const OrganizationData = useOrgDataStore();
const [position, setPosition] = useState<[number, number]>([0, 0]);
const [orgData, setOrgData] = useState<PublicOrganizationView>();
@@ -96,8 +102,85 @@ export default function OrganizationPublicProfile() {
const [posts, setPosts] = useState<IPost[]>([]);
const [authorizedUsers, setAuthorizedUsers] = useState<string[]>([]);
const [currentPage, setCurrentPage] = useState<number>(1);
const [isOrgAdmin, setIsOrgAdmin] = useState<boolean>(false);
const [showEditPopup, setShowEditPopup] = useState<boolean>(false);
const SessionInfo = useSessionStore();
// Unified effect: check admin status and manage edit button DOM element
useEffect(() => {
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");
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 {
// 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 () => {
cancelled = true;
const existing = document.getElementById("edit-header-btn");
if (existing) existing.remove();
};
}, [SessionInfo.userId, oid]);
const eventHandler = useEventHandlerStore();
const alert = useAlert();
const confirm = useConfirmStore();
@@ -338,7 +421,8 @@ export default function OrganizationPublicProfile() {
return response.data;
}
const newPosts = response.data.filter(
(newPost) => !prevPosts.some((prevPost) => prevPost._id === newPost._id),
(newPost) =>
!prevPosts.some((prevPost) => prevPost._id === newPost._id),
);
return [...prevPosts, ...newPosts];
@@ -508,7 +592,11 @@ export default function OrganizationPublicProfile() {
gap: "10px",
}}
>
<WhatsappShareButton title={shareTitle} separator=":: " url={window.location.href}>
<WhatsappShareButton
title={shareTitle}
separator=":: "
url={window.location.href}
>
<WhatsAppShareIcon size={32} round />
</WhatsappShareButton>
<FacebookShareButton title={shareTitle} url={window.location.href}>
@@ -531,6 +619,21 @@ export default function OrganizationPublicProfile() {
return (
<>
<HeaderConfProvider />
{isOrgAdmin && oid && orgData && (
<HeaderEditPopup
open={showEditPopup}
onClose={() => setShowEditPopup(false)}
companyId={oid}
headerColor={orgData.headerColor}
headerFontColor={orgData.headerFontColor}
headerFontShadowColor={orgData.headerFontShadowColor}
headerImage={orgData.headerFile}
onSaved={() => {
setShowEditPopup(false);
}}
onRefresh={() => router.refresh()}
/>
)}
{orgData && (
<div className={style.organizationContent}>
{showEditor && (
@@ -656,7 +759,9 @@ export default function OrganizationPublicProfile() {
)}
</div>
{view === VIEW.SERVICES && (
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<div
style={{ display: "flex", justifyContent: "flex-end" }}
>
<ButtonGroup
variant="outlined"
aria-label="Basic button group"
@@ -711,7 +816,8 @@ export default function OrganizationPublicProfile() {
<div
style={{
textAlign: "center",
backgroundImage: "url(/no-conversation.webp)",
backgroundImage:
"url(/no-conversation.webp)",
backgroundRepeat: "no-repeat",
backgroundPosition: "center center",
backgroundSize: "50% auto",
@@ -726,7 +832,8 @@ export default function OrganizationPublicProfile() {
<div
key={post._id}
style={{
borderBottom: "1px solid var(--gray-light)",
borderBottom:
"1px solid var(--gray-light)",
paddingTop: "5px",
paddingBottom: "10px",
paddingLeft: "30px",
@@ -740,7 +847,11 @@ export default function OrganizationPublicProfile() {
justifyContent: "space-between",
}}
>
<div style={{ color: "var(--gray-darkest)" }}>
<div
style={{
color: "var(--gray-darkest)",
}}
>
{dayjs(post.createdAt).format(
"DD MMMM YYYY HH:mm",
)}
@@ -780,22 +891,30 @@ export default function OrganizationPublicProfile() {
color: "var(--gray-darkest)",
}}
>
<ThemeProvider theme={turnosXpressTheme}>
<ThemeProvider
theme={turnosXpressTheme}
>
<MaterialButton
color="inherit"
startIcon={<ClipIcon />}
onClick={() => {
fijarPost(`${post._id}`);
fijarPost(
`${post._id}`,
);
}}
>
{post.fixed ? "Liberar" : "Fijar"}
{post.fixed
? "Liberar"
: "Fijar"}
</MaterialButton>
{post.fixed && (
<>
<MaterialButton
color="inherit"
startIcon={<UpIcon />}
startIcon={
<UpIcon />
}
onClick={() => {
moverPost(
`${post._id}`,
@@ -808,7 +927,9 @@ export default function OrganizationPublicProfile() {
<MaterialButton
color="inherit"
startIcon={<DownIcon />}
startIcon={
<DownIcon />
}
onClick={() => {
moverPost(
`${post._id}`,
@@ -824,7 +945,9 @@ export default function OrganizationPublicProfile() {
<MaterialButton
color="inherit"
onClick={() => {
editarPost(`${post._id}`);
editarPost(
`${post._id}`,
);
}}
>
<EditIcon />
@@ -833,7 +956,9 @@ export default function OrganizationPublicProfile() {
<MaterialButton
color="inherit"
onClick={() => {
eliminarPost(`${post._id}`);
eliminarPost(
`${post._id}`,
);
}}
>
<DeleteIcon />
@@ -858,7 +983,8 @@ export default function OrganizationPublicProfile() {
style={{
padding: "0px 0px 40px 0px",
display: "flex",
flexDirection: viewMode === "card" ? "row" : "column",
flexDirection:
viewMode === "card" ? "row" : "column",
justifyContent: "start",
gap: "20px",
flexWrap: "wrap",

Some files were not shown because too many files have changed in this diff Show More