Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84e17e2a32 | |||
| 7a97e278a2 | |||
| 215a5b28f4 | |||
| 1438585a0a | |||
| 0d6231a02e | |||
| 13ba0fa4de | |||
| 2df20233d4 | |||
| 1a9de06393 | |||
| ec083ddf42 | |||
| fe9eb52a6a | |||
| 392156fd31 | |||
| f5a2c0774e | |||
| 0dd2998f5c | |||
| 97943fa4a9 | |||
| 5e65a48fe1 | |||
| f086c6398c | |||
| 728d0a711c | |||
| 2d91d08e16 | |||
| eff1988d66 | |||
| 60eb940d3e | |||
| 8c1089df41 | |||
| ae63d0dea0 | |||
| deb1291274 | |||
| 583ddf1ea9 | |||
| 65d149a4e7 | |||
| 322f1d3f15 | |||
| 4460d66f39 | |||
| 0e7e690ecc | |||
| 35a132a288 | |||
| 28727e2630 | |||
| e7a94b93f1 | |||
| fccaa44e9c | |||
| 421a8b5582 | |||
| 546b66c972 | |||
| d96bde5963 | |||
| 69e6e659e0 | |||
| 86f5422828 | |||
| db6981cad5 |
@@ -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,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] } } }
|
||||||
|
])
|
||||||
@@ -1131,7 +1131,12 @@ class AppointmentManager implements IAppointmentsManager {
|
|||||||
serviceId: String(appointment.serviceId),
|
serviceId: String(appointment.serviceId),
|
||||||
employeeId: String(appointment.employeeId),
|
employeeId: String(appointment.employeeId),
|
||||||
startTime: appointment.startHour,
|
startTime: appointment.startHour,
|
||||||
endTime: appointment.endHour,
|
// Cross-midnight appointments store endHour wrapped into 0-23 (e.g. 23:00 -> 01:00
|
||||||
|
// stores 1). Normalize it to a >24 value so schedule views can position it.
|
||||||
|
endTime:
|
||||||
|
appointment.endHour < appointment.startHour
|
||||||
|
? appointment.endHour + 24
|
||||||
|
: appointment.endHour,
|
||||||
title: isNull<string>(clientName, clientUserName),
|
title: isNull<string>(clientName, clientUserName),
|
||||||
avatar: clientAvatar,
|
avatar: clientAvatar,
|
||||||
color: isNull<string>(employee.calendarColor, "#ff9900"),
|
color: isNull<string>(employee.calendarColor, "#ff9900"),
|
||||||
@@ -1257,16 +1262,21 @@ class AppointmentManager implements IAppointmentsManager {
|
|||||||
|
|
||||||
//get min startTime hour from returnData
|
//get min startTime hour from returnData
|
||||||
let minStartTime = 24;
|
let minStartTime = 24;
|
||||||
|
let maxEndTime = 24;
|
||||||
for (const event of returnData) {
|
for (const event of returnData) {
|
||||||
if (event.startTime < minStartTime) {
|
if (event.startTime < minStartTime) {
|
||||||
minStartTime = event.startTime;
|
minStartTime = event.startTime;
|
||||||
}
|
}
|
||||||
|
if (event.endTime > maxEndTime) {
|
||||||
|
maxEndTime = event.endTime;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
events: returnData,
|
events: returnData,
|
||||||
from: Math.floor(minStartTime),
|
from: Math.floor(minStartTime),
|
||||||
to: 24,
|
// Extend past 24 when an appointment crosses midnight, so the block keeps its real height.
|
||||||
|
to: Math.max(24, Math.ceil(maxEndTime)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import crypto from "crypto";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import forge from "node-forge";
|
import forge from "node-forge";
|
||||||
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
|
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
|
||||||
|
import CompaniesList from "../Companies/Companies";
|
||||||
|
import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons";
|
||||||
|
import { PlanFeatures } from "../Plans/Plans.interface";
|
||||||
import ArcaWsaaTokens from "../ArcaWsaaTokens/ArcaWsaaTokens";
|
import ArcaWsaaTokens from "../ArcaWsaaTokens/ArcaWsaaTokens";
|
||||||
import {
|
import {
|
||||||
ARCA_WSAA_ENVIRONMENT,
|
ARCA_WSAA_ENVIRONMENT,
|
||||||
@@ -123,6 +126,23 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
|||||||
await validatePermissionsByCompany({ companyId, sessionUser });
|
await validatePermissionsByCompany({ companyId, sessionUser });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async requirePaymentsFeature(companyId: string): Promise<void> {
|
||||||
|
const company = await CompaniesList.companies.findOne({ _id: companyId });
|
||||||
|
|
||||||
|
if (!company) {
|
||||||
|
throw new Error("No se ha encontrado la organización");
|
||||||
|
}
|
||||||
|
|
||||||
|
const canAccessPayments = await PlanSubscriptionsList.checkFeature({
|
||||||
|
userId: String(company.ownerId),
|
||||||
|
feature: PlanFeatures.PAYMENTS,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!canAccessPayments) {
|
||||||
|
throw new Error("La organización no tiene un plan que soporte el módulo de pagos");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async requireActiveFiscalProfile(
|
private async requireActiveFiscalProfile(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
sessionUser: string
|
sessionUser: string
|
||||||
@@ -730,6 +750,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
|||||||
|
|
||||||
public async generateCsr(data: GenerateArcaCredentialCsrParams): Promise<ArcaCredentialView> {
|
public async generateCsr(data: GenerateArcaCredentialCsrParams): Promise<ArcaCredentialView> {
|
||||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||||
|
await this.requirePaymentsFeature(data.companyId);
|
||||||
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
|
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
|
||||||
|
|
||||||
const keys = forge.pki.rsa.generateKeyPair(2048);
|
const keys = forge.pki.rsa.generateKeyPair(2048);
|
||||||
@@ -778,6 +799,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
|||||||
|
|
||||||
public async uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise<ArcaCredentialView> {
|
public async uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise<ArcaCredentialView> {
|
||||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||||
|
await this.requirePaymentsFeature(data.companyId);
|
||||||
|
|
||||||
if (!data.certificatePem || !data.certificatePem.trim()) {
|
if (!data.certificatePem || !data.certificatePem.trim()) {
|
||||||
throw new Error("Se requiere el certificado ARCA");
|
throw new Error("Se requiere el certificado ARCA");
|
||||||
@@ -836,6 +858,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
|||||||
|
|
||||||
public async testWsaaLogin(data: TestArcaWsaaLoginParams): Promise<ArcaWsaaLoginTestResult> {
|
public async testWsaaLogin(data: TestArcaWsaaLoginParams): Promise<ArcaWsaaLoginTestResult> {
|
||||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||||
|
await this.requirePaymentsFeature(data.companyId);
|
||||||
|
|
||||||
const service = ARCA_WSAA_SERVICE.WSFE;
|
const service = ARCA_WSAA_SERVICE.WSFE;
|
||||||
const environment = this.getArcaEnvironment();
|
const environment = this.getArcaEnvironment();
|
||||||
@@ -853,6 +876,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
|||||||
|
|
||||||
public async getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise<ArcaWsfeLastVoucherResult> {
|
public async getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise<ArcaWsfeLastVoucherResult> {
|
||||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||||
|
await this.requirePaymentsFeature(data.companyId);
|
||||||
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
|
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
|
||||||
const loginTicket = await this.getWsaaLoginTicket(data.companyId);
|
const loginTicket = await this.getWsaaLoginTicket(data.companyId);
|
||||||
const environment = this.getArcaEnvironment();
|
const environment = this.getArcaEnvironment();
|
||||||
@@ -886,6 +910,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
|||||||
|
|
||||||
public async issueFacturaC(data: ArcaWsfeIssueFacturaCParams): Promise<ArcaWsfeIssueFacturaCResult> {
|
public async issueFacturaC(data: ArcaWsfeIssueFacturaCParams): Promise<ArcaWsfeIssueFacturaCResult> {
|
||||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||||
|
await this.requirePaymentsFeature(data.companyId);
|
||||||
|
|
||||||
if (data.amount <= 0) {
|
if (data.amount <= 0) {
|
||||||
throw new Error("El importe de la factura debe ser mayor a cero");
|
throw new Error("El importe de la factura debe ser mayor a cero");
|
||||||
@@ -961,6 +986,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
|||||||
|
|
||||||
public async getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise<ArcaWsfeDiagnosticsResult> {
|
public async getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise<ArcaWsfeDiagnosticsResult> {
|
||||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||||
|
await this.requirePaymentsFeature(data.companyId);
|
||||||
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
|
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
|
||||||
const loginTicket = await this.getWsaaLoginTicket(data.companyId);
|
const loginTicket = await this.getWsaaLoginTicket(data.companyId);
|
||||||
const environment = this.getArcaEnvironment();
|
const environment = this.getArcaEnvironment();
|
||||||
@@ -1054,8 +1080,11 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
|||||||
|
|
||||||
await validateSessionUser({ sessionUser: data.sessionUser });
|
await validateSessionUser({ sessionUser: data.sessionUser });
|
||||||
|
|
||||||
|
let companyId: string | undefined;
|
||||||
|
|
||||||
if (data.companyId) {
|
if (data.companyId) {
|
||||||
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
|
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
|
||||||
|
companyId = data.companyId;
|
||||||
} else if (data.id) {
|
} else if (data.id) {
|
||||||
const credential = await this.arcaCredentials.findOne(data);
|
const credential = await this.arcaCredentials.findOne(data);
|
||||||
|
|
||||||
@@ -1067,12 +1096,13 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
|||||||
companyId: String(credential.companyId),
|
companyId: String(credential.companyId),
|
||||||
sessionUser: data.sessionUser,
|
sessionUser: data.sessionUser,
|
||||||
});
|
});
|
||||||
|
companyId = String(credential.companyId);
|
||||||
return [this.sanitizeCredential(credential)];
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Se requiere una organización para buscar credenciales ARCA");
|
throw new Error("Se requiere una organización para buscar credenciales ARCA");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.requirePaymentsFeature(companyId);
|
||||||
|
|
||||||
const credentials = await this.arcaCredentials.find(data);
|
const credentials = await this.arcaCredentials.find(data);
|
||||||
return credentials.map((credential) => this.sanitizeCredential(credential));
|
return credentials.map((credential) => this.sanitizeCredential(credential));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,9 @@ export class CashFlowAdapterMongoose implements ICashFlowAdapter {
|
|||||||
searchCriteria.year = filters.year;
|
searchCriteria.year = filters.year;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.month) {
|
// month 0 = enero: no usar truthy check, si no se dropea el filtro y enero
|
||||||
|
// queda sin filtrar (devolvería todos los meses del año).
|
||||||
|
if (typeof filters.month === "number") {
|
||||||
searchCriteria.month = filters.month;
|
searchCriteria.month = filters.month;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ export class CashFlowBalanceAdapterMongoose implements ICashFlowBalanceAdapter {
|
|||||||
searchCriteria.companyId = filters.companyId;
|
searchCriteria.companyId = filters.companyId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.month) {
|
// month 0 = enero: no usar truthy check, si no se dropea el filtro.
|
||||||
|
if (typeof filters.month === "number") {
|
||||||
searchCriteria.month = filters.month;
|
searchCriteria.month = filters.month;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -231,7 +231,10 @@ class CashFlowBalanceManager implements ICashFlowBalanceManager {
|
|||||||
const userSession = await this.validateSessionUser(data.sessionUser);
|
const userSession = await this.validateSessionUser(data.sessionUser);
|
||||||
await this.validatePermissions(String(userSession.id), data.companyId);
|
await this.validatePermissions(String(userSession.id), data.companyId);
|
||||||
|
|
||||||
const months = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
// Los balances se guardan con month 0-indexado (0 = enero ... 11 = diciembre),
|
||||||
|
// igual que dayjs.month(). No iterar de 1 a 12: eso saltea enero (key 0)
|
||||||
|
// y consultaba un mes 12 inexistente que el cliente renderiza como enero del año siguiente.
|
||||||
|
const months = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||||
const year = data.year;
|
const year = data.year;
|
||||||
const companyId = data.companyId;
|
const companyId = data.companyId;
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ export class ClientsAdapterMongoose implements IClientsAdapter {
|
|||||||
banned: { type: Boolean, required: false, default: false },
|
banned: { type: Boolean, required: false, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
//Unicidad por email dentro de la organización. La unicidad por usuario es
|
||||||
|
//parcial: excluye los clientes cuyo userId fue removido con $unset.
|
||||||
|
this.schema.index({ companyId: 1, email: 1 }, { unique: true });
|
||||||
|
this.schema.index(
|
||||||
|
{ companyId: 1, userId: 1 },
|
||||||
|
{ unique: true, partialFilterExpression: { userId: { $type: "oid" } } }
|
||||||
|
);
|
||||||
|
|
||||||
this.clientList = model<IClientDocument>("Client", this.schema);
|
this.clientList = model<IClientDocument>("Client", this.schema);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -266,6 +266,27 @@ class ClientManager implements IClientsManager {
|
|||||||
throw new Error("El cliente ya pertenece a la compañia");
|
throw new Error("El cliente ya pertenece a la compañia");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Si la organización ya tiene un cliente con el email del usuario, se
|
||||||
|
//vincula el usuario a ese cliente en lugar de crear un duplicado.
|
||||||
|
const existingByEmail = await this.clients.findOne({
|
||||||
|
companyId: data.companyId,
|
||||||
|
email: userCheck.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingByEmail) {
|
||||||
|
existingByEmail.userId = data.userId;
|
||||||
|
await existingByEmail.save();
|
||||||
|
|
||||||
|
await NotificationsManager.sendSystemNotification({
|
||||||
|
userId: String(userCheck._id),
|
||||||
|
subject: "Ha sido dado de alta como cliente",
|
||||||
|
message: `Su usuario ha sido dado de alta como cliente en la organización ${companyCheck.name}`,
|
||||||
|
type: NotificationType.BILLING
|
||||||
|
});
|
||||||
|
|
||||||
|
return existingByEmail;
|
||||||
|
}
|
||||||
|
|
||||||
const canAdd = await MetricsList.canAddClient(companyCheck.ownerId);
|
const canAdd = await MetricsList.canAddClient(companyCheck.ownerId);
|
||||||
|
|
||||||
if (!canAdd) {
|
if (!canAdd) {
|
||||||
@@ -274,7 +295,10 @@ class ClientManager implements IClientsManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newClient = await this.clients.create({
|
let newClient: IClientDocument;
|
||||||
|
|
||||||
|
try {
|
||||||
|
newClient = await this.clients.create({
|
||||||
...data,
|
...data,
|
||||||
...{
|
...{
|
||||||
email: userCheck.email,
|
email: userCheck.email,
|
||||||
@@ -285,6 +309,22 @@ class ClientManager implements IClientsManager {
|
|||||||
phoneNumber: userCheck.phoneNumber,
|
phoneNumber: userCheck.phoneNumber,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
//Carrera de concurrencia: el índice único rechazó el duplicado y la
|
||||||
|
//petición ganadora ya creó el cliente, lo contabilizó y notificó.
|
||||||
|
if ((error as { code?: number })?.code === 11000) {
|
||||||
|
const racedClient = await this.clients.findOne({
|
||||||
|
companyId: data.companyId,
|
||||||
|
userId: data.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (racedClient) {
|
||||||
|
return racedClient;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
await MetricsList.addClient({
|
await MetricsList.addClient({
|
||||||
userId: String(companyCheck.ownerId),
|
userId: String(companyCheck.ownerId),
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
// Unit tests for ClientManager.createClientByUser: adopt-by-email path, create
|
||||||
|
// path, the existing userId guard, and the E11000 concurrent-create race.
|
||||||
|
|
||||||
|
// Mock all external dependencies before importing the module under test
|
||||||
|
jest.mock("../../Users/Users", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
users: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Companies/Companies", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
companies: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Employees/Employee", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
checkPermission: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Metrics/Metrics", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
canAddClient: jest.fn(),
|
||||||
|
addClient: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Notifications/Notifications", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
NotificationsManager: {
|
||||||
|
sendSystemNotification: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Appointments/Appointments", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
Appointments: {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Repeats/Repeats", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
deleteRepeatsByClient: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../ClientAccounts/ClientAccount", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
getAvailableAmount: jest.fn(),
|
||||||
|
deleteClientAccount: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../../Payments/Payments", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
deletePaymentsByClient: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import ClientList from "../Clients";
|
||||||
|
import UsersManager from "../../Users/Users";
|
||||||
|
import CompaniesManager from "../../Companies/Companies";
|
||||||
|
import MetricsList from "../../Metrics/Metrics";
|
||||||
|
import { NotificationsManager } from "../../Notifications/Notifications";
|
||||||
|
import { NotificationType } from "../../SystemNotifications/SystemNotification.Interface";
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
companyId: "company-001",
|
||||||
|
userId: "user-001",
|
||||||
|
sessionUser: "session-user",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionUser = { _id: "session-user" };
|
||||||
|
|
||||||
|
const company = {
|
||||||
|
_id: "company-001",
|
||||||
|
ownerId: "owner-user",
|
||||||
|
name: "Acme",
|
||||||
|
};
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
_id: "user-001",
|
||||||
|
email: "client@example.com",
|
||||||
|
firstName: "John",
|
||||||
|
lastName: "Doe",
|
||||||
|
phoneAreaCode: "11",
|
||||||
|
phoneCountryCode: "54",
|
||||||
|
phoneNumber: "12345678",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to build a mock client document with save()
|
||||||
|
function buildClientDoc(overrides: Record<string, any> = {}): Record<string, any> {
|
||||||
|
return {
|
||||||
|
_id: "client-001",
|
||||||
|
id: "client-001",
|
||||||
|
companyId: "company-001",
|
||||||
|
email: "client@example.com",
|
||||||
|
firstName: "Admin Entered Name",
|
||||||
|
lastName: "Admin Entered Lastname",
|
||||||
|
save: jest.fn().mockResolvedValue(true),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createClientByUser", () => {
|
||||||
|
// Keep the real adapter to restore it after each test and to inspect indexes
|
||||||
|
const realAdapter = ClientList.clients;
|
||||||
|
let mockFindOne: jest.Mock;
|
||||||
|
let mockCreate: jest.Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
mockFindOne = jest.fn();
|
||||||
|
mockCreate = jest.fn();
|
||||||
|
|
||||||
|
(ClientList as any).clients = {
|
||||||
|
findOne: mockFindOne,
|
||||||
|
create: mockCreate,
|
||||||
|
};
|
||||||
|
|
||||||
|
// First call: session user validation. Second call: target user lookup.
|
||||||
|
(UsersManager.users.findOne as jest.Mock)
|
||||||
|
.mockResolvedValueOnce(sessionUser)
|
||||||
|
.mockResolvedValueOnce(user);
|
||||||
|
|
||||||
|
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue(company);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
(ClientList as any).clients = realAdapter;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Adopt path: existing client by email ---
|
||||||
|
|
||||||
|
it("adopts the existing client found by email instead of creating a duplicate", async () => {
|
||||||
|
const existingClient = buildClientDoc();
|
||||||
|
|
||||||
|
mockFindOne
|
||||||
|
.mockResolvedValueOnce(null) // guard by companyId + userId
|
||||||
|
.mockResolvedValueOnce(existingClient); // lookup by companyId + email
|
||||||
|
|
||||||
|
const result = await ClientList.createClientByUser(request);
|
||||||
|
|
||||||
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
|
expect(existingClient.userId).toBe("user-001");
|
||||||
|
expect(existingClient.save).toHaveBeenCalled();
|
||||||
|
// Admin-entered data must not be overwritten by the adopt path
|
||||||
|
expect(existingClient.firstName).toBe("Admin Entered Name");
|
||||||
|
expect(existingClient.lastName).toBe("Admin Entered Lastname");
|
||||||
|
// The client was already counted, so metrics are untouched
|
||||||
|
expect(MetricsList.canAddClient).not.toHaveBeenCalled();
|
||||||
|
expect(MetricsList.addClient).not.toHaveBeenCalled();
|
||||||
|
expect(NotificationsManager.sendSystemNotification).toHaveBeenCalledWith({
|
||||||
|
userId: "user-001",
|
||||||
|
subject: "Ha sido dado de alta como cliente",
|
||||||
|
message: "Su usuario ha sido dado de alta como cliente en la organización Acme",
|
||||||
|
type: NotificationType.BILLING,
|
||||||
|
});
|
||||||
|
expect(result).toBe(existingClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Create path: no client by userId nor email ---
|
||||||
|
|
||||||
|
it("creates a new client when no client exists by userId nor email", async () => {
|
||||||
|
const newClient = buildClientDoc({ userId: "user-001" });
|
||||||
|
|
||||||
|
mockFindOne
|
||||||
|
.mockResolvedValueOnce(null) // guard by companyId + userId
|
||||||
|
.mockResolvedValueOnce(null); // lookup by companyId + email
|
||||||
|
|
||||||
|
(MetricsList.canAddClient as jest.Mock).mockResolvedValue(true);
|
||||||
|
mockCreate.mockResolvedValue(newClient);
|
||||||
|
|
||||||
|
const result = await ClientList.createClientByUser(request);
|
||||||
|
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith({
|
||||||
|
companyId: "company-001",
|
||||||
|
userId: "user-001",
|
||||||
|
sessionUser: "session-user",
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
phoneAreaCode: user.phoneAreaCode,
|
||||||
|
phoneCountryCode: user.phoneCountryCode,
|
||||||
|
phoneNumber: user.phoneNumber,
|
||||||
|
});
|
||||||
|
expect(MetricsList.addClient).toHaveBeenCalledWith({
|
||||||
|
userId: "owner-user",
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
expect(NotificationsManager.sendSystemNotification).toHaveBeenCalledWith({
|
||||||
|
userId: "user-001",
|
||||||
|
subject: "Ha sido dado de alta como cliente",
|
||||||
|
message: "Su usuario ha sido dado de alta como cliente en la organización Acme",
|
||||||
|
type: NotificationType.BILLING,
|
||||||
|
});
|
||||||
|
expect(result).toBe(newClient);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Existing guard preserved ---
|
||||||
|
|
||||||
|
it("throws when the client already belongs to the company", async () => {
|
||||||
|
mockFindOne.mockResolvedValueOnce(buildClientDoc()); // guard by companyId + userId
|
||||||
|
|
||||||
|
await expect(ClientList.createClientByUser(request)).rejects.toThrow(
|
||||||
|
/ya pertenece a la compan/
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
|
expect(NotificationsManager.sendSystemNotification).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- E11000 concurrent-create race ---
|
||||||
|
|
||||||
|
it("returns the winning client when create hits the unique index (E11000)", async () => {
|
||||||
|
const racedClient = buildClientDoc({ userId: "user-001" });
|
||||||
|
|
||||||
|
mockFindOne
|
||||||
|
.mockResolvedValueOnce(null) // guard by companyId + userId
|
||||||
|
.mockResolvedValueOnce(null) // lookup by companyId + email
|
||||||
|
.mockResolvedValueOnce(racedClient); // re-fetch after E11000
|
||||||
|
|
||||||
|
(MetricsList.canAddClient as jest.Mock).mockResolvedValue(true);
|
||||||
|
mockCreate.mockRejectedValue({ code: 11000 });
|
||||||
|
|
||||||
|
const result = await ClientList.createClientByUser(request);
|
||||||
|
|
||||||
|
expect(result).toBe(racedClient);
|
||||||
|
// The winning request already counted and notified
|
||||||
|
expect(MetricsList.addClient).not.toHaveBeenCalled();
|
||||||
|
expect(NotificationsManager.sendSystemNotification).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rethrows non-duplicate-key errors from create", async () => {
|
||||||
|
mockFindOne
|
||||||
|
.mockResolvedValueOnce(null) // guard by companyId + userId
|
||||||
|
.mockResolvedValueOnce(null); // lookup by companyId + email
|
||||||
|
|
||||||
|
(MetricsList.canAddClient as jest.Mock).mockResolvedValue(true);
|
||||||
|
mockCreate.mockRejectedValue(new Error("database down"));
|
||||||
|
|
||||||
|
await expect(ClientList.createClientByUser(request)).rejects.toThrow("database down");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Schema indexes ---
|
||||||
|
|
||||||
|
it("declares unique indexes for companyId+email and companyId+userId", () => {
|
||||||
|
const indexes = realAdapter.schema.indexes();
|
||||||
|
|
||||||
|
const emailIndex = indexes.find(
|
||||||
|
([fields]) => fields.companyId === 1 && fields.email === 1
|
||||||
|
);
|
||||||
|
expect(emailIndex).toBeDefined();
|
||||||
|
expect(emailIndex?.[1]).toMatchObject({ unique: true });
|
||||||
|
|
||||||
|
const userIdIndex = indexes.find(
|
||||||
|
([fields]) => fields.companyId === 1 && fields.userId === 1
|
||||||
|
);
|
||||||
|
expect(userIdIndex).toBeDefined();
|
||||||
|
expect(userIdIndex?.[1]).toMatchObject({
|
||||||
|
unique: true,
|
||||||
|
partialFilterExpression: { userId: { $type: "oid" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -72,6 +72,8 @@ export class CompaniesAdapterMongoose implements ICompaniesAdapter {
|
|||||||
showPublicProfessionals: { type: Boolean, required: false, default: true },
|
showPublicProfessionals: { type: Boolean, required: false, default: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.schema.index({ published: 1, banned: 1 });
|
||||||
|
|
||||||
this.companyList = model<ICompanyDocument>("Company", this.schema);
|
this.companyList = model<ICompanyDocument>("Company", this.schema);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import {
|
|||||||
import { NotificationJobsAdapterMongoose } from "../NotificationJobs/NotificationJobs.Adapter.Mongoose";
|
import { NotificationJobsAdapterMongoose } from "../NotificationJobs/NotificationJobs.Adapter.Mongoose";
|
||||||
import { PolicyResolver, ResolvedPolicy } from "./PolicyResolver";
|
import { PolicyResolver, ResolvedPolicy } from "./PolicyResolver";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
|
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
export interface CreateJobServiceParams {
|
export interface CreateJobServiceParams {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
@@ -66,12 +68,17 @@ export class NotificationJobService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const rule of enabledReminderRules) {
|
for (const rule of enabledReminderRules) {
|
||||||
const reminderScheduledAt = this.adjustForQuietHours(
|
const originalReminderScheduledAt = dayjs(params.appointmentStart)
|
||||||
dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(),
|
.subtract(rule.offset, "minute")
|
||||||
|
.toDate();
|
||||||
|
const reminderScheduledAt = this.selectAutomaticReminderScheduledAt(
|
||||||
|
originalReminderScheduledAt,
|
||||||
|
now,
|
||||||
|
params.appointmentStart,
|
||||||
resolvedPolicy
|
resolvedPolicy
|
||||||
);
|
);
|
||||||
|
|
||||||
if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) {
|
if (reminderScheduledAt === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,12 +176,17 @@ export class NotificationJobService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const rule of enabledReminderRules) {
|
for (const rule of enabledReminderRules) {
|
||||||
const reminderScheduledAt = this.adjustForQuietHours(
|
const originalReminderScheduledAt = dayjs(params.appointmentStart)
|
||||||
dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(),
|
.subtract(rule.offset, "minute")
|
||||||
|
.toDate();
|
||||||
|
const reminderScheduledAt = this.selectAutomaticReminderScheduledAt(
|
||||||
|
originalReminderScheduledAt,
|
||||||
|
now,
|
||||||
|
params.appointmentStart,
|
||||||
resolvedPolicy
|
resolvedPolicy
|
||||||
);
|
);
|
||||||
|
|
||||||
if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) {
|
if (reminderScheduledAt === null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +251,78 @@ export class NotificationJobService {
|
|||||||
return scheduledAt;
|
return scheduledAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private selectAutomaticReminderScheduledAt(
|
||||||
|
originalScheduledAt: Date,
|
||||||
|
now: Date,
|
||||||
|
appointmentStart: Date,
|
||||||
|
policy: ResolvedPolicy
|
||||||
|
): Date | null {
|
||||||
|
const isValidCandidate = (candidate: Date): boolean =>
|
||||||
|
candidate >= now && candidate < appointmentStart;
|
||||||
|
|
||||||
|
if (!policy.quietHours?.from || !policy.quietHours?.to) {
|
||||||
|
return isValidCandidate(originalScheduledAt) ? originalScheduledAt : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromHour = parseInt(policy.quietHours.from.split(":")[0], 10);
|
||||||
|
const fromMinute = parseInt(policy.quietHours.from.split(":")[1], 10);
|
||||||
|
const toHour = parseInt(policy.quietHours.to.split(":")[0], 10);
|
||||||
|
const toMinute = parseInt(policy.quietHours.to.split(":")[1], 10);
|
||||||
|
const localScheduledAt = dayjs(originalScheduledAt).tz(policy.timezone);
|
||||||
|
const scheduledMinutes = localScheduledAt.hour() * 60 + localScheduledAt.minute();
|
||||||
|
|
||||||
|
const isInsideQuietHours = this.isTimeInQuietHours(
|
||||||
|
localScheduledAt.hour(),
|
||||||
|
localScheduledAt.minute(),
|
||||||
|
fromHour,
|
||||||
|
fromMinute,
|
||||||
|
toHour,
|
||||||
|
toMinute
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isInsideQuietHours) {
|
||||||
|
return isValidCandidate(originalScheduledAt) ? originalScheduledAt : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromMinutes = fromHour * 60 + fromMinute;
|
||||||
|
const toMinutes = toHour * 60 + toMinute;
|
||||||
|
const wrapsAcrossMidnight = fromMinutes >= toMinutes;
|
||||||
|
const isEarlyMorningSegment = scheduledMinutes < fromMinutes;
|
||||||
|
const localDay = localScheduledAt.startOf("day");
|
||||||
|
const quietStartDay =
|
||||||
|
wrapsAcrossMidnight && isEarlyMorningSegment
|
||||||
|
? localDay.subtract(1, "day")
|
||||||
|
: localDay;
|
||||||
|
const quietEndDay =
|
||||||
|
wrapsAcrossMidnight && !isEarlyMorningSegment
|
||||||
|
? localDay.add(1, "day")
|
||||||
|
: localDay;
|
||||||
|
const quietStart = quietStartDay
|
||||||
|
.hour(fromHour)
|
||||||
|
.minute(fromMinute)
|
||||||
|
.second(0)
|
||||||
|
.millisecond(localScheduledAt.millisecond());
|
||||||
|
const quietEnd = quietEndDay
|
||||||
|
.hour(toHour)
|
||||||
|
.minute(toMinute)
|
||||||
|
.second(0)
|
||||||
|
.millisecond(localScheduledAt.millisecond());
|
||||||
|
const advanceCandidate = quietStart.subtract(1, "minute").toDate();
|
||||||
|
const postponeCandidate = quietEnd.toDate();
|
||||||
|
const validAdvanceCandidate = isValidCandidate(advanceCandidate)
|
||||||
|
? advanceCandidate
|
||||||
|
: null;
|
||||||
|
const validPostponeCandidate = isValidCandidate(postponeCandidate)
|
||||||
|
? postponeCandidate
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (validAdvanceCandidate) {
|
||||||
|
return validAdvanceCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return validPostponeCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
private getPayloadChannels(payload?: CreateJobParams["payload"]): NotificationChannel[] {
|
private getPayloadChannels(payload?: CreateJobParams["payload"]): NotificationChannel[] {
|
||||||
const channels: NotificationChannel[] = [];
|
const channels: NotificationChannel[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -180,28 +180,35 @@ describe("NotificationJobService.createJob", () => {
|
|||||||
expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0);
|
expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips reminder jobs adjusted by quiet hours after appointment start", async () => {
|
it("keeps UTC adjustment for the createJob base job and uses local candidates for reminders", async () => {
|
||||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T20:00:00.000Z"));
|
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T23:00:00.000Z"));
|
||||||
const resolvedPolicy: ResolvedPolicy = {
|
const resolvedPolicy: ResolvedPolicy = {
|
||||||
channels: ["system", "email"],
|
channels: ["system"],
|
||||||
timezone: "America/Argentina/Buenos_Aires",
|
timezone: "America/Argentina/Buenos_Aires",
|
||||||
quietHours: { from: "22:00", to: "07:00" },
|
quietHours: { from: "22:00", to: "07:00" },
|
||||||
reminderRules: [{ offset: 15, enabled: true }],
|
reminderRules: [{ offset: 30, enabled: true }],
|
||||||
};
|
};
|
||||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||||
|
const baseScheduledAt = new Date("2026-07-22T02:00:00.000Z");
|
||||||
|
|
||||||
await service.createJob({
|
await service.createJob({
|
||||||
companyId: "company-1",
|
companyId: "company-1",
|
||||||
clientId: "client-1",
|
clientId: "client-1",
|
||||||
appointmentId: "appt-1",
|
appointmentId: "appt-1",
|
||||||
type: "creation",
|
type: "update",
|
||||||
appointmentStart: new Date("2026-07-21T23:30:00.000Z"),
|
scheduledAt: baseScheduledAt,
|
||||||
|
appointmentStart: new Date("2026-07-22T03:00:00.000Z"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
|
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
|
||||||
expect(createCalls.filter((job) => job.type === "creation")).toHaveLength(2);
|
expect(createCalls).toHaveLength(2);
|
||||||
expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0);
|
expect(createCalls.find((job) => job.type === "update")?.scheduledAt.toISOString()).toBe(
|
||||||
|
"2026-07-22T07:00:00.000Z"
|
||||||
|
);
|
||||||
|
expect(createCalls.find((job) => job.type === "reminder")?.scheduledAt.toISOString()).toBe(
|
||||||
|
"2026-07-22T00:59:00.000Z"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates no jobs when resolved channels are empty", async () => {
|
it("creates no jobs when resolved channels are empty", async () => {
|
||||||
@@ -330,77 +337,208 @@ describe("NotificationJobService.createJob", () => {
|
|||||||
expect(result).toHaveLength(0);
|
expect(result).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adjusts scheduledAt for quiet hours crossing midnight", async () => {
|
it("keeps an automatic reminder at its original instant outside quiet hours", async () => {
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T07:00:00.000Z"));
|
||||||
const resolvedPolicy: ResolvedPolicy = {
|
const resolvedPolicy: ResolvedPolicy = {
|
||||||
channels: ["system"],
|
channels: ["system"],
|
||||||
timezone: "America/Argentina/Buenos_Aires",
|
timezone: "UTC",
|
||||||
quietHours: { from: "22:00", to: "07:00" },
|
quietHours: { from: "08:00", to: "10:00" },
|
||||||
|
reminderRules: [{ offset: 270, enabled: true }],
|
||||||
};
|
};
|
||||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||||
|
|
||||||
// scheduledAt at 23:30 UTC — should be adjusted to 07:00 next day UTC
|
const result = await service.createReminderJobs({
|
||||||
const scheduledAt = new Date("2026-07-21T23:30:00.000Z");
|
|
||||||
|
|
||||||
await service.createJob({
|
|
||||||
companyId: "company-1",
|
companyId: "company-1",
|
||||||
clientId: "client-1",
|
clientId: "client-1",
|
||||||
appointmentId: "appt-1",
|
appointmentId: "appt-1",
|
||||||
type: "creation",
|
type: "reminder",
|
||||||
scheduledAt,
|
appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createCall = mockJobCreate.mock.calls[0][0];
|
expect(result).toHaveLength(1);
|
||||||
// 23:30 is inside quiet hours (22:00-07:00), adjusted to 07:00 next day
|
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
|
||||||
expect(createCall.scheduledAt.getUTCHours()).toBe(7);
|
"2026-07-21T07:30:00.123Z"
|
||||||
expect(createCall.scheduledAt.getUTCDate()).toBe(22); // next day
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adjusts early morning quiet hours crossing midnight to the same day", async () => {
|
it("prefers the pre-quiet candidate when the quiet-hours end is closer for a same-day range", async () => {
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T00:00:00.000Z"));
|
||||||
const resolvedPolicy: ResolvedPolicy = {
|
const resolvedPolicy: ResolvedPolicy = {
|
||||||
channels: ["system"],
|
channels: ["system"],
|
||||||
timezone: "America/Argentina/Buenos_Aires",
|
timezone: "UTC",
|
||||||
quietHours: { from: "22:00", to: "07:00" },
|
quietHours: { from: "01:00", to: "06:00" },
|
||||||
|
reminderRules: [{ offset: 150, enabled: true }],
|
||||||
};
|
};
|
||||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||||
|
|
||||||
await service.createJob({
|
const result = await service.createReminderJobs({
|
||||||
companyId: "company-1",
|
companyId: "company-1",
|
||||||
clientId: "client-1",
|
clientId: "client-1",
|
||||||
appointmentId: "appt-1",
|
appointmentId: "appt-1",
|
||||||
type: "creation",
|
type: "reminder",
|
||||||
scheduledAt: new Date("2026-07-21T05:00:00.000Z"),
|
appointmentStart: new Date("2026-07-21T08:00:00.123Z"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createCall = mockJobCreate.mock.calls[0][0];
|
expect(result).toHaveLength(1);
|
||||||
expect(createCall.scheduledAt.toISOString()).toBe("2026-07-21T07:00:00.000Z");
|
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
|
||||||
|
"2026-07-21T00:59:00.123Z"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not adjust scheduledAt when outside quiet hours", async () => {
|
it("uses the policy timezone for a cross-midnight range and advances to the prior local day", async () => {
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T22:00:00.000Z"));
|
||||||
const resolvedPolicy: ResolvedPolicy = {
|
const resolvedPolicy: ResolvedPolicy = {
|
||||||
channels: ["system"],
|
channels: ["system"],
|
||||||
timezone: "America/Argentina/Buenos_Aires",
|
timezone: "America/Argentina/Buenos_Aires",
|
||||||
quietHours: { from: "22:00", to: "07:00" },
|
quietHours: { from: "22:00", to: "07:00" },
|
||||||
|
reminderRules: [{ offset: 510, enabled: true }],
|
||||||
};
|
};
|
||||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||||
|
|
||||||
// scheduledAt at 14:00 UTC — outside quiet hours (22:00-07:00)
|
const result = await service.createReminderJobs({
|
||||||
const scheduledAt = new Date("2026-07-21T14:00:00.000Z");
|
|
||||||
|
|
||||||
await service.createJob({
|
|
||||||
companyId: "company-1",
|
companyId: "company-1",
|
||||||
clientId: "client-1",
|
clientId: "client-1",
|
||||||
appointmentId: "appt-1",
|
appointmentId: "appt-1",
|
||||||
type: "creation",
|
type: "reminder",
|
||||||
scheduledAt,
|
appointmentStart: new Date("2026-07-22T12:00:00.123Z"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createCall = mockJobCreate.mock.calls[0][0];
|
expect(result).toHaveLength(1);
|
||||||
// 14:00 is outside quiet hours, should remain unchanged
|
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
|
||||||
expect(createCall.scheduledAt.getUTCHours()).toBe(14);
|
"2026-07-22T00:59:00.123Z"
|
||||||
expect(createCall.scheduledAt.getUTCDate()).toBe(21);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advances to one minute before quiet-hours start when both candidates are valid", async () => {
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T07:00:00.000Z"));
|
||||||
|
const resolvedPolicy: ResolvedPolicy = {
|
||||||
|
channels: ["system"],
|
||||||
|
timezone: "UTC",
|
||||||
|
quietHours: { from: "08:00", to: "10:00" },
|
||||||
|
reminderRules: [{ offset: 210, enabled: true }],
|
||||||
|
};
|
||||||
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||||
|
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||||
|
|
||||||
|
const result = await service.createReminderJobs({
|
||||||
|
companyId: "company-1",
|
||||||
|
clientId: "client-1",
|
||||||
|
appointmentId: "appt-1",
|
||||||
|
type: "reminder",
|
||||||
|
appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
|
||||||
|
"2026-07-21T07:59:00.123Z"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("postpones when the advance candidate is before now", async () => {
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T08:30:00.000Z"));
|
||||||
|
const resolvedPolicy: ResolvedPolicy = {
|
||||||
|
channels: ["system"],
|
||||||
|
timezone: "UTC",
|
||||||
|
quietHours: { from: "08:00", to: "10:00" },
|
||||||
|
reminderRules: [{ offset: 150, enabled: true }],
|
||||||
|
};
|
||||||
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||||
|
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||||
|
|
||||||
|
const result = await service.createReminderJobs({
|
||||||
|
companyId: "company-1",
|
||||||
|
clientId: "client-1",
|
||||||
|
appointmentId: "appt-1",
|
||||||
|
type: "reminder",
|
||||||
|
appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
|
||||||
|
"2026-07-21T10:00:00.123Z"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the pre-quiet candidate on an exact candidate-distance tie", async () => {
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T07:00:00.000Z"));
|
||||||
|
const resolvedPolicy: ResolvedPolicy = {
|
||||||
|
channels: ["system"],
|
||||||
|
timezone: "UTC",
|
||||||
|
quietHours: { from: "08:00", to: "10:00" },
|
||||||
|
reminderRules: [{ offset: 180, enabled: true }],
|
||||||
|
};
|
||||||
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||||
|
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||||
|
|
||||||
|
const result = await service.createReminderJobs({
|
||||||
|
companyId: "company-1",
|
||||||
|
clientId: "client-1",
|
||||||
|
appointmentId: "appt-1",
|
||||||
|
type: "reminder",
|
||||||
|
appointmentStart: new Date("2026-07-21T11:59:30.123Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
|
||||||
|
"2026-07-21T07:59:00.123Z"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats quiet-hours start as inclusive and end as exclusive", async () => {
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T07:00:00.000Z"));
|
||||||
|
const resolvedPolicy: ResolvedPolicy = {
|
||||||
|
channels: ["system"],
|
||||||
|
timezone: "UTC",
|
||||||
|
quietHours: { from: "08:00", to: "10:00" },
|
||||||
|
reminderRules: [
|
||||||
|
{ offset: 240, enabled: true },
|
||||||
|
{ offset: 120, enabled: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||||
|
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||||
|
|
||||||
|
const result = await service.createReminderJobs({
|
||||||
|
companyId: "company-1",
|
||||||
|
clientId: "client-1",
|
||||||
|
appointmentId: "appt-1",
|
||||||
|
type: "reminder",
|
||||||
|
appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const scheduledTimes = mockJobCreate.mock.calls
|
||||||
|
.map((call) => call[0].scheduledAt.toISOString())
|
||||||
|
.sort();
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(scheduledTimes).toEqual([
|
||||||
|
"2026-07-21T07:59:00.123Z",
|
||||||
|
"2026-07-21T10:00:00.123Z",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits a reminder when the advance is past and postponement reaches appointment start", async () => {
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T09:30:00.000Z"));
|
||||||
|
const resolvedPolicy: ResolvedPolicy = {
|
||||||
|
channels: ["system"],
|
||||||
|
timezone: "UTC",
|
||||||
|
quietHours: { from: "08:00", to: "10:00" },
|
||||||
|
reminderRules: [{ offset: 60, enabled: true }],
|
||||||
|
};
|
||||||
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||||
|
|
||||||
|
const result = await service.createReminderJobs({
|
||||||
|
companyId: "company-1",
|
||||||
|
clientId: "client-1",
|
||||||
|
appointmentId: "appt-1",
|
||||||
|
type: "reminder",
|
||||||
|
appointmentStart: new Date("2026-07-21T10:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
expect(mockJobCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes appointmentId and type to job creation", async () => {
|
it("passes appointmentId and type to job creation", async () => {
|
||||||
@@ -428,21 +566,24 @@ describe("NotificationJobService.createJob", () => {
|
|||||||
expect(createCall.clientId).toBe("client-1");
|
expect(createCall.clientId).toBe("client-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates immediate jobs without reminder offsets", async () => {
|
it("does not apply quiet-hours adjustment to immediate reminder jobs", async () => {
|
||||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z"));
|
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T09:00:00.000Z"));
|
||||||
const resolvedPolicy: ResolvedPolicy = {
|
const resolvedPolicy: ResolvedPolicy = {
|
||||||
channels: ["system", "email"],
|
channels: ["system", "email"],
|
||||||
timezone: "America/Argentina/Buenos_Aires",
|
timezone: "UTC",
|
||||||
|
quietHours: { from: "08:00", to: "10:00" },
|
||||||
reminderRules: [{ offset: 30, enabled: true }],
|
reminderRules: [{ offset: 30, enabled: true }],
|
||||||
};
|
};
|
||||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||||
|
const scheduledAt = new Date("2026-07-21T09:30:00.123Z");
|
||||||
|
|
||||||
const result = await service.createImmediateJob({
|
const result = await service.createImmediateJob({
|
||||||
companyId: "company-1",
|
companyId: "company-1",
|
||||||
clientId: "client-1",
|
clientId: "client-1",
|
||||||
appointmentId: "appt-1",
|
appointmentId: "appt-1",
|
||||||
type: "reminder",
|
type: "reminder",
|
||||||
|
scheduledAt,
|
||||||
appointmentStart: new Date("2026-07-21T16:00:00.000Z"),
|
appointmentStart: new Date("2026-07-21T16:00:00.000Z"),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -453,12 +594,12 @@ describe("NotificationJobService.createJob", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
channel: "system",
|
channel: "system",
|
||||||
type: "reminder",
|
type: "reminder",
|
||||||
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
|
scheduledAt,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
channel: "email",
|
channel: "email",
|
||||||
type: "reminder",
|
type: "reminder",
|
||||||
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
|
scheduledAt,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
|
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
|
||||||
|
import CompaniesList from "../Companies/Companies";
|
||||||
|
import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons";
|
||||||
|
import { PlanFeatures } from "../Plans/Plans.interface";
|
||||||
import { findArcaActivityByCode } from "../ArcaActivities/ArcaActivities.Catalog";
|
import { findArcaActivityByCode } from "../ArcaActivities/ArcaActivities.Catalog";
|
||||||
import { OrganizationFiscalProfilesAdapterMongoose } from "./OrganizationFiscalProfiles.Adapter.Mongoose";
|
import { OrganizationFiscalProfilesAdapterMongoose } from "./OrganizationFiscalProfiles.Adapter.Mongoose";
|
||||||
import {
|
import {
|
||||||
@@ -70,6 +73,21 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa
|
|||||||
|
|
||||||
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
|
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
|
||||||
|
|
||||||
|
const company = await CompaniesList.companies.findOne({ _id: data.companyId });
|
||||||
|
|
||||||
|
if (!company) {
|
||||||
|
throw new Error("No se ha encontrado la organización");
|
||||||
|
}
|
||||||
|
|
||||||
|
const canAccessPayments = await PlanSubscriptionsList.checkFeature({
|
||||||
|
userId: String(company.ownerId),
|
||||||
|
feature: PlanFeatures.PAYMENTS,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!canAccessPayments) {
|
||||||
|
throw new Error("La organización no tiene un plan que soporte el módulo de pagos");
|
||||||
|
}
|
||||||
|
|
||||||
this.validateProfileFields(data);
|
this.validateProfileFields(data);
|
||||||
|
|
||||||
const existingProfile = await this.organizationFiscalProfiles.findOne({
|
const existingProfile = await this.organizationFiscalProfiles.findOne({
|
||||||
@@ -140,6 +158,18 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa
|
|||||||
companyId: data.companyId,
|
companyId: data.companyId,
|
||||||
sessionUser,
|
sessionUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const company = await CompaniesList.companies.findOne({ _id: data.companyId });
|
||||||
|
if (company) {
|
||||||
|
const canAccessPayments = await PlanSubscriptionsList.checkFeature({
|
||||||
|
userId: String(company.ownerId),
|
||||||
|
feature: PlanFeatures.PAYMENTS,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!canAccessPayments) {
|
||||||
|
throw new Error("La organización no tiene un plan que soporte el módulo de pagos");
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (data.id) {
|
} else if (data.id) {
|
||||||
const profile = await this.organizationFiscalProfiles.findOne(data);
|
const profile = await this.organizationFiscalProfiles.findOne(data);
|
||||||
|
|
||||||
@@ -152,6 +182,18 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa
|
|||||||
sessionUser,
|
sessionUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const company = await CompaniesList.companies.findOne({ _id: String(profile.companyId) });
|
||||||
|
if (company) {
|
||||||
|
const canAccessPayments = await PlanSubscriptionsList.checkFeature({
|
||||||
|
userId: String(company.ownerId),
|
||||||
|
feature: PlanFeatures.PAYMENTS,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!canAccessPayments) {
|
||||||
|
throw new Error("La organización no tiene un plan que soporte el módulo de pagos");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [profile];
|
return [profile];
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Se requiere una organización para buscar perfiles fiscales");
|
throw new Error("Se requiere una organización para buscar perfiles fiscales");
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import ClientsList from "../Clients/Clients";
|
|||||||
import RepeatsList from "../Repeats/Repeats";
|
import RepeatsList from "../Repeats/Repeats";
|
||||||
import { OAuth2Client } from "google-auth-library";
|
import { OAuth2Client } from "google-auth-library";
|
||||||
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
|
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
const cleanEmail = (email: string): string => {
|
const cleanEmail = (email: string): string => {
|
||||||
return email.toLowerCase().trim();
|
return email.toLowerCase().trim();
|
||||||
@@ -1087,10 +1088,18 @@ class UsersManager implements IUsersManager {
|
|||||||
|
|
||||||
await this.users.update(data);
|
await this.users.update(data);
|
||||||
|
|
||||||
|
const fecha = dayjs().format("DD/MM/YYYY");
|
||||||
|
const hora = dayjs().format("HH:mm");
|
||||||
|
|
||||||
await NotificationsManager.sendEmail({
|
await NotificationsManager.sendEmail({
|
||||||
email: cleanEmail(sessionUser.email),
|
email: cleanEmail(sessionUser.email),
|
||||||
subject: "TurnosXpress :: Usuario actualizado",
|
subject: "TurnosXpress :: Usuario actualizado",
|
||||||
message: `Hola ${sessionUser.firstName}, has actualizado tu perfil con éxito`,
|
templateId: "6aa948e050f4bd20460f00a5",
|
||||||
|
context: {
|
||||||
|
username: sessionUser.firstName,
|
||||||
|
fecha,
|
||||||
|
hora,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { isNull } from "../../helpers/IsNull";
|
import { isNull } from "../../helpers/IsNull";
|
||||||
import CompaniesList from "../Companies/Companies";
|
import CompaniesList from "../Companies/Companies";
|
||||||
|
import { CompaniesAdapterMongoose } from "../Companies/Companies.Adapter.Mongoose";
|
||||||
import ServiceList from "../Services/Service";
|
import ServiceList from "../Services/Service";
|
||||||
import {
|
import {
|
||||||
PublicServicesResults,
|
PublicServicesResults,
|
||||||
@@ -15,6 +16,24 @@ import { BotEventParams, BotView } from "../WapServer/WapServer.Interface";
|
|||||||
import WapServerList from "../WapServer/WapServer";
|
import WapServerList from "../WapServer/WapServer";
|
||||||
import { COMPANY_PUBLISHED_STATUS } from "../Companies/Companies.Interface";
|
import { COMPANY_PUBLISHED_STATUS } from "../Companies/Companies.Interface";
|
||||||
|
|
||||||
|
export type PublicOrganizationsParams = {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PublicOrganizationListItem = {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
creationDate: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PublicOrganizationsResult = {
|
||||||
|
data: PublicOrganizationListItem[];
|
||||||
|
page: number;
|
||||||
|
pages: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type HomeView = {
|
export type HomeView = {
|
||||||
education: PublicServicesResults;
|
education: PublicServicesResults;
|
||||||
health: PublicServicesResults;
|
health: PublicServicesResults;
|
||||||
@@ -39,6 +58,7 @@ export type PublicOrganizationViewParams = {
|
|||||||
|
|
||||||
export type PublicOrganizationView = {
|
export type PublicOrganizationView = {
|
||||||
id: string;
|
id: string;
|
||||||
|
slug: string;
|
||||||
headerFile: string;
|
headerFile: string;
|
||||||
headerColor: string;
|
headerColor: string;
|
||||||
headerFontColor: string;
|
headerFontColor: string;
|
||||||
@@ -143,6 +163,7 @@ class Views {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: isNull<string>(company.id, ""),
|
id: isNull<string>(company.id, ""),
|
||||||
|
slug: isNull<string>(company.slug, ""),
|
||||||
name: isNull<string>(company.name, ""),
|
name: isNull<string>(company.name, ""),
|
||||||
description: isNull<string>(company.description, ""),
|
description: isNull<string>(company.description, ""),
|
||||||
categoryId: isNull<number>(company.categoryId, 50000),
|
categoryId: isNull<number>(company.categoryId, 50000),
|
||||||
@@ -249,6 +270,44 @@ class Views {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async publicOrganizations(
|
||||||
|
data: PublicOrganizationsParams
|
||||||
|
): Promise<PublicOrganizationsResult> {
|
||||||
|
const { page, limit } = data;
|
||||||
|
const filter = {
|
||||||
|
published: COMPANY_PUBLISHED_STATUS.PUBLISHED,
|
||||||
|
banned: { $ne: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const companyList = (CompaniesList.companies as CompaniesAdapterMongoose).companyList;
|
||||||
|
|
||||||
|
const count = await companyList
|
||||||
|
.countDocuments(filter)
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const results = (await companyList
|
||||||
|
.find(filter)
|
||||||
|
.select("slug name description creationDate")
|
||||||
|
.sort({ creationDate: 1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.lean()
|
||||||
|
.exec()) as any[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: results.map((company) => ({
|
||||||
|
slug: String(company.slug || ""),
|
||||||
|
name: String(company.name || ""),
|
||||||
|
description: String(company.description || ""),
|
||||||
|
creationDate: company.creationDate,
|
||||||
|
})),
|
||||||
|
page,
|
||||||
|
pages: Math.ceil(count / limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public async wapView(data: BotEventParams): Promise<BotView> {
|
public async wapView(data: BotEventParams): Promise<BotView> {
|
||||||
return await WapServerList.botView(data);
|
return await WapServerList.botView(data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export class ToggleCompanyFixedPostController extends Controller {
|
|||||||
@SuccessResponse(200, "Done")
|
@SuccessResponse(200, "Done")
|
||||||
@Post()
|
@Post()
|
||||||
public async toggleFixedCompanyPost(
|
public async toggleFixedCompanyPost(
|
||||||
@Body() requestBody: FixCompanyPostParams
|
@Body() requestBody: FixCompanyPostParams,
|
||||||
): Promise<ApiVoidResult | ApiValidationError> {
|
): Promise<ApiVoidResult | ApiValidationError> {
|
||||||
try {
|
try {
|
||||||
await new CompaniesService().toggleFixedPost(requestBody);
|
await new CompaniesService().toggleFixedPost(requestBody);
|
||||||
@@ -62,7 +62,7 @@ export class MoveCompanyFixedPostController extends Controller {
|
|||||||
@SuccessResponse(200, "Done")
|
@SuccessResponse(200, "Done")
|
||||||
@Post()
|
@Post()
|
||||||
public async moveFixedCompanyPost(
|
public async moveFixedCompanyPost(
|
||||||
@Body() requestBody: MoveCompanyFixedPostParams
|
@Body() requestBody: MoveCompanyFixedPostParams,
|
||||||
): Promise<ApiVoidResult | ApiValidationError> {
|
): Promise<ApiVoidResult | ApiValidationError> {
|
||||||
try {
|
try {
|
||||||
await new CompaniesService().moveFixedPost(requestBody);
|
await new CompaniesService().moveFixedPost(requestBody);
|
||||||
@@ -86,7 +86,7 @@ export class SetNotificationsStatusCompanyController extends Controller {
|
|||||||
@SuccessResponse(200, "Done")
|
@SuccessResponse(200, "Done")
|
||||||
@Post()
|
@Post()
|
||||||
public async setNotificationAuto(
|
public async setNotificationAuto(
|
||||||
@Body() requestBody: SetNotificationAutoParams
|
@Body() requestBody: SetNotificationAutoParams,
|
||||||
): Promise<ApiVoidResult | ApiValidationError> {
|
): Promise<ApiVoidResult | ApiValidationError> {
|
||||||
try {
|
try {
|
||||||
await new CompaniesService().setNotificationAuto(requestBody);
|
await new CompaniesService().setNotificationAuto(requestBody);
|
||||||
@@ -110,7 +110,7 @@ export class SetPublishedStatusCompanyController extends Controller {
|
|||||||
@SuccessResponse(200, "Done")
|
@SuccessResponse(200, "Done")
|
||||||
@Post()
|
@Post()
|
||||||
public async setPublishedStatus(
|
public async setPublishedStatus(
|
||||||
@Body() requestBody: SetOrganizationPublishedStatusParams
|
@Body() requestBody: SetOrganizationPublishedStatusParams,
|
||||||
): Promise<ApiVoidResult | ApiValidationError> {
|
): Promise<ApiVoidResult | ApiValidationError> {
|
||||||
try {
|
try {
|
||||||
await new CompaniesService().setPublishedStatus(requestBody);
|
await new CompaniesService().setPublishedStatus(requestBody);
|
||||||
@@ -134,7 +134,7 @@ export class CreateCompanyController extends Controller {
|
|||||||
@SuccessResponse(200, "La compañia fue creada con exito")
|
@SuccessResponse(200, "La compañia fue creada con exito")
|
||||||
@Post()
|
@Post()
|
||||||
public async createCompany(
|
public async createCompany(
|
||||||
@Body() requestBody: CreateCompanyParams
|
@Body() requestBody: CreateCompanyParams,
|
||||||
): Promise<ICompany | ApiValidationError> {
|
): Promise<ICompany | ApiValidationError> {
|
||||||
try {
|
try {
|
||||||
const company = await new CompaniesService().createCompany(requestBody);
|
const company = await new CompaniesService().createCompany(requestBody);
|
||||||
@@ -155,7 +155,7 @@ export class DeleteCompanyController extends Controller {
|
|||||||
@SuccessResponse(200, "La compañia fue modificada con exito")
|
@SuccessResponse(200, "La compañia fue modificada con exito")
|
||||||
@Post()
|
@Post()
|
||||||
public async deleteCompany(
|
public async deleteCompany(
|
||||||
@Body() requestBody: DeleteCompanyParams
|
@Body() requestBody: DeleteCompanyParams,
|
||||||
): Promise<ApiVoidResult | ApiValidationError> {
|
): Promise<ApiVoidResult | ApiValidationError> {
|
||||||
try {
|
try {
|
||||||
await new CompaniesService().deleteCompany(requestBody);
|
await new CompaniesService().deleteCompany(requestBody);
|
||||||
@@ -179,7 +179,7 @@ export class UpdateCompanyController extends Controller {
|
|||||||
@SuccessResponse(200, "La compañia fue modificada con exito")
|
@SuccessResponse(200, "La compañia fue modificada con exito")
|
||||||
@Post()
|
@Post()
|
||||||
public async updateCompany(
|
public async updateCompany(
|
||||||
@Body() requestBody: UpdateCompanyParams
|
@Body() requestBody: UpdateCompanyParams,
|
||||||
): Promise<ApiVoidResult | ApiValidationError> {
|
): Promise<ApiVoidResult | ApiValidationError> {
|
||||||
try {
|
try {
|
||||||
await new CompaniesService().updateCompany(requestBody);
|
await new CompaniesService().updateCompany(requestBody);
|
||||||
@@ -202,7 +202,7 @@ export class GetCompanyByIdController extends Controller {
|
|||||||
@SuccessResponse(200, "Done")
|
@SuccessResponse(200, "Done")
|
||||||
@Post()
|
@Post()
|
||||||
public async getById(
|
public async getById(
|
||||||
@Body() requestBody: OranizationsViewByIdParams
|
@Body() requestBody: OranizationsViewByIdParams,
|
||||||
): Promise<MyOranizationsView | ApiValidationError> {
|
): Promise<MyOranizationsView | ApiValidationError> {
|
||||||
try {
|
try {
|
||||||
const companies = await new CompaniesService().getById(requestBody);
|
const companies = await new CompaniesService().getById(requestBody);
|
||||||
@@ -222,7 +222,7 @@ export class GetCompanyByUserController extends Controller {
|
|||||||
@SuccessResponse(200, "Done")
|
@SuccessResponse(200, "Done")
|
||||||
@Post()
|
@Post()
|
||||||
public async getByUserId(
|
public async getByUserId(
|
||||||
@Body() requestBody: MyOranizationsViewParams
|
@Body() requestBody: MyOranizationsViewParams,
|
||||||
): Promise<MyOranizationsView[] | ApiValidationError> {
|
): Promise<MyOranizationsView[] | ApiValidationError> {
|
||||||
try {
|
try {
|
||||||
const companies = await new CompaniesService().getByUserId(requestBody);
|
const companies = await new CompaniesService().getByUserId(requestBody);
|
||||||
@@ -242,7 +242,7 @@ export class PaginateCompanyController extends Controller {
|
|||||||
@SuccessResponse(200, "Done")
|
@SuccessResponse(200, "Done")
|
||||||
@Post()
|
@Post()
|
||||||
public async paginateUsers(
|
public async paginateUsers(
|
||||||
@Body() requestBody: PaginateCompaniesParams
|
@Body() requestBody: PaginateCompaniesParams,
|
||||||
): Promise<PaginateCompaniesResults | ApiValidationError> {
|
): Promise<PaginateCompaniesResults | ApiValidationError> {
|
||||||
try {
|
try {
|
||||||
const users = await new CompaniesService().paginateCompanies(requestBody);
|
const users = await new CompaniesService().paginateCompanies(requestBody);
|
||||||
@@ -265,7 +265,7 @@ export class UploadCompanyHeaderController extends Controller {
|
|||||||
public async uploadHeader(
|
public async uploadHeader(
|
||||||
@FormField() companyId: string,
|
@FormField() companyId: string,
|
||||||
@FormField() sessionUser: string,
|
@FormField() sessionUser: string,
|
||||||
@UploadedFile() file: Express.Multer.File
|
@UploadedFile() file: Express.Multer.File,
|
||||||
): Promise<ApiVoidResult | ApiValidationError> {
|
): Promise<ApiVoidResult | ApiValidationError> {
|
||||||
try {
|
try {
|
||||||
await new CompaniesService().setCompanyHeader({
|
await new CompaniesService().setCompanyHeader({
|
||||||
@@ -295,7 +295,7 @@ export class UploadCompanyIconController extends Controller {
|
|||||||
public async uploadIcon(
|
public async uploadIcon(
|
||||||
@FormField() companyId: string,
|
@FormField() companyId: string,
|
||||||
@FormField() sessionUser: string,
|
@FormField() sessionUser: string,
|
||||||
@UploadedFile() file: Express.Multer.File
|
@UploadedFile() file: Express.Multer.File,
|
||||||
): Promise<ApiVoidResult | ApiValidationError> {
|
): Promise<ApiVoidResult | ApiValidationError> {
|
||||||
try {
|
try {
|
||||||
await new CompaniesService().setCompanyIcon({
|
await new CompaniesService().setCompanyIcon({
|
||||||
@@ -325,7 +325,7 @@ export class DeleteCompanyHeaderController extends Controller {
|
|||||||
@SuccessResponse(200, "El archivo fue cargado con exito")
|
@SuccessResponse(200, "El archivo fue cargado con exito")
|
||||||
@Post()
|
@Post()
|
||||||
public async deleteHeader(
|
public async deleteHeader(
|
||||||
@Body() data: DeleteCompanyFileParams
|
@Body() data: DeleteCompanyFileParams,
|
||||||
): Promise<ApiVoidResult | ApiValidationError> {
|
): Promise<ApiVoidResult | ApiValidationError> {
|
||||||
try {
|
try {
|
||||||
await new CompaniesService().deleteCompanyHeader(data);
|
await new CompaniesService().deleteCompanyHeader(data);
|
||||||
@@ -349,7 +349,7 @@ export class DeleteCompanyIconController extends Controller {
|
|||||||
@SuccessResponse(200, "El archivo fue cargado con exito")
|
@SuccessResponse(200, "El archivo fue cargado con exito")
|
||||||
@Post()
|
@Post()
|
||||||
public async deleteIcon(
|
public async deleteIcon(
|
||||||
@Body() data: DeleteCompanyFileParams
|
@Body() data: DeleteCompanyFileParams,
|
||||||
): Promise<ApiVoidResult | ApiValidationError> {
|
): Promise<ApiVoidResult | ApiValidationError> {
|
||||||
try {
|
try {
|
||||||
await new CompaniesService().deleteCompanyIcon(data);
|
await new CompaniesService().deleteCompanyIcon(data);
|
||||||
@@ -372,7 +372,7 @@ export class filterAllOrganizationsController extends Controller {
|
|||||||
@SuccessResponse(200, "Done")
|
@SuccessResponse(200, "Done")
|
||||||
@Post()
|
@Post()
|
||||||
public async filterAllOrganizations(
|
public async filterAllOrganizations(
|
||||||
@Body() requestBody: PaginateCompaniesParams
|
@Body() requestBody: PaginateCompaniesParams,
|
||||||
): Promise<TextObjectFilterResult[] | ApiValidationError> {
|
): Promise<TextObjectFilterResult[] | ApiValidationError> {
|
||||||
try {
|
try {
|
||||||
const clients = await new CompaniesService().filterAll(requestBody);
|
const clients = await new CompaniesService().filterAll(requestBody);
|
||||||
@@ -392,7 +392,7 @@ export class CheckCompanyNameController extends Controller {
|
|||||||
@SuccessResponse(200, "Done")
|
@SuccessResponse(200, "Done")
|
||||||
@Post()
|
@Post()
|
||||||
public async checkName(
|
public async checkName(
|
||||||
@Body() requestBody: { name: string }
|
@Body() requestBody: { name: string },
|
||||||
): Promise<{ valid: boolean } | ApiValidationError> {
|
): Promise<{ valid: boolean } | ApiValidationError> {
|
||||||
try {
|
try {
|
||||||
const valid = await new CompaniesService().checkNameExists(requestBody.name);
|
const valid = await new CompaniesService().checkNameExists(requestBody.name);
|
||||||
@@ -413,12 +413,12 @@ export class CheckCompanyAdminController extends Controller {
|
|||||||
@SuccessResponse(200, "Done")
|
@SuccessResponse(200, "Done")
|
||||||
@Post()
|
@Post()
|
||||||
public async checkAdmin(
|
public async checkAdmin(
|
||||||
@Body() requestBody: { companyId: string; sessionUser: string }
|
@Body() requestBody: { companyId: string; sessionUser: string },
|
||||||
): Promise<{ isAdmin: boolean } | ApiValidationError> {
|
): Promise<boolean | ApiValidationError> {
|
||||||
try {
|
try {
|
||||||
const isAdmin = await new CompaniesService().checkCompanyAdmin(requestBody);
|
const isAdmin = await new CompaniesService().checkCompanyAdmin(requestBody);
|
||||||
this.setStatus(200);
|
this.setStatus(200);
|
||||||
return { isAdmin };
|
return isAdmin;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const errorOccurred: Error = e as Error;
|
const errorOccurred: Error = e as Error;
|
||||||
this.setStatus(500);
|
this.setStatus(500);
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
PublicOrganizationServiceView,
|
PublicOrganizationServiceView,
|
||||||
UserAppointmentViewParams,
|
UserAppointmentViewParams,
|
||||||
UserAppointmentView,
|
UserAppointmentView,
|
||||||
|
PublicOrganizationsParams,
|
||||||
|
PublicOrganizationsResult,
|
||||||
} from "../../Models/Views/Views";
|
} from "../../Models/Views/Views";
|
||||||
import { ViewsService } from "./Views.Service";
|
import { ViewsService } from "./Views.Service";
|
||||||
import { BotEventParams, BotView } from "../../Models/WapServer/WapServer.Interface";
|
import { BotEventParams, BotView } from "../../Models/WapServer/WapServer.Interface";
|
||||||
@@ -109,3 +111,23 @@ export class WapServerBotViewController extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Route("views/public-organizations")
|
||||||
|
export class PublicOrganizationsController extends Controller {
|
||||||
|
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||||
|
@SuccessResponse(200, "Done")
|
||||||
|
@Post()
|
||||||
|
public async publicOrganizations(
|
||||||
|
@Body() requestBody: PublicOrganizationsParams
|
||||||
|
): Promise<PublicOrganizationsResult | ApiValidationError> {
|
||||||
|
try {
|
||||||
|
const result = await new ViewsService().publicOrganizations(requestBody);
|
||||||
|
this.setStatus(200);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
const errorOccurred: Error = e as Error;
|
||||||
|
this.setStatus(500);
|
||||||
|
return new ApiValidationError(500, errorOccurred.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import views, {
|
|||||||
PublicOrganizationServiceView,
|
PublicOrganizationServiceView,
|
||||||
UserAppointmentViewParams,
|
UserAppointmentViewParams,
|
||||||
UserAppointmentView,
|
UserAppointmentView,
|
||||||
|
PublicOrganizationsParams,
|
||||||
|
PublicOrganizationsResult,
|
||||||
} from "../../Models/Views/Views";
|
} from "../../Models/Views/Views";
|
||||||
import { BotView, BotEventParams } from "../../Models/WapServer/WapServer.Interface";
|
import { BotView, BotEventParams } from "../../Models/WapServer/WapServer.Interface";
|
||||||
|
|
||||||
@@ -45,4 +47,12 @@ export class ViewsService {
|
|||||||
const view = await views.wapView(data);
|
const view = await views.wapView(data);
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async publicOrganizations(
|
||||||
|
data: PublicOrganizationsParams
|
||||||
|
): Promise<PublicOrganizationsResult> {
|
||||||
|
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||||
|
const result = await views.publicOrganizations(data);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
@@ -1,6 +1,7 @@
|
|||||||
dist/*
|
dist/*
|
||||||
node_modules
|
node_modules
|
||||||
.env
|
.env
|
||||||
|
auth/
|
||||||
|
|
||||||
*_sessions
|
*_sessions
|
||||||
*tokens
|
*tokens
|
||||||
|
|||||||
Generated
+1388
-2341
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -2,8 +2,9 @@
|
|||||||
"name": "txbot",
|
"name": "txbot",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "ts-node src/server.ts",
|
"dev": "tsx src/server.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/server.js"
|
"start": "node dist/server.js"
|
||||||
},
|
},
|
||||||
@@ -12,14 +13,14 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@whiskeysockets/baileys": "6.17.16",
|
"@whiskeysockets/baileys": "7.0.0-rc14",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"qrcode": "^1.5.4"
|
"qrcode": "^1.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.2",
|
"@types/express": "^5.0.2",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"ts-node": "^10.9.2",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-3
@@ -9,6 +9,35 @@ import { Boom } from "@hapi/boom";
|
|||||||
|
|
||||||
let sock: ReturnType<typeof makeWASocket>;
|
let sock: ReturnType<typeof makeWASocket>;
|
||||||
let latestQR: string | null = null;
|
let latestQR: string | null = null;
|
||||||
|
let reconnecting = false;
|
||||||
|
|
||||||
|
// Last-resort safety net. An uncaught error means the process is in an
|
||||||
|
// undefined state, so we log the cause and exit; the supervisor
|
||||||
|
// (docker restart: unless-stopped) brings it back in a clean state.
|
||||||
|
// We never swallow these and keep running on corrupted state.
|
||||||
|
process.on("unhandledRejection", (reason) => {
|
||||||
|
console.error("Unhandled rejection:", reason);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("uncaughtException", (err) => {
|
||||||
|
console.error("Uncaught exception:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reconnect with the rejection handled at the source. A failed attempt must
|
||||||
|
// re-arm the loop, otherwise a single transient error leaves the bot dead.
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (reconnecting) return;
|
||||||
|
reconnecting = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
startWhatsApp().catch((err) => {
|
||||||
|
console.error("Reconnect failed:", err);
|
||||||
|
reconnecting = false;
|
||||||
|
scheduleReconnect();
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
async function startWhatsApp() {
|
async function startWhatsApp() {
|
||||||
const { state, saveCreds } = await useMultiFileAuthState("./auth");
|
const { state, saveCreds } = await useMultiFileAuthState("./auth");
|
||||||
@@ -17,7 +46,6 @@ async function startWhatsApp() {
|
|||||||
sock = makeWASocket({
|
sock = makeWASocket({
|
||||||
version,
|
version,
|
||||||
auth: state,
|
auth: state,
|
||||||
printQRInTerminal: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
sock.ev.on("creds.update", saveCreds);
|
sock.ev.on("creds.update", saveCreds);
|
||||||
@@ -29,10 +57,16 @@ async function startWhatsApp() {
|
|||||||
if (connection === "close") {
|
if (connection === "close") {
|
||||||
const reason = (lastDisconnect?.error as Boom)?.output?.statusCode;
|
const reason = (lastDisconnect?.error as Boom)?.output?.statusCode;
|
||||||
console.log("Connection closed:", DisconnectReason[reason]);
|
console.log("Connection closed:", DisconnectReason[reason]);
|
||||||
setTimeout(startWhatsApp, 1000);
|
if (reason === DisconnectReason.loggedOut) {
|
||||||
|
console.log("Session logged out — not reconnecting. Re-pair via QR to resume.");
|
||||||
|
reconnecting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduleReconnect();
|
||||||
} else if (connection === "open") {
|
} else if (connection === "open") {
|
||||||
console.log("Connected to WhatsApp");
|
console.log("Connected to WhatsApp");
|
||||||
latestQR = null;
|
latestQR = null;
|
||||||
|
reconnecting = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -85,4 +119,7 @@ async function startServer() {
|
|||||||
app.listen(PORT, () => console.log(`API listening on port ${PORT}`));
|
app.listen(PORT, () => console.log(`API listening on port ${PORT}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
startServer().catch(console.error);
|
startServer().catch((err) => {
|
||||||
|
console.error("Fatal startup error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
+2
-1
@@ -26,7 +26,8 @@
|
|||||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||||
|
|
||||||
/* Modules */
|
/* Modules */
|
||||||
"module": "commonjs", /* Specify what module code is generated. */
|
"module": "NodeNext", /* Specify what module code is generated. Required for ESM-only Baileys 6.7.x. */
|
||||||
|
"moduleResolution": "NodeNext", /* Resolve modules the way Node.js ESM does. */
|
||||||
"rootDir": "src", /* Specify the root folder within your source files. */
|
"rootDir": "src", /* Specify the root folder within your source files. */
|
||||||
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import ApiRequest from "@services/Api.Service";
|
||||||
|
import { UpdateCompanyParams } from "@models/Company.model";
|
||||||
|
import { ApiVoidResult } from "@models/Api.VoidResult.type";
|
||||||
|
|
||||||
|
export const updateOrganizationColors = async (data: UpdateCompanyParams): Promise<ApiVoidResult> => {
|
||||||
|
return ApiRequest.post<ApiVoidResult>("companies/update", data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadHeaderImage = async (data: FormData): Promise<ApiVoidResult> => {
|
||||||
|
return ApiRequest.postFile<ApiVoidResult>("companies/upload-header", data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadServiceImage = async (data: FormData): Promise<ApiVoidResult> => {
|
||||||
|
return ApiRequest.postFile<ApiVoidResult>("services/upload-image", data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkOrgAdmin = async (companyId: string, sessionUser: string): Promise<boolean> => {
|
||||||
|
return ApiRequest.post<boolean>("companies/check-admin", { companyId, sessionUser });
|
||||||
|
};
|
||||||
+216
-47
@@ -23,6 +23,7 @@ import { CLIENT_ACCOUNT_MOVEMENT_TYPES } from "@core/Models/ClientAccountMovemen
|
|||||||
import { getPaymentIcon } from "@core/app/components/PaymentIcons/PaymentIcons";
|
import { getPaymentIcon } from "@core/app/components/PaymentIcons/PaymentIcons";
|
||||||
import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan";
|
import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
oid: string;
|
oid: string;
|
||||||
@@ -36,6 +37,7 @@ export default function ClientAccount() {
|
|||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const SessionInfo = useSessionStore();
|
const SessionInfo = useSessionStore();
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
const { goTo } = useNavigation();
|
const { goTo } = useNavigation();
|
||||||
|
|
||||||
@@ -68,6 +70,8 @@ export default function ClientAccount() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org/profile/" + id + "/clients",
|
url: "/admin/org/profile/" + id + "/clients",
|
||||||
title: "Clientes",
|
title: "Clientes",
|
||||||
@@ -85,6 +89,10 @@ export default function ClientAccount() {
|
|||||||
title: "Cuenta Corriente",
|
title: "Cuenta Corriente",
|
||||||
icon: <AccountBalanceOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
icon: <AccountBalanceOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -127,9 +135,22 @@ export default function ClientAccount() {
|
|||||||
|
|
||||||
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
|
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
|
||||||
<div style={{ maxWidth: "800px", margin: "0 auto", paddingBottom: "100px" }}>
|
<div style={{ maxWidth: "800px", margin: "0 auto", paddingBottom: "100px" }}>
|
||||||
|
|
||||||
{/* Top Status Card */}
|
{/* Top Status Card */}
|
||||||
<div style={{ position: "sticky", top: "70px", zIndex: 100, display: "flex", flexDirection: "column", background: "white", paddingBottom: "20px", paddingTop: "5px", margin: "0 -10px 30px -10px", paddingLeft: "10px", paddingRight: "10px" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
position: "sticky",
|
||||||
|
top: "70px",
|
||||||
|
zIndex: 100,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
background: "white",
|
||||||
|
paddingBottom: "20px",
|
||||||
|
paddingTop: "5px",
|
||||||
|
margin: "0 -10px 30px -10px",
|
||||||
|
paddingLeft: "10px",
|
||||||
|
paddingRight: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{view && (
|
{view && (
|
||||||
<motion.div
|
<motion.div
|
||||||
layout
|
layout
|
||||||
@@ -138,26 +159,35 @@ export default function ClientAccount() {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
y: 0,
|
y: 0,
|
||||||
padding: isMinimized ? "25px 50px" : "40px 20px",
|
padding: isMinimized ? "25px 50px" : "40px 20px",
|
||||||
borderRadius: isMinimized ? "15px" : "20px"
|
borderRadius: isMinimized ? "15px" : "20px",
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: isMinimized ? "row" : "column",
|
flexDirection: isMinimized ? "row" : "column",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: isMinimized ? "space-between" : "center",
|
justifyContent: isMinimized ? "space-between" : "center",
|
||||||
background: view.amount >= 0
|
background:
|
||||||
|
view.amount >= 0
|
||||||
? "linear-gradient(135deg, #1d976c 0%, #93f9b9 100%)"
|
? "linear-gradient(135deg, #1d976c 0%, #93f9b9 100%)"
|
||||||
: "linear-gradient(135deg, #cb2d3e 0%, #ef473a 100%)",
|
: "linear-gradient(135deg, #cb2d3e 0%, #ef473a 100%)",
|
||||||
color: "white",
|
color: "white",
|
||||||
boxShadow: isMinimized ? "0 4px 15px rgba(0,0,0,0.3)" : "0 10px 30px rgba(0,0,0,0.15)",
|
boxShadow: isMinimized
|
||||||
|
? "0 4px 15px rgba(0,0,0,0.3)"
|
||||||
|
: "0 10px 30px rgba(0,0,0,0.15)",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
transition: "box-shadow 0.3s ease"
|
transition: "box-shadow 0.3s ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => setForceMinimized(!forceMinimized)}
|
onClick={() => setForceMinimized(!forceMinimized)}
|
||||||
style={{ position: "absolute", top: "10px", left: "10px", color: "rgba(255,255,255,0.7)", zIndex: 10 }}
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "10px",
|
||||||
|
left: "10px",
|
||||||
|
color: "rgba(255,255,255,0.7)",
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
size="small"
|
size="small"
|
||||||
title={forceMinimized ? "Maximizar" : "Minimizar"}
|
title={forceMinimized ? "Maximizar" : "Minimizar"}
|
||||||
>
|
>
|
||||||
@@ -165,38 +195,73 @@ export default function ClientAccount() {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
{/* Decorative background circles */}
|
{/* Decorative background circles */}
|
||||||
<div style={{ position: "absolute", top: "-50px", right: "-50px", width: "150px", height: "150px", borderRadius: "50%", background: "rgba(255,255,255,0.1)" }}></div>
|
<div
|
||||||
<div style={{ position: "absolute", bottom: "-30px", left: "-30px", width: "100px", height: "100px", borderRadius: "50%", background: "rgba(255,255,255,0.1)" }}></div>
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "-50px",
|
||||||
|
right: "-50px",
|
||||||
|
width: "150px",
|
||||||
|
height: "150px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "rgba(255,255,255,0.1)",
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: "-30px",
|
||||||
|
left: "-30px",
|
||||||
|
width: "100px",
|
||||||
|
height: "100px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "rgba(255,255,255,0.1)",
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{
|
animate={{
|
||||||
fontSize: isMinimized ? "14px" : "16px",
|
fontSize: isMinimized ? "14px" : "16px",
|
||||||
marginBottom: isMinimized ? "0px" : "0px",
|
marginBottom: isMinimized ? "0px" : "0px",
|
||||||
marginLeft: isMinimized ? "30px" : "0px"
|
marginLeft: isMinimized ? "30px" : "0px",
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontWeight: "bold",
|
||||||
|
opacity: 0.9,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "1px",
|
||||||
}}
|
}}
|
||||||
style={{ fontWeight: "bold", opacity: 0.9, textTransform: "uppercase", letterSpacing: "1px" }}
|
|
||||||
>
|
>
|
||||||
Saldo Actual
|
Saldo Actual
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{
|
animate={{
|
||||||
fontSize: isMinimized ? "24px" : "56px",
|
fontSize: isMinimized ? "24px" : "56px",
|
||||||
margin: isMinimized ? "0px" : "10px 0 30px 0"
|
margin: isMinimized ? "0px" : "10px 0 30px 0",
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontWeight: "900",
|
||||||
|
textShadow: "0 2px 10px rgba(0,0,0,0.1)",
|
||||||
}}
|
}}
|
||||||
style={{ fontWeight: "900", textShadow: "0 2px 10px rgba(0,0,0,0.1)" }}
|
|
||||||
>
|
>
|
||||||
{formatPrice(view.amount)}
|
{formatPrice(view.amount)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
|
||||||
{!isMinimized && (
|
{!isMinimized && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
style={{ display: "flex", gap: "15px", flexDirection: "row", zIndex: 1 }}
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "15px",
|
||||||
|
flexDirection: "row",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
>
|
>
|
||||||
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
sx={{
|
sx={{
|
||||||
backgroundColor: "rgba(255,255,255,0.2)",
|
backgroundColor: "rgba(255,255,255,0.2)",
|
||||||
@@ -207,16 +272,29 @@ export default function ClientAccount() {
|
|||||||
width: "110px",
|
width: "110px",
|
||||||
height: "90px",
|
height: "90px",
|
||||||
borderRadius: "15px",
|
borderRadius: "15px",
|
||||||
"&:hover": { backgroundColor: "rgba(255,255,255,0.3)" }
|
"&:hover": {
|
||||||
|
backgroundColor: "rgba(255,255,255,0.3)",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
onClick={() => add(CLIENT_ACCOUNT_MOVEMENT_TYPES.DEPOSIT)}
|
onClick={() =>
|
||||||
|
add(CLIENT_ACCOUNT_MOVEMENT_TYPES.DEPOSIT)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<DepositarIcon sx={{ fontSize: "32px", marginBottom: "5px" }} />
|
<DepositarIcon
|
||||||
<span style={{ fontSize: "14px", fontWeight: "bold" }}>Ingresar</span>
|
sx={{ fontSize: "32px", marginBottom: "5px" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{ fontSize: "14px", fontWeight: "bold" }}
|
||||||
|
>
|
||||||
|
Ingresar
|
||||||
|
</span>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
<motion.div
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
sx={{
|
sx={{
|
||||||
backgroundColor: "rgba(255,255,255,0.2)",
|
backgroundColor: "rgba(255,255,255,0.2)",
|
||||||
@@ -227,16 +305,29 @@ export default function ClientAccount() {
|
|||||||
width: "110px",
|
width: "110px",
|
||||||
height: "90px",
|
height: "90px",
|
||||||
borderRadius: "15px",
|
borderRadius: "15px",
|
||||||
"&:hover": { backgroundColor: "rgba(255,255,255,0.3)" }
|
"&:hover": {
|
||||||
|
backgroundColor: "rgba(255,255,255,0.3)",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
onClick={() => add(CLIENT_ACCOUNT_MOVEMENT_TYPES.WITHDRAWAL)}
|
onClick={() =>
|
||||||
|
add(CLIENT_ACCOUNT_MOVEMENT_TYPES.WITHDRAWAL)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<RetirarIcon sx={{ fontSize: "32px", marginBottom: "5px" }} />
|
<RetirarIcon
|
||||||
<span style={{ fontSize: "14px", fontWeight: "bold" }}>Retirar</span>
|
sx={{ fontSize: "32px", marginBottom: "5px" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{ fontSize: "14px", fontWeight: "bold" }}
|
||||||
|
>
|
||||||
|
Retirar
|
||||||
|
</span>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
<motion.div
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
sx={{
|
sx={{
|
||||||
backgroundColor: "white",
|
backgroundColor: "white",
|
||||||
@@ -248,12 +339,26 @@ export default function ClientAccount() {
|
|||||||
height: "90px",
|
height: "90px",
|
||||||
borderRadius: "15px",
|
borderRadius: "15px",
|
||||||
boxShadow: "0 4px 15px rgba(0,0,0,0.1)",
|
boxShadow: "0 4px 15px rgba(0,0,0,0.1)",
|
||||||
"&:hover": { backgroundColor: "#f8f9fa" }
|
"&:hover": { backgroundColor: "#f8f9fa" },
|
||||||
}}
|
}}
|
||||||
onClick={() => goTo("/admin/org/" + id + "/client/" + clientId + "/account/movements")}
|
onClick={() =>
|
||||||
|
goTo(
|
||||||
|
"/admin/org/" +
|
||||||
|
id +
|
||||||
|
"/client/" +
|
||||||
|
clientId +
|
||||||
|
"/account/movements",
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<MovimientosIcon sx={{ fontSize: "32px", marginBottom: "5px" }} />
|
<MovimientosIcon
|
||||||
<span style={{ fontSize: "14px", fontWeight: "bold" }}>Historial</span>
|
sx={{ fontSize: "32px", marginBottom: "5px" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
style={{ fontSize: "14px", fontWeight: "bold" }}
|
||||||
|
>
|
||||||
|
Historial
|
||||||
|
</span>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -263,13 +368,40 @@ export default function ClientAccount() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "15px", padding: "0 10px" }}>
|
<div
|
||||||
<div style={{ fontSize: "20px", fontWeight: "bold", color: "var(--black-light)" }}>
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: "15px",
|
||||||
|
padding: "0 10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "20px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "var(--black-light)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Últimos Movimientos
|
Últimos Movimientos
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{ fontSize: "14px", color: "var(--wine-dark)", cursor: "pointer", fontWeight: "bold" }}
|
style={{
|
||||||
onClick={() => goTo("/admin/org/" + id + "/client/" + clientId + "/account/movements")}
|
fontSize: "14px",
|
||||||
|
color: "var(--wine-dark)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: "bold",
|
||||||
|
}}
|
||||||
|
onClick={() =>
|
||||||
|
goTo(
|
||||||
|
"/admin/org/" +
|
||||||
|
id +
|
||||||
|
"/client/" +
|
||||||
|
clientId +
|
||||||
|
"/account/movements",
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Ver todos
|
Ver todos
|
||||||
</div>
|
</div>
|
||||||
@@ -294,42 +426,79 @@ export default function ClientAccount() {
|
|||||||
boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
|
boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "15px" }}>
|
<div
|
||||||
<div style={{
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "15px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
width: "45px",
|
width: "45px",
|
||||||
height: "45px",
|
height: "45px",
|
||||||
borderRadius: "12px",
|
borderRadius: "12px",
|
||||||
backgroundColor: movement.amount > 0 ? "rgba(40, 167, 69, 0.1)" : "rgba(220, 53, 69, 0.1)",
|
backgroundColor:
|
||||||
|
movement.amount > 0
|
||||||
|
? "rgba(40, 167, 69, 0.1)"
|
||||||
|
: "rgba(220, 53, 69, 0.1)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
color: movement.amount > 0 ? "#28a745" : "#dc3545"
|
color:
|
||||||
}}>
|
movement.amount > 0 ? "#28a745" : "#dc3545",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{getPaymentIcon(movement.paymentMethod)}
|
{getPaymentIcon(movement.paymentMethod)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: "16px", fontWeight: "bold", color: "#333", marginBottom: "3px" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#333",
|
||||||
|
marginBottom: "3px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{movement.description}
|
{movement.description}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: "13px", color: "#888" }}>
|
<div style={{ fontSize: "13px", color: "#888" }}>
|
||||||
{dayjs(movement.createdAt).format("DD MMMM YYYY • HH:mm")}
|
{dayjs(movement.createdAt).format(
|
||||||
|
"DD MMMM YYYY • HH:mm",
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div
|
||||||
|
style={{
|
||||||
fontSize: "18px",
|
fontSize: "18px",
|
||||||
fontWeight: "900",
|
fontWeight: "900",
|
||||||
color: movement.amount > 0 ? "#28a745" : "#dc3545",
|
color: movement.amount > 0 ? "#28a745" : "#dc3545",
|
||||||
}}>
|
}}
|
||||||
{movement.amount > 0 ? "+" : ""}{formatPrice(movement.amount)}
|
>
|
||||||
|
{movement.amount > 0 ? "+" : ""}
|
||||||
|
{formatPrice(movement.amount)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ textAlign: "center", padding: "40px", backgroundColor: "#fff", borderRadius: "15px", color: "#888", border: "1px dashed #ccc" }}>
|
<div
|
||||||
<AccountBalanceOutlinedIcon sx={{ fontSize: "48px", color: "#ddd", marginBottom: "10px" }} />
|
style={{
|
||||||
<div style={{ fontSize: "16px", fontWeight: "bold", color: "#666" }}>
|
textAlign: "center",
|
||||||
|
padding: "40px",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderRadius: "15px",
|
||||||
|
color: "#888",
|
||||||
|
border: "1px dashed #ccc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AccountBalanceOutlinedIcon
|
||||||
|
sx={{ fontSize: "48px", color: "#ddd", marginBottom: "10px" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{ fontSize: "16px", fontWeight: "bold", color: "#666" }}
|
||||||
|
>
|
||||||
Aún no hay movimientos
|
Aún no hay movimientos
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: "14px" }}>
|
<div style={{ fontSize: "14px" }}>
|
||||||
|
|||||||
+16
-10
@@ -1,9 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useAlert } from "@core/Store/Alert.Store";
|
import { useAlert } from "@core/Store/Alert.Store";
|
||||||
import {
|
import { useEventHandlerStore, EVENT_TYPES } from "@core/Store/EventHandler.Store";
|
||||||
useEventHandlerStore,
|
|
||||||
EVENT_TYPES,
|
|
||||||
} from "@core/Store/EventHandler.Store";
|
|
||||||
import { useLocationStore } from "@core/Store/Location.Store";
|
import { useLocationStore } from "@core/Store/Location.Store";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -14,6 +11,7 @@ import ServiceItem from "@core/app/components/ServiceItem/ServiceItem";
|
|||||||
import Location from "@core/app/components/Location/Location";
|
import Location from "@core/app/components/Location/Location";
|
||||||
import { useNavigation } from "@core/app/hooks/goto";
|
import { useNavigation } from "@core/app/hooks/goto";
|
||||||
import { useMenuStore } from "@core/Store/Menu.Store";
|
import { useMenuStore } from "@core/Store/Menu.Store";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
oid: string;
|
oid: string;
|
||||||
@@ -26,12 +24,12 @@ export default function ClientAppointments() {
|
|||||||
const clientId = data.clientId;
|
const clientId = data.clientId;
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
const menu = useMenuStore();
|
const menu = useMenuStore();
|
||||||
const { goTo } = useNavigation();
|
const { goTo } = useNavigation();
|
||||||
|
|
||||||
const [appintments, setAppointments] =
|
const [appintments, setAppointments] = useState<AppointmentAdminByClientView>({
|
||||||
useState<AppointmentAdminByClientView>({
|
|
||||||
events: [],
|
events: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,6 +42,8 @@ export default function ClientAppointments() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org/profile/" + id + "/clients",
|
url: "/admin/org/profile/" + id + "/clients",
|
||||||
title: "Clientes",
|
title: "Clientes",
|
||||||
@@ -61,6 +61,10 @@ export default function ClientAppointments() {
|
|||||||
title: "Turnos reservados",
|
title: "Turnos reservados",
|
||||||
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,15 +93,17 @@ export default function ClientAppointments() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{appintments.events.length === 0 ? (
|
{appintments.events.length === 0 ? (
|
||||||
<div style={{
|
<div
|
||||||
|
style={{
|
||||||
gridColumn: "1 / -1",
|
gridColumn: "1 / -1",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
padding: "40px 20px",
|
padding: "40px 20px",
|
||||||
background: "#fff",
|
background: "#fff",
|
||||||
borderRadius: "12px",
|
borderRadius: "12px",
|
||||||
boxShadow: "0 4px 15px rgba(0,0,0,0.02)",
|
boxShadow: "0 4px 15px rgba(0,0,0,0.02)",
|
||||||
color: "#7f8c8d"
|
color: "#7f8c8d",
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
No hay turnos registrados para este cliente.
|
No hay turnos registrados para este cliente.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -113,7 +119,7 @@ export default function ClientAppointments() {
|
|||||||
"/client/" +
|
"/client/" +
|
||||||
clientId +
|
clientId +
|
||||||
"/appointments/" +
|
"/appointments/" +
|
||||||
event.id
|
event.id,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { validatePhone } from "@core/helpers/validatePhone";
|
|||||||
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
||||||
import Switch from "@core/app/components/Switch/Switch";
|
import Switch from "@core/app/components/Switch/Switch";
|
||||||
import { Box, Typography } from "@mui/material";
|
import { Box, Typography } from "@mui/material";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
oid: string;
|
oid: string;
|
||||||
@@ -29,6 +30,7 @@ export default function ClientCreate() {
|
|||||||
const confirm = useConfirmStore();
|
const confirm = useConfirmStore();
|
||||||
const SessionInfo = useSessionStore();
|
const SessionInfo = useSessionStore();
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
const [firstName, setFirstName] = useState("");
|
const [firstName, setFirstName] = useState("");
|
||||||
const [lastName, setLastName] = useState("");
|
const [lastName, setLastName] = useState("");
|
||||||
@@ -46,6 +48,8 @@ export default function ClientCreate() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org/profile/" + id + "/clients",
|
url: "/admin/org/profile/" + id + "/clients",
|
||||||
title: "Clientes",
|
title: "Clientes",
|
||||||
@@ -63,6 +67,10 @@ export default function ClientCreate() {
|
|||||||
title: "Editar",
|
title: "Editar",
|
||||||
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -92,7 +100,7 @@ export default function ClientCreate() {
|
|||||||
confirm.show(
|
confirm.show(
|
||||||
validationPhoneResult.message,
|
validationPhoneResult.message,
|
||||||
() => {},
|
() => {},
|
||||||
() => {}
|
() => {},
|
||||||
);
|
);
|
||||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||||
return;
|
return;
|
||||||
@@ -112,7 +120,7 @@ export default function ClientCreate() {
|
|||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
alert.showSuccess(
|
alert.showSuccess(
|
||||||
`Información del cliente ${firstName} ${lastName} actualizada`
|
`Información del cliente ${firstName} ${lastName} actualizada`,
|
||||||
);
|
);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
goTo("/admin/org/" + id + "/client/" + clientId + "/profile");
|
goTo("/admin/org/" + id + "/client/" + clientId + "/profile");
|
||||||
@@ -180,7 +188,9 @@ export default function ClientCreate() {
|
|||||||
/>
|
/>
|
||||||
<div style={{ marginTop: "15px" }}></div>
|
<div style={{ marginTop: "15px" }}></div>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
<Typography variant="body1" sx={{ fontWeight: "bold", paddingLeft: "10px" }}>Baneado (No puede reservar)</Typography>
|
<Typography variant="body1" sx={{ fontWeight: "bold", paddingLeft: "10px" }}>
|
||||||
|
Baneado (No puede reservar)
|
||||||
|
</Typography>
|
||||||
<Switch
|
<Switch
|
||||||
checked={banned}
|
checked={banned}
|
||||||
onChange={(checked) => {
|
onChange={(checked) => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { ClientMergeParams, OrganizationClientView } from "@core/Models/Clients.
|
|||||||
import Button from "@core/app/components/Button/Button";
|
import Button from "@core/app/components/Button/Button";
|
||||||
import MaterialAlert from "@mui/material/Alert";
|
import MaterialAlert from "@mui/material/Alert";
|
||||||
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
oid: string;
|
oid: string;
|
||||||
@@ -32,6 +33,7 @@ export default function ClientCreate() {
|
|||||||
const confirm = useConfirmStore();
|
const confirm = useConfirmStore();
|
||||||
const SessionInfo = useSessionStore();
|
const SessionInfo = useSessionStore();
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
const { goTo } = useNavigation();
|
const { goTo } = useNavigation();
|
||||||
|
|
||||||
@@ -53,7 +55,7 @@ export default function ClientCreate() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [clientMergeFilterData, setClientMergeFilterData] = useState<TextObjectFilterResult[]>(
|
const [clientMergeFilterData, setClientMergeFilterData] = useState<TextObjectFilterResult[]>(
|
||||||
[]
|
[],
|
||||||
);
|
);
|
||||||
const [clientMergeId, setClientMergeId] = useState<string>("");
|
const [clientMergeId, setClientMergeId] = useState<string>("");
|
||||||
|
|
||||||
@@ -94,7 +96,7 @@ export default function ClientCreate() {
|
|||||||
() => {
|
() => {
|
||||||
mergeClients();
|
mergeClients();
|
||||||
},
|
},
|
||||||
() => {}
|
() => {},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,6 +107,8 @@ export default function ClientCreate() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org/profile/" + id + "/clients",
|
url: "/admin/org/profile/" + id + "/clients",
|
||||||
title: "Clientes",
|
title: "Clientes",
|
||||||
@@ -122,6 +126,10 @@ export default function ClientCreate() {
|
|||||||
title: "Fusionar clientes",
|
title: "Fusionar clientes",
|
||||||
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
+31
-13
@@ -11,6 +11,7 @@ import ClientsIcon from "@mui/icons-material/AccountBoxOutlined";
|
|||||||
import NotificationsIcon from "@mui/icons-material/NotificationsOutlined";
|
import NotificationsIcon from "@mui/icons-material/NotificationsOutlined";
|
||||||
import MaterialAlert from "@mui/material/Alert";
|
import MaterialAlert from "@mui/material/Alert";
|
||||||
import styles from "./NotificationsPage.module.css";
|
import styles from "./NotificationsPage.module.css";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
oid: string;
|
oid: string;
|
||||||
@@ -20,7 +21,7 @@ type UrlData = {
|
|||||||
const CHANNELS: { key: NotificationChannel; label: string }[] = [
|
const CHANNELS: { key: NotificationChannel; label: string }[] = [
|
||||||
{ key: "whatsapp", label: "WhatsApp" },
|
{ key: "whatsapp", label: "WhatsApp" },
|
||||||
{ key: "email", label: "Email" },
|
{ key: "email", label: "Email" },
|
||||||
{ key: "system", label: "Sistema" }
|
{ key: "system", label: "Sistema" },
|
||||||
];
|
];
|
||||||
|
|
||||||
type ChannelPreference = "default" | "preferred" | "muted";
|
type ChannelPreference = "default" | "preferred" | "muted";
|
||||||
@@ -30,34 +31,41 @@ export default function NotificationsPage() {
|
|||||||
const oid = data.oid;
|
const oid = data.oid;
|
||||||
const clientId = data.clientId;
|
const clientId = data.clientId;
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
const store = useNotificationPreferencesStore();
|
const store = useNotificationPreferencesStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org",
|
url: "/admin/org",
|
||||||
title: "Organizaciones",
|
title: "Organizaciones",
|
||||||
icon: null
|
icon: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org/profile/" + oid,
|
url: "/admin/org/profile/" + oid,
|
||||||
title: "Organización",
|
title: "Organización",
|
||||||
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />
|
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />,
|
||||||
});
|
});
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org/" + oid + "/client/" + clientId + "/profile",
|
url: "/admin/org/" + oid + "/client/" + clientId + "/profile",
|
||||||
title: "Cliente",
|
title: "Cliente",
|
||||||
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />
|
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||||
});
|
});
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "",
|
url: "",
|
||||||
title: "Notificaciones",
|
title: "Notificaciones",
|
||||||
icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} />
|
icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, [oid, clientId]);
|
}, [oid, clientId]);
|
||||||
|
|
||||||
const getChannelPreference = (channel: NotificationChannel): ChannelPreference => {
|
const getChannelPreference = (channel: NotificationChannel): ChannelPreference => {
|
||||||
@@ -81,7 +89,7 @@ export default function NotificationsPage() {
|
|||||||
mutedChannels:
|
mutedChannels:
|
||||||
preference === "muted"
|
preference === "muted"
|
||||||
? [...store.formData.mutedChannels.filter((c) => c !== channel), channel]
|
? [...store.formData.mutedChannels.filter((c) => c !== channel), channel]
|
||||||
: store.formData.mutedChannels.filter((c) => c !== channel)
|
: store.formData.mutedChannels.filter((c) => c !== channel),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,7 +99,9 @@ export default function NotificationsPage() {
|
|||||||
|
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<MaterialAlert severity="info" sx={{ marginBottom: "0" }}>
|
<MaterialAlert severity="info" sx={{ marginBottom: "0" }}>
|
||||||
<b>Preferencias del cliente:</b> Configure cómo esta organización debe comunicarse con este cliente. Si deja un canal en estándar, se usará la política general de la organización.
|
<b>Preferencias del cliente:</b> Configure cómo esta organización debe
|
||||||
|
comunicarse con este cliente. Si deja un canal en estándar, se usará la política
|
||||||
|
general de la organización.
|
||||||
</MaterialAlert>
|
</MaterialAlert>
|
||||||
|
|
||||||
<div className={styles.channels}>
|
<div className={styles.channels}>
|
||||||
@@ -102,24 +112,32 @@ export default function NotificationsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className={styles.channelTitle}>{channel.label}</div>
|
<div className={styles.channelTitle}>{channel.label}</div>
|
||||||
<div className={styles.channelDescription}>
|
<div className={styles.channelDescription}>
|
||||||
{selectedPreference === "preferred" && "Priorizar este canal para el cliente."}
|
{selectedPreference === "preferred" &&
|
||||||
{selectedPreference === "muted" && "No enviar notificaciones por este canal."}
|
"Priorizar este canal para el cliente."}
|
||||||
{selectedPreference === "default" && "Usar la configuración general de la organización."}
|
{selectedPreference === "muted" &&
|
||||||
|
"No enviar notificaciones por este canal."}
|
||||||
|
{selectedPreference === "default" &&
|
||||||
|
"Usar la configuración general de la organización."}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.options}>
|
<div className={styles.options}>
|
||||||
{[
|
{[
|
||||||
{ key: "default" as ChannelPreference, label: "Estándar" },
|
{ key: "default" as ChannelPreference, label: "Estándar" },
|
||||||
{ key: "preferred" as ChannelPreference, label: "Preferido" },
|
{
|
||||||
{ key: "muted" as ChannelPreference, label: "Silenciado" }
|
key: "preferred" as ChannelPreference,
|
||||||
|
label: "Preferido",
|
||||||
|
},
|
||||||
|
{ key: "muted" as ChannelPreference, label: "Silenciado" },
|
||||||
].map((option) => {
|
].map((option) => {
|
||||||
const isSelected = selectedPreference === option.key;
|
const isSelected = selectedPreference === option.key;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={option.key}
|
key={option.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => selectPreference(channel.key, option.key)}
|
onClick={() =>
|
||||||
|
selectPreference(channel.key, option.key)
|
||||||
|
}
|
||||||
className={`${styles.optionButton} ${isSelected ? styles.optionButtonActive : ""}`}
|
className={`${styles.optionButton} ${isSelected ? styles.optionButtonActive : ""}`}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
|||||||
+15
-53
@@ -8,6 +8,7 @@ import { useLocationStore } from "@store/Location.Store";
|
|||||||
import { useNavigation } from "@hooks/goto";
|
import { useNavigation } from "@hooks/goto";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
oid: string;
|
oid: string;
|
||||||
@@ -19,11 +20,14 @@ export default function ClientProfile() {
|
|||||||
const clientId = data.clientId;
|
const clientId = data.clientId;
|
||||||
|
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
const { goTo } = useNavigation();
|
const { goTo } = useNavigation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org",
|
url: "/admin/org",
|
||||||
title: "Mis organizaciones",
|
title: "Mis organizaciones",
|
||||||
@@ -33,9 +37,7 @@ export default function ClientProfile() {
|
|||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org/profile/" + oid,
|
url: "/admin/org/profile/" + oid,
|
||||||
title: "Organizacion",
|
title: "Organizacion",
|
||||||
icon: (
|
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />,
|
||||||
<StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
@@ -43,6 +45,10 @@ export default function ClientProfile() {
|
|||||||
title: "Perfil del cliente",
|
title: "Perfil del cliente",
|
||||||
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -59,79 +65,35 @@ export default function ClientProfile() {
|
|||||||
<ButtonOption
|
<ButtonOption
|
||||||
text="Editar"
|
text="Editar"
|
||||||
icon="/profile-icon.svg"
|
icon="/profile-icon.svg"
|
||||||
onClick={() =>
|
onClick={() => goTo("/admin/org/" + oid + "/client/" + clientId + "/edit")}
|
||||||
goTo(
|
|
||||||
"/admin/org/" +
|
|
||||||
oid +
|
|
||||||
"/client/" +
|
|
||||||
clientId +
|
|
||||||
"/edit"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<ButtonOption
|
<ButtonOption
|
||||||
text="Turnos"
|
text="Turnos"
|
||||||
icon="/profile-icon.svg"
|
icon="/profile-icon.svg"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
goTo(
|
goTo("/admin/org/" + oid + "/client/" + clientId + "/appointments")
|
||||||
"/admin/org/" +
|
|
||||||
oid +
|
|
||||||
"/client/" +
|
|
||||||
clientId +
|
|
||||||
"/appointments"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ButtonOption
|
<ButtonOption
|
||||||
text="Fusionar Duplicados"
|
text="Fusionar Duplicados"
|
||||||
icon="/client-merge-icon.svg"
|
icon="/client-merge-icon.svg"
|
||||||
onClick={() =>
|
onClick={() => goTo("/admin/org/" + oid + "/client/" + clientId + "/merge")}
|
||||||
goTo(
|
|
||||||
"/admin/org/" +
|
|
||||||
oid +
|
|
||||||
"/client/" +
|
|
||||||
clientId +
|
|
||||||
"/merge"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<ButtonOption
|
<ButtonOption
|
||||||
text="Repeticiones"
|
text="Repeticiones"
|
||||||
icon="/repeat-icon.svg"
|
icon="/repeat-icon.svg"
|
||||||
onClick={() =>
|
onClick={() => goTo("/admin/org/" + oid + "/client/" + clientId + "/repeats")}
|
||||||
goTo(
|
|
||||||
"/admin/org/" +
|
|
||||||
oid +
|
|
||||||
"/client/" +
|
|
||||||
clientId +
|
|
||||||
"/repeats"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<ButtonOption
|
<ButtonOption
|
||||||
text="Cuenta"
|
text="Cuenta"
|
||||||
icon="/account-icon.svg"
|
icon="/account-icon.svg"
|
||||||
onClick={() =>
|
onClick={() => goTo("/admin/org/" + oid + "/client/" + clientId + "/account")}
|
||||||
goTo(
|
|
||||||
"/admin/org/" +
|
|
||||||
oid +
|
|
||||||
"/client/" +
|
|
||||||
clientId +
|
|
||||||
"/account"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<ButtonOption
|
<ButtonOption
|
||||||
text="Notificaciones"
|
text="Notificaciones"
|
||||||
icon="/notifications-icon.svg"
|
icon="/notifications-icon.svg"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
goTo(
|
goTo("/admin/org/" + oid + "/client/" + clientId + "/notifications")
|
||||||
"/admin/org/" +
|
|
||||||
oid +
|
|
||||||
"/client/" +
|
|
||||||
clientId +
|
|
||||||
"/notifications"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+9
-1
@@ -17,6 +17,7 @@ import { deleteRepeat, getRepeatsByClient } from "../Client.Service";
|
|||||||
import { DeleteRepeatsParams, FindRepeatsParams, RepeatView } from "@core/Models/Repeats.model";
|
import { DeleteRepeatsParams, FindRepeatsParams, RepeatView } from "@core/Models/Repeats.model";
|
||||||
|
|
||||||
import style from "./repeats.module.css";
|
import style from "./repeats.module.css";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
oid: string;
|
oid: string;
|
||||||
@@ -31,6 +32,7 @@ export default function ClientCreate() {
|
|||||||
const confirm = useConfirmStore();
|
const confirm = useConfirmStore();
|
||||||
const SessionInfo = useSessionStore();
|
const SessionInfo = useSessionStore();
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
|
|
||||||
const [repeats, setRepeats] = useState<RepeatView[]>([]);
|
const [repeats, setRepeats] = useState<RepeatView[]>([]);
|
||||||
@@ -52,6 +54,8 @@ export default function ClientCreate() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org/profile/" + id + "/clients",
|
url: "/admin/org/profile/" + id + "/clients",
|
||||||
title: "Clientes",
|
title: "Clientes",
|
||||||
@@ -69,6 +73,10 @@ export default function ClientCreate() {
|
|||||||
title: "Repeticiones",
|
title: "Repeticiones",
|
||||||
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -162,7 +170,7 @@ export default function ClientCreate() {
|
|||||||
() => {
|
() => {
|
||||||
handleDelete(repeat.id);
|
handleDelete(repeat.id);
|
||||||
},
|
},
|
||||||
() => {}
|
() => {},
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import TextObjectFilter from "@components/TextObjectFilter/TextObjectFilter";
|
|||||||
import SelectLocationMap, {
|
import SelectLocationMap, {
|
||||||
LocationCoordinates,
|
LocationCoordinates,
|
||||||
} from "@core/app/components/SelectLocationMap/SelectLocationMap";
|
} from "@core/app/components/SelectLocationMap/SelectLocationMap";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,6 +37,7 @@ export default function OrganizationEdit() {
|
|||||||
const data = useParams<UrlData>();
|
const data = useParams<UrlData>();
|
||||||
const id = data.id;
|
const id = data.id;
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
const SessionInfo = useSessionStore();
|
const SessionInfo = useSessionStore();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
@@ -140,7 +142,7 @@ export default function OrganizationEdit() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [localidadesFilterData, setLocalidadesFilterData] = useState<TextObjectFilterResult[]>(
|
const [localidadesFilterData, setLocalidadesFilterData] = useState<TextObjectFilterResult[]>(
|
||||||
[]
|
[],
|
||||||
);
|
);
|
||||||
const [selectedLocalidad, setSelectedLocalidad] = useState<string>("");
|
const [selectedLocalidad, setSelectedLocalidad] = useState<string>("");
|
||||||
|
|
||||||
@@ -188,6 +190,8 @@ export default function OrganizationEdit() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org",
|
url: "/admin/org",
|
||||||
title: "Organizaciones",
|
title: "Organizaciones",
|
||||||
@@ -205,6 +209,10 @@ export default function OrganizationEdit() {
|
|||||||
title: "Ubicación",
|
title: "Ubicación",
|
||||||
icon: <OrgConfigIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
icon: <OrgConfigIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import Button from "@core/app/components/Button/Button";
|
|||||||
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
||||||
import { deleteOrganization } from "./advanced.Service";
|
import { deleteOrganization } from "./advanced.Service";
|
||||||
import { useNavigation } from "@core/app/hooks/goto";
|
import { useNavigation } from "@core/app/hooks/goto";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,6 +24,7 @@ export default function OrganizationAdvancedEdit() {
|
|||||||
const data = useParams<UrlData>();
|
const data = useParams<UrlData>();
|
||||||
const id = data.id;
|
const id = data.id;
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
const SessionInfo = useSessionStore();
|
const SessionInfo = useSessionStore();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
@@ -33,6 +35,8 @@ export default function OrganizationAdvancedEdit() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org",
|
url: "/admin/org",
|
||||||
title: "Organizaciones",
|
title: "Organizaciones",
|
||||||
@@ -50,6 +54,10 @@ export default function OrganizationAdvancedEdit() {
|
|||||||
title: "Avanzado",
|
title: "Avanzado",
|
||||||
icon: <OrgConfigIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
icon: <OrgConfigIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const eliminarHandler = () => {
|
const eliminarHandler = () => {
|
||||||
@@ -73,7 +81,7 @@ export default function OrganizationAdvancedEdit() {
|
|||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
alert.showError("La confirmación no es correcta");
|
alert.showError("La confirmación no es correcta");
|
||||||
|
|||||||
+8
@@ -23,6 +23,7 @@ import { useNavigation } from "@hooks/goto";
|
|||||||
import Switch from "@mui/material/Switch";
|
import Switch from "@mui/material/Switch";
|
||||||
import { ThemeProvider } from "@emotion/react";
|
import { ThemeProvider } from "@emotion/react";
|
||||||
import turnosXpressTheme from "@core/app/theme/turnosXpress";
|
import turnosXpressTheme from "@core/app/theme/turnosXpress";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
dayjs.locale("es");
|
dayjs.locale("es");
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ export default function ClientCreate() {
|
|||||||
const { goTo } = useNavigation();
|
const { goTo } = useNavigation();
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
|
|
||||||
const [validation, setValidation] = useState<boolean>(true);
|
const [validation, setValidation] = useState<boolean>(true);
|
||||||
const [notification, setNotification] = useState<boolean>(true);
|
const [notification, setNotification] = useState<boolean>(true);
|
||||||
@@ -148,6 +150,8 @@ export default function ClientCreate() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org/profile/" + id,
|
url: "/admin/org/profile/" + id,
|
||||||
title: "Origanizacion",
|
title: "Origanizacion",
|
||||||
@@ -169,6 +173,10 @@ export default function ClientCreate() {
|
|||||||
title: "Crear nuevo turno",
|
title: "Crear nuevo turno",
|
||||||
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
+22
-4
@@ -31,7 +31,6 @@ import filterObjects from "@components/TextObjectFilter/Filter.Service";
|
|||||||
|
|
||||||
import dayjs, { ManipulateType } from "dayjs";
|
import dayjs, { ManipulateType } from "dayjs";
|
||||||
import "dayjs/locale/es";
|
import "dayjs/locale/es";
|
||||||
import Filterable from "@core/app/components/Filterable/Filterable";
|
|
||||||
import { useFilterStore } from "@core/Store/Filter.Store";
|
import { useFilterStore } from "@core/Store/Filter.Store";
|
||||||
import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model";
|
import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model";
|
||||||
import { FindEmployeesParams } from "@core/Models/Collaborators.model";
|
import { FindEmployeesParams } from "@core/Models/Collaborators.model";
|
||||||
@@ -496,8 +495,7 @@ export default function OrganizationProfile() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Addable onClick={handleAdd}>
|
<Addable onClick={handleAdd}>
|
||||||
<Filterable />
|
<Location showFilter />
|
||||||
<Location />
|
|
||||||
<div className={style.appointmentsTitle}>
|
<div className={style.appointmentsTitle}>
|
||||||
<h4>{dayjs(currentDate).format("dddd DD [de] MMMM, YYYY")}</h4>
|
<h4>{dayjs(currentDate).format("dddd DD [de] MMMM, YYYY")}</h4>
|
||||||
</div>
|
</div>
|
||||||
@@ -614,7 +612,27 @@ export default function OrganizationProfile() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={style.buttonPaste}
|
className={style.buttonPaste}
|
||||||
onClick={() => timePicker.show((newDay) => handleMoveAppointment(newDay))}
|
onClick={() => {
|
||||||
|
// Seed the controlled picker value with the copied
|
||||||
|
// appointment's current time. MUI only fires onAccept when the
|
||||||
|
// accepted value differs from its internal lastCommittedValue;
|
||||||
|
// without this, picking the same time twice in a row silently
|
||||||
|
// does nothing.
|
||||||
|
if (copiedAppointment) {
|
||||||
|
const currentHour = Math.floor(copiedAppointment.startTime);
|
||||||
|
const currentMinute = Math.round(
|
||||||
|
(copiedAppointment.startTime - currentHour) * 60
|
||||||
|
);
|
||||||
|
timePicker.setValue(
|
||||||
|
dayjs()
|
||||||
|
.hour(currentHour)
|
||||||
|
.minute(currentMinute)
|
||||||
|
.second(0)
|
||||||
|
.millisecond(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
timePicker.show((newDay) => handleMoveAppointment(newDay));
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ContentPasteIcon />
|
<ContentPasteIcon />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { UserAssistanceInfo } from "@core/Models/User.model";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getAssistanceUserInfo } from "./Assistance.Service";
|
import { getAssistanceUserInfo } from "./Assistance.Service";
|
||||||
import AssistanceIcon from "@mui/icons-material/HeadsetMicOutlined";
|
import AssistanceIcon from "@mui/icons-material/HeadsetMicOutlined";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,6 +24,7 @@ export default function CollaboratorsAdd() {
|
|||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
const SessionInfo = useSessionStore();
|
const SessionInfo = useSessionStore();
|
||||||
const [user, setUser] = useState<UserAssistanceInfo | null>(null);
|
const [user, setUser] = useState<UserAssistanceInfo | null>(null);
|
||||||
const [findEmail, setFindEmail] = useState<string>("");
|
const [findEmail, setFindEmail] = useState<string>("");
|
||||||
@@ -34,6 +36,8 @@ export default function CollaboratorsAdd() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org/profile/" + id,
|
url: "/admin/org/profile/" + id,
|
||||||
title: "Organizacion",
|
title: "Organizacion",
|
||||||
@@ -45,6 +49,10 @@ export default function CollaboratorsAdd() {
|
|||||||
title: "Asistencia",
|
title: "Asistencia",
|
||||||
icon: <AssistanceIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
icon: <AssistanceIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const findUser = async () => {
|
const findUser = async () => {
|
||||||
|
|||||||
-7
@@ -1,6 +1,5 @@
|
|||||||
.movementsPage {
|
.movementsPage {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
padding-top: 200px;
|
|
||||||
padding-bottom: 32px;
|
padding-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,12 +188,6 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paginationWrapper {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.invoiceModalBackdrop {
|
.invoiceModalBackdrop {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
background: rgba(15, 23, 42, 0.48);
|
background: rgba(15, 23, 42, 0.48);
|
||||||
|
|||||||
+26
-17
@@ -21,7 +21,6 @@ import {
|
|||||||
} from "@core/Models/CashFlow.model";
|
} from "@core/Models/CashFlow.model";
|
||||||
import { cashFlowPaginate, createInvoiceFromCashMovement, findInvoices, findOrganizationFiscalProfiles, Invoice, OrganizationFiscalProfile } from "../Cash.Service";
|
import { cashFlowPaginate, createInvoiceFromCashMovement, findInvoices, findOrganizationFiscalProfiles, Invoice, OrganizationFiscalProfile } from "../Cash.Service";
|
||||||
import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan";
|
import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan";
|
||||||
import Filterable from "@core/app/components/Filterable/Filterable";
|
|
||||||
import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model";
|
import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model";
|
||||||
import { TextObjectModalContent } from "@core/Store/TextObjectModal.Store";
|
import { TextObjectModalContent } from "@core/Store/TextObjectModal.Store";
|
||||||
import TextObjectFilterItem from "@core/app/components/TextObjectFilterItem/TextObjectFilterItem";
|
import TextObjectFilterItem from "@core/app/components/TextObjectFilterItem/TextObjectFilterItem";
|
||||||
@@ -39,8 +38,9 @@ import {
|
|||||||
CLIENT_ACCOUNT_MOVEMENT_TYPES,
|
CLIENT_ACCOUNT_MOVEMENT_TYPES,
|
||||||
CLIENT_ACCOUNT_PAYMENT_METHODS,
|
CLIENT_ACCOUNT_PAYMENT_METHODS,
|
||||||
} from "@core/Models/ClientAccountMovements.model";
|
} from "@core/Models/ClientAccountMovements.model";
|
||||||
import PaginationControls from "@components/PaginationControls/PaginationControls";
|
|
||||||
import { useAlert } from "@core/Store/Alert.Store";
|
import { useAlert } from "@core/Store/Alert.Store";
|
||||||
|
import { useBottomToolbarStore } from "@store/BottomToolbar.Store";
|
||||||
|
import PaginationControls from "@components/PaginationControls/PaginationControls";
|
||||||
import style from "./page.module.css";
|
import style from "./page.module.css";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
@@ -437,6 +437,7 @@ export default function CashFlowMovementsList() {
|
|||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const filter = useFilterStore();
|
const filter = useFilterStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
|
|
||||||
const [clientFilterData, setClientFilterData] = useState<TextObjectFilterResult[]>([]);
|
const [clientFilterData, setClientFilterData] = useState<TextObjectFilterResult[]>([]);
|
||||||
const [clientId, setClientId] = useState<string>("");
|
const [clientId, setClientId] = useState<string>("");
|
||||||
@@ -600,6 +601,28 @@ export default function CashFlowMovementsList() {
|
|||||||
});
|
});
|
||||||
}, [SessionInfo.userId]);
|
}, [SessionInfo.userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (movements && movements.pages > 1) {
|
||||||
|
bottomToolbar.set(
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", width: "100%" }}>
|
||||||
|
<PaginationControls
|
||||||
|
current={movements.page}
|
||||||
|
size={pageSize}
|
||||||
|
max={movements.pages}
|
||||||
|
onChange={loadMovements}
|
||||||
|
inheritColor
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
|
}, [movements?.page, movements?.pages, pageSize]);
|
||||||
|
|
||||||
const renderMovementMeta = (movement: ICashFlow) => {
|
const renderMovementMeta = (movement: ICashFlow) => {
|
||||||
const details = [
|
const details = [
|
||||||
movement.paymentMethod ? getPaymentMethodName(movement.paymentMethod) : null,
|
movement.paymentMethod ? getPaymentMethodName(movement.paymentMethod) : null,
|
||||||
@@ -862,8 +885,7 @@ export default function CashFlowMovementsList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Location />
|
<Location showFilter />
|
||||||
<Filterable />
|
|
||||||
|
|
||||||
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
|
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
|
||||||
<>
|
<>
|
||||||
@@ -948,20 +970,7 @@ export default function CashFlowMovementsList() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{movements && movements.pages > 1 && (
|
|
||||||
<div className={style.paginationWrapper}>
|
|
||||||
<PaginationControls
|
|
||||||
current={movements.page}
|
|
||||||
size={pageSize}
|
|
||||||
max={movements.pages}
|
|
||||||
onChange={loadMovements}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: "15px" }}></div>
|
|
||||||
|
|
||||||
{invoiceDraft && (
|
{invoiceDraft && (
|
||||||
<div className={style.invoiceModalBackdrop} role="presentation">
|
<div className={style.invoiceModalBackdrop} role="presentation">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { PaginateCashFlowParams, PaginateCashFlowResults } from "@core/Models/Ca
|
|||||||
import { useSessionStore } from "@core/Store/Sesion.Store";
|
import { useSessionStore } from "@core/Store/Sesion.Store";
|
||||||
import { ArcaWsfeLastVoucherResult, cashFlowPaginate, getArcaWsfeLastVoucher } from "./Cash.Service";
|
import { ArcaWsfeLastVoucherResult, cashFlowPaginate, getArcaWsfeLastVoucher } from "./Cash.Service";
|
||||||
import { useAlert } from "@core/Store/Alert.Store";
|
import { useAlert } from "@core/Store/Alert.Store";
|
||||||
|
import { useBottomToolbarStore } from "@store/BottomToolbar.Store";
|
||||||
import { IconButton } from "@mui/material";
|
import { IconButton } from "@mui/material";
|
||||||
import RetirarIcon from "@mui/icons-material/CallMadeOutlined";
|
import RetirarIcon from "@mui/icons-material/CallMadeOutlined";
|
||||||
import DepositarIcon from "@mui/icons-material/SouthWestOutlined";
|
import DepositarIcon from "@mui/icons-material/SouthWestOutlined";
|
||||||
@@ -38,6 +39,7 @@ export default function OrganizationCashFlowDetail() {
|
|||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
const SessionInfo = useSessionStore();
|
const SessionInfo = useSessionStore();
|
||||||
const { goTo } = useNavigation();
|
const { goTo } = useNavigation();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
|
|
||||||
const [dateFrom, setDateFrom] = useState<Dayjs>(dayjs().startOf("day"));
|
const [dateFrom, setDateFrom] = useState<Dayjs>(dayjs().startOf("day"));
|
||||||
const [dateTo, setDateTo] = useState<Dayjs>(dayjs().endOf("day"));
|
const [dateTo, setDateTo] = useState<Dayjs>(dayjs().endOf("day"));
|
||||||
@@ -49,6 +51,11 @@ export default function OrganizationCashFlowDetail() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -149,7 +149,9 @@ export default function OrganizationCashFlowResume() {
|
|||||||
cashFlowCalculateBalance({
|
cashFlowCalculateBalance({
|
||||||
companyId: id,
|
companyId: id,
|
||||||
year: year,
|
year: year,
|
||||||
month: month,
|
// TextMonth maneja meses 1-indexados (1 = enero); el storage de balances es
|
||||||
|
// 0-indexado (dayjs.month()), así que convertimos antes de enviar.
|
||||||
|
month: month - 1,
|
||||||
sessionUser: SessionInfo.userId,
|
sessionUser: SessionInfo.userId,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|||||||
+9
-1
@@ -12,6 +12,7 @@ import { useNavigation } from "@hooks/goto";
|
|||||||
import TextPhone from "@core/app/components/TextPhone/TextPhone";
|
import TextPhone from "@core/app/components/TextPhone/TextPhone";
|
||||||
import MaterialAlert from "@mui/material/Alert";
|
import MaterialAlert from "@mui/material/Alert";
|
||||||
import { createClient } from "../Clients.Service";
|
import { createClient } from "../Clients.Service";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,6 +24,7 @@ export default function ClientCreate() {
|
|||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const SessionInfo = useSessionStore();
|
const SessionInfo = useSessionStore();
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
const [firstName, setFirstName] = useState("");
|
const [firstName, setFirstName] = useState("");
|
||||||
const [lastName, setLastName] = useState("");
|
const [lastName, setLastName] = useState("");
|
||||||
@@ -39,6 +41,8 @@ export default function ClientCreate() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org/profile/" + id,
|
url: "/admin/org/profile/" + id,
|
||||||
title: "Origanizacion",
|
title: "Origanizacion",
|
||||||
@@ -56,6 +60,10 @@ export default function ClientCreate() {
|
|||||||
title: "Crear",
|
title: "Crear",
|
||||||
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -72,7 +80,7 @@ export default function ClientCreate() {
|
|||||||
})
|
})
|
||||||
.then((newClient) => {
|
.then((newClient) => {
|
||||||
alert.showSuccess(
|
alert.showSuccess(
|
||||||
`El cliente ${newClient.firstName} ${newClient.lastName} ha sido creado`
|
`El cliente ${newClient.firstName} ${newClient.lastName} ha sido creado`,
|
||||||
);
|
);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
goTo("/admin/org/profile/" + id + "/clients");
|
goTo("/admin/org/profile/" + id + "/clients");
|
||||||
|
|||||||
+1
-21
@@ -2,7 +2,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 60px; /* Below Location/Filterable */
|
top: 60px; /* Below Location */
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 10px;
|
bottom: 10px;
|
||||||
@@ -262,26 +262,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.paginationWrapper {
|
|
||||||
padding: 16px 30px;
|
|
||||||
background: rgba(255, 255, 255, 0.9);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
border-top: 1px solid rgba(226, 232, 240, 0.6);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 10;
|
|
||||||
border-bottom-left-radius: 24px;
|
|
||||||
border-bottom-right-radius: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.paginationWrapper {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom-left-radius: 16px;
|
|
||||||
border-bottom-right-radius: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyState {
|
.emptyState {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined";
|
|||||||
import PhoneOutlinedIcon from "@mui/icons-material/PhoneOutlined";
|
import PhoneOutlinedIcon from "@mui/icons-material/PhoneOutlined";
|
||||||
import Avatar from "@components/Avatar/Avatar";
|
import Avatar from "@components/Avatar/Avatar";
|
||||||
import PaginationControls from "@components/PaginationControls/PaginationControls";
|
import PaginationControls from "@components/PaginationControls/PaginationControls";
|
||||||
|
import { useBottomToolbarStore } from "@store/BottomToolbar.Store";
|
||||||
import pageStyle from "./page.module.css";
|
import pageStyle from "./page.module.css";
|
||||||
import { findClientsByOrganization } from "./Clients.Service";
|
import { findClientsByOrganization } from "./Clients.Service";
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +21,6 @@ import {
|
|||||||
} from "@core/Models/Clients.model";
|
} from "@core/Models/Clients.model";
|
||||||
import { useNavigation } from "@hooks/goto";
|
import { useNavigation } from "@hooks/goto";
|
||||||
import { FilterData, useFilterStore } from "@core/Store/Filter.Store";
|
import { FilterData, useFilterStore } from "@core/Store/Filter.Store";
|
||||||
import Filterable from "@core/app/components/Filterable/Filterable";
|
|
||||||
import Textbox from "@core/app/components/Textbox/Textbox";
|
import Textbox from "@core/app/components/Textbox/Textbox";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
@@ -107,6 +107,7 @@ export default function ClientsGrid() {
|
|||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
const filter = useFilterStore();
|
const filter = useFilterStore();
|
||||||
const { goTo } = useNavigation();
|
const { goTo } = useNavigation();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
|
|
||||||
const [pageSize] = useState<number>(
|
const [pageSize] = useState<number>(
|
||||||
parseInt(
|
parseInt(
|
||||||
@@ -173,14 +174,35 @@ export default function ClientsGrid() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (clients && clients.pages > 1) {
|
||||||
|
bottomToolbar.set(
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", width: "100%" }}>
|
||||||
|
<PaginationControls
|
||||||
|
current={clients.page}
|
||||||
|
size={pageSize}
|
||||||
|
max={clients.pages}
|
||||||
|
onChange={(page, size) => loadClients(page, size)}
|
||||||
|
inheritColor
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
|
}, [clients?.page, clients?.pages, pageSize]);
|
||||||
|
|
||||||
const handleClientClick = (client: ClientRow) => {
|
const handleClientClick = (client: ClientRow) => {
|
||||||
goTo("/admin/org/" + oid + "/client/" + client.id + "/profile");
|
goTo("/admin/org/" + oid + "/client/" + client.id + "/profile");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Filterable />
|
<Location showFilter />
|
||||||
<Location />
|
|
||||||
<div className={pageStyle.pageContainer}>
|
<div className={pageStyle.pageContainer}>
|
||||||
{clients?.data && clients.data.length > 0 ? (
|
{clients?.data && clients.data.length > 0 ? (
|
||||||
<div className={pageStyle.clientsGrid}>
|
<div className={pageStyle.clientsGrid}>
|
||||||
@@ -229,17 +251,6 @@ export default function ClientsGrid() {
|
|||||||
No se encontraron clientes con los filtros aplicados.
|
No se encontraron clientes con los filtros aplicados.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{clients && clients.pages > 1 && (
|
|
||||||
<div className={pageStyle.paginationWrapper}>
|
|
||||||
<PaginationControls
|
|
||||||
current={clients.page}
|
|
||||||
size={pageSize}
|
|
||||||
max={clients.pages}
|
|
||||||
onChange={(page, size) => loadClients(page, size)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { CompanyEmployeesView } from "@models/Collaborators.model";
|
|||||||
import { loadCollaboratorsByCompanyId } from "./Collaborators.Service";
|
import { loadCollaboratorsByCompanyId } from "./Collaborators.Service";
|
||||||
import { useAlert } from "@store/Alert.Store";
|
import { useAlert } from "@store/Alert.Store";
|
||||||
import CollaboratorItem from "@core/app/components/CollaboratorItem/CollaboratorItem";
|
import CollaboratorItem from "@core/app/components/CollaboratorItem/CollaboratorItem";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,6 +20,7 @@ export default function OrganizationProfile() {
|
|||||||
const data = useParams<UrlData>();
|
const data = useParams<UrlData>();
|
||||||
const id = data.id;
|
const id = data.id;
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { goTo } = useNavigation();
|
const { goTo } = useNavigation();
|
||||||
@@ -31,6 +33,8 @@ export default function OrganizationProfile() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org",
|
url: "/admin/org",
|
||||||
title: "Mis organizaciones",
|
title: "Mis organizaciones",
|
||||||
@@ -60,6 +64,10 @@ export default function OrganizationProfile() {
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
eventHandler.setEventType(EVENT_TYPES.NEED_ADD);
|
eventHandler.setEventType(EVENT_TYPES.NEED_ADD);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCollaboratorClick = (collaboratorId: string) => {
|
const handleCollaboratorClick = (collaboratorId: string) => {
|
||||||
|
|||||||
+18
-8
@@ -29,13 +29,13 @@ import DeleteIcon from "@mui/icons-material/DeleteOutlined";
|
|||||||
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
||||||
import { useAlert } from "@core/Store/Alert.Store";
|
import { useAlert } from "@core/Store/Alert.Store";
|
||||||
import Textbox from "@components/Textbox/Textbox";
|
import Textbox from "@components/Textbox/Textbox";
|
||||||
import Filterable from "@core/app/components/Filterable/Filterable";
|
|
||||||
import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model";
|
import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model";
|
||||||
import { TextObjectModalContent } from "@core/Store/TextObjectModal.Store";
|
import { TextObjectModalContent } from "@core/Store/TextObjectModal.Store";
|
||||||
import TextObjectFilterItem from "@core/app/components/TextObjectFilterItem/TextObjectFilterItem";
|
import TextObjectFilterItem from "@core/app/components/TextObjectFilterItem/TextObjectFilterItem";
|
||||||
import TextObjectFilter from "@core/app/components/TextObjectFilter/TextObjectFilter";
|
import TextObjectFilter from "@core/app/components/TextObjectFilter/TextObjectFilter";
|
||||||
import { useFilterStore } from "@core/Store/Filter.Store";
|
import { useFilterStore } from "@core/Store/Filter.Store";
|
||||||
import filterObjects from "@components/TextObjectFilter/Filter.Service";
|
import filterObjects from "@components/TextObjectFilter/Filter.Service";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -113,6 +113,7 @@ export default function DiscountsEmailGrid() {
|
|||||||
const data = useParams<UrlData>();
|
const data = useParams<UrlData>();
|
||||||
const oid = data.id;
|
const oid = data.id;
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
const SessionInfo = useSessionStore();
|
const SessionInfo = useSessionStore();
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
@@ -152,7 +153,9 @@ export default function DiscountsEmailGrid() {
|
|||||||
render: (value: DiscountEmailRow) => (
|
render: (value: DiscountEmailRow) => (
|
||||||
<div style={{ paddingBottom: "10px" }}>
|
<div style={{ paddingBottom: "10px" }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ paddingLeft: "10px", paddingTop: "10px", fontWeight: "bold" }}>
|
<div
|
||||||
|
style={{ paddingLeft: "10px", paddingTop: "10px", fontWeight: "bold" }}
|
||||||
|
>
|
||||||
{value.discountName + " (" + value.discountCode + ")"}
|
{value.discountName + " (" + value.discountCode + ")"}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ paddingLeft: "10px", paddingTop: "0px", fontWeight: "bold" }}>
|
<div style={{ paddingLeft: "10px", paddingTop: "0px", fontWeight: "bold" }}>
|
||||||
@@ -207,8 +210,10 @@ export default function DiscountsEmailGrid() {
|
|||||||
|
|
||||||
const [pageSize] = useState<number>(
|
const [pageSize] = useState<number>(
|
||||||
parseInt(
|
parseInt(
|
||||||
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE ? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE : "10"
|
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
|
||||||
)
|
? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
|
||||||
|
: "10",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const [discounts, setDiscounts] = useState<PaginateDiscountsEmailViewResults>();
|
const [discounts, setDiscounts] = useState<PaginateDiscountsEmailViewResults>();
|
||||||
const discountsRef = useRef(discounts);
|
const discountsRef = useRef(discounts);
|
||||||
@@ -220,6 +225,8 @@ export default function DiscountsEmailGrid() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org",
|
url: "/admin/org",
|
||||||
title: "Mis organizaciones",
|
title: "Mis organizaciones",
|
||||||
@@ -239,6 +246,10 @@ export default function DiscountsEmailGrid() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
loadDiscountsEmail(1, pageSize);
|
loadDiscountsEmail(1, pageSize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -268,7 +279,7 @@ export default function DiscountsEmailGrid() {
|
|||||||
setDiscountId={setDiscountId}
|
setDiscountId={setDiscountId}
|
||||||
filterEmail={filterEmail}
|
filterEmail={filterEmail}
|
||||||
setFilterEmail={setFilterEmail}
|
setFilterEmail={setFilterEmail}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
filter.setOnFilter(() => {
|
filter.setOnFilter(() => {
|
||||||
setSearchByFilters(true);
|
setSearchByFilters(true);
|
||||||
@@ -328,14 +339,13 @@ export default function DiscountsEmailGrid() {
|
|||||||
alert.showError(err.format());
|
alert.showError(err.format());
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
() => {}
|
() => {},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Location />
|
<Location showFilter />
|
||||||
<Filterable />
|
|
||||||
|
|
||||||
<div style={{ display: "flex", justifyContent: "center", marginTop: "15px" }}>
|
<div style={{ display: "flex", justifyContent: "center", marginTop: "15px" }}>
|
||||||
<ThemeProvider theme={turnosXpressTheme}>
|
<ThemeProvider theme={turnosXpressTheme}>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import turnosXpressTheme from "@core/app/theme/turnosXpress";
|
|||||||
import { ButtonGroup } from "@mui/material";
|
import { ButtonGroup } from "@mui/material";
|
||||||
import ButtonMaterial from "@mui/material/Button";
|
import ButtonMaterial from "@mui/material/Button";
|
||||||
import { useNavigation } from "@core/app/hooks/goto";
|
import { useNavigation } from "@core/app/hooks/goto";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -33,6 +34,7 @@ export default function DiscountsGrid() {
|
|||||||
const data = useParams<UrlData>();
|
const data = useParams<UrlData>();
|
||||||
const oid = data.id;
|
const oid = data.id;
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
|
|
||||||
const { goTo } = useNavigation();
|
const { goTo } = useNavigation();
|
||||||
@@ -67,7 +69,9 @@ export default function DiscountsGrid() {
|
|||||||
color: "var(--green-darkestX2)",
|
color: "var(--green-darkestX2)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{value.type === DiscountType.FIXED ? formatPrice(value.value) : value.value + "%"}
|
{value.type === DiscountType.FIXED
|
||||||
|
? formatPrice(value.value)
|
||||||
|
: value.value + "%"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -76,8 +80,10 @@ export default function DiscountsGrid() {
|
|||||||
|
|
||||||
const [pageSize] = useState<number>(
|
const [pageSize] = useState<number>(
|
||||||
parseInt(
|
parseInt(
|
||||||
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE ? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE : "10"
|
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
|
||||||
)
|
? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
|
||||||
|
: "10",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const [discounts, setDiscounts] = useState<PaginateDiscountsResults>();
|
const [discounts, setDiscounts] = useState<PaginateDiscountsResults>();
|
||||||
|
|
||||||
@@ -88,6 +94,8 @@ export default function DiscountsGrid() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org",
|
url: "/admin/org",
|
||||||
title: "Mis organizaciones",
|
title: "Mis organizaciones",
|
||||||
@@ -107,6 +115,10 @@ export default function DiscountsGrid() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
loadDiscounts(1, pageSize);
|
loadDiscounts(1, pageSize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadDiscounts = (page: number = 1, pageSize: number = 30) => {
|
const loadDiscounts = (page: number = 1, pageSize: number = 30) => {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import Switch from "@core/app/components/Switch/Switch";
|
|||||||
import { FindTemplatesParams } from "@core/Models/Templates.model";
|
import { FindTemplatesParams } from "@core/Models/Templates.model";
|
||||||
import filterObjects from "@core/app/components/TextObjectFilter/Filter.Service";
|
import filterObjects from "@core/app/components/TextObjectFilter/Filter.Service";
|
||||||
import { CompanyServiceView } from "@models/Service.model";
|
import { CompanyServiceView } from "@models/Service.model";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -47,6 +48,7 @@ export default function OrganizationEdit() {
|
|||||||
const data = useParams<UrlData>();
|
const data = useParams<UrlData>();
|
||||||
const id = data.id;
|
const id = data.id;
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
const SessionInfo = useSessionStore();
|
const SessionInfo = useSessionStore();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
@@ -66,11 +68,12 @@ export default function OrganizationEdit() {
|
|||||||
const [servicesShowPublicScores, setServicesShowPublicScores] = useState(true);
|
const [servicesShowPublicScores, setServicesShowPublicScores] = useState(true);
|
||||||
const [servicesShowPublicOpinions, setServicesShowPublicOpinions] = useState(true);
|
const [servicesShowPublicOpinions, setServicesShowPublicOpinions] = useState(true);
|
||||||
const [originalServicesShowPublicScores, setOriginalServicesShowPublicScores] = useState(true);
|
const [originalServicesShowPublicScores, setOriginalServicesShowPublicScores] = useState(true);
|
||||||
const [originalServicesShowPublicOpinions, setOriginalServicesShowPublicOpinions] = useState(true);
|
const [originalServicesShowPublicOpinions, setOriginalServicesShowPublicOpinions] =
|
||||||
|
useState(true);
|
||||||
const [services, setServices] = useState<CompanyServiceView[]>([]);
|
const [services, setServices] = useState<CompanyServiceView[]>([]);
|
||||||
|
|
||||||
const [orgPublished, setOrgPublished] = useState<COMPANY_PUBLISHED_STATUS | undefined>(
|
const [orgPublished, setOrgPublished] = useState<COMPANY_PUBLISHED_STATUS | undefined>(
|
||||||
undefined
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [savePublishedStatusAvailable, setSavePublishedStatusAvailable] = useState(false);
|
const [savePublishedStatusAvailable, setSavePublishedStatusAvailable] = useState(false);
|
||||||
@@ -199,6 +202,8 @@ export default function OrganizationEdit() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org",
|
url: "/admin/org",
|
||||||
title: "Organizaciones",
|
title: "Organizaciones",
|
||||||
@@ -218,6 +223,10 @@ export default function OrganizationEdit() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
loadCategories();
|
loadCategories();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -241,25 +250,33 @@ export default function OrganizationEdit() {
|
|||||||
setCategoryId(organization.categoryId.toString());
|
setCategoryId(organization.categoryId.toString());
|
||||||
setOrgSlug(getSlug(organization.name));
|
setOrgSlug(getSlug(organization.name));
|
||||||
setOrgPublished(organization.published);
|
setOrgPublished(organization.published);
|
||||||
setShowPublicScores(organization.showPublicScores !== undefined ? organization.showPublicScores : true);
|
setShowPublicScores(
|
||||||
setShowPublicOpinions(organization.showPublicOpinions !== undefined ? organization.showPublicOpinions : true);
|
organization.showPublicScores !== undefined
|
||||||
|
? organization.showPublicScores
|
||||||
|
: true,
|
||||||
|
);
|
||||||
|
setShowPublicOpinions(
|
||||||
|
organization.showPublicOpinions !== undefined
|
||||||
|
? organization.showPublicOpinions
|
||||||
|
: true,
|
||||||
|
);
|
||||||
setShowPublicProfessionals(
|
setShowPublicProfessionals(
|
||||||
organization.showPublicProfessionals !== undefined
|
organization.showPublicProfessionals !== undefined
|
||||||
? organization.showPublicProfessionals
|
? organization.showPublicProfessionals
|
||||||
: true
|
: true,
|
||||||
);
|
);
|
||||||
setServices(loadedServices);
|
setServices(loadedServices);
|
||||||
setServicesShowPublicScores(
|
setServicesShowPublicScores(
|
||||||
loadedServices.every((service) => service.showPublicScores !== false)
|
loadedServices.every((service) => service.showPublicScores !== false),
|
||||||
);
|
);
|
||||||
setServicesShowPublicOpinions(
|
setServicesShowPublicOpinions(
|
||||||
loadedServices.every((service) => service.showPublicOpinions !== false)
|
loadedServices.every((service) => service.showPublicOpinions !== false),
|
||||||
);
|
);
|
||||||
setOriginalServicesShowPublicScores(
|
setOriginalServicesShowPublicScores(
|
||||||
loadedServices.every((service) => service.showPublicScores !== false)
|
loadedServices.every((service) => service.showPublicScores !== false),
|
||||||
);
|
);
|
||||||
setOriginalServicesShowPublicOpinions(
|
setOriginalServicesShowPublicOpinions(
|
||||||
loadedServices.every((service) => service.showPublicOpinions !== false)
|
loadedServices.every((service) => service.showPublicOpinions !== false),
|
||||||
);
|
);
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -287,7 +304,7 @@ export default function OrganizationEdit() {
|
|||||||
confirm.show(
|
confirm.show(
|
||||||
validationPhoneResult.message,
|
validationPhoneResult.message,
|
||||||
() => {},
|
() => {},
|
||||||
() => {}
|
() => {},
|
||||||
);
|
);
|
||||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||||
return;
|
return;
|
||||||
@@ -307,7 +324,8 @@ export default function OrganizationEdit() {
|
|||||||
phoneNumber: phone,
|
phoneNumber: phone,
|
||||||
templateWapNotifId: templateWapNotifId === "" ? undefined : templateWapNotifId,
|
templateWapNotifId: templateWapNotifId === "" ? undefined : templateWapNotifId,
|
||||||
templateWapAltaId: templateWapAltaId === "" ? undefined : templateWapAltaId,
|
templateWapAltaId: templateWapAltaId === "" ? undefined : templateWapAltaId,
|
||||||
templateWapCancellationId: templateWapCancellationId === "" ? undefined : templateWapCancellationId,
|
templateWapCancellationId:
|
||||||
|
templateWapCancellationId === "" ? undefined : templateWapCancellationId,
|
||||||
...(canEditPublicVisibilityFlags
|
...(canEditPublicVisibilityFlags
|
||||||
? {
|
? {
|
||||||
showPublicScores,
|
showPublicScores,
|
||||||
@@ -321,31 +339,31 @@ export default function OrganizationEdit() {
|
|||||||
id: Yup.string().required("En este momento no es posible procesar su solicitud."),
|
id: Yup.string().required("En este momento no es posible procesar su solicitud."),
|
||||||
name: Yup.string().required("Debe introducir el nombre de la organización."),
|
name: Yup.string().required("Debe introducir el nombre de la organización."),
|
||||||
description: Yup.string().required(
|
description: Yup.string().required(
|
||||||
"Debe introducir la descripción de la organización."
|
"Debe introducir la descripción de la organización.",
|
||||||
),
|
),
|
||||||
categoryId: Yup.number().required(
|
categoryId: Yup.number().required(
|
||||||
"Debe seleccionar una categoría para la organización."
|
"Debe seleccionar una categoría para la organización.",
|
||||||
),
|
),
|
||||||
phoneAreaCode: Yup.number()
|
phoneAreaCode: Yup.number()
|
||||||
.typeError("Código de área debe ser un número.")
|
.typeError("Código de área debe ser un número.")
|
||||||
.transform((value, originalValue) =>
|
.transform((value, originalValue) =>
|
||||||
originalValue.toString().trim() === "" ? null : value
|
originalValue.toString().trim() === "" ? null : value,
|
||||||
)
|
)
|
||||||
.nullable(),
|
.nullable(),
|
||||||
phoneNumber: Yup.number()
|
phoneNumber: Yup.number()
|
||||||
.typeError("El teléfono debe ser un número.")
|
.typeError("El teléfono debe ser un número.")
|
||||||
.transform((value, originalValue) =>
|
.transform((value, originalValue) =>
|
||||||
originalValue.toString().trim() === "" ? null : value
|
originalValue.toString().trim() === "" ? null : value,
|
||||||
)
|
)
|
||||||
.nullable(),
|
.nullable(),
|
||||||
appointmentAlert: Yup.string().optional().default(""),
|
appointmentAlert: Yup.string().optional().default(""),
|
||||||
sessionUser: Yup.string().required(
|
sessionUser: Yup.string().required(
|
||||||
"Para crear una organización debe iniciar sesión."
|
"Para crear una organización debe iniciar sesión.",
|
||||||
),
|
),
|
||||||
heatMapFraction: Yup.number()
|
heatMapFraction: Yup.number()
|
||||||
.typeError("Fracción de tiempo debe ser un número.")
|
.typeError("Fracción de tiempo debe ser un número.")
|
||||||
.transform((value, originalValue) =>
|
.transform((value, originalValue) =>
|
||||||
originalValue.toString().trim() === "" ? null : value
|
originalValue.toString().trim() === "" ? null : value,
|
||||||
)
|
)
|
||||||
.nullable()
|
.nullable()
|
||||||
.integer("Fracción de tiempo debe ser un número entero en minutos.")
|
.integer("Fracción de tiempo debe ser un número entero en minutos.")
|
||||||
@@ -354,7 +372,7 @@ export default function OrganizationEdit() {
|
|||||||
appointmentTime: Yup.number()
|
appointmentTime: Yup.number()
|
||||||
.typeError("El Plazo Mínimo de Reserva debe ser un número.")
|
.typeError("El Plazo Mínimo de Reserva debe ser un número.")
|
||||||
.transform((value, originalValue) =>
|
.transform((value, originalValue) =>
|
||||||
originalValue.toString().trim() === "" ? null : value
|
originalValue.toString().trim() === "" ? null : value,
|
||||||
)
|
)
|
||||||
.nullable()
|
.nullable()
|
||||||
.integer("El Plazo Mínimo de Reserva debe ser un número entero en horas.")
|
.integer("El Plazo Mínimo de Reserva debe ser un número entero en horas.")
|
||||||
@@ -362,7 +380,7 @@ export default function OrganizationEdit() {
|
|||||||
cancellationTime: Yup.number()
|
cancellationTime: Yup.number()
|
||||||
.typeError("El Plazo Mínimo de Cancelación debe ser un número.")
|
.typeError("El Plazo Mínimo de Cancelación debe ser un número.")
|
||||||
.transform((value, originalValue) =>
|
.transform((value, originalValue) =>
|
||||||
originalValue.toString().trim() === "" ? null : value
|
originalValue.toString().trim() === "" ? null : value,
|
||||||
)
|
)
|
||||||
.nullable()
|
.nullable()
|
||||||
.integer("El Plazo Mínimo de Cancelación debe ser un número entero en horas.")
|
.integer("El Plazo Mínimo de Cancelación debe ser un número entero en horas.")
|
||||||
@@ -386,7 +404,8 @@ export default function OrganizationEdit() {
|
|||||||
originalServicesShowPublicOpinions !== servicesShowPublicOpinions;
|
originalServicesShowPublicOpinions !== servicesShowPublicOpinions;
|
||||||
const changedServices =
|
const changedServices =
|
||||||
canEditPublicVisibilityFlags &&
|
canEditPublicVisibilityFlags &&
|
||||||
(servicesShowPublicScoresChanged || servicesShowPublicOpinionsChanged)
|
(servicesShowPublicScoresChanged ||
|
||||||
|
servicesShowPublicOpinionsChanged)
|
||||||
? services
|
? services
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
@@ -414,8 +433,8 @@ export default function OrganizationEdit() {
|
|||||||
: service.showPublicOpinions !== undefined
|
: service.showPublicOpinions !== undefined
|
||||||
? service.showPublicOpinions
|
? service.showPublicOpinions
|
||||||
: true,
|
: true,
|
||||||
})
|
}),
|
||||||
)
|
),
|
||||||
).then(() => {
|
).then(() => {
|
||||||
const updatedServices = services.map((service) => ({
|
const updatedServices = services.map((service) => ({
|
||||||
...service,
|
...service,
|
||||||
@@ -434,7 +453,9 @@ export default function OrganizationEdit() {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((error: ApiError | Error) => {
|
.catch((error: ApiError | Error) => {
|
||||||
alert.showError(error instanceof ApiError ? error.format() : error.message);
|
alert.showError(
|
||||||
|
error instanceof ApiError ? error.format() : error.message,
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||||
@@ -453,7 +474,7 @@ export default function OrganizationEdit() {
|
|||||||
id: category.id.toString(),
|
id: category.id.toString(),
|
||||||
name: category.name,
|
name: category.name,
|
||||||
description: category.description,
|
description: category.description,
|
||||||
}))
|
})),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -533,7 +554,7 @@ export default function OrganizationEdit() {
|
|||||||
setOrgPublished(
|
setOrgPublished(
|
||||||
checked
|
checked
|
||||||
? COMPANY_PUBLISHED_STATUS.PUBLISHED
|
? COMPANY_PUBLISHED_STATUS.PUBLISHED
|
||||||
: COMPANY_PUBLISHED_STATUS.PRIVATE
|
: COMPANY_PUBLISHED_STATUS.PRIVATE,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -581,15 +602,23 @@ export default function OrganizationEdit() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<MaterialAlert severity="info" sx={{ marginTop: "20px" }}>
|
<MaterialAlert severity="info" sx={{ marginTop: "20px" }}>
|
||||||
<b>Visibilidad pública:</b> Elegí si querés mostrar puntuaciones, opiniones y profesionales en el perfil público y las páginas de reserva. Disponible para planes pagos.
|
<b>Visibilidad pública:</b> Elegí si querés mostrar puntuaciones, opiniones y
|
||||||
|
profesionales en el perfil público y las páginas de reserva. Disponible para planes
|
||||||
|
pagos.
|
||||||
</MaterialAlert>
|
</MaterialAlert>
|
||||||
{!canEditPublicVisibilityFlags && (
|
{!canEditPublicVisibilityFlags && (
|
||||||
<MaterialAlert severity="warning" sx={{ marginTop: "10px" }}>
|
<MaterialAlert severity="warning" sx={{ marginTop: "10px" }}>
|
||||||
Para modificar las opciones de visibilidad pública de opiniones, puntuaciones y profesionales de esta organización y sus servicios, el propietario de la organización necesita un plan pago.
|
Para modificar las opciones de visibilidad pública de opiniones, puntuaciones y
|
||||||
|
profesionales de esta organización y sus servicios, el propietario de la
|
||||||
|
organización necesita un plan pago.
|
||||||
</MaterialAlert>
|
</MaterialAlert>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" }}>
|
<div
|
||||||
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
|
style={{ display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" }}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}
|
||||||
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={showPublicScores}
|
checked={showPublicScores}
|
||||||
disabled={!canEditPublicVisibilityFlags}
|
disabled={!canEditPublicVisibilityFlags}
|
||||||
@@ -600,7 +629,9 @@ export default function OrganizationEdit() {
|
|||||||
/>
|
/>
|
||||||
Mostrar puntuaciones públicas
|
Mostrar puntuaciones públicas
|
||||||
</label>
|
</label>
|
||||||
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
|
<label
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}
|
||||||
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={showPublicOpinions}
|
checked={showPublicOpinions}
|
||||||
disabled={!canEditPublicVisibilityFlags}
|
disabled={!canEditPublicVisibilityFlags}
|
||||||
@@ -611,7 +642,9 @@ export default function OrganizationEdit() {
|
|||||||
/>
|
/>
|
||||||
Mostrar opiniones públicas
|
Mostrar opiniones públicas
|
||||||
</label>
|
</label>
|
||||||
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
|
<label
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}
|
||||||
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={showPublicProfessionals}
|
checked={showPublicProfessionals}
|
||||||
disabled={!canEditPublicVisibilityFlags}
|
disabled={!canEditPublicVisibilityFlags}
|
||||||
@@ -632,7 +665,9 @@ export default function OrganizationEdit() {
|
|||||||
{services.length > 0 && (
|
{services.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<MaterialAlert severity="info" sx={{ marginTop: "20px" }}>
|
<MaterialAlert severity="info" sx={{ marginTop: "20px" }}>
|
||||||
<b>Visibilidad pública de servicios:</b> Configurá si se muestran puntuaciones y opiniones en todos los servicios. Disponible para planes pagos.
|
<b>Visibilidad pública de servicios:</b> Configurá si se muestran
|
||||||
|
puntuaciones y opiniones en todos los servicios. Disponible para planes
|
||||||
|
pagos.
|
||||||
</MaterialAlert>
|
</MaterialAlert>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -642,7 +677,14 @@ export default function OrganizationEdit() {
|
|||||||
marginTop: "10px",
|
marginTop: "10px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
|
<label
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "10px",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={servicesShowPublicScores}
|
checked={servicesShowPublicScores}
|
||||||
disabled={!canEditPublicVisibilityFlags}
|
disabled={!canEditPublicVisibilityFlags}
|
||||||
@@ -653,7 +695,14 @@ export default function OrganizationEdit() {
|
|||||||
/>
|
/>
|
||||||
Mostrar puntuaciones públicas de los servicios
|
Mostrar puntuaciones públicas de los servicios
|
||||||
</label>
|
</label>
|
||||||
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
|
<label
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "10px",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={servicesShowPublicOpinions}
|
checked={servicesShowPublicOpinions}
|
||||||
disabled={!canEditPublicVisibilityFlags}
|
disabled={!canEditPublicVisibilityFlags}
|
||||||
@@ -666,7 +715,8 @@ export default function OrganizationEdit() {
|
|||||||
</label>
|
</label>
|
||||||
{!canEditPublicVisibilityFlags && (
|
{!canEditPublicVisibilityFlags && (
|
||||||
<span style={{ color: "var(--gray-darkest)", fontSize: "13px" }}>
|
<span style={{ color: "var(--gray-darkest)", fontSize: "13px" }}>
|
||||||
Para cambiar la visibilidad pública de los servicios necesitás un plan pago.
|
Para cambiar la visibilidad pública de los servicios necesitás un
|
||||||
|
plan pago.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ import ReceiptLongOutlinedIcon from "@mui/icons-material/ReceiptLongOutlined";
|
|||||||
import { Grid2 } from "@mui/material";
|
import { Grid2 } from "@mui/material";
|
||||||
import { ApiError } from "@models/Server.Error.model";
|
import { ApiError } from "@models/Server.Error.model";
|
||||||
import style from "./page.module.css";
|
import style from "./page.module.css";
|
||||||
|
import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan";
|
||||||
import {
|
import {
|
||||||
findArcaActivities,
|
findArcaActivities,
|
||||||
findOrganizationFiscalProfiles,
|
findOrganizationFiscalProfiles,
|
||||||
OrganizationFiscalProfileTaxCondition,
|
OrganizationFiscalProfileTaxCondition,
|
||||||
upsertOrganizationFiscalProfile,
|
upsertOrganizationFiscalProfile,
|
||||||
} from "./Fiscal.Service";
|
} from "./Fiscal.Service";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,13 +34,15 @@ export default function OrganizationFiscalProfileEdit() {
|
|||||||
const data = useParams<UrlData>();
|
const data = useParams<UrlData>();
|
||||||
const id = data.id;
|
const id = data.id;
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
const SessionInfo = useSessionStore();
|
const SessionInfo = useSessionStore();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
|
|
||||||
const [cuit, setCuit] = useState("");
|
const [cuit, setCuit] = useState("");
|
||||||
const [legalName, setLegalName] = useState("");
|
const [legalName, setLegalName] = useState("");
|
||||||
const [taxCondition, setTaxCondition] = useState<OrganizationFiscalProfileTaxCondition>("MONOTRIBUTO");
|
const [taxCondition, setTaxCondition] =
|
||||||
|
useState<OrganizationFiscalProfileTaxCondition>("MONOTRIBUTO");
|
||||||
const [pointOfSale, setPointOfSale] = useState("1");
|
const [pointOfSale, setPointOfSale] = useState("1");
|
||||||
const [activityCode, setActivityCode] = useState("");
|
const [activityCode, setActivityCode] = useState("");
|
||||||
const [activityDescription, setActivityDescription] = useState("");
|
const [activityDescription, setActivityDescription] = useState("");
|
||||||
@@ -79,6 +83,8 @@ export default function OrganizationFiscalProfileEdit() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.clear();
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org",
|
url: "/admin/org",
|
||||||
title: "Organizaciones",
|
title: "Organizaciones",
|
||||||
@@ -96,6 +102,10 @@ export default function OrganizationFiscalProfileEdit() {
|
|||||||
title: "Datos fiscales",
|
title: "Datos fiscales",
|
||||||
icon: <ReceiptLongOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
icon: <ReceiptLongOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -117,7 +127,9 @@ export default function OrganizationFiscalProfileEdit() {
|
|||||||
setLegalName(profile.legalName || "");
|
setLegalName(profile.legalName || "");
|
||||||
setTaxCondition(profile.taxCondition || "MONOTRIBUTO");
|
setTaxCondition(profile.taxCondition || "MONOTRIBUTO");
|
||||||
setPointOfSale(profile.pointOfSale ? String(profile.pointOfSale) : "1");
|
setPointOfSale(profile.pointOfSale ? String(profile.pointOfSale) : "1");
|
||||||
const profileActivity = activities.find((activity) => activity.id === profile.activityCode);
|
const profileActivity = activities.find(
|
||||||
|
(activity) => activity.id === profile.activityCode,
|
||||||
|
);
|
||||||
setActivityCode(profile.activityCode || "");
|
setActivityCode(profile.activityCode || "");
|
||||||
setActivityDescription(profile.activityDescription || profileActivity?.name || "");
|
setActivityDescription(profile.activityDescription || profileActivity?.name || "");
|
||||||
})
|
})
|
||||||
@@ -151,7 +163,10 @@ export default function OrganizationFiscalProfileEdit() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activityCode.trim() && !arcaActivities.some((activity) => activity.id === activityCode.trim())) {
|
if (
|
||||||
|
activityCode.trim() &&
|
||||||
|
!arcaActivities.some((activity) => activity.id === activityCode.trim())
|
||||||
|
) {
|
||||||
alert.showError("Seleccioná una actividad ARCA/CLAE válida.");
|
alert.showError("Seleccioná una actividad ARCA/CLAE válida.");
|
||||||
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
|
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
|
||||||
return;
|
return;
|
||||||
@@ -159,7 +174,9 @@ export default function OrganizationFiscalProfileEdit() {
|
|||||||
|
|
||||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||||
|
|
||||||
const selectedActivity = arcaActivities.find((activity) => activity.id === activityCode.trim());
|
const selectedActivity = arcaActivities.find(
|
||||||
|
(activity) => activity.id === activityCode.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
upsertOrganizationFiscalProfile({
|
upsertOrganizationFiscalProfile({
|
||||||
companyId: id,
|
companyId: id,
|
||||||
@@ -172,7 +189,9 @@ export default function OrganizationFiscalProfileEdit() {
|
|||||||
sessionUser: SessionInfo.userId,
|
sessionUser: SessionInfo.userId,
|
||||||
})
|
})
|
||||||
.then((profile) => {
|
.then((profile) => {
|
||||||
const savedActivity = arcaActivities.find((activity) => activity.id === profile.activityCode);
|
const savedActivity = arcaActivities.find(
|
||||||
|
(activity) => activity.id === profile.activityCode,
|
||||||
|
);
|
||||||
setActivityCode(profile.activityCode || "");
|
setActivityCode(profile.activityCode || "");
|
||||||
setActivityDescription(profile.activityDescription || savedActivity?.name || "");
|
setActivityDescription(profile.activityDescription || savedActivity?.name || "");
|
||||||
alert.showSuccess("Datos fiscales actualizados.");
|
alert.showSuccess("Datos fiscales actualizados.");
|
||||||
@@ -188,6 +207,7 @@ export default function OrganizationFiscalProfileEdit() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Location />
|
<Location />
|
||||||
|
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
|
||||||
<div className={style.pageContainer}>
|
<div className={style.pageContainer}>
|
||||||
<section className={style.heroCard}>
|
<section className={style.heroCard}>
|
||||||
<div className={style.heroIcon}>
|
<div className={style.heroIcon}>
|
||||||
@@ -196,7 +216,8 @@ export default function OrganizationFiscalProfileEdit() {
|
|||||||
<div>
|
<div>
|
||||||
<h2>Configuración fiscal</h2>
|
<h2>Configuración fiscal</h2>
|
||||||
<p>
|
<p>
|
||||||
Estos datos se usan para emitir Factura C desde los movimientos de caja y para precargar el asistente ARCA.
|
Estos datos se usan para emitir Factura C desde los movimientos de
|
||||||
|
caja y para precargar el asistente ARCA.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -238,7 +259,11 @@ export default function OrganizationFiscalProfileEdit() {
|
|||||||
<h3>Condición fiscal</h3>
|
<h3>Condición fiscal</h3>
|
||||||
<p>Seleccioná la condición declarada para esta organización.</p>
|
<p>Seleccioná la condición declarada para esta organización.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.optionGroup} role="radiogroup" aria-label="Condición fiscal">
|
<div
|
||||||
|
className={style.optionGroup}
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Condición fiscal"
|
||||||
|
>
|
||||||
{fiscalConditionOptions.map((option) => {
|
{fiscalConditionOptions.map((option) => {
|
||||||
const selected = taxCondition === option.id;
|
const selected = taxCondition === option.id;
|
||||||
|
|
||||||
@@ -254,7 +279,9 @@ export default function OrganizationFiscalProfileEdit() {
|
|||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
>
|
>
|
||||||
<span className={style.optionTitle}>{option.title}</span>
|
<span className={style.optionTitle}>{option.title}</span>
|
||||||
<span className={style.optionDescription}>{option.description}</span>
|
<span className={style.optionDescription}>
|
||||||
|
{option.description}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -288,7 +315,7 @@ export default function OrganizationFiscalProfileEdit() {
|
|||||||
selectedId={activityCode}
|
selectedId={activityCode}
|
||||||
onChange={(selectedActivityCode) => {
|
onChange={(selectedActivityCode) => {
|
||||||
const selectedActivity = arcaActivities.find(
|
const selectedActivity = arcaActivities.find(
|
||||||
(activity) => activity.id === selectedActivityCode
|
(activity) => activity.id === selectedActivityCode,
|
||||||
);
|
);
|
||||||
|
|
||||||
setActivityCode(selectedActivity?.id || "");
|
setActivityCode(selectedActivity?.id || "");
|
||||||
@@ -305,6 +332,7 @@ export default function OrganizationFiscalProfileEdit() {
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
</SecureByPlan>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-22
@@ -13,6 +13,7 @@ import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined";
|
|||||||
import NotificationsIcon from "@mui/icons-material/NotificationsOutlined";
|
import NotificationsIcon from "@mui/icons-material/NotificationsOutlined";
|
||||||
import MaterialAlert from "@mui/material/Alert";
|
import MaterialAlert from "@mui/material/Alert";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -21,7 +22,7 @@ type UrlData = {
|
|||||||
const CHANNELS: { key: NotificationChannel; label: string }[] = [
|
const CHANNELS: { key: NotificationChannel; label: string }[] = [
|
||||||
{ key: "whatsapp", label: "WhatsApp" },
|
{ key: "whatsapp", label: "WhatsApp" },
|
||||||
{ key: "email", label: "Email" },
|
{ key: "email", label: "Email" },
|
||||||
{ key: "system", label: "Sistema" }
|
{ key: "system", label: "Sistema" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const REMINDER_PRESETS: { minutes: number; label: string }[] = [
|
const REMINDER_PRESETS: { minutes: number; label: string }[] = [
|
||||||
@@ -30,36 +31,43 @@ const REMINDER_PRESETS: { minutes: number; label: string }[] = [
|
|||||||
{ minutes: 360, label: "6 horas antes" },
|
{ minutes: 360, label: "6 horas antes" },
|
||||||
{ minutes: 720, label: "12 horas antes" },
|
{ minutes: 720, label: "12 horas antes" },
|
||||||
{ minutes: 1440, label: "1 día antes" },
|
{ minutes: 1440, label: "1 día antes" },
|
||||||
{ minutes: 2880, label: "2 días antes" }
|
{ minutes: 2880, label: "2 días antes" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function NotificationsPage() {
|
export default function NotificationsPage() {
|
||||||
const data = useParams<UrlData>();
|
const data = useParams<UrlData>();
|
||||||
const id = data.id;
|
const id = data.id;
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
const store = useNotificationPreferencesStore();
|
const store = useNotificationPreferencesStore();
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org",
|
url: "/admin/org",
|
||||||
title: "Organizaciones",
|
title: "Organizaciones",
|
||||||
icon: null
|
icon: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org/profile/" + id,
|
url: "/admin/org/profile/" + id,
|
||||||
title: "Perfil",
|
title: "Perfil",
|
||||||
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />
|
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />,
|
||||||
});
|
});
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org/profile/" + id + "/notifications",
|
url: "/admin/org/profile/" + id + "/notifications",
|
||||||
title: "Notificaciones",
|
title: "Notificaciones",
|
||||||
icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} />
|
icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
const toggleChannel = (channel: NotificationChannel) => {
|
const toggleChannel = (channel: NotificationChannel) => {
|
||||||
@@ -73,18 +81,22 @@ export default function NotificationsPage() {
|
|||||||
const updateQuietHours = (field: "from" | "to", value: string) => {
|
const updateQuietHours = (field: "from" | "to", value: string) => {
|
||||||
const current = store.formData.quietHours || { from: "00:00", to: "00:00" };
|
const current = store.formData.quietHours || { from: "00:00", to: "00:00" };
|
||||||
store.updateFormData({
|
store.updateFormData({
|
||||||
quietHours: { ...current, [field]: value }
|
quietHours: { ...current, [field]: value },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const quietHourValue = (value: string | undefined, fallback: string) => {
|
const quietHourValue = (value: string | undefined, fallback: string) => {
|
||||||
const [hour, minute] = (value || fallback).split(":").map(Number);
|
const [hour, minute] = (value || fallback).split(":").map(Number);
|
||||||
return dayjs(new Date()).hour(hour).minute(minute || 0).second(0).millisecond(0);
|
return dayjs(new Date())
|
||||||
|
.hour(hour)
|
||||||
|
.minute(minute || 0)
|
||||||
|
.second(0)
|
||||||
|
.millisecond(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleQuietHours = () => {
|
const toggleQuietHours = () => {
|
||||||
store.updateFormData({
|
store.updateFormData({
|
||||||
quietHours: store.formData.quietHours ? null : { from: "22:00", to: "08:00" }
|
quietHours: store.formData.quietHours ? null : { from: "22:00", to: "08:00" },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,7 +107,10 @@ export default function NotificationsPage() {
|
|||||||
store.updateFormData({ reminderRules: updated });
|
store.updateFormData({ reminderRules: updated });
|
||||||
} else {
|
} else {
|
||||||
store.updateFormData({
|
store.updateFormData({
|
||||||
reminderRules: [...store.formData.reminderRules, { offset: minutes, enabled: true }]
|
reminderRules: [
|
||||||
|
...store.formData.reminderRules,
|
||||||
|
{ offset: minutes, enabled: true },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -107,10 +122,18 @@ export default function NotificationsPage() {
|
|||||||
<div style={{ marginTop: "20px" }}>
|
<div style={{ marginTop: "20px" }}>
|
||||||
{/* Channel Toggles Section */}
|
{/* Channel Toggles Section */}
|
||||||
<MaterialAlert severity="info" sx={{ marginBottom: "20px" }}>
|
<MaterialAlert severity="info" sx={{ marginBottom: "20px" }}>
|
||||||
<b>Canales de notificación:</b> Seleccione los canales por los cuales se enviarán las notificaciones automáticas.
|
<b>Canales de notificación:</b> Seleccione los canales por los cuales se
|
||||||
|
enviarán las notificaciones automáticas.
|
||||||
</MaterialAlert>
|
</MaterialAlert>
|
||||||
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px", marginBottom: "30px" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "8px",
|
||||||
|
marginBottom: "30px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{CHANNELS.map((channel) => (
|
{CHANNELS.map((channel) => (
|
||||||
<div
|
<div
|
||||||
key={channel.key}
|
key={channel.key}
|
||||||
@@ -121,10 +144,12 @@ export default function NotificationsPage() {
|
|||||||
padding: "12px 16px",
|
padding: "12px 16px",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
border: "1px solid var(--border-color)",
|
border: "1px solid var(--border-color)",
|
||||||
backgroundColor: "var(--bg-primary)"
|
backgroundColor: "var(--bg-primary)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ color: "var(--text-primary)", fontWeight: "500" }}>{channel.label}</span>
|
<span style={{ color: "var(--text-primary)", fontWeight: "500" }}>
|
||||||
|
{channel.label}
|
||||||
|
</span>
|
||||||
<Switch
|
<Switch
|
||||||
checked={store.formData.defaultChannels.includes(channel.key)}
|
checked={store.formData.defaultChannels.includes(channel.key)}
|
||||||
onChange={() => toggleChannel(channel.key)}
|
onChange={() => toggleChannel(channel.key)}
|
||||||
@@ -135,7 +160,8 @@ export default function NotificationsPage() {
|
|||||||
|
|
||||||
{/* Quiet Hours Section */}
|
{/* Quiet Hours Section */}
|
||||||
<MaterialAlert severity="info" sx={{ marginBottom: "20px" }}>
|
<MaterialAlert severity="info" sx={{ marginBottom: "20px" }}>
|
||||||
<b>Horario silencioso:</b> Las notificaciones no se enviarán durante este rango horario.
|
<b>Horario silencioso:</b> Las notificaciones no se enviarán durante este rango
|
||||||
|
horario.
|
||||||
</MaterialAlert>
|
</MaterialAlert>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -147,7 +173,7 @@ export default function NotificationsPage() {
|
|||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
border: "1px solid var(--border-color)",
|
border: "1px solid var(--border-color)",
|
||||||
backgroundColor: "var(--bg-primary)",
|
backgroundColor: "var(--bg-primary)",
|
||||||
marginBottom: "16px"
|
marginBottom: "16px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ color: "var(--text-primary)", fontWeight: "500" }}>
|
<span style={{ color: "var(--text-primary)", fontWeight: "500" }}>
|
||||||
@@ -160,13 +186,22 @@ export default function NotificationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{store.formData.quietHours && (
|
{store.formData.quietHours && (
|
||||||
<div style={{ display: "flex", gap: "16px", alignItems: "flex-end", marginBottom: "30px" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "16px",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
marginBottom: "30px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<TextTime
|
<TextTime
|
||||||
placeholder="Desde"
|
placeholder="Desde"
|
||||||
width="100%"
|
width="100%"
|
||||||
value={quietHourValue(store.formData.quietHours.from, "22:00")}
|
value={quietHourValue(store.formData.quietHours.from, "22:00")}
|
||||||
onChange={(value) => updateQuietHours("from", value.format("HH:mm"))}
|
onChange={(value) =>
|
||||||
|
updateQuietHours("from", value.format("HH:mm"))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
@@ -182,12 +217,15 @@ export default function NotificationsPage() {
|
|||||||
|
|
||||||
{/* Reminder Rules Section */}
|
{/* Reminder Rules Section */}
|
||||||
<MaterialAlert severity="info" sx={{ marginBottom: "20px" }}>
|
<MaterialAlert severity="info" sx={{ marginBottom: "20px" }}>
|
||||||
<b>Reglas de recordatorio:</b> Active los momentos en los que desea enviar recordatorios antes del turno.
|
<b>Reglas de recordatorio:</b> Active los momentos en los que desea enviar
|
||||||
|
recordatorios antes del turno.
|
||||||
</MaterialAlert>
|
</MaterialAlert>
|
||||||
|
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px" }}>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px" }}>
|
||||||
{REMINDER_PRESETS.map((preset) => {
|
{REMINDER_PRESETS.map((preset) => {
|
||||||
const isActive = store.formData.reminderRules.some((r) => r.offset === preset.minutes);
|
const isActive = store.formData.reminderRules.some(
|
||||||
|
(r) => r.offset === preset.minutes,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={preset.minutes}
|
key={preset.minutes}
|
||||||
@@ -198,11 +236,15 @@ export default function NotificationsPage() {
|
|||||||
padding: "10px 16px",
|
padding: "10px 16px",
|
||||||
borderRadius: "999px",
|
borderRadius: "999px",
|
||||||
border: `1px solid ${isActive ? "var(--primary-color)" : "var(--border-color)"}`,
|
border: `1px solid ${isActive ? "var(--primary-color)" : "var(--border-color)"}`,
|
||||||
backgroundColor: isActive ? "rgba(25, 118, 210, 0.12)" : "var(--bg-primary)",
|
backgroundColor: isActive
|
||||||
color: isActive ? "var(--primary-color)" : "var(--text-secondary)",
|
? "rgba(25, 118, 210, 0.12)"
|
||||||
|
: "var(--bg-primary)",
|
||||||
|
color: isActive
|
||||||
|
? "var(--primary-color)"
|
||||||
|
: "var(--text-secondary)",
|
||||||
fontWeight: isActive ? "600" : "500",
|
fontWeight: isActive ? "600" : "500",
|
||||||
fontSize: "14px",
|
fontSize: "14px",
|
||||||
transition: "all 0.2s ease"
|
transition: "all 0.2s ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{preset.label}
|
{preset.label}
|
||||||
|
|||||||
+274
-79
@@ -248,7 +248,7 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
const [dialogPayVisible, setDialogPayVisible] = useState<boolean>(false);
|
const [dialogPayVisible, setDialogPayVisible] = useState<boolean>(false);
|
||||||
const [dialogPayOneVisible, setDialogPayOneVisible] = useState<boolean>(false);
|
const [dialogPayOneVisible, setDialogPayOneVisible] = useState<boolean>(false);
|
||||||
const [createPaymentData, setCreatePaymentData] = useState<PaginatePaymentsView | undefined>(
|
const [createPaymentData, setCreatePaymentData] = useState<PaginatePaymentsView | undefined>(
|
||||||
undefined
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [paymentMethodFilterContent] = useState<TextObjectModalContent>({
|
const [paymentMethodFilterContent] = useState<TextObjectModalContent>({
|
||||||
@@ -284,7 +284,7 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
TextObjectFilterResult[]
|
TextObjectFilterResult[]
|
||||||
>([]);
|
>([]);
|
||||||
const [paymentMethodId, setPaymentMethodId] = useState<CLIENT_ACCOUNT_PAYMENT_METHODS>(
|
const [paymentMethodId, setPaymentMethodId] = useState<CLIENT_ACCOUNT_PAYMENT_METHODS>(
|
||||||
CLIENT_ACCOUNT_PAYMENT_METHODS.CASH
|
CLIENT_ACCOUNT_PAYMENT_METHODS.CASH,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -326,7 +326,7 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
TextObjectFilterResult[]
|
TextObjectFilterResult[]
|
||||||
>([]);
|
>([]);
|
||||||
const [paymentProcessDateId, setPaymentProcessDateId] = useState<PAYMENT_PROCCESS_DATE>(
|
const [paymentProcessDateId, setPaymentProcessDateId] = useState<PAYMENT_PROCCESS_DATE>(
|
||||||
PAYMENT_PROCCESS_DATE.NOW
|
PAYMENT_PROCCESS_DATE.NOW,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -356,7 +356,7 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
id: category.id.toString(),
|
id: category.id.toString(),
|
||||||
name: category.name,
|
name: category.name,
|
||||||
description: category.description,
|
description: category.description,
|
||||||
}))
|
})),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -370,7 +370,7 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
const [clientIds, setClientIds] = useState<string[]>([]);
|
const [clientIds, setClientIds] = useState<string[]>([]);
|
||||||
|
|
||||||
const [collaboratorFilterData, setCollaboratorFilterData] = useState<TextObjectFilterResult[]>(
|
const [collaboratorFilterData, setCollaboratorFilterData] = useState<TextObjectFilterResult[]>(
|
||||||
[]
|
[],
|
||||||
);
|
);
|
||||||
const [collaboratorId, setCollaboratorId] = useState<string>("");
|
const [collaboratorId, setCollaboratorId] = useState<string>("");
|
||||||
|
|
||||||
@@ -484,8 +484,8 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
parseInt(
|
parseInt(
|
||||||
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
|
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
|
||||||
? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
|
? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
|
||||||
: "10"
|
: "10",
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
const [payments, setPayments] = useState<PaginateNotPayedAppointmentsResult>();
|
const [payments, setPayments] = useState<PaginateNotPayedAppointmentsResult>();
|
||||||
const paymentsRef = useRef(payments);
|
const paymentsRef = useRef(payments);
|
||||||
@@ -595,7 +595,6 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const generarReporte = async () => {
|
const generarReporte = async () => {
|
||||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||||
try {
|
try {
|
||||||
@@ -625,27 +624,27 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
reader.onloadend = () => resolve(reader.result as string);
|
reader.onloadend = () => resolve(reader.result as string);
|
||||||
reader.readAsDataURL(blob);
|
reader.readAsDataURL(blob);
|
||||||
});
|
});
|
||||||
} catch(e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
const tableBody: any[] = [
|
const tableBody: any[] = [
|
||||||
[
|
[
|
||||||
{ text: "Cliente", bold: true, color: "#333333", fillColor: "#f5f5f5" },
|
{ text: "Cliente", bold: true, color: "#333333", fillColor: "#f5f5f5" },
|
||||||
{ text: "Datos del Turno", bold: true, color: "#333333", fillColor: "#f5f5f5" },
|
{ text: "Datos del Turno", bold: true, color: "#333333", fillColor: "#f5f5f5" },
|
||||||
{ text: "Monto", bold: true, color: "#333333", fillColor: "#f5f5f5" }
|
{ text: "Monto", bold: true, color: "#333333", fillColor: "#f5f5f5" },
|
||||||
]
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
report.items.forEach(item => {
|
report.items.forEach((item) => {
|
||||||
tableBody.push([
|
tableBody.push([
|
||||||
item.clientName || "-",
|
item.clientName || "-",
|
||||||
{
|
{
|
||||||
text: [
|
text: [
|
||||||
{ text: `${item.serviceName || "-"}\n`, bold: true, fontSize: 11 },
|
{ text: `${item.serviceName || "-"}\n`, bold: true, fontSize: 11 },
|
||||||
`Prof: ${item.employeeName || "-"}\nFecha: ${dayjs(item.appointmentDate).format("DD/MM/YYYY HH:mm")} hs`
|
`Prof: ${item.employeeName || "-"}\nFecha: ${dayjs(item.appointmentDate).format("DD/MM/YYYY HH:mm")} hs`,
|
||||||
],
|
],
|
||||||
fontSize: 10
|
fontSize: 10,
|
||||||
},
|
},
|
||||||
{ text: "$" + item.price.toLocaleString("es-AR"), alignment: "right" }
|
{ text: "$" + item.price.toLocaleString("es-AR"), alignment: "right" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -653,52 +652,81 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
tableBody.push([
|
tableBody.push([
|
||||||
{ text: "TOTAL ADEUDADO", bold: true, colSpan: 2, alignment: "right" },
|
{ text: "TOTAL ADEUDADO", bold: true, colSpan: 2, alignment: "right" },
|
||||||
{},
|
{},
|
||||||
{ text: "$" + report.totalAmount.toLocaleString("es-AR"), bold: true, alignment: "right" }
|
{
|
||||||
|
text: "$" + report.totalAmount.toLocaleString("es-AR"),
|
||||||
|
bold: true,
|
||||||
|
alignment: "right",
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const docDefinition: unknown = {
|
const docDefinition: unknown = {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
columns: [
|
columns: [
|
||||||
{ text: "Reporte de Turnos Adeudados", fontSize: 18, bold: true, margin: [0, 0, 0, 10], width: '*' },
|
{
|
||||||
...(logoDataUrl ? [{ image: logoDataUrl, width: 45, margin: [0, 0, 0, 10], alignment: 'right' }] : [])
|
text: "Reporte de Turnos Adeudados",
|
||||||
|
fontSize: 18,
|
||||||
|
bold: true,
|
||||||
|
margin: [0, 0, 0, 10],
|
||||||
|
width: "*",
|
||||||
|
},
|
||||||
|
...(logoDataUrl
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
image: logoDataUrl,
|
||||||
|
width: 45,
|
||||||
|
margin: [0, 0, 0, 10],
|
||||||
|
alignment: "right",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{ text: `Filtros aplicados:`, bold: true, margin: [0, 0, 0, 5] },
|
{ text: `Filtros aplicados:`, bold: true, margin: [0, 0, 0, 5] },
|
||||||
{
|
{
|
||||||
text: [
|
text: [
|
||||||
{ text: "Clientes Seleccionados: ", bold: true },
|
{ text: "Clientes Seleccionados: ", bold: true },
|
||||||
`${clientIds.length > 0 ? clientIds.length : "Todos"}`
|
`${clientIds.length > 0 ? clientIds.length : "Todos"}`,
|
||||||
],
|
],
|
||||||
margin: [0, 0, 0, 2]
|
margin: [0, 0, 0, 2],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: [
|
text: [
|
||||||
{ text: "Período: ", bold: true },
|
{ text: "Período: ", bold: true },
|
||||||
`${dateFrom ? dateFrom.format("DD/MM/YYYY") : "Inicio"} al ${dateTo ? dateTo.format("DD/MM/YYYY") : "Fin"}`
|
`${dateFrom ? dateFrom.format("DD/MM/YYYY") : "Inicio"} al ${dateTo ? dateTo.format("DD/MM/YYYY") : "Fin"}`,
|
||||||
],
|
],
|
||||||
margin: [0, 0, 0, 20]
|
margin: [0, 0, 0, 20],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
table: {
|
table: {
|
||||||
headerRows: 1,
|
headerRows: 1,
|
||||||
widths: [120, "*", "auto"],
|
widths: [120, "*", "auto"],
|
||||||
body: tableBody
|
body: tableBody,
|
||||||
},
|
},
|
||||||
layout: "lightHorizontalLines"
|
layout: "lightHorizontalLines",
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
if ((window as unknown as { Android: any }).Android && (window as unknown as { Android: any }).Android.savePdf) {
|
if (
|
||||||
|
(window as unknown as { Android: any }).Android &&
|
||||||
|
(window as unknown as { Android: any }).Android.savePdf
|
||||||
|
) {
|
||||||
pdfMake.createPdf(docDefinition as any).getBase64((data: any) => {
|
pdfMake.createPdf(docDefinition as any).getBase64((data: any) => {
|
||||||
(window as unknown as { Android: any }).Android?.savePdf(data, `reporte-adeudados.pdf`);
|
(window as unknown as { Android: any }).Android?.savePdf(
|
||||||
|
data,
|
||||||
|
`reporte-adeudados.pdf`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
pdfMake.createPdf(docDefinition as any).download(`reporte-adeudados.pdf`);
|
pdfMake.createPdf(docDefinition as any).download(`reporte-adeudados.pdf`);
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
alert.showError((error as { format?: () => string }).format ? (error as { format: () => string }).format() : "Error generando PDF");
|
alert.showError(
|
||||||
|
(error as { format?: () => string }).format
|
||||||
|
? (error as { format: () => string }).format()
|
||||||
|
: "Error generando PDF",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
eventHandler.setEventType(EVENT_TYPES.CLEAR);
|
eventHandler.setEventType(EVENT_TYPES.CLEAR);
|
||||||
}
|
}
|
||||||
@@ -832,14 +860,24 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
setMethodId={setMethodId}
|
setMethodId={setMethodId}
|
||||||
clientData={clientFilterData}
|
clientData={clientFilterData}
|
||||||
setClientIds={setClientIds}
|
setClientIds={setClientIds}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
filter.setOnFilter(() => { });
|
filter.setOnFilter(() => {});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
bottomToolbar.clear();
|
bottomToolbar.clear();
|
||||||
};
|
};
|
||||||
}, [paymentsTotal, collaboratorFilterData, serviceFilterData, methodFilterData, clientFilterData, clientIds, collaboratorId, serviceId, methodId]);
|
}, [
|
||||||
|
paymentsTotal,
|
||||||
|
collaboratorFilterData,
|
||||||
|
serviceFilterData,
|
||||||
|
methodFilterData,
|
||||||
|
clientFilterData,
|
||||||
|
clientIds,
|
||||||
|
collaboratorId,
|
||||||
|
serviceId,
|
||||||
|
methodId,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||||
@@ -881,39 +919,75 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
loadNotPayedAppointmentsTotal();
|
loadNotPayedAppointmentsTotal();
|
||||||
}, [clientIds, collaboratorId, serviceId, methodId, dateFrom, dateTo, reloadPayments]);
|
}, [clientIds, collaboratorId, serviceId, methodId, dateFrom, dateTo, reloadPayments]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Location />
|
<Location />
|
||||||
|
|
||||||
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
|
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
|
||||||
<div style={{ maxWidth: "800px", margin: "0 auto", paddingBottom: "100px", marginTop: "0px" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: "800px",
|
||||||
|
margin: "0 auto",
|
||||||
|
paddingBottom: "100px",
|
||||||
|
marginTop: "0px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Top Status Card */}
|
{/* Top Status Card */}
|
||||||
<div style={{ position: "sticky", top: "70px", zIndex: 100, marginBottom: "50px", display: "flex", flexDirection: "column", background: "white", paddingBottom: "10px", paddingTop: "5px", margin: "0 -10px", paddingLeft: "10px", paddingRight: "10px" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
position: "sticky",
|
||||||
|
top: "70px",
|
||||||
|
zIndex: 100,
|
||||||
|
marginBottom: "50px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
background: "white",
|
||||||
|
paddingBottom: "10px",
|
||||||
|
paddingTop: "5px",
|
||||||
|
margin: "0 -10px",
|
||||||
|
paddingLeft: "10px",
|
||||||
|
paddingRight: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Tabs Selector */}
|
{/* Tabs Selector */}
|
||||||
<div style={{ display: "flex", justifyContent: "center", marginBottom: "15px" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
marginBottom: "15px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ThemeProvider theme={turnosXpressTheme}>
|
<ThemeProvider theme={turnosXpressTheme}>
|
||||||
<div style={{
|
<div
|
||||||
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
background: "rgba(255,255,255,0.9)",
|
background: "rgba(255,255,255,0.9)",
|
||||||
backdropFilter: "blur(10px)",
|
backdropFilter: "blur(10px)",
|
||||||
borderRadius: "30px",
|
borderRadius: "30px",
|
||||||
padding: "5px",
|
padding: "5px",
|
||||||
boxShadow: "0 4px 15px rgba(0,0,0,0.05)"
|
boxShadow: "0 4px 15px rgba(0,0,0,0.05)",
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<ButtonMaterial
|
<ButtonMaterial
|
||||||
variant="text"
|
variant="text"
|
||||||
onClick={() => goTo(`/admin/org/profile/${id}/payments`)}
|
onClick={() => goTo(`/admin/org/profile/${id}/payments`)}
|
||||||
style={{ borderRadius: "25px", color: "#666", fontWeight: "bold", padding: "8px 25px" }}
|
style={{
|
||||||
|
borderRadius: "25px",
|
||||||
|
color: "#666",
|
||||||
|
fontWeight: "bold",
|
||||||
|
padding: "8px 25px",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Pagados
|
Pagados
|
||||||
</ButtonMaterial>
|
</ButtonMaterial>
|
||||||
<ButtonMaterial
|
<ButtonMaterial
|
||||||
variant="contained"
|
variant="contained"
|
||||||
style={{ borderRadius: "25px", boxShadow: "0 2px 8px rgba(0,0,0,0.1)", fontWeight: "bold", padding: "8px 25px" }}
|
style={{
|
||||||
|
borderRadius: "25px",
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
|
||||||
|
fontWeight: "bold",
|
||||||
|
padding: "8px 25px",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Adeudados
|
Adeudados
|
||||||
</ButtonMaterial>
|
</ButtonMaterial>
|
||||||
@@ -928,25 +1002,33 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
y: 0,
|
y: 0,
|
||||||
padding: isMinimized ? "25px 50px" : "40px 20px",
|
padding: isMinimized ? "25px 50px" : "40px 20px",
|
||||||
borderRadius: isMinimized ? "15px" : "20px"
|
borderRadius: isMinimized ? "15px" : "20px",
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
background: "linear-gradient(135deg, #FF416C 0%, #FF4B2B 100%)", // Red gradient for debts
|
background: "linear-gradient(135deg, #FF416C 0%, #FF4B2B 100%)", // Red gradient for debts
|
||||||
color: "white",
|
color: "white",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
boxShadow: isMinimized ? "0 4px 15px rgba(0,0,0,0.3)" : "0 10px 30px rgba(0,0,0,0.15)",
|
boxShadow: isMinimized
|
||||||
|
? "0 4px 15px rgba(0,0,0,0.3)"
|
||||||
|
: "0 10px 30px rgba(0,0,0,0.15)",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: isMinimized ? "row" : "column",
|
flexDirection: isMinimized ? "row" : "column",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: isMinimized ? "space-between" : "center",
|
justifyContent: isMinimized ? "space-between" : "center",
|
||||||
transition: "box-shadow 0.3s ease"
|
transition: "box-shadow 0.3s ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => setForceMinimized(!forceMinimized)}
|
onClick={() => setForceMinimized(!forceMinimized)}
|
||||||
style={{ position: "absolute", top: "10px", left: "10px", color: "rgba(255,255,255,0.7)", zIndex: 10 }}
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "10px",
|
||||||
|
left: "10px",
|
||||||
|
color: "rgba(255,255,255,0.7)",
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
size="small"
|
size="small"
|
||||||
title={forceMinimized ? "Maximizar" : "Minimizar"}
|
title={forceMinimized ? "Maximizar" : "Minimizar"}
|
||||||
>
|
>
|
||||||
@@ -954,28 +1036,59 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => filter.toggle()}
|
onClick={() => filter.toggle()}
|
||||||
style={{ position: "absolute", top: "10px", right: "10px", color: "rgba(255,255,255,0.7)", zIndex: 10 }}
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "10px",
|
||||||
|
right: "10px",
|
||||||
|
color: "rgba(255,255,255,0.7)",
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
size="small"
|
size="small"
|
||||||
title="Filtrar"
|
title="Filtrar"
|
||||||
>
|
>
|
||||||
<FilterAltOutlinedIcon />
|
<FilterAltOutlinedIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<div style={{ position: "absolute", top: "-50px", right: "-50px", width: "150px", height: "150px", borderRadius: "50%", background: "rgba(255,255,255,0.05)" }}></div>
|
<div
|
||||||
<div style={{ position: "absolute", bottom: "-30px", left: "-30px", width: "100px", height: "100px", borderRadius: "50%", background: "rgba(255,255,255,0.05)" }}></div>
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "-50px",
|
||||||
|
right: "-50px",
|
||||||
|
width: "150px",
|
||||||
|
height: "150px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "rgba(255,255,255,0.05)",
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: "-30px",
|
||||||
|
left: "-30px",
|
||||||
|
width: "100px",
|
||||||
|
height: "100px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "rgba(255,255,255,0.05)",
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{
|
animate={{
|
||||||
fontSize: isMinimized ? "14px" : "16px",
|
fontSize: isMinimized ? "14px" : "16px",
|
||||||
marginBottom: isMinimized ? "0px" : "10px"
|
marginBottom: isMinimized ? "0px" : "10px",
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontWeight: "bold",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "2px",
|
||||||
|
opacity: 0.9,
|
||||||
}}
|
}}
|
||||||
style={{ fontWeight: "bold", textTransform: "uppercase", letterSpacing: "2px", opacity: 0.9 }}
|
|
||||||
>
|
>
|
||||||
Total Adeudado
|
Total Adeudado
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{
|
animate={{
|
||||||
fontSize: isMinimized ? "24px" : "56px",
|
fontSize: isMinimized ? "24px" : "56px",
|
||||||
margin: isMinimized ? "0px" : "10px 0"
|
margin: isMinimized ? "0px" : "10px 0",
|
||||||
}}
|
}}
|
||||||
style={{ fontWeight: "900" }}
|
style={{ fontWeight: "900" }}
|
||||||
>
|
>
|
||||||
@@ -989,7 +1102,13 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
style={{ marginTop: "15px" }}
|
style={{ marginTop: "15px" }}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: "15px", width: "250px", maxWidth: "100%" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "15px",
|
||||||
|
width: "250px",
|
||||||
|
maxWidth: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<TextRangeDateModern
|
<TextRangeDateModern
|
||||||
label="Período"
|
label="Período"
|
||||||
dateFrom={dateFrom}
|
dateFrom={dateFrom}
|
||||||
@@ -998,7 +1117,14 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
setDateTo={setDateTo}
|
setDateTo={setDateTo}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: "10px", alignItems: "center", flexWrap: "wrap" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "10px",
|
||||||
|
alignItems: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ButtonMaterial
|
<ButtonMaterial
|
||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon={<PayIcon />}
|
startIcon={<PayIcon />}
|
||||||
@@ -1009,7 +1135,7 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
borderRadius: "20px",
|
borderRadius: "20px",
|
||||||
padding: "8px 20px",
|
padding: "8px 20px",
|
||||||
textTransform: "none"
|
textTransform: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Pagar Todos
|
Pagar Todos
|
||||||
@@ -1023,10 +1149,13 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
borderRadius: "20px",
|
borderRadius: "20px",
|
||||||
padding: "8px 20px",
|
padding: "8px 20px",
|
||||||
textTransform: "none"
|
textTransform: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PictureAsPdfIcon style={{ fontSize: "18px", marginRight: "5px" }} /> PDF
|
<PictureAsPdfIcon
|
||||||
|
style={{ fontSize: "18px", marginRight: "5px" }}
|
||||||
|
/>{" "}
|
||||||
|
PDF
|
||||||
</ButtonMaterial>
|
</ButtonMaterial>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -1035,10 +1164,22 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dynamic Spacer for layout shift */}
|
{/* Dynamic Spacer for layout shift */}
|
||||||
<div style={{ height: isMinimized ? "150px" : "0px", transition: "height 0.3s ease" }}></div>
|
<div
|
||||||
|
style={{
|
||||||
|
height: isMinimized ? "150px" : "0px",
|
||||||
|
transition: "height 0.3s ease",
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
|
||||||
{/* Payments List */}
|
{/* Payments List */}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "15px", padding: "15px 10px 0 10px" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "15px",
|
||||||
|
padding: "15px 10px 0 10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{payments?.data.map((payment, index) => (
|
{payments?.data.map((payment, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -1054,31 +1195,72 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
boxShadow: "0 4px 15px rgba(0,0,0,0.05)",
|
boxShadow: "0 4px 15px rgba(0,0,0,0.05)",
|
||||||
borderLeft: `4px solid var(--red)`
|
borderLeft: `4px solid var(--red)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "15px" }}>
|
<div
|
||||||
<div style={{
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "15px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
background: "rgba(255, 75, 43, 0.1)",
|
background: "rgba(255, 75, 43, 0.1)",
|
||||||
padding: "10px",
|
padding: "10px",
|
||||||
borderRadius: "12px",
|
borderRadius: "12px",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
color: "var(--red)"
|
color: "var(--red)",
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<CreditCardIcon />
|
<CreditCardIcon />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: "800", fontSize: "15px", color: "#333", marginBottom: "4px" }}>{payment.clientName}</div>
|
<div
|
||||||
<div style={{ fontWeight: "700", fontSize: "13px", color: "var(--purple)", marginBottom: "4px" }}>{payment.serviceName}</div>
|
style={{
|
||||||
|
fontWeight: "800",
|
||||||
|
fontSize: "15px",
|
||||||
|
color: "#333",
|
||||||
|
marginBottom: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{payment.clientName}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: "700",
|
||||||
|
fontSize: "13px",
|
||||||
|
color: "var(--purple)",
|
||||||
|
marginBottom: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{payment.serviceName}
|
||||||
|
</div>
|
||||||
<div style={{ fontSize: "12px", color: "#888" }}>
|
<div style={{ fontSize: "12px", color: "#888" }}>
|
||||||
{dayjs(payment.createdAt).format("DD MMMM YYYY • HH:mm")} • {payment.employeeName}
|
{dayjs(payment.createdAt).format(
|
||||||
|
"DD MMMM YYYY • HH:mm",
|
||||||
|
)}{" "}
|
||||||
|
• {payment.employeeName}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "15px" }}>
|
<div
|
||||||
<div style={{ fontWeight: "900", fontSize: "16px", color: "var(--red)" }}>
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "15px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: "900",
|
||||||
|
fontSize: "16px",
|
||||||
|
color: "var(--red)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{formatPrice(payment.price)}
|
{formatPrice(payment.price)}
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -1096,10 +1278,22 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{(!payments?.data || payments.data.length === 0) && (
|
{(!payments?.data || payments.data.length === 0) && (
|
||||||
<div style={{ textAlign: "center", padding: "50px", color: "#aaa" }}>
|
<div
|
||||||
<CreditCardIcon sx={{ fontSize: "60px", opacity: 0.2, marginBottom: "15px" }} />
|
style={{ textAlign: "center", padding: "50px", color: "#aaa" }}
|
||||||
<div style={{ fontSize: "18px", fontWeight: "bold" }}>No hay pagos adeudados</div>
|
>
|
||||||
<div style={{ fontSize: "14px", marginTop: "5px" }}>Intenta ajustar los filtros de fecha</div>
|
<CreditCardIcon
|
||||||
|
sx={{
|
||||||
|
fontSize: "60px",
|
||||||
|
opacity: 0.2,
|
||||||
|
marginBottom: "15px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: "18px", fontWeight: "bold" }}>
|
||||||
|
No hay pagos adeudados
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "14px", marginTop: "5px" }}>
|
||||||
|
Intenta ajustar los filtros de fecha
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -1107,7 +1301,9 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{payments && payments.pages > 1 && (
|
{payments && payments.pages > 1 && (
|
||||||
<div style={{ display: "flex", justifyContent: "center", marginTop: "40px" }}>
|
<div
|
||||||
|
style={{ display: "flex", justifyContent: "center", marginTop: "40px" }}
|
||||||
|
>
|
||||||
<Pagination
|
<Pagination
|
||||||
count={payments.pages}
|
count={payments.pages}
|
||||||
page={payments.page}
|
page={payments.page}
|
||||||
@@ -1134,7 +1330,7 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
data={paymentMethhodFilterData}
|
data={paymentMethhodFilterData}
|
||||||
onChange={(id) => {
|
onChange={(id) => {
|
||||||
setPaymentMethodId(
|
setPaymentMethodId(
|
||||||
id as CLIENT_ACCOUNT_PAYMENT_METHODS
|
id as CLIENT_ACCOUNT_PAYMENT_METHODS,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1185,7 +1381,7 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
data={paymentMethhodFilterData}
|
data={paymentMethhodFilterData}
|
||||||
onChange={(id) => {
|
onChange={(id) => {
|
||||||
setPaymentMethodId(
|
setPaymentMethodId(
|
||||||
id as CLIENT_ACCOUNT_PAYMENT_METHODS
|
id as CLIENT_ACCOUNT_PAYMENT_METHODS,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1219,7 +1415,6 @@ export default function OrganizationNotPayedAppointments() {
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</>
|
</>
|
||||||
</DialogWindow>
|
</DialogWindow>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</SecureByPlan>
|
</SecureByPlan>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import SearchOffIcon from "@mui/icons-material/SearchOff";
|
|||||||
import UnfoldLessIcon from "@mui/icons-material/UnfoldLess";
|
import UnfoldLessIcon from "@mui/icons-material/UnfoldLess";
|
||||||
import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore";
|
import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore";
|
||||||
import FilterAltOutlinedIcon from "@mui/icons-material/FilterAltOutlined";
|
import FilterAltOutlinedIcon from "@mui/icons-material/FilterAltOutlined";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -68,8 +69,12 @@ const FilterContent = (props: FilterContentProps) => {
|
|||||||
const [clientFilterContent] = useState<TextObjectModalContent>({
|
const [clientFilterContent] = useState<TextObjectModalContent>({
|
||||||
header: (
|
header: (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>Seleccionar Cliente:</div>
|
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
|
||||||
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>Ingrese el nombre del cliente para buscarlo.</p>
|
Seleccionar Cliente:
|
||||||
|
</div>
|
||||||
|
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>
|
||||||
|
Ingrese el nombre del cliente para buscarlo.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
bottom: <div> </div>,
|
bottom: <div> </div>,
|
||||||
@@ -81,8 +86,12 @@ const FilterContent = (props: FilterContentProps) => {
|
|||||||
const [collaboratorFilterContent] = useState<TextObjectModalContent>({
|
const [collaboratorFilterContent] = useState<TextObjectModalContent>({
|
||||||
header: (
|
header: (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>Seleccionar Colaborador:</div>
|
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
|
||||||
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>Ingrese el nombre del colaborador para buscarlo.</p>
|
Seleccionar Colaborador:
|
||||||
|
</div>
|
||||||
|
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>
|
||||||
|
Ingrese el nombre del colaborador para buscarlo.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
bottom: <div> </div>,
|
bottom: <div> </div>,
|
||||||
@@ -94,8 +103,12 @@ const FilterContent = (props: FilterContentProps) => {
|
|||||||
const [serviceFilterContent] = useState<TextObjectModalContent>({
|
const [serviceFilterContent] = useState<TextObjectModalContent>({
|
||||||
header: (
|
header: (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>Seleccionar Servicio:</div>
|
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
|
||||||
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>Ingrese el nombre del servicio para buscarlo.</p>
|
Seleccionar Servicio:
|
||||||
|
</div>
|
||||||
|
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>
|
||||||
|
Ingrese el nombre del servicio para buscarlo.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
bottom: <div> </div>,
|
bottom: <div> </div>,
|
||||||
@@ -107,8 +120,12 @@ const FilterContent = (props: FilterContentProps) => {
|
|||||||
const [methodFilterContent] = useState<TextObjectModalContent>({
|
const [methodFilterContent] = useState<TextObjectModalContent>({
|
||||||
header: (
|
header: (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>Seleccionar Método de Pago:</div>
|
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
|
||||||
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>Ingrese el nombre del método de pago para buscarlo.</p>
|
Seleccionar Método de Pago:
|
||||||
|
</div>
|
||||||
|
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>
|
||||||
|
Ingrese el nombre del método de pago para buscarlo.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
bottom: <div> </div>,
|
bottom: <div> </div>,
|
||||||
@@ -152,6 +169,7 @@ export default function OrganizationEdit() {
|
|||||||
const data = useParams<UrlData>();
|
const data = useParams<UrlData>();
|
||||||
const id = data.id;
|
const id = data.id;
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
const SessionInfo = useSessionStore();
|
const SessionInfo = useSessionStore();
|
||||||
const filter = useFilterStore();
|
const filter = useFilterStore();
|
||||||
@@ -168,7 +186,9 @@ export default function OrganizationEdit() {
|
|||||||
const [clientFilterData, setClientFilterData] = useState<TextObjectFilterResult[]>([]);
|
const [clientFilterData, setClientFilterData] = useState<TextObjectFilterResult[]>([]);
|
||||||
const [clientIds, setClientIds] = useState<string[]>([]);
|
const [clientIds, setClientIds] = useState<string[]>([]);
|
||||||
|
|
||||||
const [collaboratorFilterData, setCollaboratorFilterData] = useState<TextObjectFilterResult[]>([]);
|
const [collaboratorFilterData, setCollaboratorFilterData] = useState<TextObjectFilterResult[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
const [collaboratorId, setCollaboratorId] = useState<string>("");
|
const [collaboratorId, setCollaboratorId] = useState<string>("");
|
||||||
|
|
||||||
const [serviceFilterData, setServiceFilterData] = useState<TextObjectFilterResult[]>([]);
|
const [serviceFilterData, setServiceFilterData] = useState<TextObjectFilterResult[]>([]);
|
||||||
@@ -188,7 +208,6 @@ export default function OrganizationEdit() {
|
|||||||
|
|
||||||
const generarReporte = async () => {
|
const generarReporte = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const pdfMake = await import("pdfmake/build/pdfmake");
|
const pdfMake = await import("pdfmake/build/pdfmake");
|
||||||
const pdfFonts = (await import("pdfmake/build/vfs_fonts")) as any;
|
const pdfFonts = (await import("pdfmake/build/vfs_fonts")) as any;
|
||||||
pdfMake.vfs = pdfFonts.pdfMake ? pdfFonts.pdfMake.vfs : pdfFonts.vfs;
|
pdfMake.vfs = pdfFonts.pdfMake ? pdfFonts.pdfMake.vfs : pdfFonts.vfs;
|
||||||
@@ -201,7 +220,7 @@ export default function OrganizationEdit() {
|
|||||||
serviceId: serviceId ? serviceId : undefined,
|
serviceId: serviceId ? serviceId : undefined,
|
||||||
employeeId: collaboratorId ? collaboratorId : undefined,
|
employeeId: collaboratorId ? collaboratorId : undefined,
|
||||||
method: methodId ? (methodId as any) : undefined,
|
method: methodId ? (methodId as any) : undefined,
|
||||||
sessionUser: SessionInfo.userId
|
sessionUser: SessionInfo.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!report.items || report.items.length === 0) {
|
if (!report.items || report.items.length === 0) {
|
||||||
@@ -218,85 +237,113 @@ export default function OrganizationEdit() {
|
|||||||
reader.onloadend = () => resolve(reader.result as string);
|
reader.onloadend = () => resolve(reader.result as string);
|
||||||
reader.readAsDataURL(blob);
|
reader.readAsDataURL(blob);
|
||||||
});
|
});
|
||||||
} catch(e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
const tableBody: any[] = [
|
const tableBody: any[] = [
|
||||||
[
|
[
|
||||||
{ text: "Cliente", bold: true, color: "#333333", fillColor: "#f5f5f5" },
|
{ text: "Cliente", bold: true, color: "#333333", fillColor: "#f5f5f5" },
|
||||||
{ text: "Datos del Turno", bold: true, color: "#333333", fillColor: "#f5f5f5" },
|
{ text: "Datos del Turno", bold: true, color: "#333333", fillColor: "#f5f5f5" },
|
||||||
{ text: "Monto", bold: true, color: "#333333", fillColor: "#f5f5f5" }
|
{ text: "Monto", bold: true, color: "#333333", fillColor: "#f5f5f5" },
|
||||||
]
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
report.items.forEach(item => {
|
report.items.forEach((item) => {
|
||||||
tableBody.push([
|
tableBody.push([
|
||||||
item.clientName || "-",
|
item.clientName || "-",
|
||||||
{
|
{
|
||||||
text: [
|
text: [
|
||||||
{ text: `${item.serviceName || "-"}\n`, bold: true, fontSize: 11 },
|
{ text: `${item.serviceName || "-"}\n`, bold: true, fontSize: 11 },
|
||||||
`Prof: ${item.employeeName || "-"}\nFecha: ${dayjs(item.appointmentDate).format("DD/MM/YYYY HH:mm")} hs\nMétodo: ${item.method || "-"}`
|
`Prof: ${item.employeeName || "-"}\nFecha: ${dayjs(item.appointmentDate).format("DD/MM/YYYY HH:mm")} hs\nMétodo: ${item.method || "-"}`,
|
||||||
],
|
],
|
||||||
fontSize: 10
|
fontSize: 10,
|
||||||
},
|
},
|
||||||
{ text: "$" + item.price.toLocaleString("es-AR"), alignment: "right" }
|
{ text: "$" + item.price.toLocaleString("es-AR"), alignment: "right" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
tableBody.push([
|
tableBody.push([
|
||||||
{ text: "TOTAL PROCESADO", bold: true, colSpan: 2, alignment: "right" },
|
{ text: "TOTAL PROCESADO", bold: true, colSpan: 2, alignment: "right" },
|
||||||
{},
|
{},
|
||||||
{ text: "$" + report.totalAmount.toLocaleString("es-AR"), bold: true, alignment: "right" }
|
{
|
||||||
|
text: "$" + report.totalAmount.toLocaleString("es-AR"),
|
||||||
|
bold: true,
|
||||||
|
alignment: "right",
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const docDefinition: unknown = {
|
const docDefinition: unknown = {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
columns: [
|
columns: [
|
||||||
{ text: "Reporte de Cobros Procesados", fontSize: 18, bold: true, margin: [0, 0, 0, 10], width: '*' },
|
{
|
||||||
...(logoDataUrl ? [{ image: logoDataUrl, width: 45, margin: [0, 0, 0, 10], alignment: 'right' }] : [])
|
text: "Reporte de Cobros Procesados",
|
||||||
|
fontSize: 18,
|
||||||
|
bold: true,
|
||||||
|
margin: [0, 0, 0, 10],
|
||||||
|
width: "*",
|
||||||
|
},
|
||||||
|
...(logoDataUrl
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
image: logoDataUrl,
|
||||||
|
width: 45,
|
||||||
|
margin: [0, 0, 0, 10],
|
||||||
|
alignment: "right",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{ text: `Filtros aplicados:`, bold: true, margin: [0, 0, 0, 5] },
|
{ text: `Filtros aplicados:`, bold: true, margin: [0, 0, 0, 5] },
|
||||||
{
|
{
|
||||||
text: [
|
text: [
|
||||||
{ text: "Clientes Seleccionados: ", bold: true },
|
{ text: "Clientes Seleccionados: ", bold: true },
|
||||||
`${clientIds.length > 0 ? clientIds.length : "Todos"}`
|
`${clientIds.length > 0 ? clientIds.length : "Todos"}`,
|
||||||
],
|
],
|
||||||
margin: [0, 0, 0, 2]
|
margin: [0, 0, 0, 2],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: [
|
text: [
|
||||||
{ text: "Período: ", bold: true },
|
{ text: "Período: ", bold: true },
|
||||||
`${dateFrom ? dateFrom.format("DD/MM/YYYY") : "Inicio"} al ${dateTo ? dateTo.format("DD/MM/YYYY") : "Fin"}`
|
`${dateFrom ? dateFrom.format("DD/MM/YYYY") : "Inicio"} al ${dateTo ? dateTo.format("DD/MM/YYYY") : "Fin"}`,
|
||||||
],
|
],
|
||||||
margin: [0, 0, 0, 20]
|
margin: [0, 0, 0, 20],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
table: {
|
table: {
|
||||||
headerRows: 1,
|
headerRows: 1,
|
||||||
widths: [120, "*", "auto"],
|
widths: [120, "*", "auto"],
|
||||||
body: tableBody
|
body: tableBody,
|
||||||
},
|
},
|
||||||
layout: "lightHorizontalLines"
|
layout: "lightHorizontalLines",
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
if ((window as unknown as { Android: any }).Android && (window as unknown as { Android: any }).Android.savePdf) {
|
if (
|
||||||
|
(window as unknown as { Android: any }).Android &&
|
||||||
|
(window as unknown as { Android: any }).Android.savePdf
|
||||||
|
) {
|
||||||
pdfMake.createPdf(docDefinition as any).getBase64((data: any) => {
|
pdfMake.createPdf(docDefinition as any).getBase64((data: any) => {
|
||||||
(window as unknown as { Android: any }).Android?.savePdf(data, `reporte-procesados.pdf`);
|
(window as unknown as { Android: any }).Android?.savePdf(
|
||||||
|
data,
|
||||||
|
`reporte-procesados.pdf`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
pdfMake.createPdf(docDefinition as any).download(`reporte-procesados.pdf`);
|
pdfMake.createPdf(docDefinition as any).download(`reporte-procesados.pdf`);
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
alert.showError((error as { format?: () => string }).format ? (error as { format: () => string }).format() : "Error generando PDF");
|
alert.showError(
|
||||||
|
(error as { format?: () => string }).format
|
||||||
|
? (error as { format: () => string }).format()
|
||||||
|
: "Error generando PDF",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
const handleScroll = (e: Event) => {
|
const handleScroll = (e: Event) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
// Check if it's a DOM element scrolling
|
// Check if it's a DOM element scrolling
|
||||||
@@ -389,15 +436,23 @@ export default function OrganizationEdit() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cFilter: FindClientsParams = { companyId: id };
|
const cFilter: FindClientsParams = { companyId: id };
|
||||||
filterObjects<FindClientsParams>("clients/filter-all", cFilter).then((res) => setClientFilterData(res));
|
filterObjects<FindClientsParams>("clients/filter-all", cFilter).then((res) =>
|
||||||
|
setClientFilterData(res),
|
||||||
|
);
|
||||||
|
|
||||||
const eFilter: FindEmployeesParams = { companyId: id };
|
const eFilter: FindEmployeesParams = { companyId: id };
|
||||||
filterObjects<FindEmployeesParams>("employees/filter-all", eFilter).then((res) => setCollaboratorFilterData(res));
|
filterObjects<FindEmployeesParams>("employees/filter-all", eFilter).then((res) =>
|
||||||
|
setCollaboratorFilterData(res),
|
||||||
|
);
|
||||||
|
|
||||||
const sFilter: FindServicesParams = { companyId: id };
|
const sFilter: FindServicesParams = { companyId: id };
|
||||||
filterObjects<FindServicesParams>("services/filter-all", sFilter).then((res) => setServiceFilterData(res));
|
filterObjects<FindServicesParams>("services/filter-all", sFilter).then((res) =>
|
||||||
|
setServiceFilterData(res),
|
||||||
|
);
|
||||||
|
|
||||||
setMethodFilterData(paymentMethods.map((m) => ({ id: m.id, text: m.name, name: m.name, image: "" })));
|
setMethodFilterData(
|
||||||
|
paymentMethods.map((m) => ({ id: m.id, text: m.name, name: m.name, image: "" })),
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -414,17 +469,30 @@ export default function OrganizationEdit() {
|
|||||||
clientData={clientFilterData}
|
clientData={clientFilterData}
|
||||||
setClientIds={setClientIds}
|
setClientIds={setClientIds}
|
||||||
clientIds={clientIds}
|
clientIds={clientIds}
|
||||||
/>
|
/>,
|
||||||
);
|
);
|
||||||
filter.setOnFilter(() => { });
|
filter.setOnFilter(() => {});
|
||||||
}, [collaboratorFilterData, serviceFilterData, methodFilterData, clientFilterData]);
|
}, [collaboratorFilterData, serviceFilterData, methodFilterData, clientFilterData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||||
location.clear();
|
location.clear();
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
location.add({ url: "/admin/org", title: "Organizaciones", icon: null });
|
location.add({ url: "/admin/org", title: "Organizaciones", icon: null });
|
||||||
location.add({ url: "/admin/org/profile/" + id, title: "Perfil", icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" /> });
|
location.add({
|
||||||
location.add({ url: "/admin/org/profile/" + id, title: "Pagos", icon: <PaymentsIcon sx={{ fontSize: "32px", mr: "10px" }} /> });
|
url: "/admin/org/profile/" + id,
|
||||||
|
title: "Perfil",
|
||||||
|
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />,
|
||||||
|
});
|
||||||
|
location.add({
|
||||||
|
url: "/admin/org/profile/" + id,
|
||||||
|
title: "Pagos",
|
||||||
|
icon: <PaymentsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -444,35 +512,75 @@ export default function OrganizationEdit() {
|
|||||||
<Location />
|
<Location />
|
||||||
|
|
||||||
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
|
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
|
||||||
<div style={{ maxWidth: "800px", margin: "0 auto", paddingBottom: "100px", marginTop: "0px" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: "800px",
|
||||||
|
margin: "0 auto",
|
||||||
|
paddingBottom: "100px",
|
||||||
|
marginTop: "0px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Sentinel element to detect scroll */}
|
{/* Sentinel element to detect scroll */}
|
||||||
<div ref={sentinelRef} style={{ height: "1px", width: "100%" }}></div>
|
<div ref={sentinelRef} style={{ height: "1px", width: "100%" }}></div>
|
||||||
|
|
||||||
{/* Top Status Card */}
|
{/* Top Status Card */}
|
||||||
<div style={{ position: "sticky", top: "70px", zIndex: 100, marginBottom: "50px", display: "flex", flexDirection: "column", background: "white", paddingBottom: "10px", paddingTop: "5px", margin: "0 -10px", paddingLeft: "10px", paddingRight: "10px" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
position: "sticky",
|
||||||
|
top: "70px",
|
||||||
|
zIndex: 100,
|
||||||
|
marginBottom: "50px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
background: "white",
|
||||||
|
paddingBottom: "10px",
|
||||||
|
paddingTop: "5px",
|
||||||
|
margin: "0 -10px",
|
||||||
|
paddingLeft: "10px",
|
||||||
|
paddingRight: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Tabs Selector */}
|
{/* Tabs Selector */}
|
||||||
<div style={{ display: "flex", justifyContent: "center", marginBottom: "15px" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
marginBottom: "15px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ThemeProvider theme={turnosXpressTheme}>
|
<ThemeProvider theme={turnosXpressTheme}>
|
||||||
<div style={{
|
<div
|
||||||
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
background: "rgba(255,255,255,0.9)",
|
background: "rgba(255,255,255,0.9)",
|
||||||
backdropFilter: "blur(10px)",
|
backdropFilter: "blur(10px)",
|
||||||
borderRadius: "30px",
|
borderRadius: "30px",
|
||||||
padding: "5px",
|
padding: "5px",
|
||||||
boxShadow: "0 4px 15px rgba(0,0,0,0.05)"
|
boxShadow: "0 4px 15px rgba(0,0,0,0.05)",
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<ButtonMaterial
|
<ButtonMaterial
|
||||||
variant="contained"
|
variant="contained"
|
||||||
style={{ borderRadius: "25px", boxShadow: "0 2px 8px rgba(0,0,0,0.1)", fontWeight: "bold", padding: "8px 25px" }}
|
style={{
|
||||||
|
borderRadius: "25px",
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
|
||||||
|
fontWeight: "bold",
|
||||||
|
padding: "8px 25px",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Pagados
|
Pagados
|
||||||
</ButtonMaterial>
|
</ButtonMaterial>
|
||||||
<ButtonMaterial
|
<ButtonMaterial
|
||||||
variant="text"
|
variant="text"
|
||||||
onClick={() => goTo(`/admin/org/profile/${id}/payments/not-payed`)}
|
onClick={() =>
|
||||||
style={{ borderRadius: "25px", color: "#666", fontWeight: "bold", padding: "8px 25px" }}
|
goTo(`/admin/org/profile/${id}/payments/not-payed`)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderRadius: "25px",
|
||||||
|
color: "#666",
|
||||||
|
fontWeight: "bold",
|
||||||
|
padding: "8px 25px",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Adeudados
|
Adeudados
|
||||||
</ButtonMaterial>
|
</ButtonMaterial>
|
||||||
@@ -487,25 +595,33 @@ export default function OrganizationEdit() {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
y: 0,
|
y: 0,
|
||||||
padding: isMinimized ? "25px 50px" : "40px 20px",
|
padding: isMinimized ? "25px 50px" : "40px 20px",
|
||||||
borderRadius: isMinimized ? "15px" : "20px"
|
borderRadius: isMinimized ? "15px" : "20px",
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
background: "linear-gradient(135deg, #4b6cb7 0%, #182848 100%)",
|
background: "linear-gradient(135deg, #4b6cb7 0%, #182848 100%)",
|
||||||
color: "white",
|
color: "white",
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
boxShadow: isMinimized ? "0 4px 15px rgba(0,0,0,0.3)" : "0 10px 30px rgba(0,0,0,0.15)",
|
boxShadow: isMinimized
|
||||||
|
? "0 4px 15px rgba(0,0,0,0.3)"
|
||||||
|
: "0 10px 30px rgba(0,0,0,0.15)",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: isMinimized ? "row" : "column",
|
flexDirection: isMinimized ? "row" : "column",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: isMinimized ? "space-between" : "center",
|
justifyContent: isMinimized ? "space-between" : "center",
|
||||||
transition: "box-shadow 0.3s ease"
|
transition: "box-shadow 0.3s ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => setForceMinimized(!forceMinimized)}
|
onClick={() => setForceMinimized(!forceMinimized)}
|
||||||
style={{ position: "absolute", top: "10px", left: "10px", color: "rgba(255,255,255,0.7)", zIndex: 10 }}
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "10px",
|
||||||
|
left: "10px",
|
||||||
|
color: "rgba(255,255,255,0.7)",
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
size="small"
|
size="small"
|
||||||
title={forceMinimized ? "Maximizar" : "Minimizar"}
|
title={forceMinimized ? "Maximizar" : "Minimizar"}
|
||||||
>
|
>
|
||||||
@@ -513,28 +629,59 @@ export default function OrganizationEdit() {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => filter.toggle()}
|
onClick={() => filter.toggle()}
|
||||||
style={{ position: "absolute", top: "10px", right: "10px", color: "rgba(255,255,255,0.7)", zIndex: 10 }}
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "10px",
|
||||||
|
right: "10px",
|
||||||
|
color: "rgba(255,255,255,0.7)",
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
size="small"
|
size="small"
|
||||||
title="Filtrar"
|
title="Filtrar"
|
||||||
>
|
>
|
||||||
<FilterAltOutlinedIcon />
|
<FilterAltOutlinedIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<div style={{ position: "absolute", top: "-50px", right: "-50px", width: "150px", height: "150px", borderRadius: "50%", background: "rgba(255,255,255,0.05)" }}></div>
|
<div
|
||||||
<div style={{ position: "absolute", bottom: "-30px", left: "-30px", width: "100px", height: "100px", borderRadius: "50%", background: "rgba(255,255,255,0.05)" }}></div>
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "-50px",
|
||||||
|
right: "-50px",
|
||||||
|
width: "150px",
|
||||||
|
height: "150px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "rgba(255,255,255,0.05)",
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: "-30px",
|
||||||
|
left: "-30px",
|
||||||
|
width: "100px",
|
||||||
|
height: "100px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "rgba(255,255,255,0.05)",
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{
|
animate={{
|
||||||
fontSize: isMinimized ? "14px" : "16px",
|
fontSize: isMinimized ? "14px" : "16px",
|
||||||
marginBottom: isMinimized ? "0px" : "10px"
|
marginBottom: isMinimized ? "0px" : "10px",
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontWeight: "bold",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "2px",
|
||||||
|
opacity: 0.9,
|
||||||
}}
|
}}
|
||||||
style={{ fontWeight: "bold", textTransform: "uppercase", letterSpacing: "2px", opacity: 0.9 }}
|
|
||||||
>
|
>
|
||||||
Total Recaudado
|
Total Recaudado
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{
|
animate={{
|
||||||
fontSize: isMinimized ? "24px" : "56px",
|
fontSize: isMinimized ? "24px" : "56px",
|
||||||
margin: isMinimized ? "0px" : "10px 0"
|
margin: isMinimized ? "0px" : "10px 0",
|
||||||
}}
|
}}
|
||||||
style={{ fontWeight: "900" }}
|
style={{ fontWeight: "900" }}
|
||||||
>
|
>
|
||||||
@@ -547,7 +694,16 @@ export default function OrganizationEdit() {
|
|||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
style={{ fontSize: "14px", opacity: 0.8 }}
|
style={{ fontSize: "14px", opacity: 0.8 }}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", gap: "10px", alignItems: "center", flexWrap: "wrap", justifyContent: "center", marginTop: "10px" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "10px",
|
||||||
|
alignItems: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "center",
|
||||||
|
marginTop: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{ width: "250px", maxWidth: "100%" }}>
|
<div style={{ width: "250px", maxWidth: "100%" }}>
|
||||||
<TextRangeDateModern
|
<TextRangeDateModern
|
||||||
label="Período"
|
label="Período"
|
||||||
@@ -567,10 +723,13 @@ export default function OrganizationEdit() {
|
|||||||
borderRadius: "20px",
|
borderRadius: "20px",
|
||||||
padding: "8px 20px",
|
padding: "8px 20px",
|
||||||
textTransform: "none",
|
textTransform: "none",
|
||||||
height: "56px"
|
height: "56px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PictureAsPdfIcon style={{ fontSize: "18px", marginRight: "5px" }} /> PDF
|
<PictureAsPdfIcon
|
||||||
|
style={{ fontSize: "18px", marginRight: "5px" }}
|
||||||
|
/>{" "}
|
||||||
|
PDF
|
||||||
</ButtonMaterial>
|
</ButtonMaterial>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -579,10 +738,22 @@ export default function OrganizationEdit() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dynamic Spacer for layout shift */}
|
{/* Dynamic Spacer for layout shift */}
|
||||||
<div style={{ height: isMinimized ? "100px" : "0px", transition: "height 0.3s ease" }}></div>
|
<div
|
||||||
|
style={{
|
||||||
|
height: isMinimized ? "100px" : "0px",
|
||||||
|
transition: "height 0.3s ease",
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
|
||||||
{/* Payments List */}
|
{/* Payments List */}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px", paddingTop: "15px" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "12px",
|
||||||
|
paddingTop: "15px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{dataList.length > 0 ? (
|
{dataList.length > 0 ? (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{dataList.map((payment, index) => (
|
{dataList.map((payment, index) => (
|
||||||
@@ -602,11 +773,19 @@ export default function OrganizationEdit() {
|
|||||||
padding: "16px 20px",
|
padding: "16px 20px",
|
||||||
boxShadow: "0 4px 12px rgba(0,0,0,0.05)",
|
boxShadow: "0 4px 12px rgba(0,0,0,0.05)",
|
||||||
borderLeft: "5px solid #28a745",
|
borderLeft: "5px solid #28a745",
|
||||||
position: "relative"
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "15px", flex: 1 }}>
|
<div
|
||||||
<div style={{
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "15px",
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
width: "50px",
|
width: "50px",
|
||||||
height: "50px",
|
height: "50px",
|
||||||
borderRadius: "12px",
|
borderRadius: "12px",
|
||||||
@@ -614,31 +793,65 @@ export default function OrganizationEdit() {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
color: "#28a745"
|
color: "#28a745",
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{getPaymentIcon(payment.method)}
|
{getPaymentIcon(payment.method)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ fontSize: "16px", fontWeight: "bold", color: "#333", marginBottom: "2px" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#333",
|
||||||
|
marginBottom: "2px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{payment.clientName}
|
{payment.clientName}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: "14px", color: "var(--wine-dark)", fontWeight: "bold", marginBottom: "4px" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "var(--wine-dark)",
|
||||||
|
fontWeight: "bold",
|
||||||
|
marginBottom: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{payment.serviceName}
|
{payment.serviceName}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: "10px", fontSize: "12px", color: "#888" }}>
|
<div
|
||||||
<span>{dayjs(payment.createdAt).format("DD MMMM YYYY • HH:mm")}</span>
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "10px",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "#888",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{dayjs(payment.createdAt).format(
|
||||||
|
"DD MMMM YYYY • HH:mm",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{payment.employeeName}</span>
|
<span>{payment.employeeName}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "15px" }}>
|
<div
|
||||||
<div style={{
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "15px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
fontSize: "20px",
|
fontSize: "20px",
|
||||||
fontWeight: "900",
|
fontWeight: "900",
|
||||||
color: "#28a745",
|
color: "#28a745",
|
||||||
textAlign: "right"
|
textAlign: "right",
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
+{formatPrice(payment.price)}
|
+{formatPrice(payment.price)}
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -646,14 +859,18 @@ export default function OrganizationEdit() {
|
|||||||
size="small"
|
size="small"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
confirm.show(
|
confirm.show(
|
||||||
"¿Estás seguro que quieres eliminar el pago de " + payment.clientName + "?",
|
"¿Estás seguro que quieres eliminar el pago de " +
|
||||||
|
payment.clientName +
|
||||||
|
"?",
|
||||||
() => deletePaymentHandler(payment.id),
|
() => deletePaymentHandler(payment.id),
|
||||||
() => { }
|
() => {},
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
backgroundColor: "rgba(220, 53, 69, 0.05)",
|
backgroundColor: "rgba(220, 53, 69, 0.05)",
|
||||||
"&:hover": { backgroundColor: "rgba(220, 53, 69, 0.15)" }
|
"&:hover": {
|
||||||
|
backgroundColor: "rgba(220, 53, 69, 0.15)",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DeleteIcon fontSize="small" />
|
<DeleteIcon fontSize="small" />
|
||||||
@@ -666,17 +883,29 @@ export default function OrganizationEdit() {
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
style={{ textAlign: "center", padding: "60px 20px", backgroundColor: "#fff", borderRadius: "15px", border: "1px dashed #ccc" }}
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "60px 20px",
|
||||||
|
backgroundColor: "#fff",
|
||||||
|
borderRadius: "15px",
|
||||||
|
border: "1px dashed #ccc",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SearchOffIcon sx={{ fontSize: "64px", color: "#ccc", marginBottom: "15px" }} />
|
<SearchOffIcon
|
||||||
|
sx={{ fontSize: "64px", color: "#ccc", marginBottom: "15px" }}
|
||||||
|
/>
|
||||||
<h2 style={{ margin: 0, color: "#999" }}>Sin Pagos</h2>
|
<h2 style={{ margin: 0, color: "#999" }}>Sin Pagos</h2>
|
||||||
<p style={{ color: "#aaa" }}>No se encontraron pagos registrados para este período.</p>
|
<p style={{ color: "#aaa" }}>
|
||||||
|
No se encontraron pagos registrados para este período.
|
||||||
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{payments && payments.pages > 1 && (
|
{payments && payments.pages > 1 && (
|
||||||
<div style={{ display: "flex", justifyContent: "center", marginTop: "40px" }}>
|
<div
|
||||||
|
style={{ display: "flex", justifyContent: "center", marginTop: "40px" }}
|
||||||
|
>
|
||||||
<Pagination
|
<Pagination
|
||||||
count={payments.pages}
|
count={payments.pages}
|
||||||
page={payments.page}
|
page={payments.page}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import Avatar from "@components/Avatar/Avatar";
|
|||||||
import Button from "@core/app/components/Button/Button";
|
import Button from "@core/app/components/Button/Button";
|
||||||
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
||||||
import { getMondayWeekStart } from "@core/helpers/week";
|
import { getMondayWeekStart } from "@core/helpers/week";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
dayjs.locale("es");
|
dayjs.locale("es");
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ export default function Schedules() {
|
|||||||
const data = useParams<UrlData>();
|
const data = useParams<UrlData>();
|
||||||
const id = data.id;
|
const id = data.id;
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
const SessionInfo = useSessionStore();
|
const SessionInfo = useSessionStore();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
@@ -38,6 +40,8 @@ export default function Schedules() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org",
|
url: "/admin/org",
|
||||||
title: "Organizaciones",
|
title: "Organizaciones",
|
||||||
@@ -55,6 +59,10 @@ export default function Schedules() {
|
|||||||
title: "Horarios Habilitados",
|
title: "Horarios Habilitados",
|
||||||
icon: null,
|
icon: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -118,7 +126,7 @@ export default function Schedules() {
|
|||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
return;
|
return;
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+22
-11
@@ -80,7 +80,7 @@ export default function OrganizationService() {
|
|||||||
const [serviceDiscountName, setServiceDiscountName] = useState<string | undefined>(undefined);
|
const [serviceDiscountName, setServiceDiscountName] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const [servicePublished, setServicePublished] = useState<SERVICE_PUBLISHED_STATUS | undefined>(
|
const [servicePublished, setServicePublished] = useState<SERVICE_PUBLISHED_STATUS | undefined>(
|
||||||
undefined
|
undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [savePublishedStatusAvailable, setSavePublishedStatusAvailable] = useState(false);
|
const [savePublishedStatusAvailable, setSavePublishedStatusAvailable] = useState(false);
|
||||||
@@ -149,7 +149,7 @@ export default function OrganizationService() {
|
|||||||
>
|
>
|
||||||
Aplicar Descuento
|
Aplicar Descuento
|
||||||
</ButtonMaterial>
|
</ButtonMaterial>
|
||||||
</div>
|
</div>,
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -271,8 +271,12 @@ export default function OrganizationService() {
|
|||||||
setServiceDiscountType(response.discountType);
|
setServiceDiscountType(response.discountType);
|
||||||
setServiceDiscountValue(response.discountValue);
|
setServiceDiscountValue(response.discountValue);
|
||||||
setServiceDiscountName(response.discountName);
|
setServiceDiscountName(response.discountName);
|
||||||
setShowPublicScores(response.showPublicScores !== undefined ? response.showPublicScores : true);
|
setShowPublicScores(
|
||||||
setShowPublicOpinions(response.showPublicOpinions !== undefined ? response.showPublicOpinions : true);
|
response.showPublicScores !== undefined ? response.showPublicScores : true,
|
||||||
|
);
|
||||||
|
setShowPublicOpinions(
|
||||||
|
response.showPublicOpinions !== undefined ? response.showPublicOpinions : true,
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -283,7 +287,7 @@ export default function OrganizationService() {
|
|||||||
const formatDiscount = (
|
const formatDiscount = (
|
||||||
value: number | undefined,
|
value: number | undefined,
|
||||||
type: string | undefined,
|
type: string | undefined,
|
||||||
name: string | undefined
|
name: string | undefined,
|
||||||
): string => {
|
): string => {
|
||||||
if (value === undefined || value === null || type === undefined || type === null) {
|
if (value === undefined || value === null || type === undefined || type === null) {
|
||||||
return "";
|
return "";
|
||||||
@@ -464,7 +468,7 @@ export default function OrganizationService() {
|
|||||||
setServicePublished(
|
setServicePublished(
|
||||||
checked
|
checked
|
||||||
? SERVICE_PUBLISHED_STATUS.PUBLISHED
|
? SERVICE_PUBLISHED_STATUS.PUBLISHED
|
||||||
: SERVICE_PUBLISHED_STATUS.PRIVATE
|
: SERVICE_PUBLISHED_STATUS.PRIVATE,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -506,10 +510,15 @@ export default function OrganizationService() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<MaterialAlert severity="info" sx={{ marginTop: "20px" }}>
|
<MaterialAlert severity="info" sx={{ marginTop: "20px" }}>
|
||||||
<b>Visibilidad pública:</b> Elegí si querés mostrar puntuaciones y opiniones de este servicio en páginas públicas. Disponible para planes pagos.
|
<b>Visibilidad pública:</b> Elegí si querés mostrar puntuaciones y opiniones de este
|
||||||
|
servicio en páginas públicas. Disponible para planes pagos.
|
||||||
</MaterialAlert>
|
</MaterialAlert>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" }}>
|
<div
|
||||||
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
|
style={{ display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" }}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}
|
||||||
|
>
|
||||||
<Switch
|
<Switch
|
||||||
checked={showPublicScores}
|
checked={showPublicScores}
|
||||||
disabled={!canEditPublicRatingFlags}
|
disabled={!canEditPublicRatingFlags}
|
||||||
@@ -520,7 +529,9 @@ export default function OrganizationService() {
|
|||||||
/>
|
/>
|
||||||
Mostrar puntuaciones públicas
|
Mostrar puntuaciones públicas
|
||||||
</label>
|
</label>
|
||||||
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
|
<label
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}
|
||||||
|
>
|
||||||
<Switch
|
<Switch
|
||||||
checked={showPublicOpinions}
|
checked={showPublicOpinions}
|
||||||
disabled={!canEditPublicRatingFlags}
|
disabled={!canEditPublicRatingFlags}
|
||||||
@@ -567,7 +578,7 @@ export default function OrganizationService() {
|
|||||||
() => {
|
() => {
|
||||||
deleteDiscountHandler();
|
deleteDiscountHandler();
|
||||||
},
|
},
|
||||||
() => {}
|
() => {},
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { getServiceImage } from "@core/helpers/getServiceImage";
|
|||||||
import ServiceRatingSummary from "@components/ServiceRatingSummary/ServiceRatingSummary";
|
import ServiceRatingSummary from "@components/ServiceRatingSummary/ServiceRatingSummary";
|
||||||
|
|
||||||
import ShareOutlinedIcon from "@mui/icons-material/ShareOutlined";
|
import ShareOutlinedIcon from "@mui/icons-material/ShareOutlined";
|
||||||
|
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||||
import Dialog from "@mui/material/Dialog";
|
import Dialog from "@mui/material/Dialog";
|
||||||
import DialogContent from "@mui/material/DialogContent";
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
import DialogTitle from "@mui/material/DialogTitle";
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
@@ -31,8 +32,9 @@ import {
|
|||||||
WhatsappIcon as WhatsAppShareIcon,
|
WhatsappIcon as WhatsAppShareIcon,
|
||||||
TwitterIcon,
|
TwitterIcon,
|
||||||
EmailIcon,
|
EmailIcon,
|
||||||
TelegramIcon
|
TelegramIcon,
|
||||||
} from "react-share";
|
} from "react-share";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -42,6 +44,7 @@ export default function Org() {
|
|||||||
const urlData = useParams<UrlData>();
|
const urlData = useParams<UrlData>();
|
||||||
const id = urlData.id;
|
const id = urlData.id;
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
|
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
@@ -57,6 +60,8 @@ export default function Org() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org",
|
url: "/admin/org",
|
||||||
title: "Mis organizaciones",
|
title: "Mis organizaciones",
|
||||||
@@ -74,6 +79,10 @@ export default function Org() {
|
|||||||
title: "Mis servicios",
|
title: "Mis servicios",
|
||||||
icon: <StorefrontOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
icon: <StorefrontOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -104,7 +113,7 @@ export default function Org() {
|
|||||||
<div
|
<div
|
||||||
className={style.serviceCard}
|
className={style.serviceCard}
|
||||||
style={{
|
style={{
|
||||||
borderLeft: `6px solid ${data.color}`
|
borderLeft: `6px solid ${data.color}`,
|
||||||
}}
|
}}
|
||||||
onClick={() => handleServiceClick(data.id)}
|
onClick={() => handleServiceClick(data.id)}
|
||||||
>
|
>
|
||||||
@@ -114,24 +123,40 @@ export default function Org() {
|
|||||||
<div className={style.serviceInfo}>
|
<div className={style.serviceInfo}>
|
||||||
<h1>{data.name}</h1>
|
<h1>{data.name}</h1>
|
||||||
<p>{data.description}</p>
|
<p>{data.description}</p>
|
||||||
<ServiceRatingSummary serviceId={data.id} variant="row" showOpinionCount={false} />
|
<ServiceRatingSummary
|
||||||
|
serviceId={data.id}
|
||||||
|
variant="row"
|
||||||
|
showOpinionCount={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.serviceActions}>
|
<div className={style.serviceActions}>
|
||||||
<span className={style.servicePrice}>{formatPrice(data.price)}</span>
|
<span className={style.servicePrice}>{formatPrice(data.price)}</span>
|
||||||
|
<div className={style.iconsRow}>
|
||||||
|
<IconButton
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goTo(`/landing/service/${data.id}`);
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
title="Ver perfil público del servicio"
|
||||||
|
className={style.iconButton}
|
||||||
|
>
|
||||||
|
<OpenInNewIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
className={style.shareButton}
|
|
||||||
sx={{ position: "absolute", top: "15px", right: "15px" }}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setShareService(data);
|
setShareService(data);
|
||||||
}}
|
}}
|
||||||
size="small"
|
size="small"
|
||||||
title="Compartir servicio"
|
title="Compartir servicio"
|
||||||
|
className={style.iconButton}
|
||||||
>
|
>
|
||||||
<ShareOutlinedIcon fontSize="small" />
|
<ShareOutlinedIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,8 +171,21 @@ export default function Org() {
|
|||||||
<div style={{ marginBottom: "20px" }}> </div>
|
<div style={{ marginBottom: "20px" }}> </div>
|
||||||
|
|
||||||
{shareService && (
|
{shareService && (
|
||||||
<Dialog open={!!shareService} onClose={() => setShareService(null)} maxWidth="sm" fullWidth>
|
<Dialog
|
||||||
<DialogTitle sx={{ m: 0, p: 2, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
open={!!shareService}
|
||||||
|
onClose={() => setShareService(null)}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle
|
||||||
|
sx={{
|
||||||
|
m: 0,
|
||||||
|
p: 2,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span style={{ fontWeight: "bold" }}>Compartir Servicio</span>
|
<span style={{ fontWeight: "bold" }}>Compartir Servicio</span>
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label="close"
|
aria-label="close"
|
||||||
@@ -158,23 +196,57 @@ export default function Org() {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<div style={{ textAlign: "center", marginBottom: "30px", fontSize: "16px", color: "var(--black-light)" }}>
|
<div
|
||||||
Compartí tu servicio <strong>{shareService.name}</strong> para que tus clientes puedan reservarlo directamente.
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: "30px",
|
||||||
|
fontSize: "16px",
|
||||||
|
color: "var(--black-light)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Compartí tu servicio <strong>{shareService.name}</strong> para que tus
|
||||||
|
clientes puedan reservarlo directamente.
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", justifyContent: "center", flexDirection: "row", alignItems: "center", gap: "20px", paddingBottom: "20px" }}>
|
<div
|
||||||
<WhatsappShareButton title={"Reserva este servicio: " + shareService.name} separator=":: " url={window.location.origin + "/landing/service/" + shareService.id}>
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "20px",
|
||||||
|
paddingBottom: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WhatsappShareButton
|
||||||
|
title={"Reserva este servicio: " + shareService.name}
|
||||||
|
separator=":: "
|
||||||
|
url={window.location.origin + "/landing/service/" + shareService.id}
|
||||||
|
>
|
||||||
<WhatsAppShareIcon size={54} round />
|
<WhatsAppShareIcon size={54} round />
|
||||||
</WhatsappShareButton>
|
</WhatsappShareButton>
|
||||||
<FacebookShareButton title={"Reserva este servicio: " + shareService.name} url={window.location.origin + "/landing/service/" + shareService.id}>
|
<FacebookShareButton
|
||||||
|
title={"Reserva este servicio: " + shareService.name}
|
||||||
|
url={window.location.origin + "/landing/service/" + shareService.id}
|
||||||
|
>
|
||||||
<FacebookIcon size={54} round />
|
<FacebookIcon size={54} round />
|
||||||
</FacebookShareButton>
|
</FacebookShareButton>
|
||||||
<EmailShareButton title={"Reserva este servicio: " + shareService.name} separator=":: " url={window.location.origin + "/landing/service/" + shareService.id}>
|
<EmailShareButton
|
||||||
|
title={"Reserva este servicio: " + shareService.name}
|
||||||
|
separator=":: "
|
||||||
|
url={window.location.origin + "/landing/service/" + shareService.id}
|
||||||
|
>
|
||||||
<EmailIcon size={54} round />
|
<EmailIcon size={54} round />
|
||||||
</EmailShareButton>
|
</EmailShareButton>
|
||||||
<TelegramShareButton title={"Reserva este servicio: " + shareService.name} url={window.location.origin + "/landing/service/" + shareService.id}>
|
<TelegramShareButton
|
||||||
|
title={"Reserva este servicio: " + shareService.name}
|
||||||
|
url={window.location.origin + "/landing/service/" + shareService.id}
|
||||||
|
>
|
||||||
<TelegramIcon size={54} round />
|
<TelegramIcon size={54} round />
|
||||||
</TelegramShareButton>
|
</TelegramShareButton>
|
||||||
<TwitterShareButton title={"Reserva este servicio: " + shareService.name} url={window.location.origin + "/landing/service/" + shareService.id}>
|
<TwitterShareButton
|
||||||
|
title={"Reserva este servicio: " + shareService.name}
|
||||||
|
url={window.location.origin + "/landing/service/" + shareService.id}
|
||||||
|
>
|
||||||
<TwitterIcon size={54} round />
|
<TwitterIcon size={54} round />
|
||||||
</TwitterShareButton>
|
</TwitterShareButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+11
-8
@@ -71,26 +71,29 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: flex-end; /* changed from space-between */
|
justify-content: center;
|
||||||
min-width: 100px;
|
gap: 12px;
|
||||||
|
min-width: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.servicePrice {
|
.servicePrice {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
margin-bottom: 0; /* removed bottom margin */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.shareButton {
|
.iconsRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconButton {
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
position: absolute;
|
|
||||||
top: 15px;
|
|
||||||
right: 15px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.shareButton:hover {
|
.iconButton:hover {
|
||||||
color: var(--wine-dark);
|
color: var(--wine-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -54,7 +54,7 @@ export default function TemplateCreate() {
|
|||||||
|
|
||||||
const insertAtCursor = (
|
const insertAtCursor = (
|
||||||
ref: React.RefObject<HTMLInputElement | HTMLTextAreaElement>,
|
ref: React.RefObject<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
insertText: string
|
insertText: string,
|
||||||
) => {
|
) => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el) return; // todavía no está montado
|
if (!el) return; // todavía no está montado
|
||||||
@@ -126,14 +126,14 @@ export default function TemplateCreate() {
|
|||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
|
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Eliminar
|
Eliminar
|
||||||
</ButtonMaterial>
|
</ButtonMaterial>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>,
|
||||||
);
|
);
|
||||||
|
|
||||||
loadTemplate();
|
loadTemplate();
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "@models/Templates.model";
|
} from "@models/Templates.model";
|
||||||
import { findTemplatesByOrganization } from "./Templates.Service";
|
import { findTemplatesByOrganization } from "./Templates.Service";
|
||||||
import { useNavigation } from "@core/app/hooks/goto";
|
import { useNavigation } from "@core/app/hooks/goto";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -29,6 +30,7 @@ export default function TemplatesGrid() {
|
|||||||
const oid = data.id;
|
const oid = data.id;
|
||||||
const { goTo } = useNavigation();
|
const { goTo } = useNavigation();
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
|
|
||||||
const formatType = (type: string) => {
|
const formatType = (type: string) => {
|
||||||
@@ -91,8 +93,8 @@ export default function TemplatesGrid() {
|
|||||||
parseInt(
|
parseInt(
|
||||||
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
|
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
|
||||||
? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
|
? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
|
||||||
: "10"
|
: "10",
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
const [templates, setTemplates] = useState<PaginateTemplatesResults>();
|
const [templates, setTemplates] = useState<PaginateTemplatesResults>();
|
||||||
|
|
||||||
@@ -103,6 +105,8 @@ export default function TemplatesGrid() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org",
|
url: "/admin/org",
|
||||||
title: "Mis organizaciones",
|
title: "Mis organizaciones",
|
||||||
@@ -122,6 +126,10 @@ export default function TemplatesGrid() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
loadTemplates(1, pageSize);
|
loadTemplates(1, pageSize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadTemplates = (page: number = 1, pageSize: number = 30) => {
|
const loadTemplates = (page: number = 1, pageSize: number = 30) => {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import Image from "next/image";
|
|||||||
import Switch from "@core/app/components/Switch/Switch";
|
import Switch from "@core/app/components/Switch/Switch";
|
||||||
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
||||||
import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan";
|
import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan";
|
||||||
|
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||||
|
|
||||||
type UrlData = {
|
type UrlData = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -35,6 +36,7 @@ export default function OrganizationEdit() {
|
|||||||
const data = useParams<UrlData>();
|
const data = useParams<UrlData>();
|
||||||
const id = data.id;
|
const id = data.id;
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const bottomToolbar = useBottomToolbarStore();
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
const SessionInfo = useSessionStore();
|
const SessionInfo = useSessionStore();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
@@ -46,6 +48,8 @@ export default function OrganizationEdit() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
location.clear();
|
location.clear();
|
||||||
|
|
||||||
|
bottomToolbar.set(<></>);
|
||||||
|
|
||||||
location.add({
|
location.add({
|
||||||
url: "/admin/org",
|
url: "/admin/org",
|
||||||
title: "Organizaciones",
|
title: "Organizaciones",
|
||||||
@@ -63,6 +67,10 @@ export default function OrganizationEdit() {
|
|||||||
title: "Whatsapp",
|
title: "Whatsapp",
|
||||||
icon: <OrgConfigIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
icon: <OrgConfigIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
bottomToolbar.clear();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -420,7 +428,7 @@ export default function OrganizationEdit() {
|
|||||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
() => { }
|
() => {},
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
const [wapError, setWapError] = useState<string | null>(null);
|
const [wapError, setWapError] = useState<string | null>(null);
|
||||||
|
|
||||||
// State for ARCA onboarding flow
|
// State for ARCA onboarding flow
|
||||||
|
const [arcaIsBlocked, setArcaIsBlocked] = useState(false);
|
||||||
const [arcaCuit, setArcaCuit] = useState("");
|
const [arcaCuit, setArcaCuit] = useState("");
|
||||||
const [arcaLegalName, setArcaLegalName] = useState("");
|
const [arcaLegalName, setArcaLegalName] = useState("");
|
||||||
const [arcaTaxCondition, setArcaTaxCondition] = useState<"MONOTRIBUTO" | "EXENTO">("MONOTRIBUTO");
|
const [arcaTaxCondition, setArcaTaxCondition] = useState<"MONOTRIBUTO" | "EXENTO">("MONOTRIBUTO");
|
||||||
@@ -277,6 +278,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadOrganizationContext = (org: any) => {
|
const loadOrganizationContext = (org: any) => {
|
||||||
|
setArcaIsBlocked(false);
|
||||||
setCreatedCompanyId(org.id || org._id);
|
setCreatedCompanyId(org.id || org._id);
|
||||||
setSelectedOrganization(org);
|
setSelectedOrganization(org);
|
||||||
setOrgName(org.name || "");
|
setOrgName(org.name || "");
|
||||||
@@ -499,6 +501,18 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
setWapQr(null);
|
setWapQr(null);
|
||||||
setWapError(null);
|
setWapError(null);
|
||||||
} else if (flowId === "arca-onboarding") {
|
} else if (flowId === "arca-onboarding") {
|
||||||
|
const orgSub = SessionInfo.organizationSubscriptions[org.id || org._id];
|
||||||
|
const planSupportsPayments = orgSub ? orgSub.plan?.payments : false;
|
||||||
|
|
||||||
|
if (!planSupportsPayments) {
|
||||||
|
setArcaIsBlocked(true);
|
||||||
|
setCurrentStepIndex(0);
|
||||||
|
setArcaError(null);
|
||||||
|
setArcaLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setArcaIsBlocked(false);
|
||||||
setCurrentStepIndex(0);
|
setCurrentStepIndex(0);
|
||||||
setArcaError(null);
|
setArcaError(null);
|
||||||
setArcaLoading(false);
|
setArcaLoading(false);
|
||||||
@@ -527,6 +541,19 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
setArcaStatusLabel(getArcaStatusLabel(credential.status));
|
setArcaStatusLabel(getArcaStatusLabel(credential.status));
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
} else if (flowId === "arca-test-connection") {
|
} else if (flowId === "arca-test-connection") {
|
||||||
|
const orgSub = SessionInfo.organizationSubscriptions[org.id || org._id];
|
||||||
|
const planSupportsPayments = orgSub ? orgSub.plan?.payments : false;
|
||||||
|
|
||||||
|
if (!planSupportsPayments) {
|
||||||
|
setArcaIsBlocked(true);
|
||||||
|
setCurrentStepIndex(0);
|
||||||
|
setArcaError(null);
|
||||||
|
setArcaLoading(false);
|
||||||
|
setArcaTestResult(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setArcaIsBlocked(false);
|
||||||
setCurrentStepIndex(0);
|
setCurrentStepIndex(0);
|
||||||
setArcaError(null);
|
setArcaError(null);
|
||||||
setArcaLoading(false);
|
setArcaLoading(false);
|
||||||
@@ -2415,8 +2442,42 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
|||||||
if (flowId === "public-link") return renderPublicLinkFlow();
|
if (flowId === "public-link") return renderPublicLinkFlow();
|
||||||
if (flowId === "notifications") return renderNotificationsFlow();
|
if (flowId === "notifications") return renderNotificationsFlow();
|
||||||
if (flowId === "whatsapp-bot") return renderWhatsAppBotFlow();
|
if (flowId === "whatsapp-bot") return renderWhatsAppBotFlow();
|
||||||
if (flowId === "arca-onboarding") return renderArcaOnboardingFlow();
|
if (flowId === "arca-onboarding") {
|
||||||
if (flowId === "arca-test-connection") return renderArcaTestConnectionFlow();
|
if (arcaIsBlocked) {
|
||||||
|
return (
|
||||||
|
<QuestionCard
|
||||||
|
isActive
|
||||||
|
title="Módulo ARCA no disponible"
|
||||||
|
description="Este flujo requiere un plan con el módulo de pagos habilitado para emitir Factura C."
|
||||||
|
onNext={() => router.push("/landing/pricing")}
|
||||||
|
nextLabel="Ver planes"
|
||||||
|
>
|
||||||
|
<div style={{ color: "rgba(255,255,255,0.75)", textAlign: "center", lineHeight: 1.6 }}>
|
||||||
|
La organización seleccionada necesita un plan con el módulo de pagos activo para usar ARCA. Actualizá tu plan o volvé al asistente y elegí otra opción.
|
||||||
|
</div>
|
||||||
|
</QuestionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return renderArcaOnboardingFlow();
|
||||||
|
}
|
||||||
|
if (flowId === "arca-test-connection") {
|
||||||
|
if (arcaIsBlocked) {
|
||||||
|
return (
|
||||||
|
<QuestionCard
|
||||||
|
isActive
|
||||||
|
title="Test de conexión ARCA no disponible"
|
||||||
|
description="Este flujo requiere un plan con el módulo de pagos habilitado para probar la conexión con ARCA."
|
||||||
|
onNext={() => router.push("/landing/pricing")}
|
||||||
|
nextLabel="Ver planes"
|
||||||
|
>
|
||||||
|
<div style={{ color: "rgba(255,255,255,0.75)", textAlign: "center", lineHeight: 1.6 }}>
|
||||||
|
La organización seleccionada necesita un plan con el módulo de pagos activo para usar ARCA. Actualizá tu plan o volvé al asistente y elegí otra opción.
|
||||||
|
</div>
|
||||||
|
</QuestionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return renderArcaTestConnectionFlow();
|
||||||
|
}
|
||||||
if (flowId === "disable-schedule") return renderDisableScheduleFlow();
|
if (flowId === "disable-schedule") return renderDisableScheduleFlow();
|
||||||
if (flowId === "open-reservation-periods") return renderReservationPeriodFlow();
|
if (flowId === "open-reservation-periods") return renderReservationPeriodFlow();
|
||||||
if (flowId === "override-schedule") return renderOverrideScheduleFlow();
|
if (flowId === "override-schedule") return renderOverrideScheduleFlow();
|
||||||
|
|||||||
+315
-153
@@ -39,13 +39,47 @@ type OnboardingServiceFlowProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps) {
|
export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps) {
|
||||||
const { heatMapFraction, currentStepIndex, handleNext, serviceName, setServiceName, serviceDescription, setServiceDescription, handleKeyDown, serviceLength, setServiceLength, handleCreateService, isCreating, servicePrice, setServicePrice, serviceLimit, setServiceLimit, serviceCreateError, handleUploadServiceImage, serviceImage, setServiceImage, isStandaloneNewService, handleAssignCreatedServiceToMe, handleShowCollaboratorAssignment, handleToggleServiceCollaborator, handleAssignCreatedServiceToCollaborators, handleFinishWithoutAssignment, serviceAssignmentError, serviceAssignmentWarning, serviceAssignmentMode, serviceAssignmentCollaborators, selectedServiceCollaboratorIds, isLoadingServiceCollaborators } = props;
|
const {
|
||||||
|
heatMapFraction,
|
||||||
|
currentStepIndex,
|
||||||
|
handleNext,
|
||||||
|
serviceName,
|
||||||
|
setServiceName,
|
||||||
|
serviceDescription,
|
||||||
|
setServiceDescription,
|
||||||
|
handleKeyDown,
|
||||||
|
serviceLength,
|
||||||
|
setServiceLength,
|
||||||
|
handleCreateService,
|
||||||
|
isCreating,
|
||||||
|
servicePrice,
|
||||||
|
setServicePrice,
|
||||||
|
serviceLimit,
|
||||||
|
setServiceLimit,
|
||||||
|
serviceCreateError,
|
||||||
|
handleUploadServiceImage,
|
||||||
|
serviceImage,
|
||||||
|
setServiceImage,
|
||||||
|
isStandaloneNewService,
|
||||||
|
handleAssignCreatedServiceToMe,
|
||||||
|
handleShowCollaboratorAssignment,
|
||||||
|
handleToggleServiceCollaborator,
|
||||||
|
handleAssignCreatedServiceToCollaborators,
|
||||||
|
handleFinishWithoutAssignment,
|
||||||
|
serviceAssignmentError,
|
||||||
|
serviceAssignmentWarning,
|
||||||
|
serviceAssignmentMode,
|
||||||
|
serviceAssignmentCollaborators,
|
||||||
|
selectedServiceCollaboratorIds,
|
||||||
|
isLoadingServiceCollaborators,
|
||||||
|
} = props;
|
||||||
const [isCustomDuration, setIsCustomDuration] = React.useState(false);
|
const [isCustomDuration, setIsCustomDuration] = React.useState(false);
|
||||||
const [customDurationValue, setCustomDurationValue] = React.useState("");
|
const [customDurationValue, setCustomDurationValue] = React.useState("");
|
||||||
const fraction = heatMapFraction || 60;
|
const fraction = heatMapFraction || 60;
|
||||||
const durationOptions = [fraction, fraction * 2, fraction * 3, fraction * 4];
|
const durationOptions = [fraction, fraction * 2, fraction * 3, fraction * 4];
|
||||||
const customDurationMinutes = Number(customDurationValue);
|
const customDurationMinutes = Number(customDurationValue);
|
||||||
const isValidCustomDuration = Number.isInteger(customDurationMinutes) && customDurationMinutes > 0;
|
const isValidCustomDuration =
|
||||||
|
Number.isInteger(customDurationMinutes) && customDurationMinutes > 0;
|
||||||
|
|
||||||
const selectDurationOption = (duration: number) => {
|
const selectDurationOption = (duration: number) => {
|
||||||
setIsCustomDuration(false);
|
setIsCustomDuration(false);
|
||||||
@@ -73,7 +107,13 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
|||||||
onNext={handleNext}
|
onNext={handleNext}
|
||||||
nextLabel="Comenzar"
|
nextLabel="Comenzar"
|
||||||
>
|
>
|
||||||
<div style={{ color: "rgba(255, 255, 255, 0.7)", textAlign: "center", padding: "10px" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
color: "rgba(255, 255, 255, 0.7)",
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Vamos a crear el primer servicio de tu negocio. ¡Es muy fácil!
|
Vamos a crear el primer servicio de tu negocio. ¡Es muy fácil!
|
||||||
</div>
|
</div>
|
||||||
</QuestionCard>
|
</QuestionCard>
|
||||||
@@ -85,7 +125,9 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
|||||||
onNext={handleNext}
|
onNext={handleNext}
|
||||||
disableNext={serviceName.trim().length < 3 || serviceDescription.trim().length < 5}
|
disableNext={serviceName.trim().length < 3 || serviceDescription.trim().length < 5}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px', width: '100%' }}>
|
<div
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: "15px", width: "100%" }}
|
||||||
|
>
|
||||||
<QuestionInput
|
<QuestionInput
|
||||||
placeholder="Ej. Corte clásico con lavado"
|
placeholder="Ej. Corte clásico con lavado"
|
||||||
value={serviceName}
|
value={serviceName}
|
||||||
@@ -97,7 +139,10 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
|||||||
value={serviceDescription}
|
value={serviceDescription}
|
||||||
onChange={(e) => setServiceDescription(e.target.value)}
|
onChange={(e) => setServiceDescription(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (serviceName.trim().length >= 3 && serviceDescription.trim().length >= 5) {
|
if (
|
||||||
|
serviceName.trim().length >= 3 &&
|
||||||
|
serviceDescription.trim().length >= 5
|
||||||
|
) {
|
||||||
handleKeyDown(e, handleNext);
|
handleKeyDown(e, handleNext);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -112,12 +157,21 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
|||||||
onNext={handleNext}
|
onNext={handleNext}
|
||||||
disableNext={!serviceLength || (isCustomDuration && !isValidCustomDuration)}
|
disableNext={!serviceLength || (isCustomDuration && !isValidCustomDuration)}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap', width: '100%', justifyContent: 'center' }}>
|
<div
|
||||||
{durationOptions.map(val => {
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "10px",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
width: "100%",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{durationOptions.map((val) => {
|
||||||
const hours = Math.floor(val / 60);
|
const hours = Math.floor(val / 60);
|
||||||
const minutes = val % 60;
|
const minutes = val % 60;
|
||||||
const label = hours > 0
|
const label =
|
||||||
? `${hours}h ${minutes > 0 ? minutes + 'm' : ''}`.trim()
|
hours > 0
|
||||||
|
? `${hours}h ${minutes > 0 ? minutes + "m" : ""}`.trim()
|
||||||
: `${minutes} min`;
|
: `${minutes} min`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -125,15 +179,21 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
|||||||
key={val}
|
key={val}
|
||||||
onClick={() => selectDurationOption(val)}
|
onClick={() => selectDurationOption(val)}
|
||||||
style={{
|
style={{
|
||||||
padding: '12px 24px',
|
padding: "12px 24px",
|
||||||
borderRadius: '12px',
|
borderRadius: "12px",
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
background: !isCustomDuration && serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)',
|
background:
|
||||||
border: `2px solid ${!isCustomDuration && serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`,
|
!isCustomDuration && serviceLength === val
|
||||||
color: 'white',
|
? "var(--wine-red)"
|
||||||
fontWeight: !isCustomDuration && serviceLength === val ? 'bold' : 'normal',
|
: "rgba(255,255,255,0.05)",
|
||||||
transition: 'all 0.2s ease',
|
border: `2px solid ${!isCustomDuration && serviceLength === val ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`,
|
||||||
fontSize: '16px'
|
color: "white",
|
||||||
|
fontWeight:
|
||||||
|
!isCustomDuration && serviceLength === val
|
||||||
|
? "bold"
|
||||||
|
: "normal",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
fontSize: "16px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -143,22 +203,24 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
|||||||
<div
|
<div
|
||||||
onClick={selectCustomDuration}
|
onClick={selectCustomDuration}
|
||||||
style={{
|
style={{
|
||||||
padding: '12px 24px',
|
padding: "12px 24px",
|
||||||
borderRadius: '12px',
|
borderRadius: "12px",
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
background: isCustomDuration ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)',
|
background: isCustomDuration
|
||||||
border: `2px solid ${isCustomDuration ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`,
|
? "var(--wine-red)"
|
||||||
color: 'white',
|
: "rgba(255,255,255,0.05)",
|
||||||
fontWeight: isCustomDuration ? 'bold' : 'normal',
|
border: `2px solid ${isCustomDuration ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`,
|
||||||
transition: 'all 0.2s ease',
|
color: "white",
|
||||||
fontSize: '16px'
|
fontWeight: isCustomDuration ? "bold" : "normal",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
fontSize: "16px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Otro
|
Otro
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isCustomDuration && (
|
{isCustomDuration && (
|
||||||
<div style={{ width: '100%', marginTop: '16px' }}>
|
<div style={{ width: "100%", marginTop: "16px" }}>
|
||||||
<QuestionInput
|
<QuestionInput
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@@ -173,7 +235,14 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
|||||||
}}
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: '13px', marginTop: '8px', textAlign: 'center' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
color: "rgba(255,255,255,0.6)",
|
||||||
|
fontSize: "13px",
|
||||||
|
marginTop: "8px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Ingresá la duración total del servicio expresada en minutos.
|
Ingresá la duración total del servicio expresada en minutos.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,16 +252,31 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
|||||||
<QuestionCard
|
<QuestionCard
|
||||||
isActive={currentStepIndex === 3}
|
isActive={currentStepIndex === 3}
|
||||||
title="3. Precio y Cupos"
|
title="3. Precio y Cupos"
|
||||||
description="¿Cuánto cuesta y a cuántas personas puedes atender a la vez en este servicio?"
|
description="¿Cuánto cuesta y a cuántas personas puede atender cada profesional a la vez en este servicio?"
|
||||||
onNext={handleCreateService}
|
onNext={handleCreateService}
|
||||||
disableNext={isCreating}
|
disableNext={isCreating}
|
||||||
nextLabel={isCreating ? "Guardando..." : "Crear Servicio"}
|
nextLabel={isCreating ? "Guardando..." : "Crear Servicio"}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', width: '100%' }}>
|
<div
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: "20px", width: "100%" }}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ color: 'white', marginBottom: '10px', fontSize: '14px' }}>Precio Original</div>
|
<div style={{ color: "white", marginBottom: "10px", fontSize: "14px" }}>
|
||||||
<div style={{ position: 'relative' }}>
|
Precio Original
|
||||||
<span style={{ position: 'absolute', left: '15px', top: '50%', transform: 'translateY(-50%)', color: 'white', fontSize: '18px' }}>$</span>
|
</div>
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: "15px",
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
color: "white",
|
||||||
|
fontSize: "18px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
$
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
@@ -200,35 +284,40 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
|||||||
value={servicePrice}
|
value={servicePrice}
|
||||||
onChange={(e) => setServicePrice(e.target.value)}
|
onChange={(e) => setServicePrice(e.target.value)}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: "100%",
|
||||||
padding: '15px 15px 15px 35px',
|
padding: "15px 15px 15px 35px",
|
||||||
borderRadius: '12px',
|
borderRadius: "12px",
|
||||||
border: '1px solid rgba(255,255,255,0.2)',
|
border: "1px solid rgba(255,255,255,0.2)",
|
||||||
background: 'rgba(255,255,255,0.05)',
|
background: "rgba(255,255,255,0.05)",
|
||||||
color: 'white',
|
color: "white",
|
||||||
fontSize: '18px',
|
fontSize: "18px",
|
||||||
outline: 'none'
|
outline: "none",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ color: 'white', marginBottom: '10px', fontSize: '14px' }}>Cupos Simultáneos (Por defecto 1)</div>
|
<div style={{ color: "white", marginBottom: "10px", fontSize: "14px" }}>
|
||||||
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
|
Cupos Simultáneos (Por defecto 1)
|
||||||
{[1, 2, 3, 5, 10].map(val => (
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
|
||||||
|
{[1, 2, 3, 5, 10].map((val) => (
|
||||||
<div
|
<div
|
||||||
key={val}
|
key={val}
|
||||||
onClick={() => setServiceLimit(val)}
|
onClick={() => setServiceLimit(val)}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 16px',
|
padding: "8px 16px",
|
||||||
borderRadius: '8px',
|
borderRadius: "8px",
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
background: serviceLimit === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)',
|
background:
|
||||||
border: `1px solid ${serviceLimit === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.2)'}`,
|
serviceLimit === val
|
||||||
color: 'white',
|
? "var(--wine-red)"
|
||||||
fontWeight: serviceLimit === val ? 'bold' : 'normal',
|
: "rgba(255,255,255,0.1)",
|
||||||
transition: 'all 0.2s ease'
|
border: `1px solid ${serviceLimit === val ? "var(--wine-red)" : "rgba(255,255,255,0.2)"}`,
|
||||||
|
color: "white",
|
||||||
|
fontWeight: serviceLimit === val ? "bold" : "normal",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{val}
|
{val}
|
||||||
@@ -237,16 +326,18 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{serviceCreateError && (
|
{serviceCreateError && (
|
||||||
<div style={{
|
<div
|
||||||
padding: '14px 16px',
|
style={{
|
||||||
borderRadius: '12px',
|
padding: "14px 16px",
|
||||||
background: 'rgba(255, 99, 132, 0.12)',
|
borderRadius: "12px",
|
||||||
border: '1px solid rgba(255, 99, 132, 0.35)',
|
background: "rgba(255, 99, 132, 0.12)",
|
||||||
color: '#fecdd3',
|
border: "1px solid rgba(255, 99, 132, 0.35)",
|
||||||
fontSize: '14px',
|
color: "#fecdd3",
|
||||||
|
fontSize: "14px",
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
textAlign: 'center'
|
textAlign: "center",
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{serviceCreateError}
|
{serviceCreateError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -259,30 +350,50 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
|||||||
description="Sube una foto representativa para que los clientes sepan qué ofreces. (Opcional)"
|
description="Sube una foto representativa para que los clientes sepan qué ofreces. (Opcional)"
|
||||||
onNext={handleUploadServiceImage}
|
onNext={handleUploadServiceImage}
|
||||||
disableNext={isCreating}
|
disableNext={isCreating}
|
||||||
nextLabel={isCreating ? "Subiendo..." : (serviceImage ? (isStandaloneNewService ? "Subir y continuar" : "Subir y Finalizar") : "Omitir por ahora")}
|
nextLabel={
|
||||||
|
isCreating
|
||||||
|
? "Subiendo..."
|
||||||
|
: serviceImage
|
||||||
|
? isStandaloneNewService
|
||||||
|
? "Subir y continuar"
|
||||||
|
: "Subir y Finalizar"
|
||||||
|
: "Omitir por ahora"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "20px",
|
||||||
|
width: "100%",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "150px",
|
||||||
|
border: "2px dashed rgba(255,255,255,0.3)",
|
||||||
|
borderRadius: "12px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: serviceImage ? "transparent" : "rgba(255,255,255,0.05)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', width: '100%', alignItems: 'center' }}>
|
|
||||||
<div style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '150px',
|
|
||||||
border: '2px dashed rgba(255,255,255,0.3)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'hidden',
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: serviceImage ? 'transparent' : 'rgba(255,255,255,0.05)'
|
|
||||||
}}>
|
|
||||||
{serviceImage ? (
|
{serviceImage ? (
|
||||||
<img
|
<img
|
||||||
src={URL.createObjectURL(serviceImage)}
|
src={URL.createObjectURL(serviceImage)}
|
||||||
alt="Preview"
|
alt="Preview"
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: 'rgba(255,255,255,0.6)' }}>Toca para seleccionar imagen</span>
|
<span style={{ color: "rgba(255,255,255,0.6)" }}>
|
||||||
|
Toca para seleccionar imagen
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -293,27 +404,29 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
width: '100%',
|
width: "100%",
|
||||||
height: '100%',
|
height: "100%",
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
cursor: 'pointer'
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{serviceCreateError && (
|
{serviceCreateError && (
|
||||||
<div style={{
|
<div
|
||||||
padding: '14px 16px',
|
style={{
|
||||||
borderRadius: '12px',
|
padding: "14px 16px",
|
||||||
background: 'rgba(255, 99, 132, 0.12)',
|
borderRadius: "12px",
|
||||||
border: '1px solid rgba(255, 99, 132, 0.35)',
|
background: "rgba(255, 99, 132, 0.12)",
|
||||||
color: '#fecdd3',
|
border: "1px solid rgba(255, 99, 132, 0.35)",
|
||||||
fontSize: '14px',
|
color: "#fecdd3",
|
||||||
|
fontSize: "14px",
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
textAlign: 'center'
|
textAlign: "center",
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{serviceCreateError}
|
{serviceCreateError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -326,21 +439,28 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
|||||||
title="¿Quién brinda este servicio?"
|
title="¿Quién brinda este servicio?"
|
||||||
description="Asigná este servicio a un colaborador para que pueda recibir reservas. Si todavía no querés hacerlo, podés configurarlo después."
|
description="Asigná este servicio a un colaborador para que pueda recibir reservas. Si todavía no querés hacerlo, podés configurarlo después."
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px', width: '100%' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "14px",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleAssignCreatedServiceToMe}
|
onClick={handleAssignCreatedServiceToMe}
|
||||||
disabled={isCreating}
|
disabled={isCreating}
|
||||||
style={{
|
style={{
|
||||||
padding: '14px 18px',
|
padding: "14px 18px",
|
||||||
borderRadius: '12px',
|
borderRadius: "12px",
|
||||||
border: '1px solid var(--wine-red)',
|
border: "1px solid var(--wine-red)",
|
||||||
background: 'var(--wine-red)',
|
background: "var(--wine-red)",
|
||||||
color: 'white',
|
color: "white",
|
||||||
fontSize: '16px',
|
fontSize: "16px",
|
||||||
fontWeight: 'bold',
|
fontWeight: "bold",
|
||||||
cursor: isCreating ? 'not-allowed' : 'pointer',
|
cursor: isCreating ? "not-allowed" : "pointer",
|
||||||
opacity: isCreating ? 0.7 : 1
|
opacity: isCreating ? 0.7 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isCreating ? "Asignando..." : "Yo"}
|
{isCreating ? "Asignando..." : "Yo"}
|
||||||
@@ -350,23 +470,49 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
|||||||
onClick={handleShowCollaboratorAssignment}
|
onClick={handleShowCollaboratorAssignment}
|
||||||
disabled={isCreating || isLoadingServiceCollaborators}
|
disabled={isCreating || isLoadingServiceCollaborators}
|
||||||
style={{
|
style={{
|
||||||
padding: '14px 18px',
|
padding: "14px 18px",
|
||||||
borderRadius: '12px',
|
borderRadius: "12px",
|
||||||
border: serviceAssignmentMode === "collaborators" ? '1px solid var(--wine-red)' : '1px solid rgba(255,255,255,0.2)',
|
border:
|
||||||
background: serviceAssignmentMode === "collaborators" ? 'rgba(128, 0, 64, 0.35)' : 'rgba(255,255,255,0.05)',
|
serviceAssignmentMode === "collaborators"
|
||||||
color: 'white',
|
? "1px solid var(--wine-red)"
|
||||||
fontSize: '16px',
|
: "1px solid rgba(255,255,255,0.2)",
|
||||||
cursor: isCreating || isLoadingServiceCollaborators ? 'not-allowed' : 'pointer',
|
background:
|
||||||
opacity: isCreating || isLoadingServiceCollaborators ? 0.7 : 1
|
serviceAssignmentMode === "collaborators"
|
||||||
|
? "rgba(128, 0, 64, 0.35)"
|
||||||
|
: "rgba(255,255,255,0.05)",
|
||||||
|
color: "white",
|
||||||
|
fontSize: "16px",
|
||||||
|
cursor:
|
||||||
|
isCreating || isLoadingServiceCollaborators
|
||||||
|
? "not-allowed"
|
||||||
|
: "pointer",
|
||||||
|
opacity: isCreating || isLoadingServiceCollaborators ? 0.7 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isLoadingServiceCollaborators ? "Cargando colaboradores..." : "Uno o más colaboradores"}
|
{isLoadingServiceCollaborators
|
||||||
|
? "Cargando colaboradores..."
|
||||||
|
: "Uno o más colaboradores"}
|
||||||
</button>
|
</button>
|
||||||
{serviceAssignmentMode === "collaborators" && (
|
{serviceAssignmentMode === "collaborators" && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', width: '100%' }}>
|
<div
|
||||||
{serviceAssignmentCollaborators.length === 0 && !isLoadingServiceCollaborators && (
|
style={{
|
||||||
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', textAlign: 'center' }}>
|
display: "flex",
|
||||||
No encontramos colaboradores activos para esta organización.
|
flexDirection: "column",
|
||||||
|
gap: "10px",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{serviceAssignmentCollaborators.length === 0 &&
|
||||||
|
!isLoadingServiceCollaborators && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "rgba(255,255,255,0.7)",
|
||||||
|
fontSize: "14px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No encontramos colaboradores activos para esta
|
||||||
|
organización.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<CollaboratorPicker
|
<CollaboratorPicker
|
||||||
@@ -380,17 +526,28 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleAssignCreatedServiceToCollaborators}
|
onClick={handleAssignCreatedServiceToCollaborators}
|
||||||
disabled={isCreating || selectedServiceCollaboratorIds.length === 0}
|
disabled={
|
||||||
|
isCreating ||
|
||||||
|
selectedServiceCollaboratorIds.length === 0
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
padding: '14px 18px',
|
padding: "14px 18px",
|
||||||
borderRadius: '12px',
|
borderRadius: "12px",
|
||||||
border: '1px solid var(--wine-red)',
|
border: "1px solid var(--wine-red)",
|
||||||
background: 'var(--wine-red)',
|
background: "var(--wine-red)",
|
||||||
color: 'white',
|
color: "white",
|
||||||
fontSize: '16px',
|
fontSize: "16px",
|
||||||
fontWeight: 'bold',
|
fontWeight: "bold",
|
||||||
cursor: isCreating || selectedServiceCollaboratorIds.length === 0 ? 'not-allowed' : 'pointer',
|
cursor:
|
||||||
opacity: isCreating || selectedServiceCollaboratorIds.length === 0 ? 0.7 : 1
|
isCreating ||
|
||||||
|
selectedServiceCollaboratorIds.length === 0
|
||||||
|
? "not-allowed"
|
||||||
|
: "pointer",
|
||||||
|
opacity:
|
||||||
|
isCreating ||
|
||||||
|
selectedServiceCollaboratorIds.length === 0
|
||||||
|
? 0.7
|
||||||
|
: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isCreating ? "Asignando..." : "Asignar servicio"}
|
{isCreating ? "Asignando..." : "Asignar servicio"}
|
||||||
@@ -405,43 +562,49 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
|||||||
onClick={handleFinishWithoutAssignment}
|
onClick={handleFinishWithoutAssignment}
|
||||||
disabled={isCreating}
|
disabled={isCreating}
|
||||||
style={{
|
style={{
|
||||||
padding: '14px 18px',
|
padding: "14px 18px",
|
||||||
borderRadius: '12px',
|
borderRadius: "12px",
|
||||||
border: '1px solid rgba(255,255,255,0.2)',
|
border: "1px solid rgba(255,255,255,0.2)",
|
||||||
background: 'rgba(255,255,255,0.05)',
|
background: "rgba(255,255,255,0.05)",
|
||||||
color: 'white',
|
color: "white",
|
||||||
fontSize: '16px',
|
fontSize: "16px",
|
||||||
cursor: isCreating ? 'not-allowed' : 'pointer',
|
cursor: isCreating ? "not-allowed" : "pointer",
|
||||||
opacity: isCreating ? 0.7 : 1
|
opacity: isCreating ? 0.7 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Lo configuro después
|
Lo configuro después
|
||||||
</button>
|
</button>
|
||||||
<div style={{
|
<div
|
||||||
padding: '12px 14px',
|
style={{
|
||||||
borderRadius: '12px',
|
padding: "12px 14px",
|
||||||
background: 'rgba(245, 158, 11, 0.12)',
|
borderRadius: "12px",
|
||||||
border: '1px solid rgba(245, 158, 11, 0.35)',
|
background: "rgba(245, 158, 11, 0.12)",
|
||||||
color: '#fde68a',
|
border: "1px solid rgba(245, 158, 11, 0.35)",
|
||||||
fontSize: '14px',
|
color: "#fde68a",
|
||||||
|
fontSize: "14px",
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
textAlign: 'center'
|
textAlign: "center",
|
||||||
}}>
|
}}
|
||||||
Si lo configurás después, el servicio no va a estar disponible para reservas hasta que lo asignes a un colaborador con horarios cargados.
|
>
|
||||||
|
Si lo configurás después, el servicio no va a estar disponible
|
||||||
|
para reservas hasta que lo asignes a un colaborador con horarios
|
||||||
|
cargados.
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{serviceAssignmentError && (
|
{serviceAssignmentError && (
|
||||||
<div style={{
|
<div
|
||||||
padding: '12px 14px',
|
style={{
|
||||||
borderRadius: '12px',
|
padding: "12px 14px",
|
||||||
background: 'rgba(255, 99, 132, 0.12)',
|
borderRadius: "12px",
|
||||||
border: '1px solid rgba(255, 99, 132, 0.35)',
|
background: "rgba(255, 99, 132, 0.12)",
|
||||||
color: '#fecdd3',
|
border: "1px solid rgba(255, 99, 132, 0.35)",
|
||||||
fontSize: '14px',
|
color: "#fecdd3",
|
||||||
|
fontSize: "14px",
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
textAlign: 'center'
|
textAlign: "center",
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
{serviceAssignmentError}
|
{serviceAssignmentError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -450,5 +613,4 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface CalendarRowViewProps {
|
|||||||
|
|
||||||
type CalendarRowViewData = {
|
type CalendarRowViewData = {
|
||||||
name: string;
|
name: string;
|
||||||
|
startTime: number;
|
||||||
events: TXCalendarEvent[];
|
events: TXCalendarEvent[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -167,11 +168,13 @@ export default function CalendarRowView(props: CalendarRowViewProps) {
|
|||||||
} else {
|
} else {
|
||||||
auxData.push({
|
auxData.push({
|
||||||
name: time,
|
name: time,
|
||||||
|
startTime: item.startTime,
|
||||||
events: [item],
|
events: [item],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setData(auxData.sort((a, b) => a.name.localeCompare(b.name)));
|
// Sort by numeric hour, never by the formatted label: as strings "11:00 PM" < "5:00 PM".
|
||||||
|
setData(auxData.sort((a, b) => a.startTime - b.startTime));
|
||||||
}, [daySchedules]);
|
}, [daySchedules]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
.buttonFilter {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 10;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
top: 5px;
|
|
||||||
right: -10px;
|
|
||||||
background-color: transparent;
|
|
||||||
color: var(--gray-dark);
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: width 0.25s ease-in-out, height 0.25s ease-in-out;
|
|
||||||
|
|
||||||
width: 52px;
|
|
||||||
height: 52px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttonFilter > svg {
|
|
||||||
display: flex;
|
|
||||||
opacity: 1;
|
|
||||||
background: transparent;
|
|
||||||
animation: buttonFilterPulseShadow 1.5s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes buttonFilterPulseShadow {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
filter: drop-shadow(0px 2px 1px var(--gray-light));
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
filter: drop-shadow(0px 2px 4px var(--gray-dark));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"use client";
|
|
||||||
import { ReactNode } from "react";
|
|
||||||
import style from "./Filterable.module.css";
|
|
||||||
import FilterAltOutlinedIcon from "@mui/icons-material/FilterAltOutlined";
|
|
||||||
import { useFilterStore } from "@core/Store/Filter.Store";
|
|
||||||
|
|
||||||
interface FilterableProps {
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Filterable(props: FilterableProps): React.ReactElement {
|
|
||||||
const filter = useFilterStore();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{props.children}
|
|
||||||
<div
|
|
||||||
className={style.buttonFilter}
|
|
||||||
onClick={() => {
|
|
||||||
filter.toggle();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FilterAltOutlinedIcon sx={{ fontSize: "32px" }} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -27,15 +27,28 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerToggle {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--green-darkestX1);
|
color: var(--green-darkestX1);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggleIcon {
|
.toggleIcon {
|
||||||
@@ -45,13 +58,16 @@
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--white);
|
background: var(--white);
|
||||||
color: var(--green-darkestX1);
|
color: var(--green-darkestX1);
|
||||||
border: 1px solid var(--green-darkestX1);
|
border: 1px solid var(--green-darkestX1);
|
||||||
|
font-family: inherit;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { LineChart } from "@mui/x-charts/LineChart";
|
import { LineChart } from "@mui/x-charts/LineChart";
|
||||||
import { MyOranizationsView } from "@core/Models/Company.model";
|
import {
|
||||||
import { DashboardFinancialsResult, getDashboardFinancials } from "../../landing/dashboard/Dashboard.Service";
|
DashboardFinancialsResult,
|
||||||
|
getDashboardFinancials,
|
||||||
|
} from "../../landing/dashboard/Dashboard.Service";
|
||||||
import { loadCollaboratorsByCompanyId } from "../../admin/(organization-profile)/org/profile/[id]/collaborators/Collaborators.Service";
|
import { loadCollaboratorsByCompanyId } from "../../admin/(organization-profile)/org/profile/[id]/collaborators/Collaborators.Service";
|
||||||
import { CompanyEmployeesView } from "@models/Collaborators.model";
|
import { CompanyEmployeesView } from "@models/Collaborators.model";
|
||||||
import UnpaidIncomeCard from "../UnpaidIncomeCard/UnpaidIncomeCard";
|
import UnpaidIncomeCard from "../UnpaidIncomeCard/UnpaidIncomeCard";
|
||||||
import styles from "./FinancialWidget.module.css";
|
import styles from "./FinancialWidget.module.css";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import OpenInNewOutlinedIcon from "@mui/icons-material/OpenInNewOutlined";
|
||||||
|
import { useNavigation } from "@core/app/hooks/goto";
|
||||||
|
|
||||||
export interface FinancialWidgetProps {
|
export interface FinancialWidgetProps {
|
||||||
sessionUser: string;
|
sessionUser: string;
|
||||||
@@ -15,7 +19,12 @@ export interface FinancialWidgetProps {
|
|||||||
organizationName?: string;
|
organizationName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FinancialWidget({ sessionUser, selectedOrgId, organizationName }: FinancialWidgetProps) {
|
export default function FinancialWidget({
|
||||||
|
sessionUser,
|
||||||
|
selectedOrgId,
|
||||||
|
organizationName,
|
||||||
|
}: FinancialWidgetProps) {
|
||||||
|
const { goTo } = useNavigation();
|
||||||
const [selectedTimeframe, setSelectedTimeframe] = useState<"week" | "month" | "year">("month");
|
const [selectedTimeframe, setSelectedTimeframe] = useState<"week" | "month" | "year">("month");
|
||||||
const [selectedEmployeeId, setSelectedEmployeeId] = useState<string>("");
|
const [selectedEmployeeId, setSelectedEmployeeId] = useState<string>("");
|
||||||
const [collaborators, setCollaborators] = useState<CompanyEmployeesView[]>([]);
|
const [collaborators, setCollaborators] = useState<CompanyEmployeesView[]>([]);
|
||||||
@@ -27,10 +36,12 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedOrgId) {
|
if (selectedOrgId) {
|
||||||
loadCollaboratorsByCompanyId({ companyId: selectedOrgId }).then((res) => {
|
loadCollaboratorsByCompanyId({ companyId: selectedOrgId })
|
||||||
|
.then((res) => {
|
||||||
setCollaborators(res);
|
setCollaborators(res);
|
||||||
setSelectedEmployeeId(""); // reset employee filter when org changes
|
setSelectedEmployeeId(""); // reset employee filter when org changes
|
||||||
}).catch(console.error);
|
})
|
||||||
|
.catch(console.error);
|
||||||
}
|
}
|
||||||
}, [selectedOrgId, sessionUser]);
|
}, [selectedOrgId, sessionUser]);
|
||||||
|
|
||||||
@@ -43,14 +54,17 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
|
|||||||
sessionUser,
|
sessionUser,
|
||||||
companyId: selectedOrgId,
|
companyId: selectedOrgId,
|
||||||
timeframe: selectedTimeframe,
|
timeframe: selectedTimeframe,
|
||||||
employeeId: selectedEmployeeId || undefined
|
employeeId: selectedEmployeeId || undefined,
|
||||||
}).then((res) => {
|
})
|
||||||
|
.then((res) => {
|
||||||
setFinancials(res);
|
setFinancials(res);
|
||||||
}).catch((err) => {
|
})
|
||||||
|
.catch((err) => {
|
||||||
const errMsg = err.format ? err.format() : err.message;
|
const errMsg = err.format ? err.format() : err.message;
|
||||||
console.warn("Acceso denegado a métricas financieras: ", errMsg);
|
console.warn("Acceso denegado a métricas financieras: ", errMsg);
|
||||||
setError("No tienes permisos de administrador para esta organización.");
|
setError("No tienes permisos de administrador para esta organización.");
|
||||||
}).finally(() => setLoading(false));
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
}
|
}
|
||||||
}, [selectedOrgId, selectedTimeframe, selectedEmployeeId, sessionUser]);
|
}, [selectedOrgId, selectedTimeframe, selectedEmployeeId, sessionUser]);
|
||||||
|
|
||||||
@@ -64,32 +78,87 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
|
|||||||
const previous = financials.previousPeriodTotal;
|
const previous = financials.previousPeriodTotal;
|
||||||
|
|
||||||
if (previous === 0) {
|
if (previous === 0) {
|
||||||
if (current > 0) return <span className={styles.positive}>+100% vs periodo anterior</span>;
|
if (current > 0)
|
||||||
|
return <span className={styles.positive}>+100% vs periodo anterior</span>;
|
||||||
return <span className={styles.neutral}>Sin datos previos</span>;
|
return <span className={styles.neutral}>Sin datos previos</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const diff = current - previous;
|
const diff = current - previous;
|
||||||
const percent = (diff / previous) * 100;
|
const percent = (diff / previous) * 100;
|
||||||
|
|
||||||
if (percent > 0) return <span className={styles.positive}>+{percent.toFixed(1)}% vs periodo anterior</span>;
|
if (percent > 0)
|
||||||
if (percent < 0) return <span className={styles.negative}>{percent.toFixed(1)}% vs periodo anterior</span>;
|
return (
|
||||||
|
<span className={styles.positive}>+{percent.toFixed(1)}% vs periodo anterior</span>
|
||||||
|
);
|
||||||
|
if (percent < 0)
|
||||||
|
return (
|
||||||
|
<span className={styles.negative}>{percent.toFixed(1)}% vs periodo anterior</span>
|
||||||
|
);
|
||||||
return <span className={styles.neutral}>0% vs periodo anterior</span>;
|
return <span className={styles.neutral}>0% vs periodo anterior</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!selectedOrgId) return null;
|
if (!selectedOrgId) return null;
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<div className={styles.headerButton}>
|
||||||
|
<h3 className={styles.title}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.headerToggle}
|
||||||
|
onClick={() => setIsOpen((value) => !value)}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
>
|
||||||
|
Rendimiento Financiero {organizationName ? `- ${organizationName}` : ""}
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => goTo(`/landing/org/${selectedOrgId}`)}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
color: "var(--green-darkestX1)",
|
||||||
|
border: "1px solid var(--green-darkestX1)",
|
||||||
|
background: "var(--white)",
|
||||||
|
textDecoration: "none",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
}}
|
||||||
|
title="Ver perfil público de la organización"
|
||||||
|
>
|
||||||
|
<OpenInNewOutlinedIcon fontSize="small" />
|
||||||
|
Ver perfil
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.toggleIcon}
|
||||||
|
onClick={() => setIsOpen((value) => !value)}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-label={
|
||||||
|
isOpen ? "Colapsar métricas financieras" : "Expandir métricas financieras"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isOpen ? "−" : "+"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<button className={styles.headerButton} type="button" onClick={() => setIsOpen((value) => !value)}>
|
{header}
|
||||||
<h3 className={styles.title}>
|
|
||||||
Rendimiento Financiero {organizationName ? `- ${organizationName}` : ''}
|
|
||||||
</h3>
|
|
||||||
<span className={styles.toggleIcon}>{isOpen ? "−" : "+"}</span>
|
|
||||||
</button>
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div style={{ padding: '40px 20px', textAlign: 'center', color: '#6b7280' }}>
|
<div style={{ padding: "40px 20px", textAlign: "center", color: "#6b7280" }}>
|
||||||
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#374151' }}>Acceso Restringido</p>
|
<p style={{ fontWeight: "600", marginBottom: "8px", color: "#374151" }}>
|
||||||
|
Acceso Restringido
|
||||||
|
</p>
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -98,22 +167,17 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chartData = financials?.currentPeriodData || [];
|
const chartData = financials?.currentPeriodData || [];
|
||||||
const xAxisData = chartData.map(d => {
|
const xAxisData = chartData.map((d) => {
|
||||||
// format label nicer depending on timeframe
|
// format label nicer depending on timeframe
|
||||||
if (selectedTimeframe === "year") return dayjs(d.date).format("MMM YYYY");
|
if (selectedTimeframe === "year") return dayjs(d.date).format("MMM YYYY");
|
||||||
return dayjs(d.date).format("DD/MM");
|
return dayjs(d.date).format("DD/MM");
|
||||||
});
|
});
|
||||||
const seriesData = chartData.map(d => d.amount);
|
const seriesData = chartData.map((d) => d.amount);
|
||||||
const hasChartData = chartData.some(d => d.amount > 0);
|
const hasChartData = chartData.some((d) => d.amount > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<button className={styles.headerButton} type="button" onClick={() => setIsOpen((value) => !value)}>
|
{header}
|
||||||
<h3 className={styles.title}>
|
|
||||||
Rendimiento Financiero {organizationName ? `- ${organizationName}` : ''}
|
|
||||||
</h3>
|
|
||||||
<span className={styles.toggleIcon}>{isOpen ? "−" : "+"}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<>
|
<>
|
||||||
@@ -121,7 +185,9 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
|
|||||||
<select
|
<select
|
||||||
className={styles.select}
|
className={styles.select}
|
||||||
value={selectedTimeframe}
|
value={selectedTimeframe}
|
||||||
onChange={e => setSelectedTimeframe(e.target.value as "week" | "month" | "year")}
|
onChange={(e) =>
|
||||||
|
setSelectedTimeframe(e.target.value as "week" | "month" | "year")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<option value="week">Última Semana</option>
|
<option value="week">Última Semana</option>
|
||||||
<option value="month">Último Mes</option>
|
<option value="month">Último Mes</option>
|
||||||
@@ -131,11 +197,13 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
|
|||||||
<select
|
<select
|
||||||
className={styles.select}
|
className={styles.select}
|
||||||
value={selectedEmployeeId}
|
value={selectedEmployeeId}
|
||||||
onChange={e => setSelectedEmployeeId(e.target.value)}
|
onChange={(e) => setSelectedEmployeeId(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">Todos los Profesionales</option>
|
<option value="">Todos los Profesionales</option>
|
||||||
{collaborators.map(c => (
|
{collaborators.map((c) => (
|
||||||
<option key={c.id} value={c.id}>{c.fullName}</option>
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.fullName}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,21 +212,29 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
|
|||||||
<div className={styles.metricBox}>
|
<div className={styles.metricBox}>
|
||||||
<span className={styles.metricLabel}>Ingresos del Periodo</span>
|
<span className={styles.metricLabel}>Ingresos del Periodo</span>
|
||||||
<span className={styles.metricValue}>
|
<span className={styles.metricValue}>
|
||||||
{loading ? "..." : formatCurrency(financials?.currentPeriodTotal || 0)}
|
{loading
|
||||||
|
? "..."
|
||||||
|
: formatCurrency(financials?.currentPeriodTotal || 0)}
|
||||||
</span>
|
</span>
|
||||||
<div className={styles.metricComparison}>
|
<div className={styles.metricComparison}>
|
||||||
{!loading && renderComparison()}
|
{!loading && renderComparison()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{financials && (
|
{financials && <UnpaidIncomeCard value={financials.unpaidToday} />}
|
||||||
<UnpaidIncomeCard value={financials.unpaidToday} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className={styles.chartContainer}>
|
<div className={styles.chartContainer}>
|
||||||
<div style={{display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', color: '#9ca3af'}}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
height: "100%",
|
||||||
|
color: "#9ca3af",
|
||||||
|
}}
|
||||||
|
>
|
||||||
Cargando datos...
|
Cargando datos...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,57 +244,68 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
|
|||||||
<div className={styles.chartContainer}>
|
<div className={styles.chartContainer}>
|
||||||
<LineChart
|
<LineChart
|
||||||
height={350}
|
height={350}
|
||||||
xAxis={[{
|
xAxis={[
|
||||||
scaleType: 'point',
|
{
|
||||||
|
scaleType: "point",
|
||||||
data: xAxisData,
|
data: xAxisData,
|
||||||
}]}
|
},
|
||||||
|
]}
|
||||||
series={[
|
series={[
|
||||||
{
|
{
|
||||||
data: seriesData,
|
data: seriesData,
|
||||||
color: 'rgb(36, 169, 53)',
|
color: "rgb(36, 169, 53)",
|
||||||
area: true,
|
area: true,
|
||||||
showMark: true,
|
showMark: true,
|
||||||
curve: 'monotoneX',
|
curve: "monotoneX",
|
||||||
valueFormatter: (val: number | null) => val ? formatCurrency(val) : "0",
|
valueFormatter: (val: number | null) =>
|
||||||
|
val ? formatCurrency(val) : "0",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
grid={{ horizontal: true }}
|
grid={{ horizontal: true }}
|
||||||
margin={{ top: 20, bottom: 40, left: 70, right: 20 }}
|
margin={{ top: 20, bottom: 40, left: 70, right: 20 }}
|
||||||
sx={{
|
sx={{
|
||||||
'.MuiLineElement-root': {
|
".MuiLineElement-root": {
|
||||||
strokeWidth: 3,
|
strokeWidth: 3,
|
||||||
},
|
},
|
||||||
'.MuiAreaElement-root': {
|
".MuiAreaElement-root": {
|
||||||
fill: 'url(#gradient)',
|
fill: "url(#gradient)",
|
||||||
},
|
},
|
||||||
'.MuiChartsGrid-line': {
|
".MuiChartsGrid-line": {
|
||||||
strokeDasharray: '5 5',
|
strokeDasharray: "5 5",
|
||||||
stroke: '#e5e7eb',
|
stroke: "#e5e7eb",
|
||||||
},
|
},
|
||||||
'.MuiChartsAxis-line': {
|
".MuiChartsAxis-line": {
|
||||||
stroke: 'transparent',
|
stroke: "transparent",
|
||||||
},
|
},
|
||||||
'.MuiChartsAxis-tick': {
|
".MuiChartsAxis-tick": {
|
||||||
stroke: 'transparent',
|
stroke: "transparent",
|
||||||
},
|
},
|
||||||
'.MuiChartsAxis-tickLabel': {
|
".MuiChartsAxis-tickLabel": {
|
||||||
fill: '#9ca3af',
|
fill: "#9ca3af",
|
||||||
fontFamily: 'inherit',
|
fontFamily: "inherit",
|
||||||
fontSize: '12px',
|
fontSize: "12px",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
},
|
},
|
||||||
'.MuiMarkElement-root': {
|
".MuiMarkElement-root": {
|
||||||
stroke: 'rgb(36, 169, 53)',
|
stroke: "rgb(36, 169, 53)",
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
fill: '#ffffff',
|
fill: "#ffffff",
|
||||||
scale: '1.2',
|
scale: "1.2",
|
||||||
}
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="gradient" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="gradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="rgb(36, 169, 53)" stopOpacity={0.3}/>
|
<stop
|
||||||
<stop offset="95%" stopColor="rgb(36, 169, 53)" stopOpacity={0.0}/>
|
offset="5%"
|
||||||
|
stopColor="rgb(36, 169, 53)"
|
||||||
|
stopOpacity={0.3}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stopColor="rgb(36, 169, 53)"
|
||||||
|
stopOpacity={0.0}
|
||||||
|
/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
|
|||||||
@@ -24,6 +24,32 @@
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.locationTitleFilter {
|
||||||
|
padding-right: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterButton {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 12px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--gray-dark);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterButton:hover {
|
||||||
|
color: var(--black);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.locationContainer {
|
.locationContainer {
|
||||||
left: 6px;
|
left: 6px;
|
||||||
|
|||||||
@@ -2,18 +2,22 @@
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import style from "./Location.module.css";
|
import style from "./Location.module.css";
|
||||||
import { useLocationStore } from "@store/Location.Store";
|
import { useLocationStore } from "@store/Location.Store";
|
||||||
|
import { useFilterStore } from "@store/Filter.Store";
|
||||||
import Breadcrumbs from "@mui/material/Breadcrumbs";
|
import Breadcrumbs from "@mui/material/Breadcrumbs";
|
||||||
import Link from "@mui/material/Link";
|
import Link from "@mui/material/Link";
|
||||||
import HomeIcon from "@mui/icons-material/Home";
|
import HomeIcon from "@mui/icons-material/Home";
|
||||||
|
import FilterAltOutlinedIcon from "@mui/icons-material/FilterAltOutlined";
|
||||||
|
|
||||||
export interface LocationProps {
|
export interface LocationProps {
|
||||||
bottomSeperation?: string;
|
bottomSeperation?: string;
|
||||||
|
showFilter?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Location(props: LocationProps) {
|
export default function Location(props: LocationProps) {
|
||||||
const { bottomSeperation = "55px" } = props;
|
const { bottomSeperation = "55px", showFilter = false } = props;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const location = useLocationStore();
|
const location = useLocationStore();
|
||||||
|
const filter = useFilterStore();
|
||||||
|
|
||||||
const goTo = (path: string) => {
|
const goTo = (path: string) => {
|
||||||
router.push(path);
|
router.push(path);
|
||||||
@@ -48,10 +52,26 @@ export default function Location(props: LocationProps) {
|
|||||||
))}
|
))}
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.locationTitle}>
|
<div
|
||||||
|
className={
|
||||||
|
showFilter
|
||||||
|
? `${style.locationTitle} ${style.locationTitleFilter}`
|
||||||
|
: style.locationTitle
|
||||||
|
}
|
||||||
|
>
|
||||||
{location.last()?.icon}
|
{location.last()?.icon}
|
||||||
<h1>{location.last()?.title}</h1>
|
<h1>{location.last()?.title}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
{showFilter && (
|
||||||
|
<button
|
||||||
|
className={style.filterButton}
|
||||||
|
onClick={() => filter.toggle()}
|
||||||
|
aria-label="Filtrar"
|
||||||
|
title="Filtrar"
|
||||||
|
>
|
||||||
|
<FilterAltOutlinedIcon sx={{ fontSize: "32px" }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import classNames from "classnames";
|
|||||||
import { useNavigation } from "@core/app/hooks/goto";
|
import { useNavigation } from "@core/app/hooks/goto";
|
||||||
|
|
||||||
import ShareOutlinedIcon from "@mui/icons-material/ShareOutlined";
|
import ShareOutlinedIcon from "@mui/icons-material/ShareOutlined";
|
||||||
|
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||||
import Dialog from "@mui/material/Dialog";
|
import Dialog from "@mui/material/Dialog";
|
||||||
import DialogContent from "@mui/material/DialogContent";
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
import DialogTitle from "@mui/material/DialogTitle";
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
@@ -69,6 +70,14 @@ export default function OrganizationHeader(props: OrganizationHeaderParams): Rea
|
|||||||
icon: <ShareOutlinedIcon />,
|
icon: <ShareOutlinedIcon />,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
menu.add({
|
||||||
|
text: "Ver perfil",
|
||||||
|
onClick: () => {
|
||||||
|
goTo(`/landing/org/${props.data.id}`);
|
||||||
|
},
|
||||||
|
icon: <OpenInNewIcon />,
|
||||||
|
});
|
||||||
|
|
||||||
menu.add({
|
menu.add({
|
||||||
text: "Notificaciones",
|
text: "Notificaciones",
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Grid2, IconButton, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import turnosXpressTheme from "@core/app/theme/turnosXpress";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import { ThemeProvider } from "@emotion/react";
|
||||||
|
import { PopoverPicker } from "@components/ColorPicker/ColorPicker";
|
||||||
|
import ImagePicker from "@components/ImagePicker/ImagePicker";
|
||||||
|
import { useAlert } from "@core/Store/Alert.Store";
|
||||||
|
import { useSessionStore } from "@core/Store/Sesion.Store";
|
||||||
|
import { useOrganizationHeaderStore } from "@core/Store/OrganizationHeader.Store";
|
||||||
|
import { updateOrganizationColors, uploadHeaderImage } from "@services/Header.Edit.Service";
|
||||||
|
import { UpdateCompanyParams } from "@models/Company.model";
|
||||||
|
|
||||||
|
interface HeaderEditPopupProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
companyId: string;
|
||||||
|
headerColor: string;
|
||||||
|
headerFontColor: string;
|
||||||
|
headerFontShadowColor: string;
|
||||||
|
headerImage: string;
|
||||||
|
onSaved: () => void;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeaderEditPopup(props: HeaderEditPopupProps) {
|
||||||
|
const { open, onClose, companyId, headerColor, headerFontColor, headerFontShadowColor, headerImage, onSaved, onRefresh } = props;
|
||||||
|
const alert = useAlert();
|
||||||
|
const SessionInfo = useSessionStore();
|
||||||
|
const organizationHeader = useOrganizationHeaderStore();
|
||||||
|
|
||||||
|
const [headerColorState, setHeaderColorState] = useState("");
|
||||||
|
const [headerFontColorState, setHeaderFontColorState] = useState("");
|
||||||
|
const [headerFontShadowColorState, setHeaderFontShadowColorState] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Sync local state when values change externally
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setHeaderColorState(headerColor || "");
|
||||||
|
setHeaderFontColorState(headerFontColor || "");
|
||||||
|
setHeaderFontShadowColorState(headerFontShadowColor || "");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [open, headerColor, headerFontColor, headerFontShadowColor]);
|
||||||
|
|
||||||
|
const handleSaveColors = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updData: UpdateCompanyParams = {
|
||||||
|
id: companyId,
|
||||||
|
headerColor: headerColorState,
|
||||||
|
headerFontColor: headerFontColorState,
|
||||||
|
headerFontShadowColor: headerFontShadowColorState,
|
||||||
|
sessionUser: SessionInfo.userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateOrganizationColors(updData);
|
||||||
|
|
||||||
|
organizationHeader.setHeaderColor(headerColorState);
|
||||||
|
organizationHeader.setHeaderFontColor(headerFontColorState);
|
||||||
|
organizationHeader.setHeaderFontShadowColor(headerFontShadowColorState);
|
||||||
|
|
||||||
|
alert.showSuccess("Colores actualizados");
|
||||||
|
onSaved();
|
||||||
|
onRefresh?.();
|
||||||
|
} catch (error) {
|
||||||
|
const formatted = error instanceof Error ? error.message : "Error al guardar los colores";
|
||||||
|
alert.showError(formatted || "Error al guardar los colores");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("companyId", companyId);
|
||||||
|
formData.append("sessionUser", SessionInfo.userId);
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
uploadHeaderImage(formData).then(() => {
|
||||||
|
alert.showSuccess("Imagen actualizada");
|
||||||
|
onSaved();
|
||||||
|
onRefresh?.();
|
||||||
|
}).catch((error) => {
|
||||||
|
const formatted = error instanceof Error ? error.message : "Error al guardar la imagen";
|
||||||
|
alert.showError(formatted || "Error al guardar la imagen");
|
||||||
|
}).finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const formatted = error instanceof Error ? error.message : "Error al guardar la imagen";
|
||||||
|
alert.showError(formatted || "Error al guardar la imagen");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={turnosXpressTheme}>
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
borderRadius: 2,
|
||||||
|
height: 500,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
Personalizar
|
||||||
|
<IconButton onClick={onClose} size="small">
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent dividers sx={{ p: 3 }}>
|
||||||
|
{/* Colores */}
|
||||||
|
<Grid2 container spacing={1} mb={3}>
|
||||||
|
<Grid2 size={4}>
|
||||||
|
<PopoverPicker
|
||||||
|
placeholder="Fondo"
|
||||||
|
color={headerColorState}
|
||||||
|
onChange={(color: string) => {
|
||||||
|
setHeaderColorState(color);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid2>
|
||||||
|
<Grid2 size={4}>
|
||||||
|
<PopoverPicker
|
||||||
|
placeholder="Fuente"
|
||||||
|
color={headerFontColorState}
|
||||||
|
onChange={(color: string) => {
|
||||||
|
setHeaderFontColorState(color);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid2>
|
||||||
|
<Grid2 size={4}>
|
||||||
|
<PopoverPicker
|
||||||
|
placeholder="Sombra"
|
||||||
|
color={headerFontShadowColorState}
|
||||||
|
onChange={(color: string) => {
|
||||||
|
setHeaderFontShadowColorState(color);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid2>
|
||||||
|
</Grid2>
|
||||||
|
|
||||||
|
{/* Imagen */}
|
||||||
|
<ImagePicker
|
||||||
|
src={headerImage}
|
||||||
|
alt="Cabecera"
|
||||||
|
style={{ width: "100%", height: 120, borderRadius: 8 }}
|
||||||
|
readOnly={false}
|
||||||
|
onChange={handleImageChange}
|
||||||
|
imageSizeSrc="/org-header-size.webp"
|
||||||
|
mbLimit={5}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<Button onClick={onClose}>Cancelar</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveColors}
|
||||||
|
autoFocus
|
||||||
|
color="success"
|
||||||
|
variant="contained"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Guardar
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import HeaderEditPopup from "./HeaderEditPopup";
|
||||||
|
import { useSessionStore } from "@core/Store/Sesion.Store";
|
||||||
|
import { checkOrgAdmin } from "@services/Header.Edit.Service";
|
||||||
|
|
||||||
|
interface OrganizationHeaderEditProps {
|
||||||
|
companyId: string;
|
||||||
|
headerColor?: string;
|
||||||
|
headerFontColor?: string;
|
||||||
|
headerFontShadowColor?: string;
|
||||||
|
headerImage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrganizationHeaderEdit({
|
||||||
|
companyId,
|
||||||
|
headerColor = "",
|
||||||
|
headerFontColor = "",
|
||||||
|
headerFontShadowColor = "",
|
||||||
|
headerImage = "",
|
||||||
|
}: OrganizationHeaderEditProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const SessionInfo = useSessionStore();
|
||||||
|
const [isOrgAdmin, setIsOrgAdmin] = useState(false);
|
||||||
|
const [showEditPopup, setShowEditPopup] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!SessionInfo.userId || !companyId) {
|
||||||
|
setIsOrgAdmin(false);
|
||||||
|
const existing = document.getElementById("edit-header-btn");
|
||||||
|
if (existing) existing.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
checkOrgAdmin(companyId, SessionInfo.userId)
|
||||||
|
.then((isAdmin) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setIsOrgAdmin(isAdmin);
|
||||||
|
|
||||||
|
const wrapper = document.getElementById("header-wrapper");
|
||||||
|
if (!wrapper) return;
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
const existingBtn = document.getElementById("edit-header-btn");
|
||||||
|
if (existingBtn) existingBtn.remove();
|
||||||
|
|
||||||
|
const btn = document.createElement("div");
|
||||||
|
btn.id = "edit-header-btn";
|
||||||
|
btn.innerHTML = `
|
||||||
|
<button style="
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0,0,0,0.45);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
transition: background 0.2s;
|
||||||
|
" onmouseover="this.style.background='rgba(0,0,0,0.7)'" onmouseout="this.style.background='rgba(0,0,0,0.45)'">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
btn.querySelector("button")!.addEventListener("click", () =>
|
||||||
|
setShowEditPopup(true),
|
||||||
|
);
|
||||||
|
wrapper.appendChild(btn);
|
||||||
|
} else {
|
||||||
|
const existingBtn = document.getElementById("edit-header-btn");
|
||||||
|
if (existingBtn) existingBtn.remove();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setIsOrgAdmin(false);
|
||||||
|
|
||||||
|
const existingBtn = document.getElementById("edit-header-btn");
|
||||||
|
if (existingBtn) existingBtn.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
const existing = document.getElementById("edit-header-btn");
|
||||||
|
if (existing) existing.remove();
|
||||||
|
};
|
||||||
|
}, [SessionInfo.userId, companyId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isOrgAdmin && (
|
||||||
|
<HeaderEditPopup
|
||||||
|
open={showEditPopup}
|
||||||
|
onClose={() => setShowEditPopup(false)}
|
||||||
|
companyId={companyId}
|
||||||
|
headerColor={headerColor}
|
||||||
|
headerFontColor={headerFontColor}
|
||||||
|
headerFontShadowColor={headerFontShadowColor}
|
||||||
|
headerImage={headerImage}
|
||||||
|
onSaved={() => {
|
||||||
|
setShowEditPopup(false);
|
||||||
|
}}
|
||||||
|
onRefresh={() => router.refresh()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,10 +9,11 @@ export interface PaginationControlProps {
|
|||||||
current: number;
|
current: number;
|
||||||
size: number;
|
size: number;
|
||||||
max: number;
|
max: number;
|
||||||
|
inheritColor?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PaginationControls(props: PaginationControlProps) {
|
export default function PaginationControls(props: PaginationControlProps) {
|
||||||
const { current, size, max, onChange = () => {} } = props;
|
const { current, size, max, onChange = () => {}, inheritColor = false } = props;
|
||||||
|
|
||||||
useEffect(() => {}, [current]);
|
useEffect(() => {}, [current]);
|
||||||
|
|
||||||
@@ -36,14 +37,20 @@ export default function PaginationControls(props: PaginationControlProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.paginationControls}>
|
<div
|
||||||
<IconButton onClick={handlePrevious}>
|
className={style.paginationControls}
|
||||||
|
style={inheritColor ? { color: "var(--white)" } : undefined}
|
||||||
|
>
|
||||||
|
<IconButton color={inheritColor ? "inherit" : undefined} onClick={handlePrevious}>
|
||||||
<ArrowBackwardIcon sx={{ fontSize: 24 }} />
|
<ArrowBackwardIcon sx={{ fontSize: 24 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<span className={style.paginationLabel}>
|
<span
|
||||||
|
className={style.paginationLabel}
|
||||||
|
style={inheritColor ? { color: "var(--white)" } : undefined}
|
||||||
|
>
|
||||||
{current} / {max}
|
{current} / {max}
|
||||||
</span>
|
</span>
|
||||||
<IconButton onClick={handleNext}>
|
<IconButton color={inheritColor ? "inherit" : undefined} onClick={handleNext}>
|
||||||
<ArrowForwardIcon sx={{ fontSize: 24 }} />
|
<ArrowForwardIcon sx={{ fontSize: 24 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { PublicServiceView } from "@models/Service.model";
|
|||||||
import style from "./style.module.css";
|
import style from "./style.module.css";
|
||||||
import { getServiceImage } from "@core/helpers/getServiceImage";
|
import { getServiceImage } from "@core/helpers/getServiceImage";
|
||||||
import ServiceRatingSummary from "@components/ServiceRatingSummary/ServiceRatingSummary";
|
import ServiceRatingSummary from "@components/ServiceRatingSummary/ServiceRatingSummary";
|
||||||
|
import EditIcon from "@mui/icons-material/Edit";
|
||||||
|
|
||||||
export type FindResultsViewMode = "grid" | "card";
|
export type FindResultsViewMode = "grid" | "card";
|
||||||
|
|
||||||
@@ -14,6 +15,8 @@ export interface ProductItemProps {
|
|||||||
width?: number | string;
|
width?: number | string;
|
||||||
viewMode?: FindResultsViewMode;
|
viewMode?: FindResultsViewMode;
|
||||||
dragging?: boolean;
|
dragging?: boolean;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
onImageEdit?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductItem(props: ProductItemProps) {
|
export default function ProductItem(props: ProductItemProps) {
|
||||||
@@ -21,6 +24,8 @@ export default function ProductItem(props: ProductItemProps) {
|
|||||||
const { product } = props;
|
const { product } = props;
|
||||||
const { viewMode = "card" } = props;
|
const { viewMode = "card" } = props;
|
||||||
const { dragging = false } = props;
|
const { dragging = false } = props;
|
||||||
|
const { isAdmin = false } = props;
|
||||||
|
const { onImageEdit } = props;
|
||||||
const { goTo } = useNavigation();
|
const { goTo } = useNavigation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -38,7 +43,7 @@ export default function ProductItem(props: ProductItemProps) {
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
padding: "10px",
|
padding: "10px",
|
||||||
border: "solid 1px rgba(0, 0, 0, 0.1)",
|
border: "solid 1px rgba(0, 0, 0, 0.1)",
|
||||||
cursor: "pointer",
|
cursor: isAdmin ? "default" : "pointer",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -58,6 +63,11 @@ export default function ProductItem(props: ProductItemProps) {
|
|||||||
display: "block",
|
display: "block",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{isAdmin && (
|
||||||
|
<span className={style.adminEditButton} onClick={(e) => { e.stopPropagation(); onImageEdit?.(); }}>
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<ServiceRatingSummary
|
<ServiceRatingSummary
|
||||||
serviceId={product.id}
|
serviceId={product.id}
|
||||||
variant="card"
|
variant="card"
|
||||||
|
|||||||
@@ -8,6 +8,32 @@
|
|||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.adminEditButton {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adminEditButton:hover {
|
||||||
|
background: var(--wine-red);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adminEditButton:hover svg {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
.cardHeader {
|
.cardHeader {
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import OfferContent from "../offer-appointments/OfferContent";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Agenda Online para Profesionales | Reservas Automáticas - TurnosXpress",
|
||||||
|
description: "Agendá citas online y eliminá el ida y vuelta de mensajes. Una agenda online automática, con recordatorios y reservas 24/7 para tu negocio.",
|
||||||
|
keywords: ["agenda online", "agenda digital", "agenda de citas online", "reservas online", "agenda para profesionales"],
|
||||||
|
openGraph: {
|
||||||
|
title: "Agenda Online para Profesionales | Reservas - TurnosXpress",
|
||||||
|
description: "Tu agenda online con reservas automáticas, recordatorios y gestión simple. ¡Empezá gratis!",
|
||||||
|
type: "website",
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AgendaOnlinePage() {
|
||||||
|
return (
|
||||||
|
<OfferContent
|
||||||
|
heroTitle="Tu agenda online trabaja mientras vos trabajás"
|
||||||
|
heroSubtitle="Una agenda online que recibe reservas automáticamente, avisa a tus clientes y respeta tus horarios. Sin papeles ni mensajes a deshora."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,39 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
||||||
import CategoriesExplorer from "@core/app/components/Home/CategoriesExplorer/page";
|
import CategoriesExplorer from "@core/app/components/Home/CategoriesExplorer/page";
|
||||||
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
|
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
|
||||||
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
||||||
import style from "./style.module.css";
|
import style from "./style.module.css";
|
||||||
|
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Explorá categorías y encontrá tu próximo turno | TurnosXpress",
|
||||||
|
description:
|
||||||
|
"Categorías de servicios por rubro en TurnosXpress. Elegí el tipo de servicio que necesitás y filtrá por ubicación para encontrar opciones cercanas, rápidas y organizadas.",
|
||||||
|
alternates: {
|
||||||
|
canonical: `${baseUrl}/landing/categories`,
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
title: "Explorá categorías y encontrá tu próximo turno | TurnosXpress",
|
||||||
|
description:
|
||||||
|
"Categorías de servicios por rubro en TurnosXpress. Elegí el tipo de servicio que necesitás y filtrá por ubicación.",
|
||||||
|
url: `${baseUrl}/landing/categories`,
|
||||||
|
siteName: "TurnosXpress",
|
||||||
|
locale: "es_AR",
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary",
|
||||||
|
title: "Explorá categorías y encontrá tu próximo turno | TurnosXpress",
|
||||||
|
description: "Categorías de servicios por rubro en TurnosXpress.",
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default function CategoriesPage() {
|
export default function CategoriesPage() {
|
||||||
return (
|
return (
|
||||||
<AnimatedContainer
|
<AnimatedContainer
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
||||||
|
import ExploreCategories from "@core/app/components/ExploreCategories/ExploreCategories";
|
||||||
|
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
|
||||||
|
import useContainerHeight from "@core/app/hooks/ContainerHeight";
|
||||||
|
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
||||||
|
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
|
||||||
|
|
||||||
|
export default function GetAppointmentsContent() {
|
||||||
|
const containerHeight = useContainerHeight();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedContainer
|
||||||
|
color="#aeaeae"
|
||||||
|
minSize={150}
|
||||||
|
maxSize={500}
|
||||||
|
items={10}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ScrollToTop />
|
||||||
|
<HeaderConfProvider />
|
||||||
|
<DefaultLoginAction />
|
||||||
|
<div className="homeCentered">
|
||||||
|
<h1 className="homeTitleHeader">Quiero reservar turno</h1>
|
||||||
|
<p className="homeResumeHeader">
|
||||||
|
Reservar un turno es muy fácil. Solo tenés que explorar nuestras categorías y
|
||||||
|
buscar el servicio más adecuado para vos. Una vez que ingreses al servicio,
|
||||||
|
podrás realizar la reserva. Tené en cuenta que, para poder reservar, debés estar
|
||||||
|
registrado en nuestro sitio web.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="homeCentered">
|
||||||
|
<ExploreCategories containerHeight={containerHeight} />
|
||||||
|
|
||||||
|
<div style={{ height: "100px" }}> </div>
|
||||||
|
</div>
|
||||||
|
</AnimatedContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,44 +1,35 @@
|
|||||||
"use client";
|
import { Metadata } from "next";
|
||||||
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
import GetAppointmentsContent from "./GetAppointmentsContent";
|
||||||
import ExploreCategories from "@core/app/components/ExploreCategories/ExploreCategories";
|
|
||||||
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar";
|
||||||
import useContainerHeight from "@core/app/hooks/ContainerHeight";
|
|
||||||
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
export const metadata: Metadata = {
|
||||||
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
|
title: "Quiero reservar turno | TurnosXpress",
|
||||||
|
description:
|
||||||
|
"Reservá tu turno en TurnosXpress: explorá categorías y buscá el servicio más adecuado para vos. Registrate y reservá en minutos.",
|
||||||
|
alternates: {
|
||||||
|
canonical: `${baseUrl}/landing/get-appointments`,
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
title: "Quiero reservar turno | TurnosXpress",
|
||||||
|
description:
|
||||||
|
"Explorá categorías, buscá el servicio más adecuado para vos y reservá tu turno en TurnosXpress.",
|
||||||
|
url: `${baseUrl}/landing/get-appointments`,
|
||||||
|
siteName: "TurnosXpress",
|
||||||
|
locale: "es_AR",
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary",
|
||||||
|
title: "Quiero reservar turno | TurnosXpress",
|
||||||
|
description: "Explorá categorías y reservá tu turno en TurnosXpress.",
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default function GetAppointmentsPage() {
|
export default function GetAppointmentsPage() {
|
||||||
const containerHeight = useContainerHeight();
|
return <GetAppointmentsContent />;
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatedContainer
|
|
||||||
color="#aeaeae"
|
|
||||||
minSize={150}
|
|
||||||
maxSize={500}
|
|
||||||
items={10}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ScrollToTop />
|
|
||||||
<HeaderConfProvider />
|
|
||||||
<DefaultLoginAction />
|
|
||||||
<div className="homeCentered">
|
|
||||||
<h1 className="homeTitleHeader">Quiero reservar turno</h1>
|
|
||||||
<p className="homeResumeHeader">
|
|
||||||
Reservar un turno es muy fácil. Solo tenés que explorar nuestras categorías y
|
|
||||||
buscar el servicio más adecuado para vos. Una vez que ingreses al servicio,
|
|
||||||
podrás realizar la reserva. Tené en cuenta que, para poder reservar, debés estar
|
|
||||||
registrado en nuestro sitio web.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="homeCentered">
|
|
||||||
<ExploreCategories containerHeight={containerHeight} />
|
|
||||||
|
|
||||||
<div style={{ height: "100px" }}> </div>
|
|
||||||
</div>
|
|
||||||
</AnimatedContainer>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import style from "./help.module.css";
|
||||||
|
import { videoCategories, Video, getTotalVideoCount } from "./videoData";
|
||||||
|
import VideoModal from "./components/VideoModal";
|
||||||
|
import AnimatedContainer from "@core/app/components/AnimatedConainer/AnimatedContainer";
|
||||||
|
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
|
||||||
|
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
|
||||||
|
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function HelpContent() {
|
||||||
|
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [activeCategory, setActiveCategory] = useState<string>("all");
|
||||||
|
const [watchedVideos, setWatchedVideos] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const totalVideos = getTotalVideoCount();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const watched = localStorage.getItem("tx-help-watched");
|
||||||
|
if (watched) {
|
||||||
|
setWatchedVideos(new Set(JSON.parse(watched)));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleVideoClick = (video: Video) => {
|
||||||
|
setSelectedVideo(video);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
const newWatched = new Set(watchedVideos);
|
||||||
|
newWatched.add(video.id);
|
||||||
|
setWatchedVideos(newWatched);
|
||||||
|
localStorage.setItem("tx-help-watched", JSON.stringify([...newWatched]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVideoNavigate = (video: Video) => {
|
||||||
|
// Mark as watched without opening the modal, then let the Link navigate
|
||||||
|
const newWatched = new Set(watchedVideos);
|
||||||
|
newWatched.add(video.id);
|
||||||
|
setWatchedVideos(newWatched);
|
||||||
|
localStorage.setItem("tx-help-watched", JSON.stringify([...newWatched]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setTimeout(() => setSelectedVideo(null), 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCategoryClick = (categoryId: string) => {
|
||||||
|
setActiveCategory(categoryId);
|
||||||
|
if (categoryId !== "all") {
|
||||||
|
const element = document.getElementById(`category-${categoryId}`);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const progressPercent = Math.round((watchedVideos.size / totalVideos) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatedContainer
|
||||||
|
color="#aeaeae"
|
||||||
|
minSize={150}
|
||||||
|
maxSize={500}
|
||||||
|
items={10}
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
width: "100%",
|
||||||
|
background: "var(--white)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ScrollToTop />
|
||||||
|
<HeaderConfProvider />
|
||||||
|
<DefaultLoginAction />
|
||||||
|
|
||||||
|
<div className={style.container}>
|
||||||
|
{/* Hero Section */}
|
||||||
|
<div className={style.hero}>
|
||||||
|
<div className={style.heroIcon}>
|
||||||
|
<svg
|
||||||
|
width="36"
|
||||||
|
height="36"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polygon points="5 3 19 12 5 21 5 3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className={style.heroTitle}>Centro de Ayuda</h1>
|
||||||
|
<p className={style.heroSubtitle}>
|
||||||
|
Aprende a usar TurnosXpress paso a paso con nuestros tutoriales en video.
|
||||||
|
Desde la configuración inicial hasta las funciones más avanzadas,
|
||||||
|
tenemos todo lo que necesitas para aprovechar al máximo la plataforma.
|
||||||
|
</p>
|
||||||
|
<div className={style.heroStats}>
|
||||||
|
<div className={style.statItem}>
|
||||||
|
<span className={style.statNumber}>{totalVideos}</span>
|
||||||
|
<span className={style.statLabel}>Video tutoriales</span>
|
||||||
|
</div>
|
||||||
|
<div className={style.statItem}>
|
||||||
|
<span className={style.statNumber}>{videoCategories.length}</span>
|
||||||
|
<span className={style.statLabel}>Categorías</span>
|
||||||
|
</div>
|
||||||
|
<div className={style.statItem}>
|
||||||
|
<span className={style.statNumber}>{watchedVideos.size}</span>
|
||||||
|
<span className={style.statLabel}>Vistos</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Section */}
|
||||||
|
{watchedVideos.size > 0 && (
|
||||||
|
<div className={style.progressSection}>
|
||||||
|
<h2 className={style.progressTitle}>
|
||||||
|
Tu progreso de aprendizaje
|
||||||
|
</h2>
|
||||||
|
<p className={style.progressText}>
|
||||||
|
Has visto {watchedVideos.size} de {totalVideos} tutoriales.
|
||||||
|
{progressPercent === 100
|
||||||
|
? " ¡Felicidades! Has completado todos los tutoriales."
|
||||||
|
: ` ¡Sigue así, te faltan ${totalVideos - watchedVideos.size}!`}
|
||||||
|
</p>
|
||||||
|
<div className={style.progressBar}>
|
||||||
|
<div
|
||||||
|
className={style.progressFill}
|
||||||
|
style={{ width: `${progressPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Category Navigation */}
|
||||||
|
<div className={style.categoryNav}>
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
activeCategory === "all"
|
||||||
|
? style.categoryBtnActive
|
||||||
|
: style.categoryBtn
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveCategory("all");
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Todos
|
||||||
|
</button>
|
||||||
|
{videoCategories.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat.id}
|
||||||
|
className={
|
||||||
|
activeCategory === cat.id
|
||||||
|
? style.categoryBtnActive
|
||||||
|
: style.categoryBtn
|
||||||
|
}
|
||||||
|
onClick={() => handleCategoryClick(cat.id)}
|
||||||
|
>
|
||||||
|
{cat.icon} {cat.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Learning Path */}
|
||||||
|
<div className={style.learningPath}>
|
||||||
|
<span className={style.learningPathIcon}>
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 16v-4" />
|
||||||
|
<path d="M12 8h.01" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className={style.learningPathText}>
|
||||||
|
<strong>Recomendación:</strong> Sigue el orden de las categorías para
|
||||||
|
un aprendizaje progresivo. Comienza con "Primeros Pasos" y
|
||||||
|
avanza según tus necesidades.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video Categories */}
|
||||||
|
{videoCategories.map((category) => (
|
||||||
|
<div
|
||||||
|
key={category.id}
|
||||||
|
id={`category-${category.id}`}
|
||||||
|
className={style.categorySection}
|
||||||
|
>
|
||||||
|
<div className={style.categoryHeader}>
|
||||||
|
<div
|
||||||
|
className={style.categoryIcon}
|
||||||
|
style={{ background: category.color }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: "1.5rem" }}>{category.icon}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className={style.categoryTitle}>{category.name}</h2>
|
||||||
|
<p className={style.categoryDescription}>
|
||||||
|
{category.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={style.categoryCount}>
|
||||||
|
{category.videos.length} videos
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.videoGrid}>
|
||||||
|
{category.videos.map((video, idx) => (
|
||||||
|
<div
|
||||||
|
key={video.id}
|
||||||
|
className={style.videoCard}
|
||||||
|
onClick={() => handleVideoClick(video)}
|
||||||
|
>
|
||||||
|
<div className={style.videoThumbnail}>
|
||||||
|
<img
|
||||||
|
src={video.thumbnail}
|
||||||
|
alt={video.title}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div className={style.playOverlay}>
|
||||||
|
<div className={style.playButton}>
|
||||||
|
<div className={style.playIcon} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{watchedVideos.has(video.id) && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "8px",
|
||||||
|
left: "8px",
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, #2aaa2a, #008011)",
|
||||||
|
color: "white",
|
||||||
|
padding: "3px 8px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "3px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✓ Visto
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={style.videoContent}>
|
||||||
|
<h3 className={style.videoTitle}>{video.title}</h3>
|
||||||
|
<p className={style.videoDescription}>
|
||||||
|
{video.description}
|
||||||
|
</p>
|
||||||
|
<div className={style.videoMeta}>
|
||||||
|
<div className={style.videoStep}>
|
||||||
|
<span className={style.stepNumber}>
|
||||||
|
{idx + 1}
|
||||||
|
</span>
|
||||||
|
Paso {idx + 1} de {category.videos.length}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/landing/help/video/${video.id}`}
|
||||||
|
className={style.videoPageLink}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleVideoNavigate(video);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ver tutorial completo
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* CTA Section */}
|
||||||
|
<div className={style.ctaSection}>
|
||||||
|
<h2 className={style.ctaTitle}>¿Necesitas más ayuda?</h2>
|
||||||
|
<p className={style.ctaText}>
|
||||||
|
Si no encontrás lo que buscás, nuestro equipo de soporte está
|
||||||
|
listo para ayudarte. También podés visitar nuestro canal de YouTube
|
||||||
|
para más contenido.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: "flex", gap: "1rem", justifyContent: "center", flexWrap: "wrap" }}>
|
||||||
|
<a
|
||||||
|
href="mailto:info@turnosxpress.com.ar"
|
||||||
|
className={style.ctaButton}
|
||||||
|
>
|
||||||
|
✉ Escribinos
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://www.youtube.com/@turnosxpress"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={style.ctaButton}
|
||||||
|
style={{
|
||||||
|
background: "var(--white)",
|
||||||
|
color: "var(--wine-darkest)",
|
||||||
|
boxShadow: "0 4px 15px rgba(0,0,0,0.1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
▶ Canal de YouTube
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Video Modal */}
|
||||||
|
<VideoModal
|
||||||
|
video={selectedVideo}
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
onSelectVideo={(video) => {
|
||||||
|
setSelectedVideo(video);
|
||||||
|
const newWatched = new Set(watchedVideos);
|
||||||
|
newWatched.add(video.id);
|
||||||
|
setWatchedVideos(newWatched);
|
||||||
|
localStorage.setItem("tx-help-watched", JSON.stringify([...newWatched]));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AnimatedContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -320,6 +320,20 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.videoPageLink {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--wine-dark);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoPageLink:hover {
|
||||||
|
color: var(--wine-red);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.stepNumber {
|
.stepNumber {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|||||||
@@ -1,317 +1,36 @@
|
|||||||
"use client";
|
import { Metadata } from "next";
|
||||||
|
import HelpContent from "./HelpContent";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar";
|
||||||
import style from "./help.module.css";
|
|
||||||
import { videoCategories, Video, getTotalVideoCount } from "./videoData";
|
export const metadata: Metadata = {
|
||||||
import VideoModal from "./components/VideoModal";
|
title: "Centro de Ayuda y Tutoriales | TurnosXpress",
|
||||||
import AnimatedContainer from "@core/app/components/AnimatedConainer/AnimatedContainer";
|
description:
|
||||||
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
|
"Aprende a usar TurnosXpress paso a paso con tutoriales en video. Desde la configuración inicial hasta las funciones más avanzadas: servicios, horarios, reservas, pagos y más.",
|
||||||
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
|
alternates: {
|
||||||
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
canonical: `${baseUrl}/landing/help`,
|
||||||
import Link from "next/link";
|
},
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
title: "Centro de Ayuda y Tutoriales | TurnosXpress",
|
||||||
|
description:
|
||||||
|
"Aprende a usar TurnosXpress paso a paso con tutoriales en video. Desde la configuración inicial hasta las funciones más avanzadas: servicios, horarios, reservas, pagos y más.",
|
||||||
|
url: `${baseUrl}/landing/help`,
|
||||||
|
siteName: "TurnosXpress",
|
||||||
|
locale: "es_AR",
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary",
|
||||||
|
title: "Centro de Ayuda y Tutoriales | TurnosXpress",
|
||||||
|
description:
|
||||||
|
"Aprende a usar TurnosXpress paso a paso con tutoriales en video.",
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default function HelpPage() {
|
export default function HelpPage() {
|
||||||
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
|
return <HelpContent />;
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
||||||
const [activeCategory, setActiveCategory] = useState<string>("all");
|
|
||||||
const [watchedVideos, setWatchedVideos] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const totalVideos = getTotalVideoCount();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const watched = localStorage.getItem("tx-help-watched");
|
|
||||||
if (watched) {
|
|
||||||
setWatchedVideos(new Set(JSON.parse(watched)));
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleVideoClick = (video: Video) => {
|
|
||||||
setSelectedVideo(video);
|
|
||||||
setIsModalOpen(true);
|
|
||||||
const newWatched = new Set(watchedVideos);
|
|
||||||
newWatched.add(video.id);
|
|
||||||
setWatchedVideos(newWatched);
|
|
||||||
localStorage.setItem("tx-help-watched", JSON.stringify([...newWatched]));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
|
||||||
setIsModalOpen(false);
|
|
||||||
setTimeout(() => setSelectedVideo(null), 200);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCategoryClick = (categoryId: string) => {
|
|
||||||
setActiveCategory(categoryId);
|
|
||||||
if (categoryId !== "all") {
|
|
||||||
const element = document.getElementById(`category-${categoryId}`);
|
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const progressPercent = Math.round((watchedVideos.size / totalVideos) * 100);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatedContainer
|
|
||||||
color="#aeaeae"
|
|
||||||
minSize={150}
|
|
||||||
maxSize={500}
|
|
||||||
items={10}
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
width: "100%",
|
|
||||||
background: "var(--white)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ScrollToTop />
|
|
||||||
<HeaderConfProvider />
|
|
||||||
<DefaultLoginAction />
|
|
||||||
|
|
||||||
<div className={style.container}>
|
|
||||||
{/* Hero Section */}
|
|
||||||
<div className={style.hero}>
|
|
||||||
<div className={style.heroIcon}>
|
|
||||||
<svg
|
|
||||||
width="36"
|
|
||||||
height="36"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<polygon points="5 3 19 12 5 21 5 3" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h1 className={style.heroTitle}>Centro de Ayuda</h1>
|
|
||||||
<p className={style.heroSubtitle}>
|
|
||||||
Aprende a usar TurnosXpress paso a paso con nuestros tutoriales en video.
|
|
||||||
Desde la configuración inicial hasta las funciones más avanzadas,
|
|
||||||
tenemos todo lo que necesitas para aprovechar al máximo la plataforma.
|
|
||||||
</p>
|
|
||||||
<div className={style.heroStats}>
|
|
||||||
<div className={style.statItem}>
|
|
||||||
<span className={style.statNumber}>{totalVideos}</span>
|
|
||||||
<span className={style.statLabel}>Video tutoriales</span>
|
|
||||||
</div>
|
|
||||||
<div className={style.statItem}>
|
|
||||||
<span className={style.statNumber}>{videoCategories.length}</span>
|
|
||||||
<span className={style.statLabel}>Categorías</span>
|
|
||||||
</div>
|
|
||||||
<div className={style.statItem}>
|
|
||||||
<span className={style.statNumber}>{watchedVideos.size}</span>
|
|
||||||
<span className={style.statLabel}>Vistos</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress Section */}
|
|
||||||
{watchedVideos.size > 0 && (
|
|
||||||
<div className={style.progressSection}>
|
|
||||||
<h2 className={style.progressTitle}>
|
|
||||||
Tu progreso de aprendizaje
|
|
||||||
</h2>
|
|
||||||
<p className={style.progressText}>
|
|
||||||
Has visto {watchedVideos.size} de {totalVideos} tutoriales.
|
|
||||||
{progressPercent === 100
|
|
||||||
? " ¡Felicidades! Has completado todos los tutoriales."
|
|
||||||
: ` ¡Sigue así, te faltan ${totalVideos - watchedVideos.size}!`}
|
|
||||||
</p>
|
|
||||||
<div className={style.progressBar}>
|
|
||||||
<div
|
|
||||||
className={style.progressFill}
|
|
||||||
style={{ width: `${progressPercent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Category Navigation */}
|
|
||||||
<div className={style.categoryNav}>
|
|
||||||
<button
|
|
||||||
className={
|
|
||||||
activeCategory === "all"
|
|
||||||
? style.categoryBtnActive
|
|
||||||
: style.categoryBtn
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
setActiveCategory("all");
|
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Todos
|
|
||||||
</button>
|
|
||||||
{videoCategories.map((cat) => (
|
|
||||||
<button
|
|
||||||
key={cat.id}
|
|
||||||
className={
|
|
||||||
activeCategory === cat.id
|
|
||||||
? style.categoryBtnActive
|
|
||||||
: style.categoryBtn
|
|
||||||
}
|
|
||||||
onClick={() => handleCategoryClick(cat.id)}
|
|
||||||
>
|
|
||||||
{cat.icon} {cat.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Learning Path */}
|
|
||||||
<div className={style.learningPath}>
|
|
||||||
<span className={style.learningPathIcon}>
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<path d="M12 16v-4" />
|
|
||||||
<path d="M12 8h.01" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<span className={style.learningPathText}>
|
|
||||||
<strong>Recomendación:</strong> Sigue el orden de las categorías para
|
|
||||||
un aprendizaje progresivo. Comienza con "Primeros Pasos" y
|
|
||||||
avanza según tus necesidades.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Video Categories */}
|
|
||||||
{videoCategories.map((category) => (
|
|
||||||
<div
|
|
||||||
key={category.id}
|
|
||||||
id={`category-${category.id}`}
|
|
||||||
className={style.categorySection}
|
|
||||||
>
|
|
||||||
<div className={style.categoryHeader}>
|
|
||||||
<div
|
|
||||||
className={style.categoryIcon}
|
|
||||||
style={{ background: category.color }}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: "1.5rem" }}>{category.icon}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className={style.categoryTitle}>{category.name}</h2>
|
|
||||||
<p className={style.categoryDescription}>
|
|
||||||
{category.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className={style.categoryCount}>
|
|
||||||
{category.videos.length} videos
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={style.videoGrid}>
|
|
||||||
{category.videos.map((video, idx) => (
|
|
||||||
<div
|
|
||||||
key={video.id}
|
|
||||||
className={style.videoCard}
|
|
||||||
onClick={() => handleVideoClick(video)}
|
|
||||||
>
|
|
||||||
<div className={style.videoThumbnail}>
|
|
||||||
<img
|
|
||||||
src={video.thumbnail}
|
|
||||||
alt={video.title}
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<div className={style.playOverlay}>
|
|
||||||
<div className={style.playButton}>
|
|
||||||
<div className={style.playIcon} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{watchedVideos.has(video.id) && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "8px",
|
|
||||||
left: "8px",
|
|
||||||
background:
|
|
||||||
"linear-gradient(135deg, #2aaa2a, #008011)",
|
|
||||||
color: "white",
|
|
||||||
padding: "3px 8px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
fontSize: "0.7rem",
|
|
||||||
fontWeight: 700,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "3px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✓ Visto
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={style.videoContent}>
|
|
||||||
<h3 className={style.videoTitle}>{video.title}</h3>
|
|
||||||
<p className={style.videoDescription}>
|
|
||||||
{video.description}
|
|
||||||
</p>
|
|
||||||
<div className={style.videoMeta}>
|
|
||||||
<div className={style.videoStep}>
|
|
||||||
<span className={style.stepNumber}>
|
|
||||||
{idx + 1}
|
|
||||||
</span>
|
|
||||||
Paso {idx + 1} de {category.videos.length}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* CTA Section */}
|
|
||||||
<div className={style.ctaSection}>
|
|
||||||
<h2 className={style.ctaTitle}>¿Necesitas más ayuda?</h2>
|
|
||||||
<p className={style.ctaText}>
|
|
||||||
Si no encontrás lo que buscás, nuestro equipo de soporte está
|
|
||||||
listo para ayudarte. También podés visitar nuestro canal de YouTube
|
|
||||||
para más contenido.
|
|
||||||
</p>
|
|
||||||
<div style={{ display: "flex", gap: "1rem", justifyContent: "center", flexWrap: "wrap" }}>
|
|
||||||
<a
|
|
||||||
href="mailto:info@turnosxpress.com.ar"
|
|
||||||
className={style.ctaButton}
|
|
||||||
>
|
|
||||||
✉ Escribinos
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://www.youtube.com/@turnosxpress"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={style.ctaButton}
|
|
||||||
style={{
|
|
||||||
background: "var(--white)",
|
|
||||||
color: "var(--wine-darkest)",
|
|
||||||
boxShadow: "0 4px 15px rgba(0,0,0,0.1)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
▶ Canal de YouTube
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Video Modal */}
|
|
||||||
<VideoModal
|
|
||||||
video={selectedVideo}
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
onClose={handleCloseModal}
|
|
||||||
onSelectVideo={(video) => {
|
|
||||||
setSelectedVideo(video);
|
|
||||||
const newWatched = new Set(watchedVideos);
|
|
||||||
newWatched.add(video.id);
|
|
||||||
setWatchedVideos(newWatched);
|
|
||||||
localStorage.setItem("tx-help-watched", JSON.stringify([...newWatched]));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</AnimatedContainer>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import type { Video, VideoCategory } from "../../videoData";
|
||||||
|
import { videoCategories, getAllVideos } from "../../videoData";
|
||||||
|
|
||||||
|
// Static generation: Next.js builds a page for every video at build time
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return getAllVideos().map((video) => ({ videoId: video.id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// SEO metadata per video
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ videoId: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { videoId } = await params;
|
||||||
|
const video = getAllVideos().find((v) => v.id === videoId);
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = videoCategories.find((c) => c.videos.some((v) => v.id === videoId));
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar";
|
||||||
|
const url = `${baseUrl}/landing/help/video/${videoId}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${video.title} | TurnosXpress`,
|
||||||
|
description: video.description,
|
||||||
|
alternates: {
|
||||||
|
canonical: url,
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
type: "video.other",
|
||||||
|
title: video.title,
|
||||||
|
description: video.description,
|
||||||
|
images: [{ url: video.thumbnail, width: 480, height: 360, alt: video.title }],
|
||||||
|
siteName: "TurnosXpress",
|
||||||
|
locale: "es_AR",
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: video.title,
|
||||||
|
description: video.description,
|
||||||
|
images: [video.thumbnail],
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const allVideos = getAllVideos();
|
||||||
|
|
||||||
|
// Build flat array with category context for each video
|
||||||
|
interface VideoWithContext {
|
||||||
|
video: Video;
|
||||||
|
category: VideoCategory;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVideoWithContext(videoId: string): VideoWithContext | null {
|
||||||
|
let flatIndex = 0;
|
||||||
|
for (const cat of videoCategories) {
|
||||||
|
const idx = cat.videos.findIndex((v) => v.id === videoId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
return {
|
||||||
|
video: cat.videos[idx],
|
||||||
|
category: cat,
|
||||||
|
index: flatIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
flatIndex += cat.videos.length;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function VideoPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ videoId: string }>;
|
||||||
|
}) {
|
||||||
|
const { videoId } = await params;
|
||||||
|
const ctx = getVideoWithContext(videoId);
|
||||||
|
|
||||||
|
if (!ctx) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { video, category, index } = ctx;
|
||||||
|
const prevVideo = index > 0 ? allVideos[index - 1] : null;
|
||||||
|
const nextVideo = index < allVideos.length - 1 ? allVideos[index + 1] : null;
|
||||||
|
|
||||||
|
// JSON-LD VideoObject schema for Google rich results
|
||||||
|
const jsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "VideoObject",
|
||||||
|
name: video.title,
|
||||||
|
description: video.description,
|
||||||
|
thumbnailUrl: video.thumbnail,
|
||||||
|
contentUrl: `https://www.youtube.com/watch?v=${video.id}`,
|
||||||
|
embedUrl: `https://www.youtube.com/embed/${video.id}`,
|
||||||
|
uploadDate: video.uploadDate,
|
||||||
|
publisher: {
|
||||||
|
"@type": "Organization",
|
||||||
|
name: "TurnosXpress",
|
||||||
|
logo: {
|
||||||
|
"@type": "ImageObject",
|
||||||
|
url: `${process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar"}/favicon.ico`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
category: category.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* JSON-LD structured data */}
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ maxWidth: 900, margin: "0 auto", padding: "2rem 1.5rem" }}>
|
||||||
|
{/* Breadcrumb nav */}
|
||||||
|
<nav
|
||||||
|
style={{
|
||||||
|
marginBottom: "2rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "var(--gray-dark)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/landing/help"
|
||||||
|
style={{ color: "var(--wine-dark)", textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
Centro de Ayuda
|
||||||
|
</Link>
|
||||||
|
{" / "}
|
||||||
|
<span style={{ color: "var(--black)" }}>{video.title}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Category badge */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "0.35rem 1rem",
|
||||||
|
borderRadius: 50,
|
||||||
|
background: category.color,
|
||||||
|
color: "white",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
marginBottom: "1.25rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{category.icon} {category.name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: "2rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
color: "var(--wine-superdark)",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
lineHeight: 1.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{video.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "1.05rem",
|
||||||
|
color: "var(--gray-dark)",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
marginBottom: "2rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{video.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* YouTube embed */}
|
||||||
|
<div style={{ position: "relative", paddingBottom: "56.25%", height: 0, overflow: "hidden", borderRadius: 16, boxShadow: "0 8px 30px rgba(0,0,0,0.12)", marginBottom: "2rem" }}>
|
||||||
|
<iframe
|
||||||
|
src={`https://www.youtube.com/embed/${video.id}?rel=0&modestbranding=1`}
|
||||||
|
title={video.title}
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", border: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prev/Next navigation */}
|
||||||
|
{(prevVideo || nextVideo) && (
|
||||||
|
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}>
|
||||||
|
{prevVideo && (
|
||||||
|
<Link
|
||||||
|
href={`/landing/help/video/${prevVideo.id}`}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 200,
|
||||||
|
padding: "1rem 1.25rem",
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid var(--white-darkest)",
|
||||||
|
background: "var(--white)",
|
||||||
|
textDecoration: "none",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: "0.8rem", color: "var(--gray-dark)", marginBottom: "0.25rem" }}>
|
||||||
|
{"← Anterior"}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.95rem", fontWeight: 700, color: "var(--wine-superdark)" }}>
|
||||||
|
{prevVideo.title}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{nextVideo && (
|
||||||
|
<Link
|
||||||
|
href={`/landing/help/video/${nextVideo.id}`}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 200,
|
||||||
|
padding: "1rem 1.25rem",
|
||||||
|
borderRadius: 12,
|
||||||
|
border: "1px solid var(--white-darkest)",
|
||||||
|
background: "var(--white)",
|
||||||
|
textDecoration: "none",
|
||||||
|
textAlign: "right",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: "0.8rem", color: "var(--gray-dark)", marginBottom: "0.25rem" }}>
|
||||||
|
{"Siguiente →"}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.95rem", fontWeight: 700, color: "var(--wine-superdark)" }}>
|
||||||
|
{nextVideo.title}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Back to help CTA */}
|
||||||
|
<div style={{ textAlign: "center", marginTop: "3rem", paddingTop: "2rem", borderTop: "1px solid var(--white-darkest)" }}>
|
||||||
|
<p style={{ color: "var(--gray-dark)", marginBottom: "1rem" }}>
|
||||||
|
Querés ver más tutoriales?
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/landing/help"
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
padding: "0.75rem 2rem",
|
||||||
|
background: "linear-gradient(135deg, var(--wine-red), var(--wine-dark))",
|
||||||
|
color: "white",
|
||||||
|
borderRadius: 50,
|
||||||
|
textDecoration: "none",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ver todos los tutoriales
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ export interface Video {
|
|||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
category: string;
|
category: string;
|
||||||
order: number;
|
order: number;
|
||||||
|
/** Date the tutorial was published on turnosxpress.com.ar (ISO yyyy-mm-dd). Defensible: matches git history of videoData.ts when the Help Center was added. */
|
||||||
|
uploadDate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoCategory {
|
export interface VideoCategory {
|
||||||
@@ -31,6 +33,7 @@ export const videoCategories: VideoCategory[] = [
|
|||||||
thumbnail: "https://i2.ytimg.com/vi/QE5pEw_af5g/hqdefault.jpg",
|
thumbnail: "https://i2.ytimg.com/vi/QE5pEw_af5g/hqdefault.jpg",
|
||||||
category: "getting-started",
|
category: "getting-started",
|
||||||
order: 1,
|
order: 1,
|
||||||
|
uploadDate: "2026-09-02",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "Aufl00f4U0Y",
|
id: "Aufl00f4U0Y",
|
||||||
@@ -39,6 +42,7 @@ export const videoCategories: VideoCategory[] = [
|
|||||||
thumbnail: "https://i2.ytimg.com/vi/Aufl00f4U0Y/hqdefault.jpg",
|
thumbnail: "https://i2.ytimg.com/vi/Aufl00f4U0Y/hqdefault.jpg",
|
||||||
category: "getting-started",
|
category: "getting-started",
|
||||||
order: 2,
|
order: 2,
|
||||||
|
uploadDate: "2026-09-02",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -56,6 +60,7 @@ export const videoCategories: VideoCategory[] = [
|
|||||||
thumbnail: "https://i1.ytimg.com/vi/hgs9w9Q7w2k/hqdefault.jpg",
|
thumbnail: "https://i1.ytimg.com/vi/hgs9w9Q7w2k/hqdefault.jpg",
|
||||||
category: "services",
|
category: "services",
|
||||||
order: 1,
|
order: 1,
|
||||||
|
uploadDate: "2026-09-02",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "UthPmnzR7fg",
|
id: "UthPmnzR7fg",
|
||||||
@@ -64,6 +69,7 @@ export const videoCategories: VideoCategory[] = [
|
|||||||
thumbnail: "https://i2.ytimg.com/vi/UthPmnzR7fg/hqdefault.jpg",
|
thumbnail: "https://i2.ytimg.com/vi/UthPmnzR7fg/hqdefault.jpg",
|
||||||
category: "services",
|
category: "services",
|
||||||
order: 2,
|
order: 2,
|
||||||
|
uploadDate: "2026-09-02",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "p6_ax-B2vDA",
|
id: "p6_ax-B2vDA",
|
||||||
@@ -72,6 +78,7 @@ export const videoCategories: VideoCategory[] = [
|
|||||||
thumbnail: "https://i1.ytimg.com/vi/p6_ax-B2vDA/hqdefault.jpg",
|
thumbnail: "https://i1.ytimg.com/vi/p6_ax-B2vDA/hqdefault.jpg",
|
||||||
category: "services",
|
category: "services",
|
||||||
order: 3,
|
order: 3,
|
||||||
|
uploadDate: "2026-09-02",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tzVF2MeW37I",
|
id: "tzVF2MeW37I",
|
||||||
@@ -80,6 +87,7 @@ export const videoCategories: VideoCategory[] = [
|
|||||||
thumbnail: "https://i.ytimg.com/vi/tzVF2MeW37I/hqdefault.jpg",
|
thumbnail: "https://i.ytimg.com/vi/tzVF2MeW37I/hqdefault.jpg",
|
||||||
category: "services",
|
category: "services",
|
||||||
order: 4,
|
order: 4,
|
||||||
|
uploadDate: "2026-09-02",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -97,6 +105,7 @@ export const videoCategories: VideoCategory[] = [
|
|||||||
thumbnail: "https://i2.ytimg.com/vi/enu2ny1k6XU/hqdefault.jpg",
|
thumbnail: "https://i2.ytimg.com/vi/enu2ny1k6XU/hqdefault.jpg",
|
||||||
category: "schedules",
|
category: "schedules",
|
||||||
order: 1,
|
order: 1,
|
||||||
|
uploadDate: "2026-09-02",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "8kBxSYoisSM",
|
id: "8kBxSYoisSM",
|
||||||
@@ -105,6 +114,7 @@ export const videoCategories: VideoCategory[] = [
|
|||||||
thumbnail: "https://i1.ytimg.com/vi/8kBxSYoisSM/hqdefault.jpg",
|
thumbnail: "https://i1.ytimg.com/vi/8kBxSYoisSM/hqdefault.jpg",
|
||||||
category: "schedules",
|
category: "schedules",
|
||||||
order: 2,
|
order: 2,
|
||||||
|
uploadDate: "2026-09-02",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -122,6 +132,7 @@ export const videoCategories: VideoCategory[] = [
|
|||||||
thumbnail: "https://i3.ytimg.com/vi/BcHrqxW_PTs/hqdefault.jpg",
|
thumbnail: "https://i3.ytimg.com/vi/BcHrqxW_PTs/hqdefault.jpg",
|
||||||
category: "clients-bookings",
|
category: "clients-bookings",
|
||||||
order: 1,
|
order: 1,
|
||||||
|
uploadDate: "2026-09-02",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "MVpCGA-w7c8",
|
id: "MVpCGA-w7c8",
|
||||||
@@ -130,6 +141,7 @@ export const videoCategories: VideoCategory[] = [
|
|||||||
thumbnail: "https://i2.ytimg.com/vi/MVpCGA-w7c8/hqdefault.jpg",
|
thumbnail: "https://i2.ytimg.com/vi/MVpCGA-w7c8/hqdefault.jpg",
|
||||||
category: "clients-bookings",
|
category: "clients-bookings",
|
||||||
order: 2,
|
order: 2,
|
||||||
|
uploadDate: "2026-09-02",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -147,6 +159,7 @@ export const videoCategories: VideoCategory[] = [
|
|||||||
thumbnail: "https://i4.ytimg.com/vi/CpGNWAJo6MM/hqdefault.jpg",
|
thumbnail: "https://i4.ytimg.com/vi/CpGNWAJo6MM/hqdefault.jpg",
|
||||||
category: "payments-discounts",
|
category: "payments-discounts",
|
||||||
order: 1,
|
order: 1,
|
||||||
|
uploadDate: "2026-09-02",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "UAh7HkOcvMQ",
|
id: "UAh7HkOcvMQ",
|
||||||
@@ -155,6 +168,7 @@ export const videoCategories: VideoCategory[] = [
|
|||||||
thumbnail: "https://i2.ytimg.com/vi/UAh7HkOcvMQ/hqdefault.jpg",
|
thumbnail: "https://i2.ytimg.com/vi/UAh7HkOcvMQ/hqdefault.jpg",
|
||||||
category: "payments-discounts",
|
category: "payments-discounts",
|
||||||
order: 2,
|
order: 2,
|
||||||
|
uploadDate: "2026-09-02",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -172,6 +186,7 @@ export const videoCategories: VideoCategory[] = [
|
|||||||
thumbnail: "https://i2.ytimg.com/vi/iu7sGYW1iGk/hqdefault.jpg",
|
thumbnail: "https://i2.ytimg.com/vi/iu7sGYW1iGk/hqdefault.jpg",
|
||||||
category: "customization",
|
category: "customization",
|
||||||
order: 1,
|
order: 1,
|
||||||
|
uploadDate: "2026-09-02",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "nZkBIpmLRyI",
|
id: "nZkBIpmLRyI",
|
||||||
@@ -180,6 +195,7 @@ export const videoCategories: VideoCategory[] = [
|
|||||||
thumbnail: "https://i.ytimg.com/vi/nZkBIpmLRyI/hqdefault.jpg",
|
thumbnail: "https://i.ytimg.com/vi/nZkBIpmLRyI/hqdefault.jpg",
|
||||||
category: "customization",
|
category: "customization",
|
||||||
order: 2,
|
order: 2,
|
||||||
|
uploadDate: "2026-09-02",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "oJuvDdewG1I",
|
id: "oJuvDdewG1I",
|
||||||
@@ -188,6 +204,7 @@ export const videoCategories: VideoCategory[] = [
|
|||||||
thumbnail: "https://i4.ytimg.com/vi/oJuvDdewG1I/hqdefault.jpg",
|
thumbnail: "https://i4.ytimg.com/vi/oJuvDdewG1I/hqdefault.jpg",
|
||||||
category: "customization",
|
category: "customization",
|
||||||
order: 3,
|
order: 3,
|
||||||
|
uploadDate: "2026-09-02",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "wjJfoky6VD0",
|
id: "wjJfoky6VD0",
|
||||||
@@ -196,6 +213,7 @@ export const videoCategories: VideoCategory[] = [
|
|||||||
thumbnail: "https://i4.ytimg.com/vi/wjJfoky6VD0/hqdefault.jpg",
|
thumbnail: "https://i4.ytimg.com/vi/wjJfoky6VD0/hqdefault.jpg",
|
||||||
category: "customization",
|
category: "customization",
|
||||||
order: 4,
|
order: 4,
|
||||||
|
uploadDate: "2026-09-02",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
||||||
|
import style from "./style.module.css";
|
||||||
|
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
|
||||||
|
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
||||||
|
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
|
||||||
|
import CTABtn from "./CTABtn";
|
||||||
|
|
||||||
|
export interface OfferContentProps {
|
||||||
|
heroTitle: string;
|
||||||
|
heroSubtitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OfferContent({ heroTitle, heroSubtitle }: OfferContentProps) {
|
||||||
|
return (
|
||||||
|
<AnimatedContainer
|
||||||
|
color="#aeaeae"
|
||||||
|
minSize={150}
|
||||||
|
maxSize={500}
|
||||||
|
items={10}
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
width: "100%",
|
||||||
|
background: "var(--white)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ScrollToTop />
|
||||||
|
<HeaderConfProvider />
|
||||||
|
<DefaultLoginAction />
|
||||||
|
|
||||||
|
{/* Hero Section */}
|
||||||
|
<div className={style.heroSection}>
|
||||||
|
<h1 className={style.heroTitle}>{heroTitle}</h1>
|
||||||
|
<p className={style.heroSubtitle}>{heroSubtitle}</p>
|
||||||
|
<div className={style.btnGroup}>
|
||||||
|
<CTABtn
|
||||||
|
className={style.primaryBtn}
|
||||||
|
secondaryClassName={style.secondaryBtn}
|
||||||
|
signedOutText="¡Empezá Gratis Hoy!"
|
||||||
|
signedInText="Ver planes"
|
||||||
|
showSecondaryWhenSignedOut
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features Section */}
|
||||||
|
<div className={style.featuresSection}>
|
||||||
|
<h2 className={style.sectionTitle}>Todo lo que necesitás para crecer</h2>
|
||||||
|
<div className={style.featuresGrid}>
|
||||||
|
<div className={style.featureCard}>
|
||||||
|
<div className={style.featureIcon}>📅</div>
|
||||||
|
<h3>Agenda Inteligente</h3>
|
||||||
|
<p>Visualizá tus citas por día, semana o mes. Bloqueá horarios y manejá tu disponibilidad con un par de clics.</p>
|
||||||
|
</div>
|
||||||
|
<div className={style.featureCard}>
|
||||||
|
<div className={style.featureIcon}>🔔</div>
|
||||||
|
<h3>Recordatorios Automáticos</h3>
|
||||||
|
<p>Reducí el ausentismo hasta un 80%. El sistema envía recordatorios automáticos a tus clientes antes de cada turno.</p>
|
||||||
|
</div>
|
||||||
|
<div className={style.featureCard}>
|
||||||
|
<div className={style.featureIcon}>👥</div>
|
||||||
|
<h3>Gestión de Colaboradores</h3>
|
||||||
|
<p>Agregá a tu equipo de trabajo. Asignale a cada colaborador sus propios servicios, horarios y disponibilidad de forma individual.</p>
|
||||||
|
</div>
|
||||||
|
<div className={style.featureCard}>
|
||||||
|
<div className={style.featureIcon}>🌐</div>
|
||||||
|
<h3>Portal de Reservas Público</h3>
|
||||||
|
<p>Mayor exposición. Tus servicios aparecerán en nuestro buscador público para que nuevos clientes te encuentren y reserven al instante.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Steps Section */}
|
||||||
|
<div className={style.stepsSection}>
|
||||||
|
<h2 className={style.sectionTitle}>Recibí turnos en 4 simples pasos</h2>
|
||||||
|
|
||||||
|
<div className={style.stepItem}>
|
||||||
|
<div className={style.stepNumber}>1</div>
|
||||||
|
<div className={style.stepContent}>
|
||||||
|
<h3>Creá tu cuenta gratis</h3>
|
||||||
|
<p>Registrate en segundos. Podés probar la plataforma completa sin compromisos ni tarjetas de crédito.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.stepItem}>
|
||||||
|
<div className={style.stepNumber}>2</div>
|
||||||
|
<div className={style.stepContent}>
|
||||||
|
<h3>Configurá tu perfil</h3>
|
||||||
|
<p>Personalizá tu espacio. Agregá tu logo, ubicación y una descripción atractiva para que los clientes te elijan al instante.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.stepItem}>
|
||||||
|
<div className={style.stepNumber}>3</div>
|
||||||
|
<div className={style.stepContent}>
|
||||||
|
<h3>Definí servicios y horarios</h3>
|
||||||
|
<p>¿Qué ofrecés y cuándo estás disponible? Configurá la duración de tus turnos, pausas entre clientes y tus días laborales.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.stepItem}>
|
||||||
|
<div className={style.stepNumber}>4</div>
|
||||||
|
<div className={style.stepContent}>
|
||||||
|
<h3>¡Compartí tu enlace!</h3>
|
||||||
|
<p>Te daremos un link único. Ponelo en tu biografía de Instagram, en WhatsApp o en tu web, y dejá que los turnos se agenden solos.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA Section */}
|
||||||
|
<div className={style.ctaSection}>
|
||||||
|
<h2 className={style.ctaTitle}>¿Listo para modernizar tu negocio?</h2>
|
||||||
|
<p className={style.ctaSubtitle}>Unite a los profesionales que ya automatizaron su agenda y ganan más tiempo libre.</p>
|
||||||
|
<CTABtn />
|
||||||
|
<p style={{ marginTop: '20px', fontSize: '1rem', color: 'rgba(255, 255, 255, 0.85)' }}>
|
||||||
|
¿Necesitás más información? Contactanos a <a href="mailto:info@turnosxpress.com.ar" style={{ color: '#fff', textDecoration: 'underline', fontWeight: '600' }}>info@turnosxpress.com.ar</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</AnimatedContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
import OfferContent from "./OfferContent";
|
||||||
import style from "./style.module.css";
|
|
||||||
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
|
|
||||||
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
|
||||||
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
|
|
||||||
import CTABtn from "./CTABtn";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Ofrecé Turnos Online | Sistema de Gestión y Reservas - TurnosXpress",
|
title: "Ofrecé Turnos Online | Sistema de Gestión y Reservas - TurnosXpress",
|
||||||
@@ -19,112 +14,9 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function OfferAppointmentsPage() {
|
export default function OfferAppointmentsPage() {
|
||||||
return (
|
return (
|
||||||
<AnimatedContainer
|
<OfferContent
|
||||||
color="#aeaeae"
|
heroTitle="Multiplicá tus clientes, simplificá tus turnos"
|
||||||
minSize={150}
|
heroSubtitle="Olvidate del teléfono y los mensajes a deshora. Con nuestra plataforma, tus clientes pueden reservar turnos 24/7 de forma automática. Optimizá tu tiempo, reducí ausencias y llevá tu negocio al siguiente nivel."
|
||||||
maxSize={500}
|
|
||||||
items={10}
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
width: "100%",
|
|
||||||
background: "var(--white)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ScrollToTop />
|
|
||||||
<HeaderConfProvider />
|
|
||||||
<DefaultLoginAction />
|
|
||||||
|
|
||||||
{/* Hero Section */}
|
|
||||||
<div className={style.heroSection}>
|
|
||||||
<h1 className={style.heroTitle}>Multiplicá tus clientes, simplificá tus turnos</h1>
|
|
||||||
<p className={style.heroSubtitle}>
|
|
||||||
Olvidate del teléfono y los mensajes a deshora. Con nuestra plataforma, tus clientes pueden reservar turnos 24/7 de forma automática. Optimizá tu tiempo, reducí ausencias y llevá tu negocio al siguiente nivel.
|
|
||||||
</p>
|
|
||||||
<div className={style.btnGroup}>
|
|
||||||
<CTABtn
|
|
||||||
className={style.primaryBtn}
|
|
||||||
secondaryClassName={style.secondaryBtn}
|
|
||||||
signedOutText="¡Empezá Gratis Hoy!"
|
|
||||||
signedInText="Ver planes"
|
|
||||||
showSecondaryWhenSignedOut
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Features Section */}
|
|
||||||
<div className={style.featuresSection}>
|
|
||||||
<h2 className={style.sectionTitle}>Todo lo que necesitás para crecer</h2>
|
|
||||||
<div className={style.featuresGrid}>
|
|
||||||
<div className={style.featureCard}>
|
|
||||||
<div className={style.featureIcon}>📅</div>
|
|
||||||
<h3>Agenda Inteligente</h3>
|
|
||||||
<p>Visualizá tus citas por día, semana o mes. Bloqueá horarios y manejá tu disponibilidad con un par de clics.</p>
|
|
||||||
</div>
|
|
||||||
<div className={style.featureCard}>
|
|
||||||
<div className={style.featureIcon}>🔔</div>
|
|
||||||
<h3>Recordatorios Automáticos</h3>
|
|
||||||
<p>Reducí el ausentismo hasta un 80%. El sistema envía recordatorios automáticos a tus clientes antes de cada turno.</p>
|
|
||||||
</div>
|
|
||||||
<div className={style.featureCard}>
|
|
||||||
<div className={style.featureIcon}>👥</div>
|
|
||||||
<h3>Gestión de Colaboradores</h3>
|
|
||||||
<p>Agregá a tu equipo de trabajo. Asignale a cada colaborador sus propios servicios, horarios y disponibilidad de forma individual.</p>
|
|
||||||
</div>
|
|
||||||
<div className={style.featureCard}>
|
|
||||||
<div className={style.featureIcon}>🌐</div>
|
|
||||||
<h3>Portal de Reservas Público</h3>
|
|
||||||
<p>Mayor exposición. Tus servicios aparecerán en nuestro buscador público para que nuevos clientes te encuentren y reserven al instante.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Steps Section */}
|
|
||||||
<div className={style.stepsSection}>
|
|
||||||
<h2 className={style.sectionTitle}>Recibí turnos en 4 simples pasos</h2>
|
|
||||||
|
|
||||||
<div className={style.stepItem}>
|
|
||||||
<div className={style.stepNumber}>1</div>
|
|
||||||
<div className={style.stepContent}>
|
|
||||||
<h3>Creá tu cuenta gratis</h3>
|
|
||||||
<p>Registrate en segundos. Podés probar la plataforma completa sin compromisos ni tarjetas de crédito.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={style.stepItem}>
|
|
||||||
<div className={style.stepNumber}>2</div>
|
|
||||||
<div className={style.stepContent}>
|
|
||||||
<h3>Configurá tu perfil</h3>
|
|
||||||
<p>Personalizá tu espacio. Agregá tu logo, ubicación y una descripción atractiva para que los clientes te elijan al instante.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={style.stepItem}>
|
|
||||||
<div className={style.stepNumber}>3</div>
|
|
||||||
<div className={style.stepContent}>
|
|
||||||
<h3>Definí servicios y horarios</h3>
|
|
||||||
<p>¿Qué ofrecés y cuándo estás disponible? Configurá la duración de tus turnos, pausas entre clientes y tus días laborales.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={style.stepItem}>
|
|
||||||
<div className={style.stepNumber}>4</div>
|
|
||||||
<div className={style.stepContent}>
|
|
||||||
<h3>¡Compartí tu enlace!</h3>
|
|
||||||
<p>Te daremos un link único. Ponelo en tu biografía de Instagram, en WhatsApp o en tu web, y dejá que los turnos se agenden solos.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CTA Section */}
|
|
||||||
<div className={style.ctaSection}>
|
|
||||||
<h2 className={style.ctaTitle}>¿Listo para modernizar tu negocio?</h2>
|
|
||||||
<p className={style.ctaSubtitle}>Unite a los profesionales que ya automatizaron su agenda y ganan más tiempo libre.</p>
|
|
||||||
<CTABtn />
|
|
||||||
<p style={{ marginTop: '20px', fontSize: '1rem', color: 'rgba(255, 255, 255, 0.85)' }}>
|
|
||||||
¿Necesitás más información? Contactanos a <a href="mailto:info@turnosxpress.com.ar" style={{ color: '#fff', textDecoration: 'underline', fontWeight: '600' }}>info@turnosxpress.com.ar</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</AnimatedContainer>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import ImagePicker from "@components/ImagePicker/ImagePicker";
|
|||||||
import { useAlert } from "@core/Store/Alert.Store";
|
import { useAlert } from "@core/Store/Alert.Store";
|
||||||
import { useSessionStore } from "@core/Store/Sesion.Store";
|
import { useSessionStore } from "@core/Store/Sesion.Store";
|
||||||
import { useOrganizationHeaderStore } from "@core/Store/OrganizationHeader.Store";
|
import { useOrganizationHeaderStore } from "@core/Store/OrganizationHeader.Store";
|
||||||
import { updateOrganizationsById, uploadHeaderImage } from "./Org.Service";
|
import { updateOrganizationColors, uploadHeaderImage } from "@services/Header.Edit.Service";
|
||||||
import { UpdateCompanyParams } from "@models/Company.model";
|
import { UpdateCompanyParams } from "@models/Company.model";
|
||||||
|
|
||||||
interface HeaderEditPopupProps {
|
interface HeaderEditPopupProps {
|
||||||
@@ -22,10 +22,11 @@ interface HeaderEditPopupProps {
|
|||||||
headerFontShadowColor: string;
|
headerFontShadowColor: string;
|
||||||
headerImage: string;
|
headerImage: string;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HeaderEditPopup(props: HeaderEditPopupProps) {
|
export default function HeaderEditPopup(props: HeaderEditPopupProps) {
|
||||||
const { open, onClose, companyId, headerColor, headerFontColor, headerFontShadowColor, headerImage, onSaved } = props;
|
const { open, onClose, companyId, headerColor, headerFontColor, headerFontShadowColor, headerImage, onSaved, onRefresh } = props;
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const SessionInfo = useSessionStore();
|
const SessionInfo = useSessionStore();
|
||||||
const organizationHeader = useOrganizationHeaderStore();
|
const organizationHeader = useOrganizationHeaderStore();
|
||||||
@@ -57,13 +58,15 @@ export default function HeaderEditPopup(props: HeaderEditPopupProps) {
|
|||||||
sessionUser: SessionInfo.userId,
|
sessionUser: SessionInfo.userId,
|
||||||
};
|
};
|
||||||
|
|
||||||
await updateOrganizationsById(updData);
|
await updateOrganizationColors(updData);
|
||||||
|
|
||||||
organizationHeader.setHeaderColor(headerColorState);
|
organizationHeader.setHeaderColor(headerColorState);
|
||||||
organizationHeader.setHeaderFontColor(headerFontColorState);
|
organizationHeader.setHeaderFontColor(headerFontColorState);
|
||||||
organizationHeader.setHeaderFontShadowColor(headerFontShadowColorState);
|
organizationHeader.setHeaderFontShadowColor(headerFontShadowColorState);
|
||||||
|
|
||||||
alert.showSuccess("Colores actualizados");
|
alert.showSuccess("Colores actualizados");
|
||||||
|
onSaved();
|
||||||
|
onRefresh?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const formatted = error instanceof Error ? error.message : "Error al guardar los colores";
|
const formatted = error instanceof Error ? error.message : "Error al guardar los colores";
|
||||||
alert.showError(formatted || "Error al guardar los colores");
|
alert.showError(formatted || "Error al guardar los colores");
|
||||||
@@ -87,6 +90,7 @@ export default function HeaderEditPopup(props: HeaderEditPopupProps) {
|
|||||||
uploadHeaderImage(formData).then(() => {
|
uploadHeaderImage(formData).then(() => {
|
||||||
alert.showSuccess("Imagen actualizada");
|
alert.showSuccess("Imagen actualizada");
|
||||||
onSaved();
|
onSaved();
|
||||||
|
onRefresh?.();
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
const formatted = error instanceof Error ? error.message : "Error al guardar la imagen";
|
const formatted = error instanceof Error ? error.message : "Error al guardar la imagen";
|
||||||
alert.showError(formatted || "Error al guardar la imagen");
|
alert.showError(formatted || "Error al guardar la imagen");
|
||||||
@@ -110,6 +114,7 @@ export default function HeaderEditPopup(props: HeaderEditPopupProps) {
|
|||||||
PaperProps={{
|
PaperProps={{
|
||||||
sx: {
|
sx: {
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
|
height: 500,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import ApiServerService from "@services/Api.Server.Service";
|
|||||||
import { PublicOrganizationView } from "@core/Models/Company.model";
|
import { PublicOrganizationView } from "@core/Models/Company.model";
|
||||||
import OrganizationHeader from "@core/app/components/Home/OrganizationHeader/OrganizationHeader";
|
import OrganizationHeader from "@core/app/components/Home/OrganizationHeader/OrganizationHeader";
|
||||||
import OrganizationDataConnector from "./components/OrganizationData";
|
import OrganizationDataConnector from "./components/OrganizationData";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
const GetOrganizationData = async (companyId: string): Promise<PublicOrganizationView> => {
|
const GetOrganizationData = async (companyId: string): Promise<PublicOrganizationView> => {
|
||||||
try {
|
try {
|
||||||
@@ -28,10 +29,67 @@ type Props = {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
|
||||||
|
const { oid } = await params;
|
||||||
|
const orgData = await GetOrganizationData(oid);
|
||||||
|
|
||||||
|
if (!orgData || !orgData.id) {
|
||||||
|
return {
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar";
|
||||||
|
const canonicalUrl = `${baseUrl}/landing/org/${oid}`;
|
||||||
|
const description =
|
||||||
|
orgData.description && orgData.description.trim().length > 0
|
||||||
|
? orgData.description
|
||||||
|
: `${orgData.name} - TurnosXpress`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${orgData.name} | TurnosXpress`,
|
||||||
|
description,
|
||||||
|
alternates: {
|
||||||
|
canonical: canonicalUrl,
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
title: `${orgData.name} | TurnosXpress`,
|
||||||
|
description,
|
||||||
|
url: canonicalUrl,
|
||||||
|
siteName: "TurnosXpress",
|
||||||
|
locale: "es_AR",
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary",
|
||||||
|
title: `${orgData.name} | TurnosXpress`,
|
||||||
|
description,
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default async function OrganizationPublicProfile({ params, children }: Props) {
|
export default async function OrganizationPublicProfile({ params, children }: Props) {
|
||||||
const { oid } = await params;
|
const { oid } = await params;
|
||||||
const orgData = await GetOrganizationData(oid);
|
const orgData = await GetOrganizationData(oid);
|
||||||
|
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar";
|
||||||
|
const canonicalUrl = `${baseUrl}/landing/org/${oid}`;
|
||||||
|
|
||||||
|
const jsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Organization",
|
||||||
|
name: orgData.name,
|
||||||
|
url: canonicalUrl,
|
||||||
|
description:
|
||||||
|
orgData.description && orgData.description.trim().length > 0
|
||||||
|
? orgData.description
|
||||||
|
: `${orgData.name} - TurnosXpress`,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedContainer
|
<AnimatedContainer
|
||||||
color={orgData.headerColor}
|
color={orgData.headerColor}
|
||||||
@@ -45,6 +103,10 @@ export default async function OrganizationPublicProfile({ params, children }: Pr
|
|||||||
<div className="homeCentered">
|
<div className="homeCentered">
|
||||||
{orgData.id && (
|
{orgData.id && (
|
||||||
<>
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
<div style={{ position: "relative" }} id="header-wrapper">
|
<div style={{ position: "relative" }} id="header-wrapper">
|
||||||
<OrganizationHeader organization={orgData} />
|
<OrganizationHeader organization={orgData} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ import { useSessionStore } from "@core/Store/Sesion.Store";
|
|||||||
import { EVENT_TYPES, useEventHandlerStore } from "@core/Store/EventHandler.Store";
|
import { EVENT_TYPES, useEventHandlerStore } from "@core/Store/EventHandler.Store";
|
||||||
import { useAlert } from "@core/Store/Alert.Store";
|
import { useAlert } from "@core/Store/Alert.Store";
|
||||||
import { IPost } from "@core/Models/Posts.model";
|
import { IPost } from "@core/Models/Posts.model";
|
||||||
import { preprocessQuillHtml, transformQuillContentForDisplay } from "@core/app/helpers/htmlSainitize";
|
import {
|
||||||
|
preprocessQuillHtml,
|
||||||
|
transformQuillContentForDisplay,
|
||||||
|
} from "@core/app/helpers/htmlSainitize";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
import { ThemeProvider } from "@emotion/react";
|
import { ThemeProvider } from "@emotion/react";
|
||||||
@@ -55,7 +58,7 @@ import DownIcon from "@mui/icons-material/ArrowDownwardRounded";
|
|||||||
import PinIcon from "@mui/icons-material/PushPin";
|
import PinIcon from "@mui/icons-material/PushPin";
|
||||||
import EditIcon from "@mui/icons-material/AutoFixHigh";
|
import EditIcon from "@mui/icons-material/AutoFixHigh";
|
||||||
import turnosXpressTheme from "@core/app/theme/turnosXpress";
|
import turnosXpressTheme from "@core/app/theme/turnosXpress";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
||||||
import ReviewsCarousel from "@core/app/components/ReviewsCarousel/ReviewsCarousel";
|
import ReviewsCarousel from "@core/app/components/ReviewsCarousel/ReviewsCarousel";
|
||||||
@@ -63,7 +66,6 @@ import OrganizationProfessionals from "@core/app/components/OrganizationProfessi
|
|||||||
import { RatingTargetType } from "@core/Models/Ratings.model";
|
import { RatingTargetType } from "@core/Models/Ratings.model";
|
||||||
import HeaderEditPopup from "./HeaderEditPopup";
|
import HeaderEditPopup from "./HeaderEditPopup";
|
||||||
import { checkOrgAdmin } from "./post.service";
|
import { checkOrgAdmin } from "./post.service";
|
||||||
import { IconButton as MuiIconButton } from "@mui/material";
|
|
||||||
|
|
||||||
enum VIEW {
|
enum VIEW {
|
||||||
POSTS = "posts",
|
POSTS = "posts",
|
||||||
@@ -79,6 +81,7 @@ type UrlData = {
|
|||||||
export default function OrganizationPublicProfile() {
|
export default function OrganizationPublicProfile() {
|
||||||
const data = useParams<UrlData>();
|
const data = useParams<UrlData>();
|
||||||
const oid = data.oid;
|
const oid = data.oid;
|
||||||
|
const router = useRouter();
|
||||||
const OrganizationData = useOrgDataStore();
|
const OrganizationData = useOrgDataStore();
|
||||||
const [position, setPosition] = useState<[number, number]>([0, 0]);
|
const [position, setPosition] = useState<[number, number]>([0, 0]);
|
||||||
const [orgData, setOrgData] = useState<PublicOrganizationView>();
|
const [orgData, setOrgData] = useState<PublicOrganizationView>();
|
||||||
@@ -101,14 +104,33 @@ export default function OrganizationPublicProfile() {
|
|||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
const [isOrgAdmin, setIsOrgAdmin] = useState<boolean>(false);
|
const [isOrgAdmin, setIsOrgAdmin] = useState<boolean>(false);
|
||||||
const [showEditPopup, setShowEditPopup] = useState<boolean>(false);
|
const [showEditPopup, setShowEditPopup] = useState<boolean>(false);
|
||||||
|
const SessionInfo = useSessionStore();
|
||||||
|
|
||||||
// Append edit button to header wrapper when admin
|
// Unified effect: check admin status and manage edit button DOM element
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOrgAdmin) return;
|
if (!SessionInfo.userId || !oid) {
|
||||||
|
setIsOrgAdmin(false);
|
||||||
|
const existing = document.getElementById("edit-header-btn");
|
||||||
|
if (existing) existing.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
checkOrgAdmin(oid, SessionInfo.userId)
|
||||||
|
.then((isAdmin) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.log("isAdmin:", isAdmin);
|
||||||
|
setIsOrgAdmin(isAdmin);
|
||||||
|
|
||||||
|
// Append or remove button based on the result
|
||||||
const wrapper = document.getElementById("header-wrapper");
|
const wrapper = document.getElementById("header-wrapper");
|
||||||
if (!wrapper) return;
|
if (!wrapper) return;
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
const existingBtn = document.getElementById("edit-header-btn");
|
||||||
|
if (existingBtn) existingBtn.remove();
|
||||||
|
|
||||||
const btn = document.createElement("div");
|
const btn = document.createElement("div");
|
||||||
btn.id = "edit-header-btn";
|
btn.id = "edit-header-btn";
|
||||||
btn.innerHTML = `
|
btn.innerHTML = `
|
||||||
@@ -132,16 +154,33 @@ export default function OrganizationPublicProfile() {
|
|||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
btn.querySelector("button")!.addEventListener("click", () => setShowEditPopup(true));
|
btn.querySelector("button")!.addEventListener("click", () =>
|
||||||
|
setShowEditPopup(true),
|
||||||
|
);
|
||||||
wrapper.appendChild(btn);
|
wrapper.appendChild(btn);
|
||||||
|
} else {
|
||||||
|
// Remove button for non-admin users
|
||||||
|
const existingBtn = document.getElementById("edit-header-btn");
|
||||||
|
if (existingBtn) existingBtn.remove();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setIsOrgAdmin(false);
|
||||||
|
|
||||||
|
// Ensure button is removed on error
|
||||||
|
const existingBtn = document.getElementById("edit-header-btn");
|
||||||
|
if (existingBtn) existingBtn.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup: mark as cancelled and remove button
|
||||||
return () => {
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
const existing = document.getElementById("edit-header-btn");
|
const existing = document.getElementById("edit-header-btn");
|
||||||
if (existing) existing.remove();
|
if (existing) existing.remove();
|
||||||
};
|
};
|
||||||
}, [isOrgAdmin]);
|
}, [SessionInfo.userId, oid]);
|
||||||
|
|
||||||
const SessionInfo = useSessionStore();
|
|
||||||
const eventHandler = useEventHandlerStore();
|
const eventHandler = useEventHandlerStore();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const confirm = useConfirmStore();
|
const confirm = useConfirmStore();
|
||||||
@@ -183,22 +222,6 @@ export default function OrganizationPublicProfile() {
|
|||||||
});
|
});
|
||||||
}, [SessionInfo, authorizedUsers]);
|
}, [SessionInfo, authorizedUsers]);
|
||||||
|
|
||||||
// Check if the logged-in user is admin of this organization
|
|
||||||
useEffect(() => {
|
|
||||||
if (!SessionInfo.userId || !oid) {
|
|
||||||
setIsOrgAdmin(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
checkOrgAdmin(oid, SessionInfo.userId)
|
|
||||||
.then((isAdmin) => {
|
|
||||||
setIsOrgAdmin(isAdmin);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setIsOrgAdmin(false);
|
|
||||||
});
|
|
||||||
}, [SessionInfo.userId, oid]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!OrganizationData.orgData) return;
|
if (!OrganizationData.orgData) return;
|
||||||
if (!oid) return; //if (!OrganizationData.orgData.id) return;
|
if (!oid) return; //if (!OrganizationData.orgData.id) return;
|
||||||
@@ -398,7 +421,8 @@ export default function OrganizationPublicProfile() {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
const newPosts = response.data.filter(
|
const newPosts = response.data.filter(
|
||||||
(newPost) => !prevPosts.some((prevPost) => prevPost._id === newPost._id),
|
(newPost) =>
|
||||||
|
!prevPosts.some((prevPost) => prevPost._id === newPost._id),
|
||||||
);
|
);
|
||||||
|
|
||||||
return [...prevPosts, ...newPosts];
|
return [...prevPosts, ...newPosts];
|
||||||
@@ -568,7 +592,11 @@ export default function OrganizationPublicProfile() {
|
|||||||
gap: "10px",
|
gap: "10px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<WhatsappShareButton title={shareTitle} separator=":: " url={window.location.href}>
|
<WhatsappShareButton
|
||||||
|
title={shareTitle}
|
||||||
|
separator=":: "
|
||||||
|
url={window.location.href}
|
||||||
|
>
|
||||||
<WhatsAppShareIcon size={32} round />
|
<WhatsAppShareIcon size={32} round />
|
||||||
</WhatsappShareButton>
|
</WhatsappShareButton>
|
||||||
<FacebookShareButton title={shareTitle} url={window.location.href}>
|
<FacebookShareButton title={shareTitle} url={window.location.href}>
|
||||||
@@ -603,6 +631,7 @@ export default function OrganizationPublicProfile() {
|
|||||||
onSaved={() => {
|
onSaved={() => {
|
||||||
setShowEditPopup(false);
|
setShowEditPopup(false);
|
||||||
}}
|
}}
|
||||||
|
onRefresh={() => router.refresh()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{orgData && (
|
{orgData && (
|
||||||
@@ -730,7 +759,9 @@ export default function OrganizationPublicProfile() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{view === VIEW.SERVICES && (
|
{view === VIEW.SERVICES && (
|
||||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
<div
|
||||||
|
style={{ display: "flex", justifyContent: "flex-end" }}
|
||||||
|
>
|
||||||
<ButtonGroup
|
<ButtonGroup
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
aria-label="Basic button group"
|
aria-label="Basic button group"
|
||||||
@@ -785,7 +816,8 @@ export default function OrganizationPublicProfile() {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
backgroundImage: "url(/no-conversation.webp)",
|
backgroundImage:
|
||||||
|
"url(/no-conversation.webp)",
|
||||||
backgroundRepeat: "no-repeat",
|
backgroundRepeat: "no-repeat",
|
||||||
backgroundPosition: "center center",
|
backgroundPosition: "center center",
|
||||||
backgroundSize: "50% auto",
|
backgroundSize: "50% auto",
|
||||||
@@ -800,7 +832,8 @@ export default function OrganizationPublicProfile() {
|
|||||||
<div
|
<div
|
||||||
key={post._id}
|
key={post._id}
|
||||||
style={{
|
style={{
|
||||||
borderBottom: "1px solid var(--gray-light)",
|
borderBottom:
|
||||||
|
"1px solid var(--gray-light)",
|
||||||
paddingTop: "5px",
|
paddingTop: "5px",
|
||||||
paddingBottom: "10px",
|
paddingBottom: "10px",
|
||||||
paddingLeft: "30px",
|
paddingLeft: "30px",
|
||||||
@@ -814,7 +847,11 @@ export default function OrganizationPublicProfile() {
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ color: "var(--gray-darkest)" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
color: "var(--gray-darkest)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{dayjs(post.createdAt).format(
|
{dayjs(post.createdAt).format(
|
||||||
"DD MMMM YYYY HH:mm",
|
"DD MMMM YYYY HH:mm",
|
||||||
)}
|
)}
|
||||||
@@ -854,22 +891,30 @@ export default function OrganizationPublicProfile() {
|
|||||||
color: "var(--gray-darkest)",
|
color: "var(--gray-darkest)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ThemeProvider theme={turnosXpressTheme}>
|
<ThemeProvider
|
||||||
|
theme={turnosXpressTheme}
|
||||||
|
>
|
||||||
<MaterialButton
|
<MaterialButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
startIcon={<ClipIcon />}
|
startIcon={<ClipIcon />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
fijarPost(`${post._id}`);
|
fijarPost(
|
||||||
|
`${post._id}`,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{post.fixed ? "Liberar" : "Fijar"}
|
{post.fixed
|
||||||
|
? "Liberar"
|
||||||
|
: "Fijar"}
|
||||||
</MaterialButton>
|
</MaterialButton>
|
||||||
|
|
||||||
{post.fixed && (
|
{post.fixed && (
|
||||||
<>
|
<>
|
||||||
<MaterialButton
|
<MaterialButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
startIcon={<UpIcon />}
|
startIcon={
|
||||||
|
<UpIcon />
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
moverPost(
|
moverPost(
|
||||||
`${post._id}`,
|
`${post._id}`,
|
||||||
@@ -882,7 +927,9 @@ export default function OrganizationPublicProfile() {
|
|||||||
|
|
||||||
<MaterialButton
|
<MaterialButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
startIcon={<DownIcon />}
|
startIcon={
|
||||||
|
<DownIcon />
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
moverPost(
|
moverPost(
|
||||||
`${post._id}`,
|
`${post._id}`,
|
||||||
@@ -898,7 +945,9 @@ export default function OrganizationPublicProfile() {
|
|||||||
<MaterialButton
|
<MaterialButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
editarPost(`${post._id}`);
|
editarPost(
|
||||||
|
`${post._id}`,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EditIcon />
|
<EditIcon />
|
||||||
@@ -907,7 +956,9 @@ export default function OrganizationPublicProfile() {
|
|||||||
<MaterialButton
|
<MaterialButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
eliminarPost(`${post._id}`);
|
eliminarPost(
|
||||||
|
`${post._id}`,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DeleteIcon />
|
<DeleteIcon />
|
||||||
@@ -932,7 +983,8 @@ export default function OrganizationPublicProfile() {
|
|||||||
style={{
|
style={{
|
||||||
padding: "0px 0px 40px 0px",
|
padding: "0px 0px 40px 0px",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: viewMode === "card" ? "row" : "column",
|
flexDirection:
|
||||||
|
viewMode === "card" ? "row" : "column",
|
||||||
justifyContent: "start",
|
justifyContent: "start",
|
||||||
gap: "20px",
|
gap: "20px",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import ApiRequest from "@services/Api.Service";
|
||||||
|
import { ApiVoidResult } from "@models/Api.VoidResult.type";
|
||||||
|
|
||||||
|
export const uploadServiceImage = async (data: FormData): Promise<ApiVoidResult> => {
|
||||||
|
return ApiRequest.postFile<ApiVoidResult>("services/upload-image", data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkOrgAdmin = async (companyId: string, sessionUser: string): Promise<boolean> => {
|
||||||
|
return ApiRequest.post<boolean>("companies/check-admin", { companyId, sessionUser });
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import ProductItem from "@core/app/components/ProductItem/ProductItem";
|
||||||
|
import { PublicServiceView } from "@models/Service.model";
|
||||||
|
import { PublicOrganizationView } from "@models/Company.model";
|
||||||
|
import ServiceImageEditPopup from "./ServiceImageEditPopup";
|
||||||
|
import { useSessionStore } from "@core/Store/Sesion.Store";
|
||||||
|
import { checkOrgAdmin } from "@services/Header.Edit.Service";
|
||||||
|
|
||||||
|
interface ServiceDetailCardProps {
|
||||||
|
service: PublicServiceView;
|
||||||
|
organization: PublicOrganizationView;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ServiceDetailCard({ service, organization }: ServiceDetailCardProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const SessionInfo = useSessionStore();
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [showEditPopup, setShowEditPopup] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!SessionInfo.userId || !organization.id || !service.id) return;
|
||||||
|
|
||||||
|
checkOrgAdmin(organization.id, SessionInfo.userId)
|
||||||
|
.then((result) => {
|
||||||
|
setIsAdmin(result);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setIsAdmin(false);
|
||||||
|
});
|
||||||
|
}, [SessionInfo.userId, organization.id, service.id]);
|
||||||
|
|
||||||
|
const handleImageSaved = () => {
|
||||||
|
setShowEditPopup(false);
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProductItem
|
||||||
|
product={service}
|
||||||
|
width="100%"
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onImageEdit={() => setShowEditPopup(true)}
|
||||||
|
/>
|
||||||
|
{isAdmin && (
|
||||||
|
<ServiceImageEditPopup
|
||||||
|
open={showEditPopup}
|
||||||
|
onClose={() => setShowEditPopup(false)}
|
||||||
|
serviceId={service.id}
|
||||||
|
companyId={organization.id}
|
||||||
|
currentImage={service.image}
|
||||||
|
onSaved={handleImageSaved}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { IconButton, Dialog, DialogActions, DialogContent, DialogTitle, Button } from "@mui/material";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import turnosXpressTheme from "@core/app/theme/turnosXpress";
|
||||||
|
import { ThemeProvider } from "@emotion/react";
|
||||||
|
import ImagePicker from "@components/ImagePicker/ImagePicker";
|
||||||
|
import { useAlert } from "@core/Store/Alert.Store";
|
||||||
|
import { useSessionStore } from "@core/Store/Sesion.Store";
|
||||||
|
import { uploadServiceImage } from "@services/Header.Edit.Service";
|
||||||
|
|
||||||
|
interface ServiceImageEditPopupProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
serviceId: string;
|
||||||
|
companyId: string;
|
||||||
|
currentImage: string;
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ServiceImageEditPopup(props: ServiceImageEditPopupProps) {
|
||||||
|
const { open, onClose, serviceId, companyId, currentImage, onSaved } = props;
|
||||||
|
const alert = useAlert();
|
||||||
|
const SessionInfo = useSessionStore();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("companyId", companyId);
|
||||||
|
formData.append("serviceId", serviceId);
|
||||||
|
formData.append("sessionUser", SessionInfo.userId);
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
uploadServiceImage(formData)
|
||||||
|
.then(() => {
|
||||||
|
alert.showSuccess("Imagen actualizada");
|
||||||
|
onSaved();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const formatted = error instanceof Error ? error.message : "Error al guardar la imagen";
|
||||||
|
alert.showError(formatted || "Error al guardar la imagen");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const formatted = error instanceof Error ? error.message : "Error al guardar la imagen";
|
||||||
|
alert.showError(formatted || "Error al guardar la imagen");
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={turnosXpressTheme}>
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
borderRadius: 2,
|
||||||
|
width: 'auto',
|
||||||
|
height: 'auto',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", py: 2 }}>
|
||||||
|
Editar imagen del servicio
|
||||||
|
<IconButton onClick={onClose} size="small">
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent dividers sx={{ p: 3, display: "flex", justifyContent: "center" }}>
|
||||||
|
<ImagePicker
|
||||||
|
src={currentImage}
|
||||||
|
alt="Imagen del servicio"
|
||||||
|
style={{ width: 400, height: 400, borderRadius: 8 }}
|
||||||
|
readOnly={false}
|
||||||
|
onChange={handleImageChange}
|
||||||
|
imageSizeSrc="/service-image-size.webp"
|
||||||
|
mbLimit={5}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2, pt: 1 }}>
|
||||||
|
<Button onClick={onClose}>Cancelar</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {}}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Guardar
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { cache } from "react";
|
||||||
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
||||||
import style from "./profile.module.css";
|
import style from "./profile.module.css";
|
||||||
import ApiServerService from "@services/Api.Server.Service";
|
import ApiServerService from "@services/Api.Server.Service";
|
||||||
import { PublicOrganizationServiceView } from "@core/Models/Service.model";
|
import { PublicOrganizationServiceView } from "@core/Models/Service.model";
|
||||||
import ProductItem from "@core/app/components/ProductItem/ProductItem";
|
|
||||||
import Appointment from "./components/Appointment/Appointment";
|
import Appointment from "./components/Appointment/Appointment";
|
||||||
import OrganizationHeader from "@components/Home/OrganizationHeader/OrganizationHeader";
|
import OrganizationHeader from "@components/Home/OrganizationHeader/OrganizationHeader";
|
||||||
import ProductGallery from "@core/app/components/Home/ProductsGallery/ProductGallery";
|
import ProductGallery from "@core/app/components/Home/ProductsGallery/ProductGallery";
|
||||||
@@ -12,8 +13,11 @@ import ServicePrivate from "@core/app/components/ServicePrivate/ServicePrivate";
|
|||||||
import EvangelizeBanner from "@components/EvangelizeBanner/EvangelizeBanner";
|
import EvangelizeBanner from "@components/EvangelizeBanner/EvangelizeBanner";
|
||||||
import ReviewsCarousel from "@components/ReviewsCarousel/ReviewsCarousel";
|
import ReviewsCarousel from "@components/ReviewsCarousel/ReviewsCarousel";
|
||||||
import { RatingTargetType } from "@core/Models/Ratings.model";
|
import { RatingTargetType } from "@core/Models/Ratings.model";
|
||||||
|
import ServiceDetailCard from "./ServiceDetailCard";
|
||||||
|
import OrganizationHeaderEdit from "@components/OrganizationHeaderEdit/OrganizationHeaderEdit";
|
||||||
|
|
||||||
const GetServiceData = async (serviceId: string): Promise<PublicOrganizationServiceView> => {
|
// `cache()` makes generateMetadata and the page component share one API call per request.
|
||||||
|
const GetServiceData = cache(async (serviceId: string): Promise<PublicOrganizationServiceView> => {
|
||||||
try {
|
try {
|
||||||
const organizationServiceData = await ApiServerService.post<PublicOrganizationServiceView>(
|
const organizationServiceData = await ApiServerService.post<PublicOrganizationServiceView>(
|
||||||
"views/service",
|
"views/service",
|
||||||
@@ -26,7 +30,7 @@ const GetServiceData = async (serviceId: string): Promise<PublicOrganizationServ
|
|||||||
} catch {
|
} catch {
|
||||||
return {} as PublicOrganizationServiceView;
|
return {} as PublicOrganizationServiceView;
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
sid: string;
|
sid: string;
|
||||||
@@ -36,12 +40,153 @@ type Props = {
|
|||||||
params: Promise<Params>;
|
params: Promise<Params>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Service = PublicOrganizationServiceView["service"];
|
||||||
|
type Organization = PublicOrganizationServiceView["organization"];
|
||||||
|
|
||||||
|
const getBaseUrl = (): string => process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar";
|
||||||
|
|
||||||
|
const getServiceUrl = (sid: string): string => `${getBaseUrl()}/landing/service/${sid}`;
|
||||||
|
|
||||||
|
// Meta tags must be plain text: collapse whitespace and drop any markup.
|
||||||
|
const toPlainText = (value?: string): string =>
|
||||||
|
(value || "")
|
||||||
|
.replace(/<[^>]*>/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Cut on a word boundary so snippets never end mid-word.
|
||||||
|
const truncate = (value: string, max: number): string => {
|
||||||
|
if (value.length <= max) return value;
|
||||||
|
const cut = value.slice(0, max - 1);
|
||||||
|
const lastSpace = cut.lastIndexOf(" ");
|
||||||
|
return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// `image` can be an absolute external URL or a path served from this origin.
|
||||||
|
const resolveImage = (src: string, baseUrl: string): string | undefined => {
|
||||||
|
if (!src) return undefined;
|
||||||
|
if (/^https?:\/\//i.test(src)) return src;
|
||||||
|
return `${baseUrl}${src.startsWith("/") ? "" : "/"}${src}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTitle = (service: Service, org: Organization): string => {
|
||||||
|
const MAX = 70;
|
||||||
|
const full = `${service.name} | ${org.name} | TurnosXpress`;
|
||||||
|
// Drop the organisation rather than truncating the brand out of the title.
|
||||||
|
return full.length <= MAX ? full : truncate(`${service.name} | TurnosXpress`, MAX);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDescription = (service: Service, org: Organization): string => {
|
||||||
|
const MAX = 160;
|
||||||
|
const cta = `Reservá online con ${org.name} en TurnosXpress.`;
|
||||||
|
const body = toPlainText(service.description);
|
||||||
|
|
||||||
|
if (!body) return truncate(`${service.name}: ${cta}`, MAX);
|
||||||
|
|
||||||
|
const room = MAX - cta.length - 1;
|
||||||
|
// Keep the call to action intact; it is what is cut first when space runs out.
|
||||||
|
return room >= 60 ? `${truncate(body, room)} ${cta}` : truncate(`${body} ${cta}`, MAX);
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { sid } = await params;
|
||||||
|
const organizationService = await GetServiceData(sid);
|
||||||
|
const service = organizationService?.service;
|
||||||
|
const org = organizationService?.organization;
|
||||||
|
|
||||||
|
if (!service?.id || !org?.id) {
|
||||||
|
return {
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
const url = getServiceUrl(sid);
|
||||||
|
const title = buildTitle(service, org);
|
||||||
|
const description = buildDescription(service, org);
|
||||||
|
const image = resolveImage(service.image, baseUrl) ?? resolveImage(org.logoFile, baseUrl);
|
||||||
|
const ogImage = image ? [{ url: image, alt: service.name }] : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
alternates: {
|
||||||
|
canonical: url,
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
url,
|
||||||
|
siteName: "TurnosXpress",
|
||||||
|
locale: "es_AR",
|
||||||
|
images: ogImage,
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: image ? "summary_large_image" : "summary",
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
images: image ? [image] : undefined,
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildServiceJsonLd = (service: Service, org: Organization, url: string) => {
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
const orgUrl = org.slug
|
||||||
|
? `${baseUrl}/landing/org/${org.slug}`
|
||||||
|
: `${baseUrl}/landing/org/${org.id}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Service",
|
||||||
|
"@id": url,
|
||||||
|
name: service.name,
|
||||||
|
description: toPlainText(service.description) || `${service.name} ofrecido por ${org.name}.`,
|
||||||
|
url,
|
||||||
|
image: resolveImage(service.image, baseUrl) ?? resolveImage(org.logoFile, baseUrl),
|
||||||
|
provider: {
|
||||||
|
"@type": "Organization",
|
||||||
|
"@id": orgUrl,
|
||||||
|
name: org.name,
|
||||||
|
url: orgUrl,
|
||||||
|
},
|
||||||
|
offers: {
|
||||||
|
"@type": "Offer",
|
||||||
|
price: service.price,
|
||||||
|
priceCurrency: "ARS",
|
||||||
|
availability: "https://schema.org/InStock",
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default async function OrganizationPublicProfile({ params }: Props) {
|
export default async function OrganizationPublicProfile({ params }: Props) {
|
||||||
const { sid } = await params;
|
const { sid } = await params;
|
||||||
const organizationService = await GetServiceData(sid);
|
const organizationService = await GetServiceData(sid);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* AnimatedContainer returns null during SSR, so structured data has to sit
|
||||||
|
outside it to reach the static HTML that crawlers read first. */}
|
||||||
|
{organizationService.service && organizationService.organization && (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: JSON.stringify(
|
||||||
|
buildServiceJsonLd(
|
||||||
|
organizationService.service,
|
||||||
|
organizationService.organization,
|
||||||
|
getServiceUrl(sid),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!organizationService.organization && !organizationService.service && <ServicePrivate />}
|
{!organizationService.organization && !organizationService.service && <ServicePrivate />}
|
||||||
{organizationService.organization && organizationService.service && (
|
{organizationService.organization && organizationService.service && (
|
||||||
<AnimatedContainer
|
<AnimatedContainer
|
||||||
@@ -56,11 +201,20 @@ export default async function OrganizationPublicProfile({ params }: Props) {
|
|||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
<HeaderConfProvider />
|
<HeaderConfProvider />
|
||||||
<div className="homeCentered">
|
<div className="homeCentered">
|
||||||
|
<div id="header-wrapper" style={{ position: "relative" }}>
|
||||||
<OrganizationHeader organization={organizationService.organization} />
|
<OrganizationHeader organization={organizationService.organization} />
|
||||||
|
</div>
|
||||||
|
<OrganizationHeaderEdit
|
||||||
|
companyId={organizationService.organization.id}
|
||||||
|
headerColor={organizationService.organization.headerColor}
|
||||||
|
headerFontColor={organizationService.organization.headerFontColor}
|
||||||
|
headerFontShadowColor={organizationService.organization.headerFontShadowColor}
|
||||||
|
headerImage={organizationService.organization.headerFile}
|
||||||
|
/>
|
||||||
<div className={style.organizationContent}>
|
<div className={style.organizationContent}>
|
||||||
<div className={style.serviceSide}>
|
<div className={style.serviceSide}>
|
||||||
<div className={style.stickyWrapper}>
|
<div className={style.stickyWrapper}>
|
||||||
<ProductItem product={organizationService.service} width="100%" />
|
<ServiceDetailCard service={organizationService.service} organization={organizationService.organization} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.bookingSide}>
|
<div className={style.bookingSide}>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import OfferContent from "../offer-appointments/OfferContent";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Sistema de Turnos Online | Gestión de Reservas y Agenda - TurnosXpress",
|
||||||
|
description: "Un sistema de turnos online para gestionar reservas, reducir ausencias y organizar tu agenda. Automatizá tus turnos y probá TurnosXpress gratis.",
|
||||||
|
keywords: ["sistema de turnos", "gestión de turnos", "sistema de turnos online", "software de turnos", "reservas online"],
|
||||||
|
openGraph: {
|
||||||
|
title: "Sistema de Turnos Online | Gestión - TurnosXpress",
|
||||||
|
description: "Automatizá reservas y reducí ausencias con un sistema de turnos online. ¡Empezá gratis!",
|
||||||
|
type: "website",
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SistemaDeTurnosPage() {
|
||||||
|
return (
|
||||||
|
<OfferContent
|
||||||
|
heroTitle="Un sistema de turnos online que simplifica tu gestión"
|
||||||
|
heroSubtitle="Dejá que tu negocio reciba reservas las 24 horas sin atender el teléfono. Con nuestro sistema de turnos gestionás citas, horarios y recordatorios en un solo lugar."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import OfferContent from "../offer-appointments/OfferContent";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Turnos Online | Reservas Automáticas para tu Negocio - TurnosXpress",
|
||||||
|
description: "Ofrecé turnos online a tus clientes y dejá de coordinar por mensaje. Reservas automáticas, recordatorios y gestión simple con TurnosXpress.",
|
||||||
|
keywords: ["turnos online", "sacar turno online", "reservas de turnos", "turnos web", "turnos para profesionales"],
|
||||||
|
openGraph: {
|
||||||
|
title: "Turnos Online | Reservas Automáticas - TurnosXpress",
|
||||||
|
description: "Ofrecé turnos online con reservas 24/7 y recordatorios automáticos. ¡Empezá gratis!",
|
||||||
|
type: "website",
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TurnosOnlinePage() {
|
||||||
|
return (
|
||||||
|
<OfferContent
|
||||||
|
heroTitle="Ofrecé turnos online y ganá horas de tu día"
|
||||||
|
heroSubtitle="Tus clientes reservan turnos online desde cualquier dispositivo, a cualquier hora. Vos ganás tiempo, ellos ganan comodidad."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,9 +8,14 @@ export default function robots(): MetadataRoute.Robots {
|
|||||||
allow: "/",
|
allow: "/",
|
||||||
disallow: [
|
disallow: [
|
||||||
"/admin/",
|
"/admin/",
|
||||||
"/dashboard/",
|
"/user/",
|
||||||
"/my-appointments/",
|
"/messenger/",
|
||||||
"/offer-appointments/",
|
"/landing/dashboard/",
|
||||||
|
"/landing/my-appointments/",
|
||||||
|
"/landing/current-plan/",
|
||||||
|
"/landing/upgrade-plan/",
|
||||||
|
"/landing/pending-ratings/",
|
||||||
|
"/landing/subscription/",
|
||||||
"/landing/recover-account/",
|
"/landing/recover-account/",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ import { NextResponse } from "next/server";
|
|||||||
import ApiServerService from "@core/Services/Api.Server.Service";
|
import ApiServerService from "@core/Services/Api.Server.Service";
|
||||||
import { PublicServicesResults } from "@models/Service.model";
|
import { PublicServicesResults } from "@models/Service.model";
|
||||||
import { sliderFrames } from "@core/app/Services/SliderFrames";
|
import { sliderFrames } from "@core/app/Services/SliderFrames";
|
||||||
|
import { getAllVideos } from "@core/app/landing/help/videoData";
|
||||||
|
|
||||||
|
// Escape text content so the sitemap is always well-formed XML
|
||||||
|
// (URLs with query strings, service names/descriptions, etc.).
|
||||||
|
function escapeXml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar";
|
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar";
|
||||||
@@ -37,6 +49,25 @@ export async function GET() {
|
|||||||
changeFrequency: "daily",
|
changeFrequency: "daily",
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/landing/help`,
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: 0.8,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Dynamic video routes (one per tutorial)
|
||||||
|
// Note: video thumbnails are hosted on YouTube's CDN (i*.ytimg.com); Google's image
|
||||||
|
// sitemap extension targets images on your own domain, so they are omitted here.
|
||||||
|
const allVideos = getAllVideos();
|
||||||
|
const videoRoutes: SitemapRoute[] = allVideos.map((video) => ({
|
||||||
|
url: `${baseUrl}/landing/help/video/${video.id}`,
|
||||||
|
changeFrequency: "monthly",
|
||||||
|
priority: 0.8,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Login and signup routes (lower priority)
|
||||||
|
const authRoutes: SitemapRoute[] = [
|
||||||
{
|
{
|
||||||
url: `${baseUrl}/landing/login`,
|
url: `${baseUrl}/landing/login`,
|
||||||
changeFrequency: "monthly",
|
changeFrequency: "monthly",
|
||||||
@@ -47,6 +78,10 @@ export async function GET() {
|
|||||||
changeFrequency: "monthly",
|
changeFrequency: "monthly",
|
||||||
priority: 0.5,
|
priority: 0.5,
|
||||||
},
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Marketing pages
|
||||||
|
const marketingRoutes: SitemapRoute[] = [
|
||||||
{
|
{
|
||||||
url: `${baseUrl}/landing/offer-appointments`,
|
url: `${baseUrl}/landing/offer-appointments`,
|
||||||
changeFrequency: "weekly",
|
changeFrequency: "weekly",
|
||||||
@@ -57,6 +92,56 @@ export async function GET() {
|
|||||||
changeFrequency: "weekly",
|
changeFrequency: "weekly",
|
||||||
priority: 0.9,
|
priority: 0.9,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/landing/sistema-de-turnos`,
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: 0.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/landing/agenda-online`,
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: 0.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/landing/turnos-online`,
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: 0.9,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Category exploration pages
|
||||||
|
// /landing/reviews is intentionally omitted: it renders rating lists driven by
|
||||||
|
// query params (?targetType=&targetId=) — parametric thin pages, not stable URLs.
|
||||||
|
const explorationRoutes: SitemapRoute[] = [
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/landing/categories`,
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: 0.8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/landing/get-appointments`,
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: 0.6,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Legal pages (rarely change, low priority)
|
||||||
|
const legalRoutes: SitemapRoute[] = [
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/landing/terms`,
|
||||||
|
changeFrequency: "yearly",
|
||||||
|
priority: 0.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/landing/privacy`,
|
||||||
|
changeFrequency: "yearly",
|
||||||
|
priority: 0.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/landing/cookies`,
|
||||||
|
changeFrequency: "yearly",
|
||||||
|
priority: 0.3,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let serviceRoutes: SitemapRoute[] = [];
|
let serviceRoutes: SitemapRoute[] = [];
|
||||||
@@ -75,19 +160,12 @@ export async function GET() {
|
|||||||
if (servicesResult && servicesResult.data) {
|
if (servicesResult && servicesResult.data) {
|
||||||
totalPages = servicesResult.pages || 1;
|
totalPages = servicesResult.pages || 1;
|
||||||
|
|
||||||
|
// Image sitemap entries are omitted: service images may be hosted
|
||||||
|
// on arbitrary external domains, which the image extension can't verify.
|
||||||
const newRoutes = servicesResult.data.map((service) => ({
|
const newRoutes = servicesResult.data.map((service) => ({
|
||||||
url: `${baseUrl}/landing/service/${service.id}`,
|
url: `${baseUrl}/landing/service/${service.id}`,
|
||||||
changeFrequency: "weekly",
|
changeFrequency: "weekly",
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: service.image.startsWith("http")
|
|
||||||
? service.image
|
|
||||||
: `${process.env.NEXT_PUBLIC_API_URL}${service.image}`,
|
|
||||||
title: service.name,
|
|
||||||
caption: service.description,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
serviceRoutes = [...serviceRoutes, ...newRoutes];
|
serviceRoutes = [...serviceRoutes, ...newRoutes];
|
||||||
@@ -102,7 +180,44 @@ export async function GET() {
|
|||||||
console.error("Error generating sitemap:", error);
|
console.error("Error generating sitemap:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allRoutes = [...staticRoutes, ...serviceRoutes];
|
let orgRoutes: SitemapRoute[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
let currentPage = 1;
|
||||||
|
let totalPages = 1;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const orgsResult = await ApiServerService.post<{
|
||||||
|
data: { slug: string; name: string; description: string; creationDate?: string }[];
|
||||||
|
page: number;
|
||||||
|
pages: number;
|
||||||
|
}>("views/public-organizations", {
|
||||||
|
page: currentPage,
|
||||||
|
limit: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (orgsResult && orgsResult.data) {
|
||||||
|
totalPages = orgsResult.pages || 1;
|
||||||
|
|
||||||
|
const newRoutes = orgsResult.data.map((org) => ({
|
||||||
|
url: `${baseUrl}/landing/org/${org.slug}`,
|
||||||
|
changeFrequency: "weekly" as const,
|
||||||
|
priority: 0.8,
|
||||||
|
}));
|
||||||
|
|
||||||
|
orgRoutes = [...orgRoutes, ...newRoutes];
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPage++;
|
||||||
|
} while (currentPage <= totalPages);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating org sitemap:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allRoutes = [...staticRoutes, ...videoRoutes, ...authRoutes, ...marketingRoutes, ...orgRoutes, ...explorationRoutes, ...legalRoutes, ...serviceRoutes];
|
||||||
|
|
||||||
const sitemapXml = `<?xml version="1.0" encoding="UTF-8"?>
|
const sitemapXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||||
@@ -111,18 +226,18 @@ export async function GET() {
|
|||||||
.map((route) => {
|
.map((route) => {
|
||||||
return `
|
return `
|
||||||
<url>
|
<url>
|
||||||
<loc>${route.url}</loc>
|
<loc>${escapeXml(route.url)}</loc>
|
||||||
${route.lastModified ? `<lastmod>${route.lastModified}</lastmod>` : ""}
|
${route.lastModified ? `<lastmod>${escapeXml(route.lastModified)}</lastmod>` : ""}
|
||||||
<changefreq>${route.changeFrequency}</changefreq>
|
<changefreq>${escapeXml(route.changeFrequency)}</changefreq>
|
||||||
<priority>${route.priority}</priority>
|
<priority>${route.priority}</priority>
|
||||||
${route.images
|
${route.images
|
||||||
? route.images
|
? route.images
|
||||||
.map(
|
.map(
|
||||||
(img) => `
|
(img) => `
|
||||||
<image:image>
|
<image:image>
|
||||||
<image:loc>${img.url}</image:loc>
|
<image:loc>${escapeXml(img.url)}</image:loc>
|
||||||
<image:title><![CDATA[${img.title}]]></image:title>
|
<image:title>${escapeXml(img.title)}</image:title>
|
||||||
<image:caption><![CDATA[${img.caption}]]></image:caption>
|
<image:caption>${escapeXml(img.caption)}</image:caption>
|
||||||
</image:image>`
|
</image:image>`
|
||||||
)
|
)
|
||||||
.join("")
|
.join("")
|
||||||
@@ -135,7 +250,10 @@ export async function GET() {
|
|||||||
|
|
||||||
return new NextResponse(sitemapXml, {
|
return new NextResponse(sitemapXml, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/xml",
|
"Content-Type": "application/xml; charset=utf-8",
|
||||||
|
// Regenerated on every request (fetch no-store): cache at the edge/CDN
|
||||||
|
// so crawlers and browsers don't re-hit the services API each time.
|
||||||
|
"Cache-Control": "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user