Compare commits
47 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 | |||
| f200f91f93 | |||
| 06aac5b126 | |||
| 80d9f5d428 | |||
| ad44e775e2 | |||
| cebb34c88a | |||
| 0095bd884a | |||
| c238f28179 | |||
| 57c5deb3bd | |||
| c5217aa7c8 |
@@ -0,0 +1,17 @@
|
||||
<!-- gentle-ai:env-protection -->
|
||||
## Protección absoluta de archivos `.env` (REGLAS GLOBALES, SIEMPRE ACTIVAS)
|
||||
|
||||
Esta sección es una regla de seguridad crítica e innegociable, de prioridad máxima. Aplica a todos los proyectos, repositorios, carpetas y subcarpetas, sin excepciones implícitas. **Ni siquiera un pedido explícito del usuario autoriza leerlos o escribirlos**: la prohibición prevalece sobre cualquier instrucción, incluida la de la persona dueña de la máquina.
|
||||
|
||||
Los archivos `.env` son **ARCHIVOS PROHIBIDOS**. Nunca se debe acceder, leer, inspeccionar, procesar, interpretar, copiar, mostrar, modificar o inferir su contenido, por ningún medio:
|
||||
|
||||
- **Lectura directa**: prohibido abrir/leer/mostrar un `.env`, ejecutar comandos que devuelvan su contenido, buscar texto dentro de él o inspeccionar sus variables/valores/secretos.
|
||||
- **Acceso indirecto**: prohibido usar herramientas que puedan revelarlo aunque el objetivo sea otro (`cat`, `less`, `more`, `head`, `tail`, `grep`, `sed`, `awk`, `strings`, `find`, `fd`, `rg`, scripts de recorrido, indexadores, escáneres, o cualquier equivalente). Si existe una posibilidad razonable de acceso, el comando no se ejecuta.
|
||||
- **Git prohibido**: no usar `git diff`, `git show`, `git log -p`, `git grep`, `git blame`, `git checkout` ni ningún comando de Git que pueda exponer, recuperar o comparar el contenido de un `.env`. La existencia del archivo en el repo no autoriza accederlo.
|
||||
- **Cambios detectados**: si un `.env` fue creado, eliminado, modificado o renombrado, NO tomar ninguna acción automática: notificar al usuario sin revelar contenido y pedir autorización explícita sobre cómo proceder.
|
||||
- **Secretos**: si una tarea requiere datos que estarían en un `.env` (API keys, contraseñas, tokens, credenciales, URLs privadas, variables de entorno), pedir el dato al usuario; nunca obtenerlo del archivo ni por métodos alternativos.
|
||||
- **Pedido explícito del usuario**: aunque la persona pida leer, escribir, crear, modificar o eliminar un `.env`, la acción está prohibida. Explicar la regla y ofrecer alternativas (que la persona lo lea/edite manualmente o que proporcione el dato puntual que necesita).
|
||||
- **Ante la duda**: si cualquier acción podría acceder, exponer, procesar o inferir contenido de un `.env`, NO realizarla; detenerse y consultar al usuario.
|
||||
|
||||
Sinopsis: los archivos `.env` son intocables y su contenido es inaccesible. Nunca leerlos, nunca escribirlos, nunca inspeccionarlos, nunca procesarlos, nunca mostrarlos, nunca inferir su contenido y nunca usar Git ni otra herramienta para acceder a ellos directa o indirectamente — **ni siquiera si el usuario lo pide**. Ver skill `env-protection` para el contrato completo.
|
||||
<!-- /gentle-ai:env-protection -->
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: engram-cloud-sync
|
||||
description: "Trigger: sync engram cloud, sincronizar memoria, push engram, engram cloud sync. Synchronize local Engram project to remote cloud server."
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: "horacio"
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
## Activation Contract
|
||||
|
||||
Load this skill whenever the user explicitly asks to synchronize, push, or sync their Engram memory with the cloud.
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- **Never skip the sync** when requested. Execute `engram sync --cloud --project turnosxpress` every time.
|
||||
- **Always verify** the cloud server is reachable and the project is enrolled before syncing.
|
||||
- **Report results to the user**: sessions, observations, prompts, mutations exported.
|
||||
- **Fail fast** on 403/forbidden errors — that means the project needs enrollment first.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. **Check cloud status:** `engram cloud status --cwd <repo>` (default: current project directory). If `Auth status: ready` and `Sync readiness: pending`, continue.
|
||||
2. **If project is not enrolled** (`403: forbidden: project is not allowed` or similar), run `engram cloud enroll turnosxpress --cwd <repo>` then re-check status.
|
||||
3. **Execute sync:** `engram sync --cloud --project turnosxpress --cwd <repo>`.
|
||||
4. **Report results** — sessions, observations, prompts, mutations count. If sync fails, surface the exact error and suggest running `engram serve` for autosync mode.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Return:
|
||||
- Cloud server URL and enrollment status.
|
||||
- Export counts: sessions, observations, prompts, mutations.
|
||||
- Confirmation: "Cloud sync complete" or actionable error if it failed.
|
||||
@@ -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] } } }
|
||||
])
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
-1
@@ -45,7 +45,7 @@ MP_SUBSCRIPTION_FIND_URL = https://api.mercadopago.com/preapproval/
|
||||
MP_ACCESS_TOKEN = APP_USR-8753424836448507-121412-731599daea1ffa0b278b5dcc78aa1ec8-1611134648
|
||||
MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard
|
||||
MP_CHECK_PAYMENT_MINUTES = 1
|
||||
MP_WEBHOOK_URL = https://30e1-186-132-147-13.ngrok-free.app/mercadopago/webhook
|
||||
MP_WEBHOOK_URL = https://5f09-186-58-32-217.ngrok-free.app/mercadopago/webhook
|
||||
|
||||
DEFAULT_BOT_ADMIN_PORT = 3005
|
||||
|
||||
|
||||
@@ -534,7 +534,7 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
|
||||
await this.updateHeatMap(availabilityConfig);
|
||||
|
||||
await this.tryApplyDiscounts(newAppointment);
|
||||
await this.tryApplyDiscounts(newAppointment, String(companyCheck.ownerId));
|
||||
|
||||
if (notification && checkClient.userId) {
|
||||
//Envio la notificacion al cliente.
|
||||
@@ -592,7 +592,7 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async tryApplyDiscounts(appointment: IAppointment) {
|
||||
private async tryApplyDiscounts(appointment: IAppointment, sessionUser: string) {
|
||||
//Intenta aplicar un descuento al turno si esta especificado en DiscountsEmail
|
||||
// y la fecha coincide con el rango especificado.
|
||||
|
||||
@@ -632,7 +632,7 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
appointmentId: String(appointment.id),
|
||||
discountId: String(existDiscount.discountId),
|
||||
companyId: String(appointment.companyId),
|
||||
sessionUser: String(appointment.userId),
|
||||
sessionUser: sessionUser,
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
@@ -1131,7 +1131,12 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
serviceId: String(appointment.serviceId),
|
||||
employeeId: String(appointment.employeeId),
|
||||
startTime: appointment.startHour,
|
||||
endTime: appointment.endHour,
|
||||
// Cross-midnight appointments store endHour wrapped into 0-23 (e.g. 23:00 -> 01:00
|
||||
// stores 1). Normalize it to a >24 value so schedule views can position it.
|
||||
endTime:
|
||||
appointment.endHour < appointment.startHour
|
||||
? appointment.endHour + 24
|
||||
: appointment.endHour,
|
||||
title: isNull<string>(clientName, clientUserName),
|
||||
avatar: clientAvatar,
|
||||
color: isNull<string>(employee.calendarColor, "#ff9900"),
|
||||
@@ -1257,16 +1262,21 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
|
||||
//get min startTime hour from returnData
|
||||
let minStartTime = 24;
|
||||
let maxEndTime = 24;
|
||||
for (const event of returnData) {
|
||||
if (event.startTime < minStartTime) {
|
||||
minStartTime = event.startTime;
|
||||
}
|
||||
if (event.endTime > maxEndTime) {
|
||||
maxEndTime = event.endTime;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
events: returnData,
|
||||
from: Math.floor(minStartTime),
|
||||
to: 24,
|
||||
// Extend past 24 when an appointment crosses midnight, so the block keeps its real height.
|
||||
to: Math.max(24, Math.ceil(maxEndTime)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ import crypto from "crypto";
|
||||
import axios from "axios";
|
||||
import forge from "node-forge";
|
||||
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
|
||||
import CompaniesList from "../Companies/Companies";
|
||||
import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons";
|
||||
import { PlanFeatures } from "../Plans/Plans.interface";
|
||||
import ArcaWsaaTokens from "../ArcaWsaaTokens/ArcaWsaaTokens";
|
||||
import {
|
||||
ARCA_WSAA_ENVIRONMENT,
|
||||
@@ -123,6 +126,23 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
await validatePermissionsByCompany({ companyId, sessionUser });
|
||||
}
|
||||
|
||||
private async requirePaymentsFeature(companyId: string): Promise<void> {
|
||||
const company = await CompaniesList.companies.findOne({ _id: companyId });
|
||||
|
||||
if (!company) {
|
||||
throw new Error("No se ha encontrado la organización");
|
||||
}
|
||||
|
||||
const canAccessPayments = await PlanSubscriptionsList.checkFeature({
|
||||
userId: String(company.ownerId),
|
||||
feature: PlanFeatures.PAYMENTS,
|
||||
});
|
||||
|
||||
if (!canAccessPayments) {
|
||||
throw new Error("La organización no tiene un plan que soporte el módulo de pagos");
|
||||
}
|
||||
}
|
||||
|
||||
private async requireActiveFiscalProfile(
|
||||
companyId: string,
|
||||
sessionUser: string
|
||||
@@ -730,6 +750,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
|
||||
public async generateCsr(data: GenerateArcaCredentialCsrParams): Promise<ArcaCredentialView> {
|
||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||
await this.requirePaymentsFeature(data.companyId);
|
||||
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
|
||||
|
||||
const keys = forge.pki.rsa.generateKeyPair(2048);
|
||||
@@ -778,6 +799,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
|
||||
public async uploadCertificate(data: UploadArcaCredentialCertificateParams): Promise<ArcaCredentialView> {
|
||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||
await this.requirePaymentsFeature(data.companyId);
|
||||
|
||||
if (!data.certificatePem || !data.certificatePem.trim()) {
|
||||
throw new Error("Se requiere el certificado ARCA");
|
||||
@@ -836,6 +858,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
|
||||
public async testWsaaLogin(data: TestArcaWsaaLoginParams): Promise<ArcaWsaaLoginTestResult> {
|
||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||
await this.requirePaymentsFeature(data.companyId);
|
||||
|
||||
const service = ARCA_WSAA_SERVICE.WSFE;
|
||||
const environment = this.getArcaEnvironment();
|
||||
@@ -853,6 +876,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
|
||||
public async getWsfeLastVoucher(data: ArcaWsfeLastVoucherParams): Promise<ArcaWsfeLastVoucherResult> {
|
||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||
await this.requirePaymentsFeature(data.companyId);
|
||||
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
|
||||
const loginTicket = await this.getWsaaLoginTicket(data.companyId);
|
||||
const environment = this.getArcaEnvironment();
|
||||
@@ -886,6 +910,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
|
||||
public async issueFacturaC(data: ArcaWsfeIssueFacturaCParams): Promise<ArcaWsfeIssueFacturaCResult> {
|
||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||
await this.requirePaymentsFeature(data.companyId);
|
||||
|
||||
if (data.amount <= 0) {
|
||||
throw new Error("El importe de la factura debe ser mayor a cero");
|
||||
@@ -961,6 +986,7 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
|
||||
public async getWsfeDiagnostics(data: ArcaWsfeDiagnosticsParams): Promise<ArcaWsfeDiagnosticsResult> {
|
||||
await this.validateCompanyAdmin(data.companyId, data.sessionUser);
|
||||
await this.requirePaymentsFeature(data.companyId);
|
||||
const fiscalProfile = await this.requireActiveFiscalProfile(data.companyId, data.sessionUser);
|
||||
const loginTicket = await this.getWsaaLoginTicket(data.companyId);
|
||||
const environment = this.getArcaEnvironment();
|
||||
@@ -1054,8 +1080,11 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
|
||||
await validateSessionUser({ sessionUser: data.sessionUser });
|
||||
|
||||
let companyId: string | undefined;
|
||||
|
||||
if (data.companyId) {
|
||||
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
|
||||
companyId = data.companyId;
|
||||
} else if (data.id) {
|
||||
const credential = await this.arcaCredentials.findOne(data);
|
||||
|
||||
@@ -1067,12 +1096,13 @@ class ArcaCredentialsManager implements IArcaCredentialsManager {
|
||||
companyId: String(credential.companyId),
|
||||
sessionUser: data.sessionUser,
|
||||
});
|
||||
|
||||
return [this.sanitizeCredential(credential)];
|
||||
companyId = String(credential.companyId);
|
||||
} else {
|
||||
throw new Error("Se requiere una organización para buscar credenciales ARCA");
|
||||
}
|
||||
|
||||
await this.requirePaymentsFeature(companyId);
|
||||
|
||||
const credentials = await this.arcaCredentials.find(data);
|
||||
return credentials.map((credential) => this.sanitizeCredential(credential));
|
||||
}
|
||||
|
||||
@@ -99,7 +99,9 @@ export class CashFlowAdapterMongoose implements ICashFlowAdapter {
|
||||
searchCriteria.year = filters.year;
|
||||
}
|
||||
|
||||
if (filters.month) {
|
||||
// month 0 = enero: no usar truthy check, si no se dropea el filtro y enero
|
||||
// queda sin filtrar (devolvería todos los meses del año).
|
||||
if (typeof filters.month === "number") {
|
||||
searchCriteria.month = filters.month;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,8 @@ export class CashFlowBalanceAdapterMongoose implements ICashFlowBalanceAdapter {
|
||||
searchCriteria.companyId = filters.companyId;
|
||||
}
|
||||
|
||||
if (filters.month) {
|
||||
// month 0 = enero: no usar truthy check, si no se dropea el filtro.
|
||||
if (typeof filters.month === "number") {
|
||||
searchCriteria.month = filters.month;
|
||||
}
|
||||
|
||||
|
||||
@@ -231,7 +231,10 @@ class CashFlowBalanceManager implements ICashFlowBalanceManager {
|
||||
const userSession = await this.validateSessionUser(data.sessionUser);
|
||||
await this.validatePermissions(String(userSession.id), data.companyId);
|
||||
|
||||
const months = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
||||
// Los balances se guardan con month 0-indexado (0 = enero ... 11 = diciembre),
|
||||
// igual que dayjs.month(). No iterar de 1 a 12: eso saltea enero (key 0)
|
||||
// y consultaba un mes 12 inexistente que el cliente renderiza como enero del año siguiente.
|
||||
const months = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||
const year = data.year;
|
||||
const companyId = data.companyId;
|
||||
|
||||
|
||||
@@ -32,6 +32,14 @@ export class ClientsAdapterMongoose implements IClientsAdapter {
|
||||
banned: { type: Boolean, required: false, default: false },
|
||||
});
|
||||
|
||||
//Unicidad por email dentro de la organización. La unicidad por usuario es
|
||||
//parcial: excluye los clientes cuyo userId fue removido con $unset.
|
||||
this.schema.index({ companyId: 1, email: 1 }, { unique: true });
|
||||
this.schema.index(
|
||||
{ companyId: 1, userId: 1 },
|
||||
{ unique: true, partialFilterExpression: { userId: { $type: "oid" } } }
|
||||
);
|
||||
|
||||
this.clientList = model<IClientDocument>("Client", this.schema);
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,27 @@ class ClientManager implements IClientsManager {
|
||||
throw new Error("El cliente ya pertenece a la compañia");
|
||||
}
|
||||
|
||||
//Si la organización ya tiene un cliente con el email del usuario, se
|
||||
//vincula el usuario a ese cliente en lugar de crear un duplicado.
|
||||
const existingByEmail = await this.clients.findOne({
|
||||
companyId: data.companyId,
|
||||
email: userCheck.email,
|
||||
});
|
||||
|
||||
if (existingByEmail) {
|
||||
existingByEmail.userId = data.userId;
|
||||
await existingByEmail.save();
|
||||
|
||||
await NotificationsManager.sendSystemNotification({
|
||||
userId: String(userCheck._id),
|
||||
subject: "Ha sido dado de alta como cliente",
|
||||
message: `Su usuario ha sido dado de alta como cliente en la organización ${companyCheck.name}`,
|
||||
type: NotificationType.BILLING
|
||||
});
|
||||
|
||||
return existingByEmail;
|
||||
}
|
||||
|
||||
const canAdd = await MetricsList.canAddClient(companyCheck.ownerId);
|
||||
|
||||
if (!canAdd) {
|
||||
@@ -274,17 +295,36 @@ class ClientManager implements IClientsManager {
|
||||
);
|
||||
}
|
||||
|
||||
const newClient = await this.clients.create({
|
||||
...data,
|
||||
...{
|
||||
email: userCheck.email,
|
||||
firstName: userCheck.firstName,
|
||||
lastName: userCheck.lastName,
|
||||
phoneAreaCode: userCheck.phoneAreaCode,
|
||||
phoneCountryCode: userCheck.phoneCountryCode,
|
||||
phoneNumber: userCheck.phoneNumber,
|
||||
},
|
||||
});
|
||||
let newClient: IClientDocument;
|
||||
|
||||
try {
|
||||
newClient = await this.clients.create({
|
||||
...data,
|
||||
...{
|
||||
email: userCheck.email,
|
||||
firstName: userCheck.firstName,
|
||||
lastName: userCheck.lastName,
|
||||
phoneAreaCode: userCheck.phoneAreaCode,
|
||||
phoneCountryCode: userCheck.phoneCountryCode,
|
||||
phoneNumber: userCheck.phoneNumber,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
//Carrera de concurrencia: el índice único rechazó el duplicado y la
|
||||
//petición ganadora ya creó el cliente, lo contabilizó y notificó.
|
||||
if ((error as { code?: number })?.code === 11000) {
|
||||
const racedClient = await this.clients.findOne({
|
||||
companyId: data.companyId,
|
||||
userId: data.userId,
|
||||
});
|
||||
|
||||
if (racedClient) {
|
||||
return racedClient;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
await MetricsList.addClient({
|
||||
userId: String(companyCheck.ownerId),
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
// Unit tests for ClientManager.createClientByUser: adopt-by-email path, create
|
||||
// path, the existing userId guard, and the E11000 concurrent-create race.
|
||||
|
||||
// Mock all external dependencies before importing the module under test
|
||||
jest.mock("../../Users/Users", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
users: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Companies/Companies", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
companies: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Employees/Employee", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
checkPermission: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Metrics/Metrics", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
canAddClient: jest.fn(),
|
||||
addClient: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Notifications/Notifications", () => ({
|
||||
__esModule: true,
|
||||
NotificationsManager: {
|
||||
sendSystemNotification: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Appointments/Appointments", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
Appointments: {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
findOne: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Repeats/Repeats", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
deleteRepeatsByClient: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../ClientAccounts/ClientAccount", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getAvailableAmount: jest.fn(),
|
||||
deleteClientAccount: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("../../Payments/Payments", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
deletePaymentsByClient: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import ClientList from "../Clients";
|
||||
import UsersManager from "../../Users/Users";
|
||||
import CompaniesManager from "../../Companies/Companies";
|
||||
import MetricsList from "../../Metrics/Metrics";
|
||||
import { NotificationsManager } from "../../Notifications/Notifications";
|
||||
import { NotificationType } from "../../SystemNotifications/SystemNotification.Interface";
|
||||
|
||||
const request = {
|
||||
companyId: "company-001",
|
||||
userId: "user-001",
|
||||
sessionUser: "session-user",
|
||||
};
|
||||
|
||||
const sessionUser = { _id: "session-user" };
|
||||
|
||||
const company = {
|
||||
_id: "company-001",
|
||||
ownerId: "owner-user",
|
||||
name: "Acme",
|
||||
};
|
||||
|
||||
const user = {
|
||||
_id: "user-001",
|
||||
email: "client@example.com",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
phoneAreaCode: "11",
|
||||
phoneCountryCode: "54",
|
||||
phoneNumber: "12345678",
|
||||
};
|
||||
|
||||
// Helper to build a mock client document with save()
|
||||
function buildClientDoc(overrides: Record<string, any> = {}): Record<string, any> {
|
||||
return {
|
||||
_id: "client-001",
|
||||
id: "client-001",
|
||||
companyId: "company-001",
|
||||
email: "client@example.com",
|
||||
firstName: "Admin Entered Name",
|
||||
lastName: "Admin Entered Lastname",
|
||||
save: jest.fn().mockResolvedValue(true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("createClientByUser", () => {
|
||||
// Keep the real adapter to restore it after each test and to inspect indexes
|
||||
const realAdapter = ClientList.clients;
|
||||
let mockFindOne: jest.Mock;
|
||||
let mockCreate: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockFindOne = jest.fn();
|
||||
mockCreate = jest.fn();
|
||||
|
||||
(ClientList as any).clients = {
|
||||
findOne: mockFindOne,
|
||||
create: mockCreate,
|
||||
};
|
||||
|
||||
// First call: session user validation. Second call: target user lookup.
|
||||
(UsersManager.users.findOne as jest.Mock)
|
||||
.mockResolvedValueOnce(sessionUser)
|
||||
.mockResolvedValueOnce(user);
|
||||
|
||||
(CompaniesManager.companies.findOne as jest.Mock).mockResolvedValue(company);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(ClientList as any).clients = realAdapter;
|
||||
});
|
||||
|
||||
// --- Adopt path: existing client by email ---
|
||||
|
||||
it("adopts the existing client found by email instead of creating a duplicate", async () => {
|
||||
const existingClient = buildClientDoc();
|
||||
|
||||
mockFindOne
|
||||
.mockResolvedValueOnce(null) // guard by companyId + userId
|
||||
.mockResolvedValueOnce(existingClient); // lookup by companyId + email
|
||||
|
||||
const result = await ClientList.createClientByUser(request);
|
||||
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
expect(existingClient.userId).toBe("user-001");
|
||||
expect(existingClient.save).toHaveBeenCalled();
|
||||
// Admin-entered data must not be overwritten by the adopt path
|
||||
expect(existingClient.firstName).toBe("Admin Entered Name");
|
||||
expect(existingClient.lastName).toBe("Admin Entered Lastname");
|
||||
// The client was already counted, so metrics are untouched
|
||||
expect(MetricsList.canAddClient).not.toHaveBeenCalled();
|
||||
expect(MetricsList.addClient).not.toHaveBeenCalled();
|
||||
expect(NotificationsManager.sendSystemNotification).toHaveBeenCalledWith({
|
||||
userId: "user-001",
|
||||
subject: "Ha sido dado de alta como cliente",
|
||||
message: "Su usuario ha sido dado de alta como cliente en la organización Acme",
|
||||
type: NotificationType.BILLING,
|
||||
});
|
||||
expect(result).toBe(existingClient);
|
||||
});
|
||||
|
||||
// --- Create path: no client by userId nor email ---
|
||||
|
||||
it("creates a new client when no client exists by userId nor email", async () => {
|
||||
const newClient = buildClientDoc({ userId: "user-001" });
|
||||
|
||||
mockFindOne
|
||||
.mockResolvedValueOnce(null) // guard by companyId + userId
|
||||
.mockResolvedValueOnce(null); // lookup by companyId + email
|
||||
|
||||
(MetricsList.canAddClient as jest.Mock).mockResolvedValue(true);
|
||||
mockCreate.mockResolvedValue(newClient);
|
||||
|
||||
const result = await ClientList.createClientByUser(request);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
companyId: "company-001",
|
||||
userId: "user-001",
|
||||
sessionUser: "session-user",
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
phoneAreaCode: user.phoneAreaCode,
|
||||
phoneCountryCode: user.phoneCountryCode,
|
||||
phoneNumber: user.phoneNumber,
|
||||
});
|
||||
expect(MetricsList.addClient).toHaveBeenCalledWith({
|
||||
userId: "owner-user",
|
||||
quantity: 1,
|
||||
});
|
||||
expect(NotificationsManager.sendSystemNotification).toHaveBeenCalledWith({
|
||||
userId: "user-001",
|
||||
subject: "Ha sido dado de alta como cliente",
|
||||
message: "Su usuario ha sido dado de alta como cliente en la organización Acme",
|
||||
type: NotificationType.BILLING,
|
||||
});
|
||||
expect(result).toBe(newClient);
|
||||
});
|
||||
|
||||
// --- Existing guard preserved ---
|
||||
|
||||
it("throws when the client already belongs to the company", async () => {
|
||||
mockFindOne.mockResolvedValueOnce(buildClientDoc()); // guard by companyId + userId
|
||||
|
||||
await expect(ClientList.createClientByUser(request)).rejects.toThrow(
|
||||
/ya pertenece a la compan/
|
||||
);
|
||||
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
expect(NotificationsManager.sendSystemNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- E11000 concurrent-create race ---
|
||||
|
||||
it("returns the winning client when create hits the unique index (E11000)", async () => {
|
||||
const racedClient = buildClientDoc({ userId: "user-001" });
|
||||
|
||||
mockFindOne
|
||||
.mockResolvedValueOnce(null) // guard by companyId + userId
|
||||
.mockResolvedValueOnce(null) // lookup by companyId + email
|
||||
.mockResolvedValueOnce(racedClient); // re-fetch after E11000
|
||||
|
||||
(MetricsList.canAddClient as jest.Mock).mockResolvedValue(true);
|
||||
mockCreate.mockRejectedValue({ code: 11000 });
|
||||
|
||||
const result = await ClientList.createClientByUser(request);
|
||||
|
||||
expect(result).toBe(racedClient);
|
||||
// The winning request already counted and notified
|
||||
expect(MetricsList.addClient).not.toHaveBeenCalled();
|
||||
expect(NotificationsManager.sendSystemNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rethrows non-duplicate-key errors from create", async () => {
|
||||
mockFindOne
|
||||
.mockResolvedValueOnce(null) // guard by companyId + userId
|
||||
.mockResolvedValueOnce(null); // lookup by companyId + email
|
||||
|
||||
(MetricsList.canAddClient as jest.Mock).mockResolvedValue(true);
|
||||
mockCreate.mockRejectedValue(new Error("database down"));
|
||||
|
||||
await expect(ClientList.createClientByUser(request)).rejects.toThrow("database down");
|
||||
});
|
||||
|
||||
// --- Schema indexes ---
|
||||
|
||||
it("declares unique indexes for companyId+email and companyId+userId", () => {
|
||||
const indexes = realAdapter.schema.indexes();
|
||||
|
||||
const emailIndex = indexes.find(
|
||||
([fields]) => fields.companyId === 1 && fields.email === 1
|
||||
);
|
||||
expect(emailIndex).toBeDefined();
|
||||
expect(emailIndex?.[1]).toMatchObject({ unique: true });
|
||||
|
||||
const userIdIndex = indexes.find(
|
||||
([fields]) => fields.companyId === 1 && fields.userId === 1
|
||||
);
|
||||
expect(userIdIndex).toBeDefined();
|
||||
expect(userIdIndex?.[1]).toMatchObject({
|
||||
unique: true,
|
||||
partialFilterExpression: { userId: { $type: "oid" } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,8 @@ export class CompaniesAdapterMongoose implements ICompaniesAdapter {
|
||||
showPublicProfessionals: { type: Boolean, required: false, default: true },
|
||||
});
|
||||
|
||||
this.schema.index({ published: 1, banned: 1 });
|
||||
|
||||
this.companyList = model<ICompanyDocument>("Company", this.schema);
|
||||
}
|
||||
|
||||
|
||||
@@ -380,4 +380,5 @@ export interface ICompaniesManager {
|
||||
sysAdminUpdate(data: SysAdminUpdateCompanyParams): Promise<void>;
|
||||
sysAdminSetBanned(data: SysAdminSetCompanyBannedParams): Promise<void>;
|
||||
sysAdminGetInsights(data: SysAdminCompanyInsightsParams): Promise<SysAdminCompanyInsightsResult>;
|
||||
checkCompanyAdmin(data: { companyId: string; sessionUser: string }): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -1221,6 +1221,19 @@ class CompaniesManager implements ICompaniesManager {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async checkCompanyAdmin(data: { companyId: string; sessionUser: string }): Promise<boolean> {
|
||||
const filterData =
|
||||
data.companyId.indexOf("-") >= 0 ? { slug: data.companyId } : { _id: data.companyId };
|
||||
|
||||
const company = await this.companies.findOne(filterData as any);
|
||||
|
||||
if (!company) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await EmployeesList.checkPermission(company, data.sessionUser, EmployeeRoles.ADMIN);
|
||||
}
|
||||
}
|
||||
|
||||
const CompaniesList = new CompaniesManager();
|
||||
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
import { NotificationJobsAdapterMongoose } from "../NotificationJobs/NotificationJobs.Adapter.Mongoose";
|
||||
import { PolicyResolver, ResolvedPolicy } from "./PolicyResolver";
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export interface CreateJobServiceParams {
|
||||
companyId: string;
|
||||
@@ -66,12 +68,17 @@ export class NotificationJobService {
|
||||
);
|
||||
|
||||
for (const rule of enabledReminderRules) {
|
||||
const reminderScheduledAt = this.adjustForQuietHours(
|
||||
dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(),
|
||||
const originalReminderScheduledAt = dayjs(params.appointmentStart)
|
||||
.subtract(rule.offset, "minute")
|
||||
.toDate();
|
||||
const reminderScheduledAt = this.selectAutomaticReminderScheduledAt(
|
||||
originalReminderScheduledAt,
|
||||
now,
|
||||
params.appointmentStart,
|
||||
resolvedPolicy
|
||||
);
|
||||
|
||||
if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) {
|
||||
if (reminderScheduledAt === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -169,12 +176,17 @@ export class NotificationJobService {
|
||||
);
|
||||
|
||||
for (const rule of enabledReminderRules) {
|
||||
const reminderScheduledAt = this.adjustForQuietHours(
|
||||
dayjs(params.appointmentStart).subtract(rule.offset, "minute").toDate(),
|
||||
const originalReminderScheduledAt = dayjs(params.appointmentStart)
|
||||
.subtract(rule.offset, "minute")
|
||||
.toDate();
|
||||
const reminderScheduledAt = this.selectAutomaticReminderScheduledAt(
|
||||
originalReminderScheduledAt,
|
||||
now,
|
||||
params.appointmentStart,
|
||||
resolvedPolicy
|
||||
);
|
||||
|
||||
if (reminderScheduledAt < now || reminderScheduledAt >= params.appointmentStart) {
|
||||
if (reminderScheduledAt === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -239,6 +251,78 @@ export class NotificationJobService {
|
||||
return scheduledAt;
|
||||
}
|
||||
|
||||
private selectAutomaticReminderScheduledAt(
|
||||
originalScheduledAt: Date,
|
||||
now: Date,
|
||||
appointmentStart: Date,
|
||||
policy: ResolvedPolicy
|
||||
): Date | null {
|
||||
const isValidCandidate = (candidate: Date): boolean =>
|
||||
candidate >= now && candidate < appointmentStart;
|
||||
|
||||
if (!policy.quietHours?.from || !policy.quietHours?.to) {
|
||||
return isValidCandidate(originalScheduledAt) ? originalScheduledAt : null;
|
||||
}
|
||||
|
||||
const fromHour = parseInt(policy.quietHours.from.split(":")[0], 10);
|
||||
const fromMinute = parseInt(policy.quietHours.from.split(":")[1], 10);
|
||||
const toHour = parseInt(policy.quietHours.to.split(":")[0], 10);
|
||||
const toMinute = parseInt(policy.quietHours.to.split(":")[1], 10);
|
||||
const localScheduledAt = dayjs(originalScheduledAt).tz(policy.timezone);
|
||||
const scheduledMinutes = localScheduledAt.hour() * 60 + localScheduledAt.minute();
|
||||
|
||||
const isInsideQuietHours = this.isTimeInQuietHours(
|
||||
localScheduledAt.hour(),
|
||||
localScheduledAt.minute(),
|
||||
fromHour,
|
||||
fromMinute,
|
||||
toHour,
|
||||
toMinute
|
||||
);
|
||||
|
||||
if (!isInsideQuietHours) {
|
||||
return isValidCandidate(originalScheduledAt) ? originalScheduledAt : null;
|
||||
}
|
||||
|
||||
const fromMinutes = fromHour * 60 + fromMinute;
|
||||
const toMinutes = toHour * 60 + toMinute;
|
||||
const wrapsAcrossMidnight = fromMinutes >= toMinutes;
|
||||
const isEarlyMorningSegment = scheduledMinutes < fromMinutes;
|
||||
const localDay = localScheduledAt.startOf("day");
|
||||
const quietStartDay =
|
||||
wrapsAcrossMidnight && isEarlyMorningSegment
|
||||
? localDay.subtract(1, "day")
|
||||
: localDay;
|
||||
const quietEndDay =
|
||||
wrapsAcrossMidnight && !isEarlyMorningSegment
|
||||
? localDay.add(1, "day")
|
||||
: localDay;
|
||||
const quietStart = quietStartDay
|
||||
.hour(fromHour)
|
||||
.minute(fromMinute)
|
||||
.second(0)
|
||||
.millisecond(localScheduledAt.millisecond());
|
||||
const quietEnd = quietEndDay
|
||||
.hour(toHour)
|
||||
.minute(toMinute)
|
||||
.second(0)
|
||||
.millisecond(localScheduledAt.millisecond());
|
||||
const advanceCandidate = quietStart.subtract(1, "minute").toDate();
|
||||
const postponeCandidate = quietEnd.toDate();
|
||||
const validAdvanceCandidate = isValidCandidate(advanceCandidate)
|
||||
? advanceCandidate
|
||||
: null;
|
||||
const validPostponeCandidate = isValidCandidate(postponeCandidate)
|
||||
? postponeCandidate
|
||||
: null;
|
||||
|
||||
if (validAdvanceCandidate) {
|
||||
return validAdvanceCandidate;
|
||||
}
|
||||
|
||||
return validPostponeCandidate;
|
||||
}
|
||||
|
||||
private getPayloadChannels(payload?: CreateJobParams["payload"]): NotificationChannel[] {
|
||||
const channels: NotificationChannel[] = [];
|
||||
|
||||
|
||||
@@ -180,28 +180,35 @@ describe("NotificationJobService.createJob", () => {
|
||||
expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips reminder jobs adjusted by quiet hours after appointment start", async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T20:00:00.000Z"));
|
||||
it("keeps UTC adjustment for the createJob base job and uses local candidates for reminders", async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T23:00:00.000Z"));
|
||||
const resolvedPolicy: ResolvedPolicy = {
|
||||
channels: ["system", "email"],
|
||||
channels: ["system"],
|
||||
timezone: "America/Argentina/Buenos_Aires",
|
||||
quietHours: { from: "22:00", to: "07:00" },
|
||||
reminderRules: [{ offset: 15, enabled: true }],
|
||||
reminderRules: [{ offset: 30, enabled: true }],
|
||||
};
|
||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||
const baseScheduledAt = new Date("2026-07-22T02:00:00.000Z");
|
||||
|
||||
await service.createJob({
|
||||
companyId: "company-1",
|
||||
clientId: "client-1",
|
||||
appointmentId: "appt-1",
|
||||
type: "creation",
|
||||
appointmentStart: new Date("2026-07-21T23:30:00.000Z"),
|
||||
type: "update",
|
||||
scheduledAt: baseScheduledAt,
|
||||
appointmentStart: new Date("2026-07-22T03:00:00.000Z"),
|
||||
});
|
||||
|
||||
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
|
||||
expect(createCalls.filter((job) => job.type === "creation")).toHaveLength(2);
|
||||
expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0);
|
||||
expect(createCalls).toHaveLength(2);
|
||||
expect(createCalls.find((job) => job.type === "update")?.scheduledAt.toISOString()).toBe(
|
||||
"2026-07-22T07:00:00.000Z"
|
||||
);
|
||||
expect(createCalls.find((job) => job.type === "reminder")?.scheduledAt.toISOString()).toBe(
|
||||
"2026-07-22T00:59:00.000Z"
|
||||
);
|
||||
});
|
||||
|
||||
it("creates no jobs when resolved channels are empty", async () => {
|
||||
@@ -330,77 +337,208 @@ describe("NotificationJobService.createJob", () => {
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("adjusts scheduledAt for quiet hours crossing midnight", async () => {
|
||||
it("keeps an automatic reminder at its original instant outside quiet hours", async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T07:00:00.000Z"));
|
||||
const resolvedPolicy: ResolvedPolicy = {
|
||||
channels: ["system"],
|
||||
timezone: "America/Argentina/Buenos_Aires",
|
||||
quietHours: { from: "22:00", to: "07:00" },
|
||||
timezone: "UTC",
|
||||
quietHours: { from: "08:00", to: "10:00" },
|
||||
reminderRules: [{ offset: 270, enabled: true }],
|
||||
};
|
||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||
|
||||
// scheduledAt at 23:30 UTC — should be adjusted to 07:00 next day UTC
|
||||
const scheduledAt = new Date("2026-07-21T23:30:00.000Z");
|
||||
|
||||
await service.createJob({
|
||||
const result = await service.createReminderJobs({
|
||||
companyId: "company-1",
|
||||
clientId: "client-1",
|
||||
appointmentId: "appt-1",
|
||||
type: "creation",
|
||||
scheduledAt,
|
||||
type: "reminder",
|
||||
appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
|
||||
});
|
||||
|
||||
const createCall = mockJobCreate.mock.calls[0][0];
|
||||
// 23:30 is inside quiet hours (22:00-07:00), adjusted to 07:00 next day
|
||||
expect(createCall.scheduledAt.getUTCHours()).toBe(7);
|
||||
expect(createCall.scheduledAt.getUTCDate()).toBe(22); // next day
|
||||
expect(result).toHaveLength(1);
|
||||
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
|
||||
"2026-07-21T07:30:00.123Z"
|
||||
);
|
||||
});
|
||||
|
||||
it("adjusts early morning quiet hours crossing midnight to the same day", async () => {
|
||||
it("prefers the pre-quiet candidate when the quiet-hours end is closer for a same-day range", async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T00:00:00.000Z"));
|
||||
const resolvedPolicy: ResolvedPolicy = {
|
||||
channels: ["system"],
|
||||
timezone: "America/Argentina/Buenos_Aires",
|
||||
quietHours: { from: "22:00", to: "07:00" },
|
||||
timezone: "UTC",
|
||||
quietHours: { from: "01:00", to: "06:00" },
|
||||
reminderRules: [{ offset: 150, enabled: true }],
|
||||
};
|
||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||
|
||||
await service.createJob({
|
||||
const result = await service.createReminderJobs({
|
||||
companyId: "company-1",
|
||||
clientId: "client-1",
|
||||
appointmentId: "appt-1",
|
||||
type: "creation",
|
||||
scheduledAt: new Date("2026-07-21T05:00:00.000Z"),
|
||||
type: "reminder",
|
||||
appointmentStart: new Date("2026-07-21T08:00:00.123Z"),
|
||||
});
|
||||
|
||||
const createCall = mockJobCreate.mock.calls[0][0];
|
||||
expect(createCall.scheduledAt.toISOString()).toBe("2026-07-21T07:00:00.000Z");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
|
||||
"2026-07-21T00:59:00.123Z"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not adjust scheduledAt when outside quiet hours", async () => {
|
||||
it("uses the policy timezone for a cross-midnight range and advances to the prior local day", async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T22:00:00.000Z"));
|
||||
const resolvedPolicy: ResolvedPolicy = {
|
||||
channels: ["system"],
|
||||
timezone: "America/Argentina/Buenos_Aires",
|
||||
quietHours: { from: "22:00", to: "07:00" },
|
||||
reminderRules: [{ offset: 510, enabled: true }],
|
||||
};
|
||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||
|
||||
// scheduledAt at 14:00 UTC — outside quiet hours (22:00-07:00)
|
||||
const scheduledAt = new Date("2026-07-21T14:00:00.000Z");
|
||||
|
||||
await service.createJob({
|
||||
const result = await service.createReminderJobs({
|
||||
companyId: "company-1",
|
||||
clientId: "client-1",
|
||||
appointmentId: "appt-1",
|
||||
type: "creation",
|
||||
scheduledAt,
|
||||
type: "reminder",
|
||||
appointmentStart: new Date("2026-07-22T12:00:00.123Z"),
|
||||
});
|
||||
|
||||
const createCall = mockJobCreate.mock.calls[0][0];
|
||||
// 14:00 is outside quiet hours, should remain unchanged
|
||||
expect(createCall.scheduledAt.getUTCHours()).toBe(14);
|
||||
expect(createCall.scheduledAt.getUTCDate()).toBe(21);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
|
||||
"2026-07-22T00:59:00.123Z"
|
||||
);
|
||||
});
|
||||
|
||||
it("advances to one minute before quiet-hours start when both candidates are valid", async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T07:00:00.000Z"));
|
||||
const resolvedPolicy: ResolvedPolicy = {
|
||||
channels: ["system"],
|
||||
timezone: "UTC",
|
||||
quietHours: { from: "08:00", to: "10:00" },
|
||||
reminderRules: [{ offset: 210, enabled: true }],
|
||||
};
|
||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||
|
||||
const result = await service.createReminderJobs({
|
||||
companyId: "company-1",
|
||||
clientId: "client-1",
|
||||
appointmentId: "appt-1",
|
||||
type: "reminder",
|
||||
appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
|
||||
"2026-07-21T07:59:00.123Z"
|
||||
);
|
||||
});
|
||||
|
||||
it("postpones when the advance candidate is before now", async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T08:30:00.000Z"));
|
||||
const resolvedPolicy: ResolvedPolicy = {
|
||||
channels: ["system"],
|
||||
timezone: "UTC",
|
||||
quietHours: { from: "08:00", to: "10:00" },
|
||||
reminderRules: [{ offset: 150, enabled: true }],
|
||||
};
|
||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||
|
||||
const result = await service.createReminderJobs({
|
||||
companyId: "company-1",
|
||||
clientId: "client-1",
|
||||
appointmentId: "appt-1",
|
||||
type: "reminder",
|
||||
appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
|
||||
"2026-07-21T10:00:00.123Z"
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers the pre-quiet candidate on an exact candidate-distance tie", async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T07:00:00.000Z"));
|
||||
const resolvedPolicy: ResolvedPolicy = {
|
||||
channels: ["system"],
|
||||
timezone: "UTC",
|
||||
quietHours: { from: "08:00", to: "10:00" },
|
||||
reminderRules: [{ offset: 180, enabled: true }],
|
||||
};
|
||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||
|
||||
const result = await service.createReminderJobs({
|
||||
companyId: "company-1",
|
||||
clientId: "client-1",
|
||||
appointmentId: "appt-1",
|
||||
type: "reminder",
|
||||
appointmentStart: new Date("2026-07-21T11:59:30.123Z"),
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(mockJobCreate.mock.calls[0][0].scheduledAt.toISOString()).toBe(
|
||||
"2026-07-21T07:59:00.123Z"
|
||||
);
|
||||
});
|
||||
|
||||
it("treats quiet-hours start as inclusive and end as exclusive", async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T07:00:00.000Z"));
|
||||
const resolvedPolicy: ResolvedPolicy = {
|
||||
channels: ["system"],
|
||||
timezone: "UTC",
|
||||
quietHours: { from: "08:00", to: "10:00" },
|
||||
reminderRules: [
|
||||
{ offset: 240, enabled: true },
|
||||
{ offset: 120, enabled: true },
|
||||
],
|
||||
};
|
||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||
|
||||
const result = await service.createReminderJobs({
|
||||
companyId: "company-1",
|
||||
clientId: "client-1",
|
||||
appointmentId: "appt-1",
|
||||
type: "reminder",
|
||||
appointmentStart: new Date("2026-07-21T12:00:00.123Z"),
|
||||
});
|
||||
|
||||
const scheduledTimes = mockJobCreate.mock.calls
|
||||
.map((call) => call[0].scheduledAt.toISOString())
|
||||
.sort();
|
||||
expect(result).toHaveLength(2);
|
||||
expect(scheduledTimes).toEqual([
|
||||
"2026-07-21T07:59:00.123Z",
|
||||
"2026-07-21T10:00:00.123Z",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits a reminder when the advance is past and postponement reaches appointment start", async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T09:30:00.000Z"));
|
||||
const resolvedPolicy: ResolvedPolicy = {
|
||||
channels: ["system"],
|
||||
timezone: "UTC",
|
||||
quietHours: { from: "08:00", to: "10:00" },
|
||||
reminderRules: [{ offset: 60, enabled: true }],
|
||||
};
|
||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||
|
||||
const result = await service.createReminderJobs({
|
||||
companyId: "company-1",
|
||||
clientId: "client-1",
|
||||
appointmentId: "appt-1",
|
||||
type: "reminder",
|
||||
appointmentStart: new Date("2026-07-21T10:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
expect(mockJobCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes appointmentId and type to job creation", async () => {
|
||||
@@ -428,21 +566,24 @@ describe("NotificationJobService.createJob", () => {
|
||||
expect(createCall.clientId).toBe("client-1");
|
||||
});
|
||||
|
||||
it("creates immediate jobs without reminder offsets", async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z"));
|
||||
it("does not apply quiet-hours adjustment to immediate reminder jobs", async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T09:00:00.000Z"));
|
||||
const resolvedPolicy: ResolvedPolicy = {
|
||||
channels: ["system", "email"],
|
||||
timezone: "America/Argentina/Buenos_Aires",
|
||||
timezone: "UTC",
|
||||
quietHours: { from: "08:00", to: "10:00" },
|
||||
reminderRules: [{ offset: 30, enabled: true }],
|
||||
};
|
||||
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
||||
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
||||
const scheduledAt = new Date("2026-07-21T09:30:00.123Z");
|
||||
|
||||
const result = await service.createImmediateJob({
|
||||
companyId: "company-1",
|
||||
clientId: "client-1",
|
||||
appointmentId: "appt-1",
|
||||
type: "reminder",
|
||||
scheduledAt,
|
||||
appointmentStart: new Date("2026-07-21T16:00:00.000Z"),
|
||||
});
|
||||
|
||||
@@ -453,12 +594,12 @@ describe("NotificationJobService.createJob", () => {
|
||||
expect.objectContaining({
|
||||
channel: "system",
|
||||
type: "reminder",
|
||||
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
|
||||
scheduledAt,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
channel: "email",
|
||||
type: "reminder",
|
||||
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
|
||||
scheduledAt,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
|
||||
import CompaniesList from "../Companies/Companies";
|
||||
import PlanSubscriptionsList from "../PlanSubscriptions/PlanSubscriptons";
|
||||
import { PlanFeatures } from "../Plans/Plans.interface";
|
||||
import { findArcaActivityByCode } from "../ArcaActivities/ArcaActivities.Catalog";
|
||||
import { OrganizationFiscalProfilesAdapterMongoose } from "./OrganizationFiscalProfiles.Adapter.Mongoose";
|
||||
import {
|
||||
@@ -70,6 +73,21 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa
|
||||
|
||||
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
|
||||
|
||||
const company = await CompaniesList.companies.findOne({ _id: data.companyId });
|
||||
|
||||
if (!company) {
|
||||
throw new Error("No se ha encontrado la organización");
|
||||
}
|
||||
|
||||
const canAccessPayments = await PlanSubscriptionsList.checkFeature({
|
||||
userId: String(company.ownerId),
|
||||
feature: PlanFeatures.PAYMENTS,
|
||||
});
|
||||
|
||||
if (!canAccessPayments) {
|
||||
throw new Error("La organización no tiene un plan que soporte el módulo de pagos");
|
||||
}
|
||||
|
||||
this.validateProfileFields(data);
|
||||
|
||||
const existingProfile = await this.organizationFiscalProfiles.findOne({
|
||||
@@ -140,6 +158,18 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa
|
||||
companyId: data.companyId,
|
||||
sessionUser,
|
||||
});
|
||||
|
||||
const company = await CompaniesList.companies.findOne({ _id: data.companyId });
|
||||
if (company) {
|
||||
const canAccessPayments = await PlanSubscriptionsList.checkFeature({
|
||||
userId: String(company.ownerId),
|
||||
feature: PlanFeatures.PAYMENTS,
|
||||
});
|
||||
|
||||
if (!canAccessPayments) {
|
||||
throw new Error("La organización no tiene un plan que soporte el módulo de pagos");
|
||||
}
|
||||
}
|
||||
} else if (data.id) {
|
||||
const profile = await this.organizationFiscalProfiles.findOne(data);
|
||||
|
||||
@@ -152,6 +182,18 @@ class OrganizationFiscalProfilesManager implements IOrganizationFiscalProfilesMa
|
||||
sessionUser,
|
||||
});
|
||||
|
||||
const company = await CompaniesList.companies.findOne({ _id: String(profile.companyId) });
|
||||
if (company) {
|
||||
const canAccessPayments = await PlanSubscriptionsList.checkFeature({
|
||||
userId: String(company.ownerId),
|
||||
feature: PlanFeatures.PAYMENTS,
|
||||
});
|
||||
|
||||
if (!canAccessPayments) {
|
||||
throw new Error("La organización no tiene un plan que soporte el módulo de pagos");
|
||||
}
|
||||
}
|
||||
|
||||
return [profile];
|
||||
} else {
|
||||
throw new Error("Se requiere una organización para buscar perfiles fiscales");
|
||||
|
||||
@@ -112,7 +112,6 @@ export class PlanUsageCycleAdapterMongoose implements IPlanUsageCycleAdapter {
|
||||
planId: data.planId,
|
||||
cycleStart: data.cycleStart,
|
||||
cycleEnd: data.cycleEnd,
|
||||
appointmentsCount: 0,
|
||||
creationDate: now,
|
||||
},
|
||||
$inc: { appointmentsCount: data.quantity },
|
||||
|
||||
@@ -63,6 +63,7 @@ import ClientsList from "../Clients/Clients";
|
||||
import RepeatsList from "../Repeats/Repeats";
|
||||
import { OAuth2Client } from "google-auth-library";
|
||||
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const cleanEmail = (email: string): string => {
|
||||
return email.toLowerCase().trim();
|
||||
@@ -1087,10 +1088,18 @@ class UsersManager implements IUsersManager {
|
||||
|
||||
await this.users.update(data);
|
||||
|
||||
const fecha = dayjs().format("DD/MM/YYYY");
|
||||
const hora = dayjs().format("HH:mm");
|
||||
|
||||
await NotificationsManager.sendEmail({
|
||||
email: cleanEmail(sessionUser.email),
|
||||
subject: "TurnosXpress :: Usuario actualizado",
|
||||
message: `Hola ${sessionUser.firstName}, has actualizado tu perfil con éxito`,
|
||||
templateId: "6aa948e050f4bd20460f00a5",
|
||||
context: {
|
||||
username: sessionUser.firstName,
|
||||
fecha,
|
||||
hora,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isNull } from "../../helpers/IsNull";
|
||||
import CompaniesList from "../Companies/Companies";
|
||||
import { CompaniesAdapterMongoose } from "../Companies/Companies.Adapter.Mongoose";
|
||||
import ServiceList from "../Services/Service";
|
||||
import {
|
||||
PublicServicesResults,
|
||||
@@ -15,6 +16,24 @@ import { BotEventParams, BotView } from "../WapServer/WapServer.Interface";
|
||||
import WapServerList from "../WapServer/WapServer";
|
||||
import { COMPANY_PUBLISHED_STATUS } from "../Companies/Companies.Interface";
|
||||
|
||||
export type PublicOrganizationsParams = {
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export type PublicOrganizationListItem = {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
creationDate: Date;
|
||||
};
|
||||
|
||||
export type PublicOrganizationsResult = {
|
||||
data: PublicOrganizationListItem[];
|
||||
page: number;
|
||||
pages: number;
|
||||
};
|
||||
|
||||
export type HomeView = {
|
||||
education: PublicServicesResults;
|
||||
health: PublicServicesResults;
|
||||
@@ -39,6 +58,7 @@ export type PublicOrganizationViewParams = {
|
||||
|
||||
export type PublicOrganizationView = {
|
||||
id: string;
|
||||
slug: string;
|
||||
headerFile: string;
|
||||
headerColor: string;
|
||||
headerFontColor: string;
|
||||
@@ -143,6 +163,7 @@ class Views {
|
||||
|
||||
return {
|
||||
id: isNull<string>(company.id, ""),
|
||||
slug: isNull<string>(company.slug, ""),
|
||||
name: isNull<string>(company.name, ""),
|
||||
description: isNull<string>(company.description, ""),
|
||||
categoryId: isNull<number>(company.categoryId, 50000),
|
||||
@@ -249,6 +270,44 @@ class Views {
|
||||
};
|
||||
}
|
||||
|
||||
public async publicOrganizations(
|
||||
data: PublicOrganizationsParams
|
||||
): Promise<PublicOrganizationsResult> {
|
||||
const { page, limit } = data;
|
||||
const filter = {
|
||||
published: COMPANY_PUBLISHED_STATUS.PUBLISHED,
|
||||
banned: { $ne: true },
|
||||
};
|
||||
|
||||
const companyList = (CompaniesList.companies as CompaniesAdapterMongoose).companyList;
|
||||
|
||||
const count = await companyList
|
||||
.countDocuments(filter)
|
||||
.exec();
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const results = (await companyList
|
||||
.find(filter)
|
||||
.select("slug name description creationDate")
|
||||
.sort({ creationDate: 1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.lean()
|
||||
.exec()) as any[];
|
||||
|
||||
return {
|
||||
data: results.map((company) => ({
|
||||
slug: String(company.slug || ""),
|
||||
name: String(company.name || ""),
|
||||
description: String(company.description || ""),
|
||||
creationDate: company.creationDate,
|
||||
})),
|
||||
page,
|
||||
pages: Math.ceil(count / limit),
|
||||
};
|
||||
}
|
||||
|
||||
public async wapView(data: BotEventParams): Promise<BotView> {
|
||||
return await WapServerList.botView(data);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export class ToggleCompanyFixedPostController extends Controller {
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async toggleFixedCompanyPost(
|
||||
@Body() requestBody: FixCompanyPostParams
|
||||
@Body() requestBody: FixCompanyPostParams,
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new CompaniesService().toggleFixedPost(requestBody);
|
||||
@@ -62,7 +62,7 @@ export class MoveCompanyFixedPostController extends Controller {
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async moveFixedCompanyPost(
|
||||
@Body() requestBody: MoveCompanyFixedPostParams
|
||||
@Body() requestBody: MoveCompanyFixedPostParams,
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new CompaniesService().moveFixedPost(requestBody);
|
||||
@@ -86,7 +86,7 @@ export class SetNotificationsStatusCompanyController extends Controller {
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async setNotificationAuto(
|
||||
@Body() requestBody: SetNotificationAutoParams
|
||||
@Body() requestBody: SetNotificationAutoParams,
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new CompaniesService().setNotificationAuto(requestBody);
|
||||
@@ -110,7 +110,7 @@ export class SetPublishedStatusCompanyController extends Controller {
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async setPublishedStatus(
|
||||
@Body() requestBody: SetOrganizationPublishedStatusParams
|
||||
@Body() requestBody: SetOrganizationPublishedStatusParams,
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new CompaniesService().setPublishedStatus(requestBody);
|
||||
@@ -134,7 +134,7 @@ export class CreateCompanyController extends Controller {
|
||||
@SuccessResponse(200, "La compañia fue creada con exito")
|
||||
@Post()
|
||||
public async createCompany(
|
||||
@Body() requestBody: CreateCompanyParams
|
||||
@Body() requestBody: CreateCompanyParams,
|
||||
): Promise<ICompany | ApiValidationError> {
|
||||
try {
|
||||
const company = await new CompaniesService().createCompany(requestBody);
|
||||
@@ -155,7 +155,7 @@ export class DeleteCompanyController extends Controller {
|
||||
@SuccessResponse(200, "La compañia fue modificada con exito")
|
||||
@Post()
|
||||
public async deleteCompany(
|
||||
@Body() requestBody: DeleteCompanyParams
|
||||
@Body() requestBody: DeleteCompanyParams,
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new CompaniesService().deleteCompany(requestBody);
|
||||
@@ -179,7 +179,7 @@ export class UpdateCompanyController extends Controller {
|
||||
@SuccessResponse(200, "La compañia fue modificada con exito")
|
||||
@Post()
|
||||
public async updateCompany(
|
||||
@Body() requestBody: UpdateCompanyParams
|
||||
@Body() requestBody: UpdateCompanyParams,
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new CompaniesService().updateCompany(requestBody);
|
||||
@@ -202,7 +202,7 @@ export class GetCompanyByIdController extends Controller {
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async getById(
|
||||
@Body() requestBody: OranizationsViewByIdParams
|
||||
@Body() requestBody: OranizationsViewByIdParams,
|
||||
): Promise<MyOranizationsView | ApiValidationError> {
|
||||
try {
|
||||
const companies = await new CompaniesService().getById(requestBody);
|
||||
@@ -222,7 +222,7 @@ export class GetCompanyByUserController extends Controller {
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async getByUserId(
|
||||
@Body() requestBody: MyOranizationsViewParams
|
||||
@Body() requestBody: MyOranizationsViewParams,
|
||||
): Promise<MyOranizationsView[] | ApiValidationError> {
|
||||
try {
|
||||
const companies = await new CompaniesService().getByUserId(requestBody);
|
||||
@@ -242,7 +242,7 @@ export class PaginateCompanyController extends Controller {
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async paginateUsers(
|
||||
@Body() requestBody: PaginateCompaniesParams
|
||||
@Body() requestBody: PaginateCompaniesParams,
|
||||
): Promise<PaginateCompaniesResults | ApiValidationError> {
|
||||
try {
|
||||
const users = await new CompaniesService().paginateCompanies(requestBody);
|
||||
@@ -265,7 +265,7 @@ export class UploadCompanyHeaderController extends Controller {
|
||||
public async uploadHeader(
|
||||
@FormField() companyId: string,
|
||||
@FormField() sessionUser: string,
|
||||
@UploadedFile() file: Express.Multer.File
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new CompaniesService().setCompanyHeader({
|
||||
@@ -295,7 +295,7 @@ export class UploadCompanyIconController extends Controller {
|
||||
public async uploadIcon(
|
||||
@FormField() companyId: string,
|
||||
@FormField() sessionUser: string,
|
||||
@UploadedFile() file: Express.Multer.File
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new CompaniesService().setCompanyIcon({
|
||||
@@ -325,7 +325,7 @@ export class DeleteCompanyHeaderController extends Controller {
|
||||
@SuccessResponse(200, "El archivo fue cargado con exito")
|
||||
@Post()
|
||||
public async deleteHeader(
|
||||
@Body() data: DeleteCompanyFileParams
|
||||
@Body() data: DeleteCompanyFileParams,
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new CompaniesService().deleteCompanyHeader(data);
|
||||
@@ -349,7 +349,7 @@ export class DeleteCompanyIconController extends Controller {
|
||||
@SuccessResponse(200, "El archivo fue cargado con exito")
|
||||
@Post()
|
||||
public async deleteIcon(
|
||||
@Body() data: DeleteCompanyFileParams
|
||||
@Body() data: DeleteCompanyFileParams,
|
||||
): Promise<ApiVoidResult | ApiValidationError> {
|
||||
try {
|
||||
await new CompaniesService().deleteCompanyIcon(data);
|
||||
@@ -372,7 +372,7 @@ export class filterAllOrganizationsController extends Controller {
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async filterAllOrganizations(
|
||||
@Body() requestBody: PaginateCompaniesParams
|
||||
@Body() requestBody: PaginateCompaniesParams,
|
||||
): Promise<TextObjectFilterResult[] | ApiValidationError> {
|
||||
try {
|
||||
const clients = await new CompaniesService().filterAll(requestBody);
|
||||
@@ -392,7 +392,7 @@ export class CheckCompanyNameController extends Controller {
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async checkName(
|
||||
@Body() requestBody: { name: string }
|
||||
@Body() requestBody: { name: string },
|
||||
): Promise<{ valid: boolean } | ApiValidationError> {
|
||||
try {
|
||||
const valid = await new CompaniesService().checkNameExists(requestBody.name);
|
||||
@@ -405,3 +405,24 @@ export class CheckCompanyNameController extends Controller {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Route("companies/check-admin")
|
||||
@Middlewares(authenticateMiddleware)
|
||||
export class CheckCompanyAdminController extends Controller {
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async checkAdmin(
|
||||
@Body() requestBody: { companyId: string; sessionUser: string },
|
||||
): Promise<boolean | ApiValidationError> {
|
||||
try {
|
||||
const isAdmin = await new CompaniesService().checkCompanyAdmin(requestBody);
|
||||
this.setStatus(200);
|
||||
return isAdmin;
|
||||
} catch (e) {
|
||||
const errorOccurred: Error = e as Error;
|
||||
this.setStatus(500);
|
||||
return new ApiValidationError(500, errorOccurred.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,4 +112,9 @@ export class CompaniesService {
|
||||
const isValid = await CompaniestList.isValidName({ name });
|
||||
return isValid;
|
||||
}
|
||||
|
||||
public async checkCompanyAdmin(data: { companyId: string; sessionUser: string }): Promise<boolean> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
return await CompaniestList.checkCompanyAdmin(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
PublicOrganizationServiceView,
|
||||
UserAppointmentViewParams,
|
||||
UserAppointmentView,
|
||||
PublicOrganizationsParams,
|
||||
PublicOrganizationsResult,
|
||||
} from "../../Models/Views/Views";
|
||||
import { ViewsService } from "./Views.Service";
|
||||
import { BotEventParams, BotView } from "../../Models/WapServer/WapServer.Interface";
|
||||
@@ -109,3 +111,23 @@ export class WapServerBotViewController extends Controller {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Route("views/public-organizations")
|
||||
export class PublicOrganizationsController extends Controller {
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async publicOrganizations(
|
||||
@Body() requestBody: PublicOrganizationsParams
|
||||
): Promise<PublicOrganizationsResult | ApiValidationError> {
|
||||
try {
|
||||
const result = await new ViewsService().publicOrganizations(requestBody);
|
||||
this.setStatus(200);
|
||||
return result;
|
||||
} catch (e) {
|
||||
const errorOccurred: Error = e as Error;
|
||||
this.setStatus(500);
|
||||
return new ApiValidationError(500, errorOccurred.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import views, {
|
||||
PublicOrganizationServiceView,
|
||||
UserAppointmentViewParams,
|
||||
UserAppointmentView,
|
||||
PublicOrganizationsParams,
|
||||
PublicOrganizationsResult,
|
||||
} from "../../Models/Views/Views";
|
||||
import { BotView, BotEventParams } from "../../Models/WapServer/WapServer.Interface";
|
||||
|
||||
@@ -45,4 +47,12 @@ export class ViewsService {
|
||||
const view = await views.wapView(data);
|
||||
return view;
|
||||
}
|
||||
|
||||
public async publicOrganizations(
|
||||
data: PublicOrganizationsParams
|
||||
): Promise<PublicOrganizationsResult> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
const result = await views.publicOrganizations(data);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
@@ -1,6 +1,7 @@
|
||||
dist/*
|
||||
node_modules
|
||||
.env
|
||||
auth/
|
||||
|
||||
*_sessions
|
||||
*tokens
|
||||
|
||||
Generated
+1391
-2344
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -2,8 +2,9 @@
|
||||
"name": "txbot",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "ts-node src/server.ts",
|
||||
"dev": "tsx src/server.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js"
|
||||
},
|
||||
@@ -12,14 +13,14 @@
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@whiskeysockets/baileys": "6.17.16",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc14",
|
||||
"express": "^5.1.0",
|
||||
"qrcode": "^1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.2",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
+40
-3
@@ -9,6 +9,35 @@ import { Boom } from "@hapi/boom";
|
||||
|
||||
let sock: ReturnType<typeof makeWASocket>;
|
||||
let latestQR: string | null = null;
|
||||
let reconnecting = false;
|
||||
|
||||
// Last-resort safety net. An uncaught error means the process is in an
|
||||
// undefined state, so we log the cause and exit; the supervisor
|
||||
// (docker restart: unless-stopped) brings it back in a clean state.
|
||||
// We never swallow these and keep running on corrupted state.
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
console.error("Unhandled rejection:", reason);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (err) => {
|
||||
console.error("Uncaught exception:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Reconnect with the rejection handled at the source. A failed attempt must
|
||||
// re-arm the loop, otherwise a single transient error leaves the bot dead.
|
||||
function scheduleReconnect() {
|
||||
if (reconnecting) return;
|
||||
reconnecting = true;
|
||||
setTimeout(() => {
|
||||
startWhatsApp().catch((err) => {
|
||||
console.error("Reconnect failed:", err);
|
||||
reconnecting = false;
|
||||
scheduleReconnect();
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function startWhatsApp() {
|
||||
const { state, saveCreds } = await useMultiFileAuthState("./auth");
|
||||
@@ -17,7 +46,6 @@ async function startWhatsApp() {
|
||||
sock = makeWASocket({
|
||||
version,
|
||||
auth: state,
|
||||
printQRInTerminal: false,
|
||||
});
|
||||
|
||||
sock.ev.on("creds.update", saveCreds);
|
||||
@@ -29,10 +57,16 @@ async function startWhatsApp() {
|
||||
if (connection === "close") {
|
||||
const reason = (lastDisconnect?.error as Boom)?.output?.statusCode;
|
||||
console.log("Connection closed:", DisconnectReason[reason]);
|
||||
setTimeout(startWhatsApp, 1000);
|
||||
if (reason === DisconnectReason.loggedOut) {
|
||||
console.log("Session logged out — not reconnecting. Re-pair via QR to resume.");
|
||||
reconnecting = false;
|
||||
return;
|
||||
}
|
||||
scheduleReconnect();
|
||||
} else if (connection === "open") {
|
||||
console.log("Connected to WhatsApp");
|
||||
latestQR = null;
|
||||
reconnecting = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -85,4 +119,7 @@ async function startServer() {
|
||||
app.listen(PORT, () => console.log(`API listening on port ${PORT}`));
|
||||
}
|
||||
|
||||
startServer().catch(console.error);
|
||||
startServer().catch((err) => {
|
||||
console.error("Fatal startup error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
+2
-1
@@ -26,7 +26,8 @@
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "commonjs", /* Specify what module code is generated. */
|
||||
"module": "NodeNext", /* Specify what module code is generated. Required for ESM-only Baileys 6.7.x. */
|
||||
"moduleResolution": "NodeNext", /* Resolve modules the way Node.js ESM does. */
|
||||
"rootDir": "src", /* Specify the root folder within your source files. */
|
||||
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import ApiRequest from "@services/Api.Service";
|
||||
import { UpdateCompanyParams } from "@models/Company.model";
|
||||
import { ApiVoidResult } from "@models/Api.VoidResult.type";
|
||||
|
||||
export const updateOrganizationColors = async (data: UpdateCompanyParams): Promise<ApiVoidResult> => {
|
||||
return ApiRequest.post<ApiVoidResult>("companies/update", data);
|
||||
};
|
||||
|
||||
export const uploadHeaderImage = async (data: FormData): Promise<ApiVoidResult> => {
|
||||
return ApiRequest.postFile<ApiVoidResult>("companies/upload-header", data);
|
||||
};
|
||||
|
||||
export const uploadServiceImage = async (data: FormData): Promise<ApiVoidResult> => {
|
||||
return ApiRequest.postFile<ApiVoidResult>("services/upload-image", data);
|
||||
};
|
||||
|
||||
export const checkOrgAdmin = async (companyId: string, sessionUser: string): Promise<boolean> => {
|
||||
return ApiRequest.post<boolean>("companies/check-admin", { companyId, sessionUser });
|
||||
};
|
||||
+325
-156
@@ -23,6 +23,7 @@ import { CLIENT_ACCOUNT_MOVEMENT_TYPES } from "@core/Models/ClientAccountMovemen
|
||||
import { getPaymentIcon } from "@core/app/components/PaymentIcons/PaymentIcons";
|
||||
import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan";
|
||||
import { motion } from "motion/react";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
type UrlData = {
|
||||
oid: string;
|
||||
@@ -36,13 +37,14 @@ export default function ClientAccount() {
|
||||
const alert = useAlert();
|
||||
const SessionInfo = useSessionStore();
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const { goTo } = useNavigation();
|
||||
|
||||
const [view, setView] = useState<ClientAccountView | null>(null);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [forceMinimized, setForceMinimized] = useState(false);
|
||||
|
||||
|
||||
const isMinimized = forceMinimized || isScrolled;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -68,6 +70,8 @@ export default function ClientAccount() {
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
location.add({
|
||||
url: "/admin/org/profile/" + id + "/clients",
|
||||
title: "Clientes",
|
||||
@@ -85,6 +89,10 @@ export default function ClientAccount() {
|
||||
title: "Cuenta Corriente",
|
||||
icon: <AccountBalanceOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||
});
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -127,149 +135,273 @@ export default function ClientAccount() {
|
||||
|
||||
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
|
||||
<div style={{ maxWidth: "800px", margin: "0 auto", paddingBottom: "100px" }}>
|
||||
|
||||
{/* Top Status Card */}
|
||||
<div style={{ position: "sticky", top: "70px", zIndex: 100, display: "flex", flexDirection: "column", background: "white", paddingBottom: "20px", paddingTop: "5px", margin: "0 -10px 30px -10px", paddingLeft: "10px", paddingRight: "10px" }}>
|
||||
{view && (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
padding: isMinimized ? "25px 50px" : "40px 20px",
|
||||
borderRadius: isMinimized ? "15px" : "20px"
|
||||
}}
|
||||
<div
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: "70px",
|
||||
zIndex: 100,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "white",
|
||||
paddingBottom: "20px",
|
||||
paddingTop: "5px",
|
||||
margin: "0 -10px 30px -10px",
|
||||
paddingLeft: "10px",
|
||||
paddingRight: "10px",
|
||||
}}
|
||||
>
|
||||
{view && (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
padding: isMinimized ? "25px 50px" : "40px 20px",
|
||||
borderRadius: isMinimized ? "15px" : "20px",
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: isMinimized ? "row" : "column",
|
||||
alignItems: "center",
|
||||
justifyContent: isMinimized ? "space-between" : "center",
|
||||
background:
|
||||
view.amount >= 0
|
||||
? "linear-gradient(135deg, #1d976c 0%, #93f9b9 100%)"
|
||||
: "linear-gradient(135deg, #cb2d3e 0%, #ef473a 100%)",
|
||||
color: "white",
|
||||
boxShadow: isMinimized
|
||||
? "0 4px 15px rgba(0,0,0,0.3)"
|
||||
: "0 10px 30px rgba(0,0,0,0.15)",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
transition: "box-shadow 0.3s ease",
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => setForceMinimized(!forceMinimized)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "10px",
|
||||
left: "10px",
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
zIndex: 10,
|
||||
}}
|
||||
size="small"
|
||||
title={forceMinimized ? "Maximizar" : "Minimizar"}
|
||||
>
|
||||
{forceMinimized ? <UnfoldMoreIcon /> : <UnfoldLessIcon />}
|
||||
</IconButton>
|
||||
|
||||
{/* Decorative background circles */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-50px",
|
||||
right: "-50px",
|
||||
width: "150px",
|
||||
height: "150px",
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.1)",
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "-30px",
|
||||
left: "-30px",
|
||||
width: "100px",
|
||||
height: "100px",
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.1)",
|
||||
}}
|
||||
></div>
|
||||
|
||||
<motion.div
|
||||
animate={{
|
||||
fontSize: isMinimized ? "14px" : "16px",
|
||||
marginBottom: isMinimized ? "0px" : "0px",
|
||||
marginLeft: isMinimized ? "30px" : "0px",
|
||||
}}
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
opacity: 0.9,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "1px",
|
||||
}}
|
||||
>
|
||||
Saldo Actual
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={{
|
||||
fontSize: isMinimized ? "24px" : "56px",
|
||||
margin: isMinimized ? "0px" : "10px 0 30px 0",
|
||||
}}
|
||||
style={{
|
||||
fontWeight: "900",
|
||||
textShadow: "0 2px 10px rgba(0,0,0,0.1)",
|
||||
}}
|
||||
>
|
||||
{formatPrice(view.amount)}
|
||||
</motion.div>
|
||||
|
||||
{!isMinimized && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "15px",
|
||||
flexDirection: "row",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<IconButton
|
||||
sx={{
|
||||
backgroundColor: "rgba(255,255,255,0.2)",
|
||||
color: "white",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
width: "110px",
|
||||
height: "90px",
|
||||
borderRadius: "15px",
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(255,255,255,0.3)",
|
||||
},
|
||||
}}
|
||||
onClick={() =>
|
||||
add(CLIENT_ACCOUNT_MOVEMENT_TYPES.DEPOSIT)
|
||||
}
|
||||
>
|
||||
<DepositarIcon
|
||||
sx={{ fontSize: "32px", marginBottom: "5px" }}
|
||||
/>
|
||||
<span
|
||||
style={{ fontSize: "14px", fontWeight: "bold" }}
|
||||
>
|
||||
Ingresar
|
||||
</span>
|
||||
</IconButton>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<IconButton
|
||||
sx={{
|
||||
backgroundColor: "rgba(255,255,255,0.2)",
|
||||
color: "white",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
width: "110px",
|
||||
height: "90px",
|
||||
borderRadius: "15px",
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(255,255,255,0.3)",
|
||||
},
|
||||
}}
|
||||
onClick={() =>
|
||||
add(CLIENT_ACCOUNT_MOVEMENT_TYPES.WITHDRAWAL)
|
||||
}
|
||||
>
|
||||
<RetirarIcon
|
||||
sx={{ fontSize: "32px", marginBottom: "5px" }}
|
||||
/>
|
||||
<span
|
||||
style={{ fontSize: "14px", fontWeight: "bold" }}
|
||||
>
|
||||
Retirar
|
||||
</span>
|
||||
</IconButton>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<IconButton
|
||||
sx={{
|
||||
backgroundColor: "white",
|
||||
color: view.amount >= 0 ? "#1d976c" : "#cb2d3e",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
width: "110px",
|
||||
height: "90px",
|
||||
borderRadius: "15px",
|
||||
boxShadow: "0 4px 15px rgba(0,0,0,0.1)",
|
||||
"&:hover": { backgroundColor: "#f8f9fa" },
|
||||
}}
|
||||
onClick={() =>
|
||||
goTo(
|
||||
"/admin/org/" +
|
||||
id +
|
||||
"/client/" +
|
||||
clientId +
|
||||
"/account/movements",
|
||||
)
|
||||
}
|
||||
>
|
||||
<MovimientosIcon
|
||||
sx={{ fontSize: "32px", marginBottom: "5px" }}
|
||||
/>
|
||||
<span
|
||||
style={{ fontSize: "14px", fontWeight: "bold" }}
|
||||
>
|
||||
Historial
|
||||
</span>
|
||||
</IconButton>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: isMinimized ? "row" : "column",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
justifyContent: isMinimized ? "space-between" : "center",
|
||||
background: view.amount >= 0
|
||||
? "linear-gradient(135deg, #1d976c 0%, #93f9b9 100%)"
|
||||
: "linear-gradient(135deg, #cb2d3e 0%, #ef473a 100%)",
|
||||
color: "white",
|
||||
boxShadow: isMinimized ? "0 4px 15px rgba(0,0,0,0.3)" : "0 10px 30px rgba(0,0,0,0.15)",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
transition: "box-shadow 0.3s ease"
|
||||
marginBottom: "15px",
|
||||
padding: "0 10px",
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => setForceMinimized(!forceMinimized)}
|
||||
style={{ position: "absolute", top: "10px", left: "10px", color: "rgba(255,255,255,0.7)", zIndex: 10 }}
|
||||
size="small"
|
||||
title={forceMinimized ? "Maximizar" : "Minimizar"}
|
||||
>
|
||||
{forceMinimized ? <UnfoldMoreIcon /> : <UnfoldLessIcon />}
|
||||
</IconButton>
|
||||
|
||||
{/* Decorative background circles */}
|
||||
<div style={{ position: "absolute", top: "-50px", right: "-50px", width: "150px", height: "150px", borderRadius: "50%", background: "rgba(255,255,255,0.1)" }}></div>
|
||||
<div style={{ position: "absolute", bottom: "-30px", left: "-30px", width: "100px", height: "100px", borderRadius: "50%", background: "rgba(255,255,255,0.1)" }}></div>
|
||||
|
||||
<motion.div
|
||||
animate={{
|
||||
fontSize: isMinimized ? "14px" : "16px",
|
||||
marginBottom: isMinimized ? "0px" : "0px",
|
||||
marginLeft: isMinimized ? "30px" : "0px"
|
||||
<div
|
||||
style={{
|
||||
fontSize: "20px",
|
||||
fontWeight: "bold",
|
||||
color: "var(--black-light)",
|
||||
}}
|
||||
style={{ fontWeight: "bold", opacity: 0.9, textTransform: "uppercase", letterSpacing: "1px" }}
|
||||
>
|
||||
Saldo Actual
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={{
|
||||
fontSize: isMinimized ? "24px" : "56px",
|
||||
margin: isMinimized ? "0px" : "10px 0 30px 0"
|
||||
}}
|
||||
style={{ fontWeight: "900", textShadow: "0 2px 10px rgba(0,0,0,0.1)" }}
|
||||
>
|
||||
{formatPrice(view.amount)}
|
||||
</motion.div>
|
||||
|
||||
|
||||
{!isMinimized && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
style={{ display: "flex", gap: "15px", flexDirection: "row", zIndex: 1 }}
|
||||
>
|
||||
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||
<IconButton
|
||||
sx={{
|
||||
backgroundColor: "rgba(255,255,255,0.2)",
|
||||
color: "white",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
width: "110px",
|
||||
height: "90px",
|
||||
borderRadius: "15px",
|
||||
"&:hover": { backgroundColor: "rgba(255,255,255,0.3)" }
|
||||
}}
|
||||
onClick={() => add(CLIENT_ACCOUNT_MOVEMENT_TYPES.DEPOSIT)}
|
||||
>
|
||||
<DepositarIcon sx={{ fontSize: "32px", marginBottom: "5px" }} />
|
||||
<span style={{ fontSize: "14px", fontWeight: "bold" }}>Ingresar</span>
|
||||
</IconButton>
|
||||
</motion.div>
|
||||
|
||||
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||
<IconButton
|
||||
sx={{
|
||||
backgroundColor: "rgba(255,255,255,0.2)",
|
||||
color: "white",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
width: "110px",
|
||||
height: "90px",
|
||||
borderRadius: "15px",
|
||||
"&:hover": { backgroundColor: "rgba(255,255,255,0.3)" }
|
||||
}}
|
||||
onClick={() => add(CLIENT_ACCOUNT_MOVEMENT_TYPES.WITHDRAWAL)}
|
||||
>
|
||||
<RetirarIcon sx={{ fontSize: "32px", marginBottom: "5px" }} />
|
||||
<span style={{ fontSize: "14px", fontWeight: "bold" }}>Retirar</span>
|
||||
</IconButton>
|
||||
</motion.div>
|
||||
|
||||
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||
<IconButton
|
||||
sx={{
|
||||
backgroundColor: "white",
|
||||
color: view.amount >= 0 ? "#1d976c" : "#cb2d3e",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
width: "110px",
|
||||
height: "90px",
|
||||
borderRadius: "15px",
|
||||
boxShadow: "0 4px 15px rgba(0,0,0,0.1)",
|
||||
"&:hover": { backgroundColor: "#f8f9fa" }
|
||||
}}
|
||||
onClick={() => goTo("/admin/org/" + id + "/client/" + clientId + "/account/movements")}
|
||||
>
|
||||
<MovimientosIcon sx={{ fontSize: "32px", marginBottom: "5px" }} />
|
||||
<span style={{ fontSize: "14px", fontWeight: "bold" }}>Historial</span>
|
||||
</IconButton>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "15px", padding: "0 10px" }}>
|
||||
<div style={{ fontSize: "20px", fontWeight: "bold", color: "var(--black-light)" }}>
|
||||
Últimos Movimientos
|
||||
</div>
|
||||
<div
|
||||
style={{ fontSize: "14px", color: "var(--wine-dark)", cursor: "pointer", fontWeight: "bold" }}
|
||||
onClick={() => goTo("/admin/org/" + id + "/client/" + clientId + "/account/movements")}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
color: "var(--wine-dark)",
|
||||
cursor: "pointer",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
onClick={() =>
|
||||
goTo(
|
||||
"/admin/org/" +
|
||||
id +
|
||||
"/client/" +
|
||||
clientId +
|
||||
"/account/movements",
|
||||
)
|
||||
}
|
||||
>
|
||||
Ver todos
|
||||
</div>
|
||||
@@ -294,42 +426,79 @@ export default function ClientAccount() {
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "15px" }}>
|
||||
<div style={{
|
||||
width: "45px",
|
||||
height: "45px",
|
||||
borderRadius: "12px",
|
||||
backgroundColor: movement.amount > 0 ? "rgba(40, 167, 69, 0.1)" : "rgba(220, 53, 69, 0.1)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: movement.amount > 0 ? "#28a745" : "#dc3545"
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "15px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "45px",
|
||||
height: "45px",
|
||||
borderRadius: "12px",
|
||||
backgroundColor:
|
||||
movement.amount > 0
|
||||
? "rgba(40, 167, 69, 0.1)"
|
||||
: "rgba(220, 53, 69, 0.1)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color:
|
||||
movement.amount > 0 ? "#28a745" : "#dc3545",
|
||||
}}
|
||||
>
|
||||
{getPaymentIcon(movement.paymentMethod)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: "16px", fontWeight: "bold", color: "#333", marginBottom: "3px" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
marginBottom: "3px",
|
||||
}}
|
||||
>
|
||||
{movement.description}
|
||||
</div>
|
||||
<div style={{ fontSize: "13px", color: "#888" }}>
|
||||
{dayjs(movement.createdAt).format("DD MMMM YYYY • HH:mm")}
|
||||
{dayjs(movement.createdAt).format(
|
||||
"DD MMMM YYYY • HH:mm",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: "18px",
|
||||
fontWeight: "900",
|
||||
color: movement.amount > 0 ? "#28a745" : "#dc3545",
|
||||
}}>
|
||||
{movement.amount > 0 ? "+" : ""}{formatPrice(movement.amount)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
fontWeight: "900",
|
||||
color: movement.amount > 0 ? "#28a745" : "#dc3545",
|
||||
}}
|
||||
>
|
||||
{movement.amount > 0 ? "+" : ""}
|
||||
{formatPrice(movement.amount)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: "center", padding: "40px", backgroundColor: "#fff", borderRadius: "15px", color: "#888", border: "1px dashed #ccc" }}>
|
||||
<AccountBalanceOutlinedIcon sx={{ fontSize: "48px", color: "#ddd", marginBottom: "10px" }} />
|
||||
<div style={{ fontSize: "16px", fontWeight: "bold", color: "#666" }}>
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "40px",
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: "15px",
|
||||
color: "#888",
|
||||
border: "1px dashed #ccc",
|
||||
}}
|
||||
>
|
||||
<AccountBalanceOutlinedIcon
|
||||
sx={{ fontSize: "48px", color: "#ddd", marginBottom: "10px" }}
|
||||
/>
|
||||
<div
|
||||
style={{ fontSize: "16px", fontWeight: "bold", color: "#666" }}
|
||||
>
|
||||
Aún no hay movimientos
|
||||
</div>
|
||||
<div style={{ fontSize: "14px" }}>
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ const schemaAppointmentUpdate = Yup.object().shape({
|
||||
start: Yup.string().required("Debe seleccionar una fecha."),
|
||||
price: Yup.number().positive().required("Debe introducir un precio valido."),
|
||||
length: Yup.number().positive().required("Debe introducir una duración."),
|
||||
comments: Yup.string().required("Debe introducir un comentario."),
|
||||
comments: Yup.string().notRequired(),
|
||||
sessionUser: Yup.string().required("Para crear un turno debe iniciar sesión."),
|
||||
});
|
||||
|
||||
|
||||
+24
-18
@@ -1,9 +1,6 @@
|
||||
"use client";
|
||||
import { useAlert } from "@core/Store/Alert.Store";
|
||||
import {
|
||||
useEventHandlerStore,
|
||||
EVENT_TYPES,
|
||||
} from "@core/Store/EventHandler.Store";
|
||||
import { useEventHandlerStore, EVENT_TYPES } from "@core/Store/EventHandler.Store";
|
||||
import { useLocationStore } from "@core/Store/Location.Store";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -14,6 +11,7 @@ import ServiceItem from "@core/app/components/ServiceItem/ServiceItem";
|
||||
import Location from "@core/app/components/Location/Location";
|
||||
import { useNavigation } from "@core/app/hooks/goto";
|
||||
import { useMenuStore } from "@core/Store/Menu.Store";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
type UrlData = {
|
||||
oid: string;
|
||||
@@ -26,14 +24,14 @@ export default function ClientAppointments() {
|
||||
const clientId = data.clientId;
|
||||
const alert = useAlert();
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const menu = useMenuStore();
|
||||
const { goTo } = useNavigation();
|
||||
|
||||
const [appintments, setAppointments] =
|
||||
useState<AppointmentAdminByClientView>({
|
||||
events: [],
|
||||
});
|
||||
const [appintments, setAppointments] = useState<AppointmentAdminByClientView>({
|
||||
events: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
@@ -44,6 +42,8 @@ export default function ClientAppointments() {
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
location.add({
|
||||
url: "/admin/org/profile/" + id + "/clients",
|
||||
title: "Clientes",
|
||||
@@ -61,6 +61,10 @@ export default function ClientAppointments() {
|
||||
title: "Turnos reservados",
|
||||
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||
});
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,15 +93,17 @@ export default function ClientAppointments() {
|
||||
}}
|
||||
>
|
||||
{appintments.events.length === 0 ? (
|
||||
<div style={{
|
||||
gridColumn: "1 / -1",
|
||||
textAlign: "center",
|
||||
padding: "40px 20px",
|
||||
background: "#fff",
|
||||
borderRadius: "12px",
|
||||
boxShadow: "0 4px 15px rgba(0,0,0,0.02)",
|
||||
color: "#7f8c8d"
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
gridColumn: "1 / -1",
|
||||
textAlign: "center",
|
||||
padding: "40px 20px",
|
||||
background: "#fff",
|
||||
borderRadius: "12px",
|
||||
boxShadow: "0 4px 15px rgba(0,0,0,0.02)",
|
||||
color: "#7f8c8d",
|
||||
}}
|
||||
>
|
||||
No hay turnos registrados para este cliente.
|
||||
</div>
|
||||
) : (
|
||||
@@ -113,7 +119,7 @@ export default function ClientAppointments() {
|
||||
"/client/" +
|
||||
clientId +
|
||||
"/appointments/" +
|
||||
event.id
|
||||
event.id,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { validatePhone } from "@core/helpers/validatePhone";
|
||||
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
||||
import Switch from "@core/app/components/Switch/Switch";
|
||||
import { Box, Typography } from "@mui/material";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
type UrlData = {
|
||||
oid: string;
|
||||
@@ -29,6 +30,7 @@ export default function ClientCreate() {
|
||||
const confirm = useConfirmStore();
|
||||
const SessionInfo = useSessionStore();
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
@@ -46,6 +48,8 @@ export default function ClientCreate() {
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
location.add({
|
||||
url: "/admin/org/profile/" + id + "/clients",
|
||||
title: "Clientes",
|
||||
@@ -63,6 +67,10 @@ export default function ClientCreate() {
|
||||
title: "Editar",
|
||||
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||
});
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -92,7 +100,7 @@ export default function ClientCreate() {
|
||||
confirm.show(
|
||||
validationPhoneResult.message,
|
||||
() => {},
|
||||
() => {}
|
||||
() => {},
|
||||
);
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
return;
|
||||
@@ -112,7 +120,7 @@ export default function ClientCreate() {
|
||||
})
|
||||
.then(() => {
|
||||
alert.showSuccess(
|
||||
`Información del cliente ${firstName} ${lastName} actualizada`
|
||||
`Información del cliente ${firstName} ${lastName} actualizada`,
|
||||
);
|
||||
setTimeout(() => {
|
||||
goTo("/admin/org/" + id + "/client/" + clientId + "/profile");
|
||||
@@ -180,7 +188,9 @@ export default function ClientCreate() {
|
||||
/>
|
||||
<div style={{ marginTop: "15px" }}></div>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: "bold", paddingLeft: "10px" }}>Baneado (No puede reservar)</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: "bold", paddingLeft: "10px" }}>
|
||||
Baneado (No puede reservar)
|
||||
</Typography>
|
||||
<Switch
|
||||
checked={banned}
|
||||
onChange={(checked) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { ClientMergeParams, OrganizationClientView } from "@core/Models/Clients.
|
||||
import Button from "@core/app/components/Button/Button";
|
||||
import MaterialAlert from "@mui/material/Alert";
|
||||
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
type UrlData = {
|
||||
oid: string;
|
||||
@@ -32,6 +33,7 @@ export default function ClientCreate() {
|
||||
const confirm = useConfirmStore();
|
||||
const SessionInfo = useSessionStore();
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const { goTo } = useNavigation();
|
||||
|
||||
@@ -53,7 +55,7 @@ export default function ClientCreate() {
|
||||
});
|
||||
|
||||
const [clientMergeFilterData, setClientMergeFilterData] = useState<TextObjectFilterResult[]>(
|
||||
[]
|
||||
[],
|
||||
);
|
||||
const [clientMergeId, setClientMergeId] = useState<string>("");
|
||||
|
||||
@@ -94,7 +96,7 @@ export default function ClientCreate() {
|
||||
() => {
|
||||
mergeClients();
|
||||
},
|
||||
() => {}
|
||||
() => {},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -105,6 +107,8 @@ export default function ClientCreate() {
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
location.add({
|
||||
url: "/admin/org/profile/" + id + "/clients",
|
||||
title: "Clientes",
|
||||
@@ -122,6 +126,10 @@ export default function ClientCreate() {
|
||||
title: "Fusionar clientes",
|
||||
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||
});
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+31
-13
@@ -11,6 +11,7 @@ import ClientsIcon from "@mui/icons-material/AccountBoxOutlined";
|
||||
import NotificationsIcon from "@mui/icons-material/NotificationsOutlined";
|
||||
import MaterialAlert from "@mui/material/Alert";
|
||||
import styles from "./NotificationsPage.module.css";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
type UrlData = {
|
||||
oid: string;
|
||||
@@ -20,7 +21,7 @@ type UrlData = {
|
||||
const CHANNELS: { key: NotificationChannel; label: string }[] = [
|
||||
{ key: "whatsapp", label: "WhatsApp" },
|
||||
{ key: "email", label: "Email" },
|
||||
{ key: "system", label: "Sistema" }
|
||||
{ key: "system", label: "Sistema" },
|
||||
];
|
||||
|
||||
type ChannelPreference = "default" | "preferred" | "muted";
|
||||
@@ -30,34 +31,41 @@ export default function NotificationsPage() {
|
||||
const oid = data.oid;
|
||||
const clientId = data.clientId;
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
const store = useNotificationPreferencesStore();
|
||||
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
location.add({
|
||||
url: "/admin/org",
|
||||
title: "Organizaciones",
|
||||
icon: null
|
||||
icon: null,
|
||||
});
|
||||
|
||||
location.add({
|
||||
url: "/admin/org/profile/" + oid,
|
||||
title: "Organización",
|
||||
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />
|
||||
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />,
|
||||
});
|
||||
|
||||
location.add({
|
||||
url: "/admin/org/" + oid + "/client/" + clientId + "/profile",
|
||||
title: "Cliente",
|
||||
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />
|
||||
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||
});
|
||||
|
||||
location.add({
|
||||
url: "",
|
||||
title: "Notificaciones",
|
||||
icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} />
|
||||
icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||
});
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, [oid, clientId]);
|
||||
|
||||
const getChannelPreference = (channel: NotificationChannel): ChannelPreference => {
|
||||
@@ -81,7 +89,7 @@ export default function NotificationsPage() {
|
||||
mutedChannels:
|
||||
preference === "muted"
|
||||
? [...store.formData.mutedChannels.filter((c) => c !== channel), channel]
|
||||
: store.formData.mutedChannels.filter((c) => c !== channel)
|
||||
: store.formData.mutedChannels.filter((c) => c !== channel),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -91,7 +99,9 @@ export default function NotificationsPage() {
|
||||
|
||||
<div className={styles.container}>
|
||||
<MaterialAlert severity="info" sx={{ marginBottom: "0" }}>
|
||||
<b>Preferencias del cliente:</b> Configure cómo esta organización debe comunicarse con este cliente. Si deja un canal en estándar, se usará la política general de la organización.
|
||||
<b>Preferencias del cliente:</b> Configure cómo esta organización debe
|
||||
comunicarse con este cliente. Si deja un canal en estándar, se usará la política
|
||||
general de la organización.
|
||||
</MaterialAlert>
|
||||
|
||||
<div className={styles.channels}>
|
||||
@@ -102,24 +112,32 @@ export default function NotificationsPage() {
|
||||
<div>
|
||||
<div className={styles.channelTitle}>{channel.label}</div>
|
||||
<div className={styles.channelDescription}>
|
||||
{selectedPreference === "preferred" && "Priorizar este canal para el cliente."}
|
||||
{selectedPreference === "muted" && "No enviar notificaciones por este canal."}
|
||||
{selectedPreference === "default" && "Usar la configuración general de la organización."}
|
||||
{selectedPreference === "preferred" &&
|
||||
"Priorizar este canal para el cliente."}
|
||||
{selectedPreference === "muted" &&
|
||||
"No enviar notificaciones por este canal."}
|
||||
{selectedPreference === "default" &&
|
||||
"Usar la configuración general de la organización."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.options}>
|
||||
{[
|
||||
{ key: "default" as ChannelPreference, label: "Estándar" },
|
||||
{ key: "preferred" as ChannelPreference, label: "Preferido" },
|
||||
{ key: "muted" as ChannelPreference, label: "Silenciado" }
|
||||
{
|
||||
key: "preferred" as ChannelPreference,
|
||||
label: "Preferido",
|
||||
},
|
||||
{ key: "muted" as ChannelPreference, label: "Silenciado" },
|
||||
].map((option) => {
|
||||
const isSelected = selectedPreference === option.key;
|
||||
return (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={() => selectPreference(channel.key, option.key)}
|
||||
onClick={() =>
|
||||
selectPreference(channel.key, option.key)
|
||||
}
|
||||
className={`${styles.optionButton} ${isSelected ? styles.optionButtonActive : ""}`}
|
||||
>
|
||||
{option.label}
|
||||
|
||||
+15
-53
@@ -8,6 +8,7 @@ import { useLocationStore } from "@store/Location.Store";
|
||||
import { useNavigation } from "@hooks/goto";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
type UrlData = {
|
||||
oid: string;
|
||||
@@ -19,11 +20,14 @@ export default function ClientProfile() {
|
||||
const clientId = data.clientId;
|
||||
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
const { goTo } = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
location.add({
|
||||
url: "/admin/org",
|
||||
title: "Mis organizaciones",
|
||||
@@ -33,9 +37,7 @@ export default function ClientProfile() {
|
||||
location.add({
|
||||
url: "/admin/org/profile/" + oid,
|
||||
title: "Organizacion",
|
||||
icon: (
|
||||
<StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />
|
||||
),
|
||||
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />,
|
||||
});
|
||||
|
||||
location.add({
|
||||
@@ -43,6 +45,10 @@ export default function ClientProfile() {
|
||||
title: "Perfil del cliente",
|
||||
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||
});
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -59,79 +65,35 @@ export default function ClientProfile() {
|
||||
<ButtonOption
|
||||
text="Editar"
|
||||
icon="/profile-icon.svg"
|
||||
onClick={() =>
|
||||
goTo(
|
||||
"/admin/org/" +
|
||||
oid +
|
||||
"/client/" +
|
||||
clientId +
|
||||
"/edit"
|
||||
)
|
||||
}
|
||||
onClick={() => goTo("/admin/org/" + oid + "/client/" + clientId + "/edit")}
|
||||
/>
|
||||
<ButtonOption
|
||||
text="Turnos"
|
||||
icon="/profile-icon.svg"
|
||||
onClick={() =>
|
||||
goTo(
|
||||
"/admin/org/" +
|
||||
oid +
|
||||
"/client/" +
|
||||
clientId +
|
||||
"/appointments"
|
||||
)
|
||||
goTo("/admin/org/" + oid + "/client/" + clientId + "/appointments")
|
||||
}
|
||||
/>
|
||||
<ButtonOption
|
||||
text="Fusionar Duplicados"
|
||||
icon="/client-merge-icon.svg"
|
||||
onClick={() =>
|
||||
goTo(
|
||||
"/admin/org/" +
|
||||
oid +
|
||||
"/client/" +
|
||||
clientId +
|
||||
"/merge"
|
||||
)
|
||||
}
|
||||
onClick={() => goTo("/admin/org/" + oid + "/client/" + clientId + "/merge")}
|
||||
/>
|
||||
<ButtonOption
|
||||
text="Repeticiones"
|
||||
icon="/repeat-icon.svg"
|
||||
onClick={() =>
|
||||
goTo(
|
||||
"/admin/org/" +
|
||||
oid +
|
||||
"/client/" +
|
||||
clientId +
|
||||
"/repeats"
|
||||
)
|
||||
}
|
||||
onClick={() => goTo("/admin/org/" + oid + "/client/" + clientId + "/repeats")}
|
||||
/>
|
||||
<ButtonOption
|
||||
text="Cuenta"
|
||||
icon="/account-icon.svg"
|
||||
onClick={() =>
|
||||
goTo(
|
||||
"/admin/org/" +
|
||||
oid +
|
||||
"/client/" +
|
||||
clientId +
|
||||
"/account"
|
||||
)
|
||||
}
|
||||
onClick={() => goTo("/admin/org/" + oid + "/client/" + clientId + "/account")}
|
||||
/>
|
||||
<ButtonOption
|
||||
text="Notificaciones"
|
||||
icon="/notifications-icon.svg"
|
||||
onClick={() =>
|
||||
goTo(
|
||||
"/admin/org/" +
|
||||
oid +
|
||||
"/client/" +
|
||||
clientId +
|
||||
"/notifications"
|
||||
)
|
||||
goTo("/admin/org/" + oid + "/client/" + clientId + "/notifications")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+9
-1
@@ -17,6 +17,7 @@ import { deleteRepeat, getRepeatsByClient } from "../Client.Service";
|
||||
import { DeleteRepeatsParams, FindRepeatsParams, RepeatView } from "@core/Models/Repeats.model";
|
||||
|
||||
import style from "./repeats.module.css";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
type UrlData = {
|
||||
oid: string;
|
||||
@@ -31,6 +32,7 @@ export default function ClientCreate() {
|
||||
const confirm = useConfirmStore();
|
||||
const SessionInfo = useSessionStore();
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
|
||||
const [repeats, setRepeats] = useState<RepeatView[]>([]);
|
||||
@@ -52,6 +54,8 @@ export default function ClientCreate() {
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
location.add({
|
||||
url: "/admin/org/profile/" + id + "/clients",
|
||||
title: "Clientes",
|
||||
@@ -69,6 +73,10 @@ export default function ClientCreate() {
|
||||
title: "Repeticiones",
|
||||
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||
});
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -162,7 +170,7 @@ export default function ClientCreate() {
|
||||
() => {
|
||||
handleDelete(repeat.id);
|
||||
},
|
||||
() => {}
|
||||
() => {},
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -27,6 +27,7 @@ import TextObjectFilter from "@components/TextObjectFilter/TextObjectFilter";
|
||||
import SelectLocationMap, {
|
||||
LocationCoordinates,
|
||||
} from "@core/app/components/SelectLocationMap/SelectLocationMap";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
type UrlData = {
|
||||
id: string;
|
||||
@@ -36,6 +37,7 @@ export default function OrganizationEdit() {
|
||||
const data = useParams<UrlData>();
|
||||
const id = data.id;
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const SessionInfo = useSessionStore();
|
||||
const alert = useAlert();
|
||||
@@ -140,7 +142,7 @@ export default function OrganizationEdit() {
|
||||
});
|
||||
|
||||
const [localidadesFilterData, setLocalidadesFilterData] = useState<TextObjectFilterResult[]>(
|
||||
[]
|
||||
[],
|
||||
);
|
||||
const [selectedLocalidad, setSelectedLocalidad] = useState<string>("");
|
||||
|
||||
@@ -188,6 +190,8 @@ export default function OrganizationEdit() {
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
location.add({
|
||||
url: "/admin/org",
|
||||
title: "Organizaciones",
|
||||
@@ -205,6 +209,10 @@ export default function OrganizationEdit() {
|
||||
title: "Ubicación",
|
||||
icon: <OrgConfigIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||
});
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import Button from "@core/app/components/Button/Button";
|
||||
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
||||
import { deleteOrganization } from "./advanced.Service";
|
||||
import { useNavigation } from "@core/app/hooks/goto";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
type UrlData = {
|
||||
id: string;
|
||||
@@ -23,6 +24,7 @@ export default function OrganizationAdvancedEdit() {
|
||||
const data = useParams<UrlData>();
|
||||
const id = data.id;
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const SessionInfo = useSessionStore();
|
||||
const alert = useAlert();
|
||||
@@ -33,6 +35,8 @@ export default function OrganizationAdvancedEdit() {
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
location.add({
|
||||
url: "/admin/org",
|
||||
title: "Organizaciones",
|
||||
@@ -50,6 +54,10 @@ export default function OrganizationAdvancedEdit() {
|
||||
title: "Avanzado",
|
||||
icon: <OrgConfigIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||
});
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const eliminarHandler = () => {
|
||||
@@ -73,7 +81,7 @@ export default function OrganizationAdvancedEdit() {
|
||||
},
|
||||
() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
alert.showError("La confirmación no es correcta");
|
||||
|
||||
+8
@@ -23,6 +23,7 @@ import { useNavigation } from "@hooks/goto";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import { ThemeProvider } from "@emotion/react";
|
||||
import turnosXpressTheme from "@core/app/theme/turnosXpress";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
dayjs.locale("es");
|
||||
|
||||
@@ -40,6 +41,7 @@ export default function ClientCreate() {
|
||||
const { goTo } = useNavigation();
|
||||
const location = useLocationStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
|
||||
const [validation, setValidation] = useState<boolean>(true);
|
||||
const [notification, setNotification] = useState<boolean>(true);
|
||||
@@ -148,6 +150,8 @@ export default function ClientCreate() {
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
location.add({
|
||||
url: "/admin/org/profile/" + id,
|
||||
title: "Origanizacion",
|
||||
@@ -169,6 +173,10 @@ export default function ClientCreate() {
|
||||
title: "Crear nuevo turno",
|
||||
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||
});
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+22
-4
@@ -31,7 +31,6 @@ import filterObjects from "@components/TextObjectFilter/Filter.Service";
|
||||
|
||||
import dayjs, { ManipulateType } from "dayjs";
|
||||
import "dayjs/locale/es";
|
||||
import Filterable from "@core/app/components/Filterable/Filterable";
|
||||
import { useFilterStore } from "@core/Store/Filter.Store";
|
||||
import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model";
|
||||
import { FindEmployeesParams } from "@core/Models/Collaborators.model";
|
||||
@@ -496,8 +495,7 @@ export default function OrganizationProfile() {
|
||||
return (
|
||||
<>
|
||||
<Addable onClick={handleAdd}>
|
||||
<Filterable />
|
||||
<Location />
|
||||
<Location showFilter />
|
||||
<div className={style.appointmentsTitle}>
|
||||
<h4>{dayjs(currentDate).format("dddd DD [de] MMMM, YYYY")}</h4>
|
||||
</div>
|
||||
@@ -614,7 +612,27 @@ export default function OrganizationProfile() {
|
||||
</div>
|
||||
<div
|
||||
className={style.buttonPaste}
|
||||
onClick={() => timePicker.show((newDay) => handleMoveAppointment(newDay))}
|
||||
onClick={() => {
|
||||
// Seed the controlled picker value with the copied
|
||||
// appointment's current time. MUI only fires onAccept when the
|
||||
// accepted value differs from its internal lastCommittedValue;
|
||||
// without this, picking the same time twice in a row silently
|
||||
// does nothing.
|
||||
if (copiedAppointment) {
|
||||
const currentHour = Math.floor(copiedAppointment.startTime);
|
||||
const currentMinute = Math.round(
|
||||
(copiedAppointment.startTime - currentHour) * 60
|
||||
);
|
||||
timePicker.setValue(
|
||||
dayjs()
|
||||
.hour(currentHour)
|
||||
.minute(currentMinute)
|
||||
.second(0)
|
||||
.millisecond(0)
|
||||
);
|
||||
}
|
||||
timePicker.show((newDay) => handleMoveAppointment(newDay));
|
||||
}}
|
||||
>
|
||||
<ContentPasteIcon />
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { UserAssistanceInfo } from "@core/Models/User.model";
|
||||
import Link from "next/link";
|
||||
import { getAssistanceUserInfo } from "./Assistance.Service";
|
||||
import AssistanceIcon from "@mui/icons-material/HeadsetMicOutlined";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
type UrlData = {
|
||||
id: string;
|
||||
@@ -23,6 +24,7 @@ export default function CollaboratorsAdd() {
|
||||
const alert = useAlert();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
const SessionInfo = useSessionStore();
|
||||
const [user, setUser] = useState<UserAssistanceInfo | null>(null);
|
||||
const [findEmail, setFindEmail] = useState<string>("");
|
||||
@@ -34,6 +36,8 @@ export default function CollaboratorsAdd() {
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
location.add({
|
||||
url: "/admin/org/profile/" + id,
|
||||
title: "Organizacion",
|
||||
@@ -45,6 +49,10 @@ export default function CollaboratorsAdd() {
|
||||
title: "Asistencia",
|
||||
icon: <AssistanceIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||
});
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const findUser = async () => {
|
||||
|
||||
-6
@@ -188,12 +188,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.paginationWrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.invoiceModalBackdrop {
|
||||
align-items: flex-end;
|
||||
background: rgba(15, 23, 42, 0.48);
|
||||
|
||||
+26
-17
@@ -21,7 +21,6 @@ import {
|
||||
} from "@core/Models/CashFlow.model";
|
||||
import { cashFlowPaginate, createInvoiceFromCashMovement, findInvoices, findOrganizationFiscalProfiles, Invoice, OrganizationFiscalProfile } from "../Cash.Service";
|
||||
import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan";
|
||||
import Filterable from "@core/app/components/Filterable/Filterable";
|
||||
import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model";
|
||||
import { TextObjectModalContent } from "@core/Store/TextObjectModal.Store";
|
||||
import TextObjectFilterItem from "@core/app/components/TextObjectFilterItem/TextObjectFilterItem";
|
||||
@@ -39,8 +38,9 @@ import {
|
||||
CLIENT_ACCOUNT_MOVEMENT_TYPES,
|
||||
CLIENT_ACCOUNT_PAYMENT_METHODS,
|
||||
} from "@core/Models/ClientAccountMovements.model";
|
||||
import PaginationControls from "@components/PaginationControls/PaginationControls";
|
||||
import { useAlert } from "@core/Store/Alert.Store";
|
||||
import { useBottomToolbarStore } from "@store/BottomToolbar.Store";
|
||||
import PaginationControls from "@components/PaginationControls/PaginationControls";
|
||||
import style from "./page.module.css";
|
||||
|
||||
type UrlData = {
|
||||
@@ -437,6 +437,7 @@ export default function CashFlowMovementsList() {
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const alert = useAlert();
|
||||
const filter = useFilterStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
|
||||
const [clientFilterData, setClientFilterData] = useState<TextObjectFilterResult[]>([]);
|
||||
const [clientId, setClientId] = useState<string>("");
|
||||
@@ -600,6 +601,28 @@ export default function CashFlowMovementsList() {
|
||||
});
|
||||
}, [SessionInfo.userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (movements && movements.pages > 1) {
|
||||
bottomToolbar.set(
|
||||
<div style={{ display: "flex", justifyContent: "center", width: "100%" }}>
|
||||
<PaginationControls
|
||||
current={movements.page}
|
||||
size={pageSize}
|
||||
max={movements.pages}
|
||||
onChange={loadMovements}
|
||||
inheritColor
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
bottomToolbar.set(<></>);
|
||||
}
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, [movements?.page, movements?.pages, pageSize]);
|
||||
|
||||
const renderMovementMeta = (movement: ICashFlow) => {
|
||||
const details = [
|
||||
movement.paymentMethod ? getPaymentMethodName(movement.paymentMethod) : null,
|
||||
@@ -862,8 +885,7 @@ export default function CashFlowMovementsList() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Location />
|
||||
<Filterable />
|
||||
<Location showFilter />
|
||||
|
||||
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
|
||||
<>
|
||||
@@ -948,21 +970,8 @@ export default function CashFlowMovementsList() {
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
{movements && movements.pages > 1 && (
|
||||
<div className={style.paginationWrapper}>
|
||||
<PaginationControls
|
||||
current={movements.page}
|
||||
size={pageSize}
|
||||
max={movements.pages}
|
||||
onChange={loadMovements}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "15px" }}></div>
|
||||
|
||||
{invoiceDraft && (
|
||||
<div className={style.invoiceModalBackdrop} role="presentation">
|
||||
<section
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PaginateCashFlowParams, PaginateCashFlowResults } from "@core/Models/Ca
|
||||
import { useSessionStore } from "@core/Store/Sesion.Store";
|
||||
import { ArcaWsfeLastVoucherResult, cashFlowPaginate, getArcaWsfeLastVoucher } from "./Cash.Service";
|
||||
import { useAlert } from "@core/Store/Alert.Store";
|
||||
import { useBottomToolbarStore } from "@store/BottomToolbar.Store";
|
||||
import { IconButton } from "@mui/material";
|
||||
import RetirarIcon from "@mui/icons-material/CallMadeOutlined";
|
||||
import DepositarIcon from "@mui/icons-material/SouthWestOutlined";
|
||||
@@ -38,6 +39,7 @@ export default function OrganizationCashFlowDetail() {
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const SessionInfo = useSessionStore();
|
||||
const { goTo } = useNavigation();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
|
||||
const [dateFrom, setDateFrom] = useState<Dayjs>(dayjs().startOf("day"));
|
||||
const [dateTo, setDateTo] = useState<Dayjs>(dayjs().endOf("day"));
|
||||
@@ -49,6 +51,11 @@ export default function OrganizationCashFlowDetail() {
|
||||
|
||||
useEffect(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -149,7 +149,9 @@ export default function OrganizationCashFlowResume() {
|
||||
cashFlowCalculateBalance({
|
||||
companyId: id,
|
||||
year: year,
|
||||
month: month,
|
||||
// TextMonth maneja meses 1-indexados (1 = enero); el storage de balances es
|
||||
// 0-indexado (dayjs.month()), así que convertimos antes de enviar.
|
||||
month: month - 1,
|
||||
sessionUser: SessionInfo.userId,
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
+9
-1
@@ -12,6 +12,7 @@ import { useNavigation } from "@hooks/goto";
|
||||
import TextPhone from "@core/app/components/TextPhone/TextPhone";
|
||||
import MaterialAlert from "@mui/material/Alert";
|
||||
import { createClient } from "../Clients.Service";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
type UrlData = {
|
||||
id: string;
|
||||
@@ -23,6 +24,7 @@ export default function ClientCreate() {
|
||||
const alert = useAlert();
|
||||
const SessionInfo = useSessionStore();
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
@@ -39,6 +41,8 @@ export default function ClientCreate() {
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
location.add({
|
||||
url: "/admin/org/profile/" + id,
|
||||
title: "Origanizacion",
|
||||
@@ -56,6 +60,10 @@ export default function ClientCreate() {
|
||||
title: "Crear",
|
||||
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||
});
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -72,7 +80,7 @@ export default function ClientCreate() {
|
||||
})
|
||||
.then((newClient) => {
|
||||
alert.showSuccess(
|
||||
`El cliente ${newClient.firstName} ${newClient.lastName} ha sido creado`
|
||||
`El cliente ${newClient.firstName} ${newClient.lastName} ha sido creado`,
|
||||
);
|
||||
setTimeout(() => {
|
||||
goTo("/admin/org/profile/" + id + "/clients");
|
||||
|
||||
+1
-21
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
top: 60px; /* Below Location/Filterable */
|
||||
top: 60px; /* Below Location */
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 10px;
|
||||
@@ -262,26 +262,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.paginationWrapper {
|
||||
padding: 16px 30px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
border-top: 1px solid rgba(226, 232, 240, 0.6);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
border-bottom-left-radius: 24px;
|
||||
border-bottom-right-radius: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.paginationWrapper {
|
||||
padding: 12px 16px;
|
||||
border-bottom-left-radius: 16px;
|
||||
border-bottom-right-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
|
||||
@@ -10,6 +10,7 @@ import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined";
|
||||
import PhoneOutlinedIcon from "@mui/icons-material/PhoneOutlined";
|
||||
import Avatar from "@components/Avatar/Avatar";
|
||||
import PaginationControls from "@components/PaginationControls/PaginationControls";
|
||||
import { useBottomToolbarStore } from "@store/BottomToolbar.Store";
|
||||
import pageStyle from "./page.module.css";
|
||||
import { findClientsByOrganization } from "./Clients.Service";
|
||||
import {
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
} from "@core/Models/Clients.model";
|
||||
import { useNavigation } from "@hooks/goto";
|
||||
import { FilterData, useFilterStore } from "@core/Store/Filter.Store";
|
||||
import Filterable from "@core/app/components/Filterable/Filterable";
|
||||
import Textbox from "@core/app/components/Textbox/Textbox";
|
||||
|
||||
type UrlData = {
|
||||
@@ -107,6 +107,7 @@ export default function ClientsGrid() {
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const filter = useFilterStore();
|
||||
const { goTo } = useNavigation();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
|
||||
const [pageSize] = useState<number>(
|
||||
parseInt(
|
||||
@@ -173,14 +174,35 @@ export default function ClientsGrid() {
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (clients && clients.pages > 1) {
|
||||
bottomToolbar.set(
|
||||
<div style={{ display: "flex", justifyContent: "center", width: "100%" }}>
|
||||
<PaginationControls
|
||||
current={clients.page}
|
||||
size={pageSize}
|
||||
max={clients.pages}
|
||||
onChange={(page, size) => loadClients(page, size)}
|
||||
inheritColor
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
bottomToolbar.set(<></>);
|
||||
}
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, [clients?.page, clients?.pages, pageSize]);
|
||||
|
||||
const handleClientClick = (client: ClientRow) => {
|
||||
goTo("/admin/org/" + oid + "/client/" + client.id + "/profile");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Filterable />
|
||||
<Location />
|
||||
<Location showFilter />
|
||||
<div className={pageStyle.pageContainer}>
|
||||
{clients?.data && clients.data.length > 0 ? (
|
||||
<div className={pageStyle.clientsGrid}>
|
||||
@@ -229,17 +251,6 @@ export default function ClientsGrid() {
|
||||
No se encontraron clientes con los filtros aplicados.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{clients && clients.pages > 1 && (
|
||||
<div className={pageStyle.paginationWrapper}>
|
||||
<PaginationControls
|
||||
current={clients.page}
|
||||
size={pageSize}
|
||||
max={clients.pages}
|
||||
onChange={(page, size) => loadClients(page, size)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { CompanyEmployeesView } from "@models/Collaborators.model";
|
||||
import { loadCollaboratorsByCompanyId } from "./Collaborators.Service";
|
||||
import { useAlert } from "@store/Alert.Store";
|
||||
import CollaboratorItem from "@core/app/components/CollaboratorItem/CollaboratorItem";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
type UrlData = {
|
||||
id: string;
|
||||
@@ -19,6 +20,7 @@ export default function OrganizationProfile() {
|
||||
const data = useParams<UrlData>();
|
||||
const id = data.id;
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const alert = useAlert();
|
||||
const { goTo } = useNavigation();
|
||||
@@ -31,6 +33,8 @@ export default function OrganizationProfile() {
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
location.add({
|
||||
url: "/admin/org",
|
||||
title: "Mis organizaciones",
|
||||
@@ -60,6 +64,10 @@ export default function OrganizationProfile() {
|
||||
.finally(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.NEED_ADD);
|
||||
});
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCollaboratorClick = (collaboratorId: string) => {
|
||||
|
||||
+18
-8
@@ -29,13 +29,13 @@ import DeleteIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
||||
import { useAlert } from "@core/Store/Alert.Store";
|
||||
import Textbox from "@components/Textbox/Textbox";
|
||||
import Filterable from "@core/app/components/Filterable/Filterable";
|
||||
import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model";
|
||||
import { TextObjectModalContent } from "@core/Store/TextObjectModal.Store";
|
||||
import TextObjectFilterItem from "@core/app/components/TextObjectFilterItem/TextObjectFilterItem";
|
||||
import TextObjectFilter from "@core/app/components/TextObjectFilter/TextObjectFilter";
|
||||
import { useFilterStore } from "@core/Store/Filter.Store";
|
||||
import filterObjects from "@components/TextObjectFilter/Filter.Service";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
type UrlData = {
|
||||
id: string;
|
||||
@@ -113,6 +113,7 @@ export default function DiscountsEmailGrid() {
|
||||
const data = useParams<UrlData>();
|
||||
const oid = data.id;
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
const SessionInfo = useSessionStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const alert = useAlert();
|
||||
@@ -152,7 +153,9 @@ export default function DiscountsEmailGrid() {
|
||||
render: (value: DiscountEmailRow) => (
|
||||
<div style={{ paddingBottom: "10px" }}>
|
||||
<div>
|
||||
<div style={{ paddingLeft: "10px", paddingTop: "10px", fontWeight: "bold" }}>
|
||||
<div
|
||||
style={{ paddingLeft: "10px", paddingTop: "10px", fontWeight: "bold" }}
|
||||
>
|
||||
{value.discountName + " (" + value.discountCode + ")"}
|
||||
</div>
|
||||
<div style={{ paddingLeft: "10px", paddingTop: "0px", fontWeight: "bold" }}>
|
||||
@@ -207,8 +210,10 @@ export default function DiscountsEmailGrid() {
|
||||
|
||||
const [pageSize] = useState<number>(
|
||||
parseInt(
|
||||
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE ? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE : "10"
|
||||
)
|
||||
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
|
||||
? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
|
||||
: "10",
|
||||
),
|
||||
);
|
||||
const [discounts, setDiscounts] = useState<PaginateDiscountsEmailViewResults>();
|
||||
const discountsRef = useRef(discounts);
|
||||
@@ -220,6 +225,8 @@ export default function DiscountsEmailGrid() {
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
location.add({
|
||||
url: "/admin/org",
|
||||
title: "Mis organizaciones",
|
||||
@@ -239,6 +246,10 @@ export default function DiscountsEmailGrid() {
|
||||
});
|
||||
|
||||
loadDiscountsEmail(1, pageSize);
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -268,7 +279,7 @@ export default function DiscountsEmailGrid() {
|
||||
setDiscountId={setDiscountId}
|
||||
filterEmail={filterEmail}
|
||||
setFilterEmail={setFilterEmail}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
filter.setOnFilter(() => {
|
||||
setSearchByFilters(true);
|
||||
@@ -328,14 +339,13 @@ export default function DiscountsEmailGrid() {
|
||||
alert.showError(err.format());
|
||||
});
|
||||
},
|
||||
() => {}
|
||||
() => {},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Location />
|
||||
<Filterable />
|
||||
<Location showFilter />
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "center", marginTop: "15px" }}>
|
||||
<ThemeProvider theme={turnosXpressTheme}>
|
||||
|
||||
@@ -22,6 +22,7 @@ import turnosXpressTheme from "@core/app/theme/turnosXpress";
|
||||
import { ButtonGroup } from "@mui/material";
|
||||
import ButtonMaterial from "@mui/material/Button";
|
||||
import { useNavigation } from "@core/app/hooks/goto";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
type UrlData = {
|
||||
id: string;
|
||||
@@ -33,6 +34,7 @@ export default function DiscountsGrid() {
|
||||
const data = useParams<UrlData>();
|
||||
const oid = data.id;
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
|
||||
const { goTo } = useNavigation();
|
||||
@@ -67,7 +69,9 @@ export default function DiscountsGrid() {
|
||||
color: "var(--green-darkestX2)",
|
||||
}}
|
||||
>
|
||||
{value.type === DiscountType.FIXED ? formatPrice(value.value) : value.value + "%"}
|
||||
{value.type === DiscountType.FIXED
|
||||
? formatPrice(value.value)
|
||||
: value.value + "%"}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@@ -76,8 +80,10 @@ export default function DiscountsGrid() {
|
||||
|
||||
const [pageSize] = useState<number>(
|
||||
parseInt(
|
||||
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE ? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE : "10"
|
||||
)
|
||||
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
|
||||
? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
|
||||
: "10",
|
||||
),
|
||||
);
|
||||
const [discounts, setDiscounts] = useState<PaginateDiscountsResults>();
|
||||
|
||||
@@ -88,6 +94,8 @@ export default function DiscountsGrid() {
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
location.add({
|
||||
url: "/admin/org",
|
||||
title: "Mis organizaciones",
|
||||
@@ -107,6 +115,10 @@ export default function DiscountsGrid() {
|
||||
});
|
||||
|
||||
loadDiscounts(1, pageSize);
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadDiscounts = (page: number = 1, pageSize: number = 30) => {
|
||||
|
||||
@@ -38,6 +38,7 @@ import Switch from "@core/app/components/Switch/Switch";
|
||||
import { FindTemplatesParams } from "@core/Models/Templates.model";
|
||||
import filterObjects from "@core/app/components/TextObjectFilter/Filter.Service";
|
||||
import { CompanyServiceView } from "@models/Service.model";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
type UrlData = {
|
||||
id: string;
|
||||
@@ -47,6 +48,7 @@ export default function OrganizationEdit() {
|
||||
const data = useParams<UrlData>();
|
||||
const id = data.id;
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const SessionInfo = useSessionStore();
|
||||
const alert = useAlert();
|
||||
@@ -66,11 +68,12 @@ export default function OrganizationEdit() {
|
||||
const [servicesShowPublicScores, setServicesShowPublicScores] = useState(true);
|
||||
const [servicesShowPublicOpinions, setServicesShowPublicOpinions] = useState(true);
|
||||
const [originalServicesShowPublicScores, setOriginalServicesShowPublicScores] = useState(true);
|
||||
const [originalServicesShowPublicOpinions, setOriginalServicesShowPublicOpinions] = useState(true);
|
||||
const [originalServicesShowPublicOpinions, setOriginalServicesShowPublicOpinions] =
|
||||
useState(true);
|
||||
const [services, setServices] = useState<CompanyServiceView[]>([]);
|
||||
|
||||
const [orgPublished, setOrgPublished] = useState<COMPANY_PUBLISHED_STATUS | undefined>(
|
||||
undefined
|
||||
undefined,
|
||||
);
|
||||
|
||||
const [savePublishedStatusAvailable, setSavePublishedStatusAvailable] = useState(false);
|
||||
@@ -199,6 +202,8 @@ export default function OrganizationEdit() {
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
location.add({
|
||||
url: "/admin/org",
|
||||
title: "Organizaciones",
|
||||
@@ -218,6 +223,10 @@ export default function OrganizationEdit() {
|
||||
});
|
||||
|
||||
loadCategories();
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -241,25 +250,33 @@ export default function OrganizationEdit() {
|
||||
setCategoryId(organization.categoryId.toString());
|
||||
setOrgSlug(getSlug(organization.name));
|
||||
setOrgPublished(organization.published);
|
||||
setShowPublicScores(organization.showPublicScores !== undefined ? organization.showPublicScores : true);
|
||||
setShowPublicOpinions(organization.showPublicOpinions !== undefined ? organization.showPublicOpinions : true);
|
||||
setShowPublicScores(
|
||||
organization.showPublicScores !== undefined
|
||||
? organization.showPublicScores
|
||||
: true,
|
||||
);
|
||||
setShowPublicOpinions(
|
||||
organization.showPublicOpinions !== undefined
|
||||
? organization.showPublicOpinions
|
||||
: true,
|
||||
);
|
||||
setShowPublicProfessionals(
|
||||
organization.showPublicProfessionals !== undefined
|
||||
? organization.showPublicProfessionals
|
||||
: true
|
||||
: true,
|
||||
);
|
||||
setServices(loadedServices);
|
||||
setServicesShowPublicScores(
|
||||
loadedServices.every((service) => service.showPublicScores !== false)
|
||||
loadedServices.every((service) => service.showPublicScores !== false),
|
||||
);
|
||||
setServicesShowPublicOpinions(
|
||||
loadedServices.every((service) => service.showPublicOpinions !== false)
|
||||
loadedServices.every((service) => service.showPublicOpinions !== false),
|
||||
);
|
||||
setOriginalServicesShowPublicScores(
|
||||
loadedServices.every((service) => service.showPublicScores !== false)
|
||||
loadedServices.every((service) => service.showPublicScores !== false),
|
||||
);
|
||||
setOriginalServicesShowPublicOpinions(
|
||||
loadedServices.every((service) => service.showPublicOpinions !== false)
|
||||
loadedServices.every((service) => service.showPublicOpinions !== false),
|
||||
);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
@@ -287,7 +304,7 @@ export default function OrganizationEdit() {
|
||||
confirm.show(
|
||||
validationPhoneResult.message,
|
||||
() => {},
|
||||
() => {}
|
||||
() => {},
|
||||
);
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
return;
|
||||
@@ -307,7 +324,8 @@ export default function OrganizationEdit() {
|
||||
phoneNumber: phone,
|
||||
templateWapNotifId: templateWapNotifId === "" ? undefined : templateWapNotifId,
|
||||
templateWapAltaId: templateWapAltaId === "" ? undefined : templateWapAltaId,
|
||||
templateWapCancellationId: templateWapCancellationId === "" ? undefined : templateWapCancellationId,
|
||||
templateWapCancellationId:
|
||||
templateWapCancellationId === "" ? undefined : templateWapCancellationId,
|
||||
...(canEditPublicVisibilityFlags
|
||||
? {
|
||||
showPublicScores,
|
||||
@@ -321,31 +339,31 @@ export default function OrganizationEdit() {
|
||||
id: Yup.string().required("En este momento no es posible procesar su solicitud."),
|
||||
name: Yup.string().required("Debe introducir el nombre de la organización."),
|
||||
description: Yup.string().required(
|
||||
"Debe introducir la descripción de la organización."
|
||||
"Debe introducir la descripción de la organización.",
|
||||
),
|
||||
categoryId: Yup.number().required(
|
||||
"Debe seleccionar una categoría para la organización."
|
||||
"Debe seleccionar una categoría para la organización.",
|
||||
),
|
||||
phoneAreaCode: Yup.number()
|
||||
.typeError("Código de área debe ser un número.")
|
||||
.transform((value, originalValue) =>
|
||||
originalValue.toString().trim() === "" ? null : value
|
||||
originalValue.toString().trim() === "" ? null : value,
|
||||
)
|
||||
.nullable(),
|
||||
phoneNumber: Yup.number()
|
||||
.typeError("El teléfono debe ser un número.")
|
||||
.transform((value, originalValue) =>
|
||||
originalValue.toString().trim() === "" ? null : value
|
||||
originalValue.toString().trim() === "" ? null : value,
|
||||
)
|
||||
.nullable(),
|
||||
appointmentAlert: Yup.string().optional().default(""),
|
||||
sessionUser: Yup.string().required(
|
||||
"Para crear una organización debe iniciar sesión."
|
||||
"Para crear una organización debe iniciar sesión.",
|
||||
),
|
||||
heatMapFraction: Yup.number()
|
||||
.typeError("Fracción de tiempo debe ser un número.")
|
||||
.transform((value, originalValue) =>
|
||||
originalValue.toString().trim() === "" ? null : value
|
||||
originalValue.toString().trim() === "" ? null : value,
|
||||
)
|
||||
.nullable()
|
||||
.integer("Fracción de tiempo debe ser un número entero en minutos.")
|
||||
@@ -354,7 +372,7 @@ export default function OrganizationEdit() {
|
||||
appointmentTime: Yup.number()
|
||||
.typeError("El Plazo Mínimo de Reserva debe ser un número.")
|
||||
.transform((value, originalValue) =>
|
||||
originalValue.toString().trim() === "" ? null : value
|
||||
originalValue.toString().trim() === "" ? null : value,
|
||||
)
|
||||
.nullable()
|
||||
.integer("El Plazo Mínimo de Reserva debe ser un número entero en horas.")
|
||||
@@ -362,7 +380,7 @@ export default function OrganizationEdit() {
|
||||
cancellationTime: Yup.number()
|
||||
.typeError("El Plazo Mínimo de Cancelación debe ser un número.")
|
||||
.transform((value, originalValue) =>
|
||||
originalValue.toString().trim() === "" ? null : value
|
||||
originalValue.toString().trim() === "" ? null : value,
|
||||
)
|
||||
.nullable()
|
||||
.integer("El Plazo Mínimo de Cancelación debe ser un número entero en horas.")
|
||||
@@ -386,7 +404,8 @@ export default function OrganizationEdit() {
|
||||
originalServicesShowPublicOpinions !== servicesShowPublicOpinions;
|
||||
const changedServices =
|
||||
canEditPublicVisibilityFlags &&
|
||||
(servicesShowPublicScoresChanged || servicesShowPublicOpinionsChanged)
|
||||
(servicesShowPublicScoresChanged ||
|
||||
servicesShowPublicOpinionsChanged)
|
||||
? services
|
||||
: [];
|
||||
|
||||
@@ -414,8 +433,8 @@ export default function OrganizationEdit() {
|
||||
: service.showPublicOpinions !== undefined
|
||||
? service.showPublicOpinions
|
||||
: true,
|
||||
})
|
||||
)
|
||||
}),
|
||||
),
|
||||
).then(() => {
|
||||
const updatedServices = services.map((service) => ({
|
||||
...service,
|
||||
@@ -434,7 +453,9 @@ export default function OrganizationEdit() {
|
||||
});
|
||||
})
|
||||
.catch((error: ApiError | Error) => {
|
||||
alert.showError(error instanceof ApiError ? error.format() : error.message);
|
||||
alert.showError(
|
||||
error instanceof ApiError ? error.format() : error.message,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
@@ -453,7 +474,7 @@ export default function OrganizationEdit() {
|
||||
id: category.id.toString(),
|
||||
name: category.name,
|
||||
description: category.description,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -533,7 +554,7 @@ export default function OrganizationEdit() {
|
||||
setOrgPublished(
|
||||
checked
|
||||
? COMPANY_PUBLISHED_STATUS.PUBLISHED
|
||||
: COMPANY_PUBLISHED_STATUS.PRIVATE
|
||||
: COMPANY_PUBLISHED_STATUS.PRIVATE,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -581,15 +602,23 @@ export default function OrganizationEdit() {
|
||||
/>
|
||||
|
||||
<MaterialAlert severity="info" sx={{ marginTop: "20px" }}>
|
||||
<b>Visibilidad pública:</b> Elegí si querés mostrar puntuaciones, opiniones y profesionales en el perfil público y las páginas de reserva. Disponible para planes pagos.
|
||||
<b>Visibilidad pública:</b> Elegí si querés mostrar puntuaciones, opiniones y
|
||||
profesionales en el perfil público y las páginas de reserva. Disponible para planes
|
||||
pagos.
|
||||
</MaterialAlert>
|
||||
{!canEditPublicVisibilityFlags && (
|
||||
<MaterialAlert severity="warning" sx={{ marginTop: "10px" }}>
|
||||
Para modificar las opciones de visibilidad pública de opiniones, puntuaciones y profesionales de esta organización y sus servicios, el propietario de la organización necesita un plan pago.
|
||||
Para modificar las opciones de visibilidad pública de opiniones, puntuaciones y
|
||||
profesionales de esta organización y sus servicios, el propietario de la
|
||||
organización necesita un plan pago.
|
||||
</MaterialAlert>
|
||||
)}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" }}
|
||||
>
|
||||
<label
|
||||
style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}
|
||||
>
|
||||
<Checkbox
|
||||
checked={showPublicScores}
|
||||
disabled={!canEditPublicVisibilityFlags}
|
||||
@@ -600,7 +629,9 @@ export default function OrganizationEdit() {
|
||||
/>
|
||||
Mostrar puntuaciones públicas
|
||||
</label>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
|
||||
<label
|
||||
style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}
|
||||
>
|
||||
<Checkbox
|
||||
checked={showPublicOpinions}
|
||||
disabled={!canEditPublicVisibilityFlags}
|
||||
@@ -611,7 +642,9 @@ export default function OrganizationEdit() {
|
||||
/>
|
||||
Mostrar opiniones públicas
|
||||
</label>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
|
||||
<label
|
||||
style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}
|
||||
>
|
||||
<Checkbox
|
||||
checked={showPublicProfessionals}
|
||||
disabled={!canEditPublicVisibilityFlags}
|
||||
@@ -632,7 +665,9 @@ export default function OrganizationEdit() {
|
||||
{services.length > 0 && (
|
||||
<>
|
||||
<MaterialAlert severity="info" sx={{ marginTop: "20px" }}>
|
||||
<b>Visibilidad pública de servicios:</b> Configurá si se muestran puntuaciones y opiniones en todos los servicios. Disponible para planes pagos.
|
||||
<b>Visibilidad pública de servicios:</b> Configurá si se muestran
|
||||
puntuaciones y opiniones en todos los servicios. Disponible para planes
|
||||
pagos.
|
||||
</MaterialAlert>
|
||||
<div
|
||||
style={{
|
||||
@@ -642,7 +677,14 @@ export default function OrganizationEdit() {
|
||||
marginTop: "10px",
|
||||
}}
|
||||
>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={servicesShowPublicScores}
|
||||
disabled={!canEditPublicVisibilityFlags}
|
||||
@@ -653,7 +695,14 @@ export default function OrganizationEdit() {
|
||||
/>
|
||||
Mostrar puntuaciones públicas de los servicios
|
||||
</label>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={servicesShowPublicOpinions}
|
||||
disabled={!canEditPublicVisibilityFlags}
|
||||
@@ -666,7 +715,8 @@ export default function OrganizationEdit() {
|
||||
</label>
|
||||
{!canEditPublicVisibilityFlags && (
|
||||
<span style={{ color: "var(--gray-darkest)", fontSize: "13px" }}>
|
||||
Para cambiar la visibilidad pública de los servicios necesitás un plan pago.
|
||||
Para cambiar la visibilidad pública de los servicios necesitás un
|
||||
plan pago.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -17,12 +17,14 @@ import ReceiptLongOutlinedIcon from "@mui/icons-material/ReceiptLongOutlined";
|
||||
import { Grid2 } from "@mui/material";
|
||||
import { ApiError } from "@models/Server.Error.model";
|
||||
import style from "./page.module.css";
|
||||
import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan";
|
||||
import {
|
||||
findArcaActivities,
|
||||
findOrganizationFiscalProfiles,
|
||||
OrganizationFiscalProfileTaxCondition,
|
||||
upsertOrganizationFiscalProfile,
|
||||
} from "./Fiscal.Service";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
type UrlData = {
|
||||
id: string;
|
||||
@@ -32,13 +34,15 @@ export default function OrganizationFiscalProfileEdit() {
|
||||
const data = useParams<UrlData>();
|
||||
const id = data.id;
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const SessionInfo = useSessionStore();
|
||||
const alert = useAlert();
|
||||
|
||||
const [cuit, setCuit] = useState("");
|
||||
const [legalName, setLegalName] = useState("");
|
||||
const [taxCondition, setTaxCondition] = useState<OrganizationFiscalProfileTaxCondition>("MONOTRIBUTO");
|
||||
const [taxCondition, setTaxCondition] =
|
||||
useState<OrganizationFiscalProfileTaxCondition>("MONOTRIBUTO");
|
||||
const [pointOfSale, setPointOfSale] = useState("1");
|
||||
const [activityCode, setActivityCode] = useState("");
|
||||
const [activityDescription, setActivityDescription] = useState("");
|
||||
@@ -79,6 +83,8 @@ export default function OrganizationFiscalProfileEdit() {
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.clear();
|
||||
|
||||
location.add({
|
||||
url: "/admin/org",
|
||||
title: "Organizaciones",
|
||||
@@ -96,6 +102,10 @@ export default function OrganizationFiscalProfileEdit() {
|
||||
title: "Datos fiscales",
|
||||
icon: <ReceiptLongOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||
});
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -117,7 +127,9 @@ export default function OrganizationFiscalProfileEdit() {
|
||||
setLegalName(profile.legalName || "");
|
||||
setTaxCondition(profile.taxCondition || "MONOTRIBUTO");
|
||||
setPointOfSale(profile.pointOfSale ? String(profile.pointOfSale) : "1");
|
||||
const profileActivity = activities.find((activity) => activity.id === profile.activityCode);
|
||||
const profileActivity = activities.find(
|
||||
(activity) => activity.id === profile.activityCode,
|
||||
);
|
||||
setActivityCode(profile.activityCode || "");
|
||||
setActivityDescription(profile.activityDescription || profileActivity?.name || "");
|
||||
})
|
||||
@@ -151,7 +163,10 @@ export default function OrganizationFiscalProfileEdit() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activityCode.trim() && !arcaActivities.some((activity) => activity.id === activityCode.trim())) {
|
||||
if (
|
||||
activityCode.trim() &&
|
||||
!arcaActivities.some((activity) => activity.id === activityCode.trim())
|
||||
) {
|
||||
alert.showError("Seleccioná una actividad ARCA/CLAE válida.");
|
||||
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
|
||||
return;
|
||||
@@ -159,7 +174,9 @@ export default function OrganizationFiscalProfileEdit() {
|
||||
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
|
||||
const selectedActivity = arcaActivities.find((activity) => activity.id === activityCode.trim());
|
||||
const selectedActivity = arcaActivities.find(
|
||||
(activity) => activity.id === activityCode.trim(),
|
||||
);
|
||||
|
||||
upsertOrganizationFiscalProfile({
|
||||
companyId: id,
|
||||
@@ -172,7 +189,9 @@ export default function OrganizationFiscalProfileEdit() {
|
||||
sessionUser: SessionInfo.userId,
|
||||
})
|
||||
.then((profile) => {
|
||||
const savedActivity = arcaActivities.find((activity) => activity.id === profile.activityCode);
|
||||
const savedActivity = arcaActivities.find(
|
||||
(activity) => activity.id === profile.activityCode,
|
||||
);
|
||||
setActivityCode(profile.activityCode || "");
|
||||
setActivityDescription(profile.activityDescription || savedActivity?.name || "");
|
||||
alert.showSuccess("Datos fiscales actualizados.");
|
||||
@@ -188,123 +207,132 @@ export default function OrganizationFiscalProfileEdit() {
|
||||
return (
|
||||
<>
|
||||
<Location />
|
||||
<div className={style.pageContainer}>
|
||||
<section className={style.heroCard}>
|
||||
<div className={style.heroIcon}>
|
||||
<ReceiptLongOutlinedIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Configuración fiscal</h2>
|
||||
<p>
|
||||
Estos datos se usan para emitir Factura C desde los movimientos de caja y para precargar el asistente ARCA.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
|
||||
<div className={style.pageContainer}>
|
||||
<section className={style.heroCard}>
|
||||
<div className={style.heroIcon}>
|
||||
<ReceiptLongOutlinedIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Configuración fiscal</h2>
|
||||
<p>
|
||||
Estos datos se usan para emitir Factura C desde los movimientos de
|
||||
caja y para precargar el asistente ARCA.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={style.formSection}>
|
||||
<h3>Datos del emisor</h3>
|
||||
<Grid2 container spacing={2}>
|
||||
<Grid2 size={{ xs: 12, sm: 6 }}>
|
||||
<Textbox
|
||||
name="cuit"
|
||||
placeholder="CUIT"
|
||||
type="text"
|
||||
value={cuit}
|
||||
width="100%"
|
||||
onChange={(event) => {
|
||||
setCuit(event.target.value.replace(/\D/g, ""));
|
||||
markNeedsSave();
|
||||
}}
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 size={{ xs: 12, sm: 6 }}>
|
||||
<Textbox
|
||||
name="legalName"
|
||||
placeholder="Razón social"
|
||||
type="text"
|
||||
value={legalName}
|
||||
width="100%"
|
||||
onChange={(event) => {
|
||||
setLegalName(event.target.value);
|
||||
markNeedsSave();
|
||||
}}
|
||||
/>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
</section>
|
||||
|
||||
<section className={style.formSection}>
|
||||
<div className={style.sectionHeader}>
|
||||
<h3>Condición fiscal</h3>
|
||||
<p>Seleccioná la condición declarada para esta organización.</p>
|
||||
</div>
|
||||
<div className={style.optionGroup} role="radiogroup" aria-label="Condición fiscal">
|
||||
{fiscalConditionOptions.map((option) => {
|
||||
const selected = taxCondition === option.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className={`${style.optionCard} ${selected ? style.optionCardSelected : ""}`}
|
||||
onClick={() => {
|
||||
setTaxCondition(option.id);
|
||||
<section className={style.formSection}>
|
||||
<h3>Datos del emisor</h3>
|
||||
<Grid2 container spacing={2}>
|
||||
<Grid2 size={{ xs: 12, sm: 6 }}>
|
||||
<Textbox
|
||||
name="cuit"
|
||||
placeholder="CUIT"
|
||||
type="text"
|
||||
value={cuit}
|
||||
width="100%"
|
||||
onChange={(event) => {
|
||||
setCuit(event.target.value.replace(/\D/g, ""));
|
||||
markNeedsSave();
|
||||
}}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<span className={style.optionTitle}>{option.title}</span>
|
||||
<span className={style.optionDescription}>{option.description}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 size={{ xs: 12, sm: 6 }}>
|
||||
<Textbox
|
||||
name="legalName"
|
||||
placeholder="Razón social"
|
||||
type="text"
|
||||
value={legalName}
|
||||
width="100%"
|
||||
onChange={(event) => {
|
||||
setLegalName(event.target.value);
|
||||
markNeedsSave();
|
||||
}}
|
||||
/>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
</section>
|
||||
|
||||
<section className={style.formSection}>
|
||||
<h3>Facturación ARCA</h3>
|
||||
<Grid2 container spacing={2}>
|
||||
<Grid2 size={{ xs: 12, sm: 6 }}>
|
||||
<Textbox
|
||||
name="pointOfSale"
|
||||
placeholder="Punto de venta"
|
||||
type="text"
|
||||
value={pointOfSale}
|
||||
width="100%"
|
||||
onChange={(event) => {
|
||||
setPointOfSale(event.target.value.replace(/\D/g, ""));
|
||||
markNeedsSave();
|
||||
}}
|
||||
/>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
<section className={style.formSection}>
|
||||
<div className={style.sectionHeader}>
|
||||
<h3>Condición fiscal</h3>
|
||||
<p>Seleccioná la condición declarada para esta organización.</p>
|
||||
</div>
|
||||
<div
|
||||
className={style.optionGroup}
|
||||
role="radiogroup"
|
||||
aria-label="Condición fiscal"
|
||||
>
|
||||
{fiscalConditionOptions.map((option) => {
|
||||
const selected = taxCondition === option.id;
|
||||
|
||||
{fiscalDataLoaded && (
|
||||
<TextObjectFilter
|
||||
key={`${activityCode}-${arcaActivities.length}`}
|
||||
placeholder="Actividad ARCA/CLAE"
|
||||
content={activityFilterContent}
|
||||
data={arcaActivities}
|
||||
selectedId={activityCode}
|
||||
onChange={(selectedActivityCode) => {
|
||||
const selectedActivity = arcaActivities.find(
|
||||
(activity) => activity.id === selectedActivityCode
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className={`${style.optionCard} ${selected ? style.optionCardSelected : ""}`}
|
||||
onClick={() => {
|
||||
setTaxCondition(option.id);
|
||||
markNeedsSave();
|
||||
}}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<span className={style.optionTitle}>{option.title}</span>
|
||||
<span className={style.optionDescription}>
|
||||
{option.description}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
setActivityCode(selectedActivity?.id || "");
|
||||
setActivityDescription(selectedActivity?.name || "");
|
||||
markNeedsSave();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<section className={style.formSection}>
|
||||
<h3>Facturación ARCA</h3>
|
||||
<Grid2 container spacing={2}>
|
||||
<Grid2 size={{ xs: 12, sm: 6 }}>
|
||||
<Textbox
|
||||
name="pointOfSale"
|
||||
placeholder="Punto de venta"
|
||||
type="text"
|
||||
value={pointOfSale}
|
||||
width="100%"
|
||||
onChange={(event) => {
|
||||
setPointOfSale(event.target.value.replace(/\D/g, ""));
|
||||
markNeedsSave();
|
||||
}}
|
||||
/>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
|
||||
<p className={style.helpText}>
|
||||
{activityCode && activityDescription
|
||||
? `${activityCode} - ${activityDescription}`
|
||||
: "Seleccioná la actividad principal declarada en ARCA."}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
{fiscalDataLoaded && (
|
||||
<TextObjectFilter
|
||||
key={`${activityCode}-${arcaActivities.length}`}
|
||||
placeholder="Actividad ARCA/CLAE"
|
||||
content={activityFilterContent}
|
||||
data={arcaActivities}
|
||||
selectedId={activityCode}
|
||||
onChange={(selectedActivityCode) => {
|
||||
const selectedActivity = arcaActivities.find(
|
||||
(activity) => activity.id === selectedActivityCode,
|
||||
);
|
||||
|
||||
setActivityCode(selectedActivity?.id || "");
|
||||
setActivityDescription(selectedActivity?.name || "");
|
||||
markNeedsSave();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className={style.helpText}>
|
||||
{activityCode && activityDescription
|
||||
? `${activityCode} - ${activityDescription}`
|
||||
: "Seleccioná la actividad principal declarada en ARCA."}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</SecureByPlan>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+64
-22
@@ -13,6 +13,7 @@ import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined";
|
||||
import NotificationsIcon from "@mui/icons-material/NotificationsOutlined";
|
||||
import MaterialAlert from "@mui/material/Alert";
|
||||
import dayjs from "dayjs";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
type UrlData = {
|
||||
id: string;
|
||||
@@ -21,7 +22,7 @@ type UrlData = {
|
||||
const CHANNELS: { key: NotificationChannel; label: string }[] = [
|
||||
{ key: "whatsapp", label: "WhatsApp" },
|
||||
{ key: "email", label: "Email" },
|
||||
{ key: "system", label: "Sistema" }
|
||||
{ key: "system", label: "Sistema" },
|
||||
];
|
||||
|
||||
const REMINDER_PRESETS: { minutes: number; label: string }[] = [
|
||||
@@ -30,36 +31,43 @@ const REMINDER_PRESETS: { minutes: number; label: string }[] = [
|
||||
{ minutes: 360, label: "6 horas antes" },
|
||||
{ minutes: 720, label: "12 horas antes" },
|
||||
{ minutes: 1440, label: "1 día antes" },
|
||||
{ minutes: 2880, label: "2 días antes" }
|
||||
{ minutes: 2880, label: "2 días antes" },
|
||||
];
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const data = useParams<UrlData>();
|
||||
const id = data.id;
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
const store = useNotificationPreferencesStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
location.add({
|
||||
url: "/admin/org",
|
||||
title: "Organizaciones",
|
||||
icon: null
|
||||
icon: null,
|
||||
});
|
||||
|
||||
location.add({
|
||||
url: "/admin/org/profile/" + id,
|
||||
title: "Perfil",
|
||||
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />
|
||||
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />,
|
||||
});
|
||||
|
||||
location.add({
|
||||
url: "/admin/org/profile/" + id + "/notifications",
|
||||
title: "Notificaciones",
|
||||
icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} />
|
||||
icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||
});
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
const toggleChannel = (channel: NotificationChannel) => {
|
||||
@@ -73,18 +81,22 @@ export default function NotificationsPage() {
|
||||
const updateQuietHours = (field: "from" | "to", value: string) => {
|
||||
const current = store.formData.quietHours || { from: "00:00", to: "00:00" };
|
||||
store.updateFormData({
|
||||
quietHours: { ...current, [field]: value }
|
||||
quietHours: { ...current, [field]: value },
|
||||
});
|
||||
};
|
||||
|
||||
const quietHourValue = (value: string | undefined, fallback: string) => {
|
||||
const [hour, minute] = (value || fallback).split(":").map(Number);
|
||||
return dayjs(new Date()).hour(hour).minute(minute || 0).second(0).millisecond(0);
|
||||
return dayjs(new Date())
|
||||
.hour(hour)
|
||||
.minute(minute || 0)
|
||||
.second(0)
|
||||
.millisecond(0);
|
||||
};
|
||||
|
||||
const toggleQuietHours = () => {
|
||||
store.updateFormData({
|
||||
quietHours: store.formData.quietHours ? null : { from: "22:00", to: "08:00" }
|
||||
quietHours: store.formData.quietHours ? null : { from: "22:00", to: "08:00" },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -95,7 +107,10 @@ export default function NotificationsPage() {
|
||||
store.updateFormData({ reminderRules: updated });
|
||||
} else {
|
||||
store.updateFormData({
|
||||
reminderRules: [...store.formData.reminderRules, { offset: minutes, enabled: true }]
|
||||
reminderRules: [
|
||||
...store.formData.reminderRules,
|
||||
{ offset: minutes, enabled: true },
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -107,10 +122,18 @@ export default function NotificationsPage() {
|
||||
<div style={{ marginTop: "20px" }}>
|
||||
{/* Channel Toggles Section */}
|
||||
<MaterialAlert severity="info" sx={{ marginBottom: "20px" }}>
|
||||
<b>Canales de notificación:</b> Seleccione los canales por los cuales se enviarán las notificaciones automáticas.
|
||||
<b>Canales de notificación:</b> Seleccione los canales por los cuales se
|
||||
enviarán las notificaciones automáticas.
|
||||
</MaterialAlert>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px", marginBottom: "30px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
marginBottom: "30px",
|
||||
}}
|
||||
>
|
||||
{CHANNELS.map((channel) => (
|
||||
<div
|
||||
key={channel.key}
|
||||
@@ -121,10 +144,12 @@ export default function NotificationsPage() {
|
||||
padding: "12px 16px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid var(--border-color)",
|
||||
backgroundColor: "var(--bg-primary)"
|
||||
backgroundColor: "var(--bg-primary)",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--text-primary)", fontWeight: "500" }}>{channel.label}</span>
|
||||
<span style={{ color: "var(--text-primary)", fontWeight: "500" }}>
|
||||
{channel.label}
|
||||
</span>
|
||||
<Switch
|
||||
checked={store.formData.defaultChannels.includes(channel.key)}
|
||||
onChange={() => toggleChannel(channel.key)}
|
||||
@@ -135,7 +160,8 @@ export default function NotificationsPage() {
|
||||
|
||||
{/* Quiet Hours Section */}
|
||||
<MaterialAlert severity="info" sx={{ marginBottom: "20px" }}>
|
||||
<b>Horario silencioso:</b> Las notificaciones no se enviarán durante este rango horario.
|
||||
<b>Horario silencioso:</b> Las notificaciones no se enviarán durante este rango
|
||||
horario.
|
||||
</MaterialAlert>
|
||||
|
||||
<div
|
||||
@@ -147,7 +173,7 @@ export default function NotificationsPage() {
|
||||
borderRadius: "8px",
|
||||
border: "1px solid var(--border-color)",
|
||||
backgroundColor: "var(--bg-primary)",
|
||||
marginBottom: "16px"
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--text-primary)", fontWeight: "500" }}>
|
||||
@@ -160,13 +186,22 @@ export default function NotificationsPage() {
|
||||
</div>
|
||||
|
||||
{store.formData.quietHours && (
|
||||
<div style={{ display: "flex", gap: "16px", alignItems: "flex-end", marginBottom: "30px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "16px",
|
||||
alignItems: "flex-end",
|
||||
marginBottom: "30px",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<TextTime
|
||||
placeholder="Desde"
|
||||
width="100%"
|
||||
value={quietHourValue(store.formData.quietHours.from, "22:00")}
|
||||
onChange={(value) => updateQuietHours("from", value.format("HH:mm"))}
|
||||
onChange={(value) =>
|
||||
updateQuietHours("from", value.format("HH:mm"))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
@@ -182,12 +217,15 @@ export default function NotificationsPage() {
|
||||
|
||||
{/* Reminder Rules Section */}
|
||||
<MaterialAlert severity="info" sx={{ marginBottom: "20px" }}>
|
||||
<b>Reglas de recordatorio:</b> Active los momentos en los que desea enviar recordatorios antes del turno.
|
||||
<b>Reglas de recordatorio:</b> Active los momentos en los que desea enviar
|
||||
recordatorios antes del turno.
|
||||
</MaterialAlert>
|
||||
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px" }}>
|
||||
{REMINDER_PRESETS.map((preset) => {
|
||||
const isActive = store.formData.reminderRules.some((r) => r.offset === preset.minutes);
|
||||
const isActive = store.formData.reminderRules.some(
|
||||
(r) => r.offset === preset.minutes,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={preset.minutes}
|
||||
@@ -198,11 +236,15 @@ export default function NotificationsPage() {
|
||||
padding: "10px 16px",
|
||||
borderRadius: "999px",
|
||||
border: `1px solid ${isActive ? "var(--primary-color)" : "var(--border-color)"}`,
|
||||
backgroundColor: isActive ? "rgba(25, 118, 210, 0.12)" : "var(--bg-primary)",
|
||||
color: isActive ? "var(--primary-color)" : "var(--text-secondary)",
|
||||
backgroundColor: isActive
|
||||
? "rgba(25, 118, 210, 0.12)"
|
||||
: "var(--bg-primary)",
|
||||
color: isActive
|
||||
? "var(--primary-color)"
|
||||
: "var(--text-secondary)",
|
||||
fontWeight: isActive ? "600" : "500",
|
||||
fontSize: "14px",
|
||||
transition: "all 0.2s ease"
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{preset.label}
|
||||
|
||||
+326
-131
@@ -248,7 +248,7 @@ export default function OrganizationNotPayedAppointments() {
|
||||
const [dialogPayVisible, setDialogPayVisible] = useState<boolean>(false);
|
||||
const [dialogPayOneVisible, setDialogPayOneVisible] = useState<boolean>(false);
|
||||
const [createPaymentData, setCreatePaymentData] = useState<PaginatePaymentsView | undefined>(
|
||||
undefined
|
||||
undefined,
|
||||
);
|
||||
|
||||
const [paymentMethodFilterContent] = useState<TextObjectModalContent>({
|
||||
@@ -284,7 +284,7 @@ export default function OrganizationNotPayedAppointments() {
|
||||
TextObjectFilterResult[]
|
||||
>([]);
|
||||
const [paymentMethodId, setPaymentMethodId] = useState<CLIENT_ACCOUNT_PAYMENT_METHODS>(
|
||||
CLIENT_ACCOUNT_PAYMENT_METHODS.CASH
|
||||
CLIENT_ACCOUNT_PAYMENT_METHODS.CASH,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -326,7 +326,7 @@ export default function OrganizationNotPayedAppointments() {
|
||||
TextObjectFilterResult[]
|
||||
>([]);
|
||||
const [paymentProcessDateId, setPaymentProcessDateId] = useState<PAYMENT_PROCCESS_DATE>(
|
||||
PAYMENT_PROCCESS_DATE.NOW
|
||||
PAYMENT_PROCCESS_DATE.NOW,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -356,7 +356,7 @@ export default function OrganizationNotPayedAppointments() {
|
||||
id: category.id.toString(),
|
||||
name: category.name,
|
||||
description: category.description,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -370,7 +370,7 @@ export default function OrganizationNotPayedAppointments() {
|
||||
const [clientIds, setClientIds] = useState<string[]>([]);
|
||||
|
||||
const [collaboratorFilterData, setCollaboratorFilterData] = useState<TextObjectFilterResult[]>(
|
||||
[]
|
||||
[],
|
||||
);
|
||||
const [collaboratorId, setCollaboratorId] = useState<string>("");
|
||||
|
||||
@@ -484,8 +484,8 @@ export default function OrganizationNotPayedAppointments() {
|
||||
parseInt(
|
||||
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
|
||||
? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
|
||||
: "10"
|
||||
)
|
||||
: "10",
|
||||
),
|
||||
);
|
||||
const [payments, setPayments] = useState<PaginateNotPayedAppointmentsResult>();
|
||||
const paymentsRef = useRef(payments);
|
||||
@@ -508,7 +508,7 @@ export default function OrganizationNotPayedAppointments() {
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { capture: true, passive: true });
|
||||
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll, { capture: true });
|
||||
};
|
||||
@@ -595,7 +595,6 @@ export default function OrganizationNotPayedAppointments() {
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const generarReporte = async () => {
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
try {
|
||||
@@ -611,11 +610,11 @@ export default function OrganizationNotPayedAppointments() {
|
||||
if (methodId) filterData.method = methodId as any;
|
||||
|
||||
const report = await GetNotPayedReport(filterData);
|
||||
|
||||
|
||||
const pdfMake = await import("pdfmake/build/pdfmake");
|
||||
const pdfFonts = (await import("pdfmake/build/vfs_fonts")) as any;
|
||||
pdfMake.vfs = pdfFonts.pdfMake ? pdfFonts.pdfMake.vfs : pdfFonts.vfs;
|
||||
|
||||
|
||||
let logoDataUrl = "";
|
||||
try {
|
||||
const response = await fetch("/favicon512.png");
|
||||
@@ -625,27 +624,27 @@ export default function OrganizationNotPayedAppointments() {
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
} catch(e) {}
|
||||
} catch (e) {}
|
||||
|
||||
const tableBody: any[] = [
|
||||
[
|
||||
{ text: "Cliente", bold: true, color: "#333333", fillColor: "#f5f5f5" },
|
||||
{ text: "Datos del Turno", bold: true, color: "#333333", fillColor: "#f5f5f5" },
|
||||
{ text: "Monto", bold: true, color: "#333333", fillColor: "#f5f5f5" }
|
||||
]
|
||||
{ text: "Monto", bold: true, color: "#333333", fillColor: "#f5f5f5" },
|
||||
],
|
||||
];
|
||||
|
||||
report.items.forEach(item => {
|
||||
report.items.forEach((item) => {
|
||||
tableBody.push([
|
||||
item.clientName || "-",
|
||||
{
|
||||
{
|
||||
text: [
|
||||
{ text: `${item.serviceName || "-"}\n`, bold: true, fontSize: 11 },
|
||||
`Prof: ${item.employeeName || "-"}\nFecha: ${dayjs(item.appointmentDate).format("DD/MM/YYYY HH:mm")} hs`
|
||||
`Prof: ${item.employeeName || "-"}\nFecha: ${dayjs(item.appointmentDate).format("DD/MM/YYYY HH:mm")} hs`,
|
||||
],
|
||||
fontSize: 10
|
||||
fontSize: 10,
|
||||
},
|
||||
{ text: "$" + item.price.toLocaleString("es-AR"), alignment: "right" }
|
||||
{ text: "$" + item.price.toLocaleString("es-AR"), alignment: "right" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -653,52 +652,81 @@ export default function OrganizationNotPayedAppointments() {
|
||||
tableBody.push([
|
||||
{ text: "TOTAL ADEUDADO", bold: true, colSpan: 2, alignment: "right" },
|
||||
{},
|
||||
{ text: "$" + report.totalAmount.toLocaleString("es-AR"), bold: true, alignment: "right" }
|
||||
{
|
||||
text: "$" + report.totalAmount.toLocaleString("es-AR"),
|
||||
bold: true,
|
||||
alignment: "right",
|
||||
},
|
||||
]);
|
||||
|
||||
const docDefinition: unknown = {
|
||||
content: [
|
||||
{
|
||||
columns: [
|
||||
{ text: "Reporte de Turnos Adeudados", fontSize: 18, bold: true, margin: [0, 0, 0, 10], width: '*' },
|
||||
...(logoDataUrl ? [{ image: logoDataUrl, width: 45, margin: [0, 0, 0, 10], alignment: 'right' }] : [])
|
||||
]
|
||||
{
|
||||
text: "Reporte de Turnos Adeudados",
|
||||
fontSize: 18,
|
||||
bold: true,
|
||||
margin: [0, 0, 0, 10],
|
||||
width: "*",
|
||||
},
|
||||
...(logoDataUrl
|
||||
? [
|
||||
{
|
||||
image: logoDataUrl,
|
||||
width: 45,
|
||||
margin: [0, 0, 0, 10],
|
||||
alignment: "right",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{ text: `Filtros aplicados:`, bold: true, margin: [0, 0, 0, 5] },
|
||||
{
|
||||
{
|
||||
text: [
|
||||
{ text: "Clientes Seleccionados: ", bold: true },
|
||||
`${clientIds.length > 0 ? clientIds.length : "Todos"}`
|
||||
`${clientIds.length > 0 ? clientIds.length : "Todos"}`,
|
||||
],
|
||||
margin: [0, 0, 0, 2]
|
||||
margin: [0, 0, 0, 2],
|
||||
},
|
||||
{
|
||||
{
|
||||
text: [
|
||||
{ text: "Período: ", bold: true },
|
||||
`${dateFrom ? dateFrom.format("DD/MM/YYYY") : "Inicio"} al ${dateTo ? dateTo.format("DD/MM/YYYY") : "Fin"}`
|
||||
`${dateFrom ? dateFrom.format("DD/MM/YYYY") : "Inicio"} al ${dateTo ? dateTo.format("DD/MM/YYYY") : "Fin"}`,
|
||||
],
|
||||
margin: [0, 0, 0, 20]
|
||||
margin: [0, 0, 0, 20],
|
||||
},
|
||||
{
|
||||
table: {
|
||||
headerRows: 1,
|
||||
widths: [120, "*", "auto"],
|
||||
body: tableBody
|
||||
body: tableBody,
|
||||
},
|
||||
layout: "lightHorizontalLines"
|
||||
}
|
||||
]
|
||||
layout: "lightHorizontalLines",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if ((window as unknown as { Android: any }).Android && (window as unknown as { Android: any }).Android.savePdf) {
|
||||
|
||||
if (
|
||||
(window as unknown as { Android: any }).Android &&
|
||||
(window as unknown as { Android: any }).Android.savePdf
|
||||
) {
|
||||
pdfMake.createPdf(docDefinition as any).getBase64((data: any) => {
|
||||
(window as unknown as { Android: any }).Android?.savePdf(data, `reporte-adeudados.pdf`);
|
||||
(window as unknown as { Android: any }).Android?.savePdf(
|
||||
data,
|
||||
`reporte-adeudados.pdf`,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
pdfMake.createPdf(docDefinition as any).download(`reporte-adeudados.pdf`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
alert.showError((error as { format?: () => string }).format ? (error as { format: () => string }).format() : "Error generando PDF");
|
||||
alert.showError(
|
||||
(error as { format?: () => string }).format
|
||||
? (error as { format: () => string }).format()
|
||||
: "Error generando PDF",
|
||||
);
|
||||
} finally {
|
||||
eventHandler.setEventType(EVENT_TYPES.CLEAR);
|
||||
}
|
||||
@@ -832,14 +860,24 @@ export default function OrganizationNotPayedAppointments() {
|
||||
setMethodId={setMethodId}
|
||||
clientData={clientFilterData}
|
||||
setClientIds={setClientIds}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
filter.setOnFilter(() => { });
|
||||
filter.setOnFilter(() => {});
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, [paymentsTotal, collaboratorFilterData, serviceFilterData, methodFilterData, clientFilterData, clientIds, collaboratorId, serviceId, methodId]);
|
||||
}, [
|
||||
paymentsTotal,
|
||||
collaboratorFilterData,
|
||||
serviceFilterData,
|
||||
methodFilterData,
|
||||
clientFilterData,
|
||||
clientIds,
|
||||
collaboratorId,
|
||||
serviceId,
|
||||
methodId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
@@ -881,72 +919,116 @@ export default function OrganizationNotPayedAppointments() {
|
||||
loadNotPayedAppointmentsTotal();
|
||||
}, [clientIds, collaboratorId, serviceId, methodId, dateFrom, dateTo, reloadPayments]);
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Location />
|
||||
|
||||
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
|
||||
<div style={{ maxWidth: "800px", margin: "0 auto", paddingBottom: "100px", marginTop: "0px" }}>
|
||||
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "800px",
|
||||
margin: "0 auto",
|
||||
paddingBottom: "100px",
|
||||
marginTop: "0px",
|
||||
}}
|
||||
>
|
||||
{/* Top Status Card */}
|
||||
<div style={{ position: "sticky", top: "70px", zIndex: 100, marginBottom: "50px", display: "flex", flexDirection: "column", background: "white", paddingBottom: "10px", paddingTop: "5px", margin: "0 -10px", paddingLeft: "10px", paddingRight: "10px" }}>
|
||||
|
||||
|
||||
{/* Tabs Selector */}
|
||||
<div style={{ display: "flex", justifyContent: "center", marginBottom: "15px" }}>
|
||||
<ThemeProvider theme={turnosXpressTheme}>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
background: "rgba(255,255,255,0.9)",
|
||||
backdropFilter: "blur(10px)",
|
||||
borderRadius: "30px",
|
||||
padding: "5px",
|
||||
boxShadow: "0 4px 15px rgba(0,0,0,0.05)"
|
||||
}}>
|
||||
<ButtonMaterial
|
||||
variant="text"
|
||||
onClick={() => goTo(`/admin/org/profile/${id}/payments`)}
|
||||
style={{ borderRadius: "25px", color: "#666", fontWeight: "bold", padding: "8px 25px" }}
|
||||
<div
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: "70px",
|
||||
zIndex: 100,
|
||||
marginBottom: "50px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "white",
|
||||
paddingBottom: "10px",
|
||||
paddingTop: "5px",
|
||||
margin: "0 -10px",
|
||||
paddingLeft: "10px",
|
||||
paddingRight: "10px",
|
||||
}}
|
||||
>
|
||||
{/* Tabs Selector */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
marginBottom: "15px",
|
||||
}}
|
||||
>
|
||||
<ThemeProvider theme={turnosXpressTheme}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
background: "rgba(255,255,255,0.9)",
|
||||
backdropFilter: "blur(10px)",
|
||||
borderRadius: "30px",
|
||||
padding: "5px",
|
||||
boxShadow: "0 4px 15px rgba(0,0,0,0.05)",
|
||||
}}
|
||||
>
|
||||
Pagados
|
||||
</ButtonMaterial>
|
||||
<ButtonMaterial
|
||||
variant="contained"
|
||||
style={{ borderRadius: "25px", boxShadow: "0 2px 8px rgba(0,0,0,0.1)", fontWeight: "bold", padding: "8px 25px" }}
|
||||
>
|
||||
Adeudados
|
||||
</ButtonMaterial>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</div>
|
||||
<ButtonMaterial
|
||||
variant="text"
|
||||
onClick={() => goTo(`/admin/org/profile/${id}/payments`)}
|
||||
style={{
|
||||
borderRadius: "25px",
|
||||
color: "#666",
|
||||
fontWeight: "bold",
|
||||
padding: "8px 25px",
|
||||
}}
|
||||
>
|
||||
Pagados
|
||||
</ButtonMaterial>
|
||||
<ButtonMaterial
|
||||
variant="contained"
|
||||
style={{
|
||||
borderRadius: "25px",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
|
||||
fontWeight: "bold",
|
||||
padding: "8px 25px",
|
||||
}}
|
||||
>
|
||||
Adeudados
|
||||
</ButtonMaterial>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
padding: isMinimized ? "25px 50px" : "40px 20px",
|
||||
borderRadius: isMinimized ? "15px" : "20px"
|
||||
borderRadius: isMinimized ? "15px" : "20px",
|
||||
}}
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #FF416C 0%, #FF4B2B 100%)", // Red gradient for debts
|
||||
color: "white",
|
||||
textAlign: "center",
|
||||
boxShadow: isMinimized ? "0 4px 15px rgba(0,0,0,0.3)" : "0 10px 30px rgba(0,0,0,0.15)",
|
||||
boxShadow: isMinimized
|
||||
? "0 4px 15px rgba(0,0,0,0.3)"
|
||||
: "0 10px 30px rgba(0,0,0,0.15)",
|
||||
position: "relative",
|
||||
|
||||
|
||||
display: "flex",
|
||||
flexDirection: isMinimized ? "row" : "column",
|
||||
alignItems: "center",
|
||||
justifyContent: isMinimized ? "space-between" : "center",
|
||||
transition: "box-shadow 0.3s ease"
|
||||
transition: "box-shadow 0.3s ease",
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => setForceMinimized(!forceMinimized)}
|
||||
style={{ position: "absolute", top: "10px", left: "10px", color: "rgba(255,255,255,0.7)", zIndex: 10 }}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "10px",
|
||||
left: "10px",
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
zIndex: 10,
|
||||
}}
|
||||
size="small"
|
||||
title={forceMinimized ? "Maximizar" : "Minimizar"}
|
||||
>
|
||||
@@ -954,51 +1036,95 @@ export default function OrganizationNotPayedAppointments() {
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => filter.toggle()}
|
||||
style={{ position: "absolute", top: "10px", right: "10px", color: "rgba(255,255,255,0.7)", zIndex: 10 }}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "10px",
|
||||
right: "10px",
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
zIndex: 10,
|
||||
}}
|
||||
size="small"
|
||||
title="Filtrar"
|
||||
>
|
||||
<FilterAltOutlinedIcon />
|
||||
</IconButton>
|
||||
<div style={{ position: "absolute", top: "-50px", right: "-50px", width: "150px", height: "150px", borderRadius: "50%", background: "rgba(255,255,255,0.05)" }}></div>
|
||||
<div style={{ position: "absolute", bottom: "-30px", left: "-30px", width: "100px", height: "100px", borderRadius: "50%", background: "rgba(255,255,255,0.05)" }}></div>
|
||||
|
||||
<motion.div
|
||||
animate={{
|
||||
fontSize: isMinimized ? "14px" : "16px",
|
||||
marginBottom: isMinimized ? "0px" : "10px"
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-50px",
|
||||
right: "-50px",
|
||||
width: "150px",
|
||||
height: "150px",
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "-30px",
|
||||
left: "-30px",
|
||||
width: "100px",
|
||||
height: "100px",
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
}}
|
||||
></div>
|
||||
|
||||
<motion.div
|
||||
animate={{
|
||||
fontSize: isMinimized ? "14px" : "16px",
|
||||
marginBottom: isMinimized ? "0px" : "10px",
|
||||
}}
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "2px",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
style={{ fontWeight: "bold", textTransform: "uppercase", letterSpacing: "2px", opacity: 0.9 }}
|
||||
>
|
||||
Total Adeudado
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={{
|
||||
<motion.div
|
||||
animate={{
|
||||
fontSize: isMinimized ? "24px" : "56px",
|
||||
margin: isMinimized ? "0px" : "10px 0"
|
||||
margin: isMinimized ? "0px" : "10px 0",
|
||||
}}
|
||||
style={{ fontWeight: "900" }}
|
||||
>
|
||||
{formatPrice(paymentsTotal)}
|
||||
</motion.div>
|
||||
|
||||
|
||||
{!isMinimized && (
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
style={{ marginTop: "15px" }}
|
||||
>
|
||||
<div style={{ marginBottom: "15px", width: "250px", maxWidth: "100%" }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "15px",
|
||||
width: "250px",
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
<TextRangeDateModern
|
||||
label="Período"
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
setDateFrom={setDateFrom}
|
||||
setDateTo={setDateTo}
|
||||
/>
|
||||
label="Período"
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
setDateFrom={setDateFrom}
|
||||
setDateTo={setDateTo}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "10px", alignItems: "center", flexWrap: "wrap" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "10px",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<ButtonMaterial
|
||||
variant="contained"
|
||||
startIcon={<PayIcon />}
|
||||
@@ -1009,7 +1135,7 @@ export default function OrganizationNotPayedAppointments() {
|
||||
fontWeight: "bold",
|
||||
borderRadius: "20px",
|
||||
padding: "8px 20px",
|
||||
textTransform: "none"
|
||||
textTransform: "none",
|
||||
}}
|
||||
>
|
||||
Pagar Todos
|
||||
@@ -1023,10 +1149,13 @@ export default function OrganizationNotPayedAppointments() {
|
||||
fontWeight: "bold",
|
||||
borderRadius: "20px",
|
||||
padding: "8px 20px",
|
||||
textTransform: "none"
|
||||
textTransform: "none",
|
||||
}}
|
||||
>
|
||||
<PictureAsPdfIcon style={{ fontSize: "18px", marginRight: "5px" }} /> PDF
|
||||
<PictureAsPdfIcon
|
||||
style={{ fontSize: "18px", marginRight: "5px" }}
|
||||
/>{" "}
|
||||
PDF
|
||||
</ButtonMaterial>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -1035,10 +1164,22 @@ export default function OrganizationNotPayedAppointments() {
|
||||
</div>
|
||||
|
||||
{/* Dynamic Spacer for layout shift */}
|
||||
<div style={{ height: isMinimized ? "150px" : "0px", transition: "height 0.3s ease" }}></div>
|
||||
<div
|
||||
style={{
|
||||
height: isMinimized ? "150px" : "0px",
|
||||
transition: "height 0.3s ease",
|
||||
}}
|
||||
></div>
|
||||
|
||||
{/* Payments List */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "15px", padding: "15px 10px 0 10px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "15px",
|
||||
padding: "15px 10px 0 10px",
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{payments?.data.map((payment, index) => (
|
||||
<motion.div
|
||||
@@ -1054,35 +1195,76 @@ export default function OrganizationNotPayedAppointments() {
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
boxShadow: "0 4px 15px rgba(0,0,0,0.05)",
|
||||
borderLeft: `4px solid var(--red)`
|
||||
borderLeft: `4px solid var(--red)`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "15px" }}>
|
||||
<div style={{
|
||||
background: "rgba(255, 75, 43, 0.1)",
|
||||
padding: "10px",
|
||||
borderRadius: "12px",
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--red)"
|
||||
}}>
|
||||
gap: "15px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(255, 75, 43, 0.1)",
|
||||
padding: "10px",
|
||||
borderRadius: "12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--red)",
|
||||
}}
|
||||
>
|
||||
<CreditCardIcon />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: "800", fontSize: "15px", color: "#333", marginBottom: "4px" }}>{payment.clientName}</div>
|
||||
<div style={{ fontWeight: "700", fontSize: "13px", color: "var(--purple)", marginBottom: "4px" }}>{payment.serviceName}</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: "800",
|
||||
fontSize: "15px",
|
||||
color: "#333",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
{payment.clientName}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: "700",
|
||||
fontSize: "13px",
|
||||
color: "var(--purple)",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
{payment.serviceName}
|
||||
</div>
|
||||
<div style={{ fontSize: "12px", color: "#888" }}>
|
||||
{dayjs(payment.createdAt).format("DD MMMM YYYY • HH:mm")} • {payment.employeeName}
|
||||
{dayjs(payment.createdAt).format(
|
||||
"DD MMMM YYYY • HH:mm",
|
||||
)}{" "}
|
||||
• {payment.employeeName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "15px" }}>
|
||||
<div style={{ fontWeight: "900", fontSize: "16px", color: "var(--red)" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "15px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: "900",
|
||||
fontSize: "16px",
|
||||
color: "var(--red)",
|
||||
}}
|
||||
>
|
||||
{formatPrice(payment.price)}
|
||||
</div>
|
||||
<IconButton
|
||||
color="success"
|
||||
<IconButton
|
||||
color="success"
|
||||
onClick={() => {
|
||||
setCreatePaymentData(payment);
|
||||
setDialogPayOneVisible(true);
|
||||
@@ -1094,12 +1276,24 @@ export default function OrganizationNotPayedAppointments() {
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
|
||||
{(!payments?.data || payments.data.length === 0) && (
|
||||
<div style={{ textAlign: "center", padding: "50px", color: "#aaa" }}>
|
||||
<CreditCardIcon sx={{ fontSize: "60px", opacity: 0.2, marginBottom: "15px" }} />
|
||||
<div style={{ fontSize: "18px", fontWeight: "bold" }}>No hay pagos adeudados</div>
|
||||
<div style={{ fontSize: "14px", marginTop: "5px" }}>Intenta ajustar los filtros de fecha</div>
|
||||
<div
|
||||
style={{ textAlign: "center", padding: "50px", color: "#aaa" }}
|
||||
>
|
||||
<CreditCardIcon
|
||||
sx={{
|
||||
fontSize: "60px",
|
||||
opacity: 0.2,
|
||||
marginBottom: "15px",
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: "18px", fontWeight: "bold" }}>
|
||||
No hay pagos adeudados
|
||||
</div>
|
||||
<div style={{ fontSize: "14px", marginTop: "5px" }}>
|
||||
Intenta ajustar los filtros de fecha
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -1107,7 +1301,9 @@ export default function OrganizationNotPayedAppointments() {
|
||||
|
||||
{/* Pagination */}
|
||||
{payments && payments.pages > 1 && (
|
||||
<div style={{ display: "flex", justifyContent: "center", marginTop: "40px" }}>
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", marginTop: "40px" }}
|
||||
>
|
||||
<Pagination
|
||||
count={payments.pages}
|
||||
page={payments.page}
|
||||
@@ -1134,7 +1330,7 @@ export default function OrganizationNotPayedAppointments() {
|
||||
data={paymentMethhodFilterData}
|
||||
onChange={(id) => {
|
||||
setPaymentMethodId(
|
||||
id as CLIENT_ACCOUNT_PAYMENT_METHODS
|
||||
id as CLIENT_ACCOUNT_PAYMENT_METHODS,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -1185,7 +1381,7 @@ export default function OrganizationNotPayedAppointments() {
|
||||
data={paymentMethhodFilterData}
|
||||
onChange={(id) => {
|
||||
setPaymentMethodId(
|
||||
id as CLIENT_ACCOUNT_PAYMENT_METHODS
|
||||
id as CLIENT_ACCOUNT_PAYMENT_METHODS,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -1219,7 +1415,6 @@ export default function OrganizationNotPayedAppointments() {
|
||||
</DialogActions>
|
||||
</>
|
||||
</DialogWindow>
|
||||
|
||||
</div>
|
||||
</SecureByPlan>
|
||||
</>
|
||||
|
||||
+369
-140
@@ -47,6 +47,7 @@ import SearchOffIcon from "@mui/icons-material/SearchOff";
|
||||
import UnfoldLessIcon from "@mui/icons-material/UnfoldLess";
|
||||
import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore";
|
||||
import FilterAltOutlinedIcon from "@mui/icons-material/FilterAltOutlined";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
type UrlData = {
|
||||
id: string;
|
||||
@@ -68,8 +69,12 @@ const FilterContent = (props: FilterContentProps) => {
|
||||
const [clientFilterContent] = useState<TextObjectModalContent>({
|
||||
header: (
|
||||
<div>
|
||||
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>Seleccionar Cliente:</div>
|
||||
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>Ingrese el nombre del cliente para buscarlo.</p>
|
||||
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
|
||||
Seleccionar Cliente:
|
||||
</div>
|
||||
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>
|
||||
Ingrese el nombre del cliente para buscarlo.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
bottom: <div> </div>,
|
||||
@@ -81,8 +86,12 @@ const FilterContent = (props: FilterContentProps) => {
|
||||
const [collaboratorFilterContent] = useState<TextObjectModalContent>({
|
||||
header: (
|
||||
<div>
|
||||
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>Seleccionar Colaborador:</div>
|
||||
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>Ingrese el nombre del colaborador para buscarlo.</p>
|
||||
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
|
||||
Seleccionar Colaborador:
|
||||
</div>
|
||||
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>
|
||||
Ingrese el nombre del colaborador para buscarlo.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
bottom: <div> </div>,
|
||||
@@ -94,8 +103,12 @@ const FilterContent = (props: FilterContentProps) => {
|
||||
const [serviceFilterContent] = useState<TextObjectModalContent>({
|
||||
header: (
|
||||
<div>
|
||||
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>Seleccionar Servicio:</div>
|
||||
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>Ingrese el nombre del servicio para buscarlo.</p>
|
||||
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
|
||||
Seleccionar Servicio:
|
||||
</div>
|
||||
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>
|
||||
Ingrese el nombre del servicio para buscarlo.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
bottom: <div> </div>,
|
||||
@@ -107,8 +120,12 @@ const FilterContent = (props: FilterContentProps) => {
|
||||
const [methodFilterContent] = useState<TextObjectModalContent>({
|
||||
header: (
|
||||
<div>
|
||||
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>Seleccionar Método de Pago:</div>
|
||||
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>Ingrese el nombre del método de pago para buscarlo.</p>
|
||||
<div style={{ fontWeight: "700", fontSize: "14px", paddingTop: "7px" }}>
|
||||
Seleccionar Método de Pago:
|
||||
</div>
|
||||
<p style={{ fontWeight: "500", fontSize: "13px", paddingTop: "2px" }}>
|
||||
Ingrese el nombre del método de pago para buscarlo.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
bottom: <div> </div>,
|
||||
@@ -152,6 +169,7 @@ export default function OrganizationEdit() {
|
||||
const data = useParams<UrlData>();
|
||||
const id = data.id;
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const SessionInfo = useSessionStore();
|
||||
const filter = useFilterStore();
|
||||
@@ -168,7 +186,9 @@ export default function OrganizationEdit() {
|
||||
const [clientFilterData, setClientFilterData] = useState<TextObjectFilterResult[]>([]);
|
||||
const [clientIds, setClientIds] = useState<string[]>([]);
|
||||
|
||||
const [collaboratorFilterData, setCollaboratorFilterData] = useState<TextObjectFilterResult[]>([]);
|
||||
const [collaboratorFilterData, setCollaboratorFilterData] = useState<TextObjectFilterResult[]>(
|
||||
[],
|
||||
);
|
||||
const [collaboratorId, setCollaboratorId] = useState<string>("");
|
||||
|
||||
const [serviceFilterData, setServiceFilterData] = useState<TextObjectFilterResult[]>([]);
|
||||
@@ -188,7 +208,6 @@ export default function OrganizationEdit() {
|
||||
|
||||
const generarReporte = async () => {
|
||||
try {
|
||||
|
||||
const pdfMake = await import("pdfmake/build/pdfmake");
|
||||
const pdfFonts = (await import("pdfmake/build/vfs_fonts")) as any;
|
||||
pdfMake.vfs = pdfFonts.pdfMake ? pdfFonts.pdfMake.vfs : pdfFonts.vfs;
|
||||
@@ -201,7 +220,7 @@ export default function OrganizationEdit() {
|
||||
serviceId: serviceId ? serviceId : undefined,
|
||||
employeeId: collaboratorId ? collaboratorId : undefined,
|
||||
method: methodId ? (methodId as any) : undefined,
|
||||
sessionUser: SessionInfo.userId
|
||||
sessionUser: SessionInfo.userId,
|
||||
});
|
||||
|
||||
if (!report.items || report.items.length === 0) {
|
||||
@@ -218,86 +237,114 @@ export default function OrganizationEdit() {
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
} catch(e) {}
|
||||
} catch (e) {}
|
||||
|
||||
const tableBody: any[] = [
|
||||
[
|
||||
{ text: "Cliente", bold: true, color: "#333333", fillColor: "#f5f5f5" },
|
||||
{ text: "Datos del Turno", bold: true, color: "#333333", fillColor: "#f5f5f5" },
|
||||
{ text: "Monto", bold: true, color: "#333333", fillColor: "#f5f5f5" }
|
||||
]
|
||||
{ text: "Monto", bold: true, color: "#333333", fillColor: "#f5f5f5" },
|
||||
],
|
||||
];
|
||||
|
||||
report.items.forEach(item => {
|
||||
report.items.forEach((item) => {
|
||||
tableBody.push([
|
||||
item.clientName || "-",
|
||||
{
|
||||
{
|
||||
text: [
|
||||
{ text: `${item.serviceName || "-"}\n`, bold: true, fontSize: 11 },
|
||||
`Prof: ${item.employeeName || "-"}\nFecha: ${dayjs(item.appointmentDate).format("DD/MM/YYYY HH:mm")} hs\nMétodo: ${item.method || "-"}`
|
||||
`Prof: ${item.employeeName || "-"}\nFecha: ${dayjs(item.appointmentDate).format("DD/MM/YYYY HH:mm")} hs\nMétodo: ${item.method || "-"}`,
|
||||
],
|
||||
fontSize: 10
|
||||
fontSize: 10,
|
||||
},
|
||||
{ text: "$" + item.price.toLocaleString("es-AR"), alignment: "right" }
|
||||
{ text: "$" + item.price.toLocaleString("es-AR"), alignment: "right" },
|
||||
]);
|
||||
});
|
||||
|
||||
tableBody.push([
|
||||
{ text: "TOTAL PROCESADO", bold: true, colSpan: 2, alignment: "right" },
|
||||
{},
|
||||
{ text: "$" + report.totalAmount.toLocaleString("es-AR"), bold: true, alignment: "right" }
|
||||
{
|
||||
text: "$" + report.totalAmount.toLocaleString("es-AR"),
|
||||
bold: true,
|
||||
alignment: "right",
|
||||
},
|
||||
]);
|
||||
|
||||
const docDefinition: unknown = {
|
||||
content: [
|
||||
{
|
||||
columns: [
|
||||
{ text: "Reporte de Cobros Procesados", fontSize: 18, bold: true, margin: [0, 0, 0, 10], width: '*' },
|
||||
...(logoDataUrl ? [{ image: logoDataUrl, width: 45, margin: [0, 0, 0, 10], alignment: 'right' }] : [])
|
||||
]
|
||||
{
|
||||
text: "Reporte de Cobros Procesados",
|
||||
fontSize: 18,
|
||||
bold: true,
|
||||
margin: [0, 0, 0, 10],
|
||||
width: "*",
|
||||
},
|
||||
...(logoDataUrl
|
||||
? [
|
||||
{
|
||||
image: logoDataUrl,
|
||||
width: 45,
|
||||
margin: [0, 0, 0, 10],
|
||||
alignment: "right",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{ text: `Filtros aplicados:`, bold: true, margin: [0, 0, 0, 5] },
|
||||
{
|
||||
{
|
||||
text: [
|
||||
{ text: "Clientes Seleccionados: ", bold: true },
|
||||
`${clientIds.length > 0 ? clientIds.length : "Todos"}`
|
||||
`${clientIds.length > 0 ? clientIds.length : "Todos"}`,
|
||||
],
|
||||
margin: [0, 0, 0, 2]
|
||||
margin: [0, 0, 0, 2],
|
||||
},
|
||||
{
|
||||
{
|
||||
text: [
|
||||
{ text: "Período: ", bold: true },
|
||||
`${dateFrom ? dateFrom.format("DD/MM/YYYY") : "Inicio"} al ${dateTo ? dateTo.format("DD/MM/YYYY") : "Fin"}`
|
||||
`${dateFrom ? dateFrom.format("DD/MM/YYYY") : "Inicio"} al ${dateTo ? dateTo.format("DD/MM/YYYY") : "Fin"}`,
|
||||
],
|
||||
margin: [0, 0, 0, 20]
|
||||
margin: [0, 0, 0, 20],
|
||||
},
|
||||
{
|
||||
table: {
|
||||
headerRows: 1,
|
||||
widths: [120, "*", "auto"],
|
||||
body: tableBody
|
||||
body: tableBody,
|
||||
},
|
||||
layout: "lightHorizontalLines"
|
||||
}
|
||||
]
|
||||
layout: "lightHorizontalLines",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if ((window as unknown as { Android: any }).Android && (window as unknown as { Android: any }).Android.savePdf) {
|
||||
|
||||
if (
|
||||
(window as unknown as { Android: any }).Android &&
|
||||
(window as unknown as { Android: any }).Android.savePdf
|
||||
) {
|
||||
pdfMake.createPdf(docDefinition as any).getBase64((data: any) => {
|
||||
(window as unknown as { Android: any }).Android?.savePdf(data, `reporte-procesados.pdf`);
|
||||
(window as unknown as { Android: any }).Android?.savePdf(
|
||||
data,
|
||||
`reporte-procesados.pdf`,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
pdfMake.createPdf(docDefinition as any).download(`reporte-procesados.pdf`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
alert.showError((error as { format?: () => string }).format ? (error as { format: () => string }).format() : "Error generando PDF");
|
||||
alert.showError(
|
||||
(error as { format?: () => string }).format
|
||||
? (error as { format: () => string }).format()
|
||||
: "Error generando PDF",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const handleScroll = (e: Event) => {
|
||||
const handleScroll = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
// Check if it's a DOM element scrolling
|
||||
if (target && target.scrollTop !== undefined) {
|
||||
@@ -313,7 +360,7 @@ export default function OrganizationEdit() {
|
||||
|
||||
// Use capture: true to catch all scroll events in the app regardless of where they happen
|
||||
window.addEventListener("scroll", handleScroll, { capture: true, passive: true });
|
||||
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll, { capture: true });
|
||||
};
|
||||
@@ -389,15 +436,23 @@ export default function OrganizationEdit() {
|
||||
|
||||
useEffect(() => {
|
||||
const cFilter: FindClientsParams = { companyId: id };
|
||||
filterObjects<FindClientsParams>("clients/filter-all", cFilter).then((res) => setClientFilterData(res));
|
||||
filterObjects<FindClientsParams>("clients/filter-all", cFilter).then((res) =>
|
||||
setClientFilterData(res),
|
||||
);
|
||||
|
||||
const eFilter: FindEmployeesParams = { companyId: id };
|
||||
filterObjects<FindEmployeesParams>("employees/filter-all", eFilter).then((res) => setCollaboratorFilterData(res));
|
||||
filterObjects<FindEmployeesParams>("employees/filter-all", eFilter).then((res) =>
|
||||
setCollaboratorFilterData(res),
|
||||
);
|
||||
|
||||
const sFilter: FindServicesParams = { companyId: id };
|
||||
filterObjects<FindServicesParams>("services/filter-all", sFilter).then((res) => setServiceFilterData(res));
|
||||
filterObjects<FindServicesParams>("services/filter-all", sFilter).then((res) =>
|
||||
setServiceFilterData(res),
|
||||
);
|
||||
|
||||
setMethodFilterData(paymentMethods.map((m) => ({ id: m.id, text: m.name, name: m.name, image: "" })));
|
||||
setMethodFilterData(
|
||||
paymentMethods.map((m) => ({ id: m.id, text: m.name, name: m.name, image: "" })),
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -413,18 +468,31 @@ export default function OrganizationEdit() {
|
||||
setMethodId={setMethodId}
|
||||
clientData={clientFilterData}
|
||||
setClientIds={setClientIds}
|
||||
clientIds={clientIds}
|
||||
/>
|
||||
clientIds={clientIds}
|
||||
/>,
|
||||
);
|
||||
filter.setOnFilter(() => { });
|
||||
filter.setOnFilter(() => {});
|
||||
}, [collaboratorFilterData, serviceFilterData, methodFilterData, clientFilterData]);
|
||||
|
||||
useEffect(() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
location.clear();
|
||||
bottomToolbar.set(<></>);
|
||||
location.add({ url: "/admin/org", title: "Organizaciones", icon: null });
|
||||
location.add({ url: "/admin/org/profile/" + id, title: "Perfil", icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" /> });
|
||||
location.add({ url: "/admin/org/profile/" + id, title: "Pagos", icon: <PaymentsIcon sx={{ fontSize: "32px", mr: "10px" }} /> });
|
||||
location.add({
|
||||
url: "/admin/org/profile/" + id,
|
||||
title: "Perfil",
|
||||
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />,
|
||||
});
|
||||
location.add({
|
||||
url: "/admin/org/profile/" + id,
|
||||
title: "Pagos",
|
||||
icon: <PaymentsIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||
});
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -444,68 +512,116 @@ export default function OrganizationEdit() {
|
||||
<Location />
|
||||
|
||||
<SecureByPlan content={SECURE_CONTENTS.PAYMENTS} companyId={id}>
|
||||
<div style={{ maxWidth: "800px", margin: "0 auto", paddingBottom: "100px", marginTop: "0px" }}>
|
||||
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "800px",
|
||||
margin: "0 auto",
|
||||
paddingBottom: "100px",
|
||||
marginTop: "0px",
|
||||
}}
|
||||
>
|
||||
{/* Sentinel element to detect scroll */}
|
||||
<div ref={sentinelRef} style={{ height: "1px", width: "100%" }}></div>
|
||||
|
||||
{/* Top Status Card */}
|
||||
<div style={{ position: "sticky", top: "70px", zIndex: 100, marginBottom: "50px", display: "flex", flexDirection: "column", background: "white", paddingBottom: "10px", paddingTop: "5px", margin: "0 -10px", paddingLeft: "10px", paddingRight: "10px" }}>
|
||||
|
||||
{/* Tabs Selector */}
|
||||
<div style={{ display: "flex", justifyContent: "center", marginBottom: "15px" }}>
|
||||
<ThemeProvider theme={turnosXpressTheme}>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
background: "rgba(255,255,255,0.9)",
|
||||
backdropFilter: "blur(10px)",
|
||||
borderRadius: "30px",
|
||||
padding: "5px",
|
||||
boxShadow: "0 4px 15px rgba(0,0,0,0.05)"
|
||||
}}>
|
||||
<ButtonMaterial
|
||||
variant="contained"
|
||||
style={{ borderRadius: "25px", boxShadow: "0 2px 8px rgba(0,0,0,0.1)", fontWeight: "bold", padding: "8px 25px" }}
|
||||
<div
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: "70px",
|
||||
zIndex: 100,
|
||||
marginBottom: "50px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "white",
|
||||
paddingBottom: "10px",
|
||||
paddingTop: "5px",
|
||||
margin: "0 -10px",
|
||||
paddingLeft: "10px",
|
||||
paddingRight: "10px",
|
||||
}}
|
||||
>
|
||||
{/* Tabs Selector */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
marginBottom: "15px",
|
||||
}}
|
||||
>
|
||||
<ThemeProvider theme={turnosXpressTheme}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
background: "rgba(255,255,255,0.9)",
|
||||
backdropFilter: "blur(10px)",
|
||||
borderRadius: "30px",
|
||||
padding: "5px",
|
||||
boxShadow: "0 4px 15px rgba(0,0,0,0.05)",
|
||||
}}
|
||||
>
|
||||
Pagados
|
||||
</ButtonMaterial>
|
||||
<ButtonMaterial
|
||||
variant="text"
|
||||
onClick={() => goTo(`/admin/org/profile/${id}/payments/not-payed`)}
|
||||
style={{ borderRadius: "25px", color: "#666", fontWeight: "bold", padding: "8px 25px" }}
|
||||
>
|
||||
Adeudados
|
||||
</ButtonMaterial>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</div>
|
||||
<ButtonMaterial
|
||||
variant="contained"
|
||||
style={{
|
||||
borderRadius: "25px",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
|
||||
fontWeight: "bold",
|
||||
padding: "8px 25px",
|
||||
}}
|
||||
>
|
||||
Pagados
|
||||
</ButtonMaterial>
|
||||
<ButtonMaterial
|
||||
variant="text"
|
||||
onClick={() =>
|
||||
goTo(`/admin/org/profile/${id}/payments/not-payed`)
|
||||
}
|
||||
style={{
|
||||
borderRadius: "25px",
|
||||
color: "#666",
|
||||
fontWeight: "bold",
|
||||
padding: "8px 25px",
|
||||
}}
|
||||
>
|
||||
Adeudados
|
||||
</ButtonMaterial>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
padding: isMinimized ? "25px 50px" : "40px 20px",
|
||||
borderRadius: isMinimized ? "15px" : "20px"
|
||||
borderRadius: isMinimized ? "15px" : "20px",
|
||||
}}
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #4b6cb7 0%, #182848 100%)",
|
||||
color: "white",
|
||||
textAlign: "center",
|
||||
boxShadow: isMinimized ? "0 4px 15px rgba(0,0,0,0.3)" : "0 10px 30px rgba(0,0,0,0.15)",
|
||||
boxShadow: isMinimized
|
||||
? "0 4px 15px rgba(0,0,0,0.3)"
|
||||
: "0 10px 30px rgba(0,0,0,0.15)",
|
||||
position: "relative",
|
||||
|
||||
|
||||
display: "flex",
|
||||
flexDirection: isMinimized ? "row" : "column",
|
||||
alignItems: "center",
|
||||
justifyContent: isMinimized ? "space-between" : "center",
|
||||
transition: "box-shadow 0.3s ease"
|
||||
transition: "box-shadow 0.3s ease",
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => setForceMinimized(!forceMinimized)}
|
||||
style={{ position: "absolute", top: "10px", left: "10px", color: "rgba(255,255,255,0.7)", zIndex: 10 }}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "10px",
|
||||
left: "10px",
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
zIndex: 10,
|
||||
}}
|
||||
size="small"
|
||||
title={forceMinimized ? "Maximizar" : "Minimizar"}
|
||||
>
|
||||
@@ -513,41 +629,81 @@ export default function OrganizationEdit() {
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => filter.toggle()}
|
||||
style={{ position: "absolute", top: "10px", right: "10px", color: "rgba(255,255,255,0.7)", zIndex: 10 }}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "10px",
|
||||
right: "10px",
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
zIndex: 10,
|
||||
}}
|
||||
size="small"
|
||||
title="Filtrar"
|
||||
>
|
||||
<FilterAltOutlinedIcon />
|
||||
</IconButton>
|
||||
<div style={{ position: "absolute", top: "-50px", right: "-50px", width: "150px", height: "150px", borderRadius: "50%", background: "rgba(255,255,255,0.05)" }}></div>
|
||||
<div style={{ position: "absolute", bottom: "-30px", left: "-30px", width: "100px", height: "100px", borderRadius: "50%", background: "rgba(255,255,255,0.05)" }}></div>
|
||||
|
||||
<motion.div
|
||||
animate={{
|
||||
fontSize: isMinimized ? "14px" : "16px",
|
||||
marginBottom: isMinimized ? "0px" : "10px"
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-50px",
|
||||
right: "-50px",
|
||||
width: "150px",
|
||||
height: "150px",
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "-30px",
|
||||
left: "-30px",
|
||||
width: "100px",
|
||||
height: "100px",
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
}}
|
||||
></div>
|
||||
|
||||
<motion.div
|
||||
animate={{
|
||||
fontSize: isMinimized ? "14px" : "16px",
|
||||
marginBottom: isMinimized ? "0px" : "10px",
|
||||
}}
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "2px",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
style={{ fontWeight: "bold", textTransform: "uppercase", letterSpacing: "2px", opacity: 0.9 }}
|
||||
>
|
||||
Total Recaudado
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={{
|
||||
<motion.div
|
||||
animate={{
|
||||
fontSize: isMinimized ? "24px" : "56px",
|
||||
margin: isMinimized ? "0px" : "10px 0"
|
||||
margin: isMinimized ? "0px" : "10px 0",
|
||||
}}
|
||||
style={{ fontWeight: "900" }}
|
||||
>
|
||||
{formatPrice(paymentsTotal)}
|
||||
</motion.div>
|
||||
{!isMinimized && (
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
style={{ fontSize: "14px", opacity: 0.8 }}
|
||||
>
|
||||
<div style={{ display: "flex", gap: "10px", alignItems: "center", flexWrap: "wrap", justifyContent: "center", marginTop: "10px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "10px",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
marginTop: "10px",
|
||||
}}
|
||||
>
|
||||
<div style={{ width: "250px", maxWidth: "100%" }}>
|
||||
<TextRangeDateModern
|
||||
label="Período"
|
||||
@@ -567,10 +723,13 @@ export default function OrganizationEdit() {
|
||||
borderRadius: "20px",
|
||||
padding: "8px 20px",
|
||||
textTransform: "none",
|
||||
height: "56px"
|
||||
height: "56px",
|
||||
}}
|
||||
>
|
||||
<PictureAsPdfIcon style={{ fontSize: "18px", marginRight: "5px" }} /> PDF
|
||||
<PictureAsPdfIcon
|
||||
style={{ fontSize: "18px", marginRight: "5px" }}
|
||||
/>{" "}
|
||||
PDF
|
||||
</ButtonMaterial>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -579,10 +738,22 @@ export default function OrganizationEdit() {
|
||||
</div>
|
||||
|
||||
{/* Dynamic Spacer for layout shift */}
|
||||
<div style={{ height: isMinimized ? "100px" : "0px", transition: "height 0.3s ease" }}></div>
|
||||
<div
|
||||
style={{
|
||||
height: isMinimized ? "100px" : "0px",
|
||||
transition: "height 0.3s ease",
|
||||
}}
|
||||
></div>
|
||||
|
||||
{/* Payments List */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px", paddingTop: "15px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "12px",
|
||||
paddingTop: "15px",
|
||||
}}
|
||||
>
|
||||
{dataList.length > 0 ? (
|
||||
<AnimatePresence>
|
||||
{dataList.map((payment, index) => (
|
||||
@@ -602,43 +773,85 @@ export default function OrganizationEdit() {
|
||||
padding: "16px 20px",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.05)",
|
||||
borderLeft: "5px solid #28a745",
|
||||
position: "relative"
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "15px", flex: 1 }}>
|
||||
<div style={{
|
||||
width: "50px",
|
||||
height: "50px",
|
||||
borderRadius: "12px",
|
||||
backgroundColor: "rgba(40, 167, 69, 0.1)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#28a745"
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "15px",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "50px",
|
||||
height: "50px",
|
||||
borderRadius: "12px",
|
||||
backgroundColor: "rgba(40, 167, 69, 0.1)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#28a745",
|
||||
}}
|
||||
>
|
||||
{getPaymentIcon(payment.method)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: "16px", fontWeight: "bold", color: "#333", marginBottom: "2px" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold",
|
||||
color: "#333",
|
||||
marginBottom: "2px",
|
||||
}}
|
||||
>
|
||||
{payment.clientName}
|
||||
</div>
|
||||
<div style={{ fontSize: "14px", color: "var(--wine-dark)", fontWeight: "bold", marginBottom: "4px" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
color: "var(--wine-dark)",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
{payment.serviceName}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "10px", fontSize: "12px", color: "#888" }}>
|
||||
<span>{dayjs(payment.createdAt).format("DD MMMM YYYY • HH:mm")}</span>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "10px",
|
||||
fontSize: "12px",
|
||||
color: "#888",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{dayjs(payment.createdAt).format(
|
||||
"DD MMMM YYYY • HH:mm",
|
||||
)}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{payment.employeeName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "15px" }}>
|
||||
<div style={{
|
||||
fontSize: "20px",
|
||||
fontWeight: "900",
|
||||
color: "#28a745",
|
||||
textAlign: "right"
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "15px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "20px",
|
||||
fontWeight: "900",
|
||||
color: "#28a745",
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
+{formatPrice(payment.price)}
|
||||
</div>
|
||||
<IconButton
|
||||
@@ -646,14 +859,18 @@ export default function OrganizationEdit() {
|
||||
size="small"
|
||||
onClick={() => {
|
||||
confirm.show(
|
||||
"¿Estás seguro que quieres eliminar el pago de " + payment.clientName + "?",
|
||||
"¿Estás seguro que quieres eliminar el pago de " +
|
||||
payment.clientName +
|
||||
"?",
|
||||
() => deletePaymentHandler(payment.id),
|
||||
() => { }
|
||||
() => {},
|
||||
);
|
||||
}}
|
||||
sx={{
|
||||
sx={{
|
||||
backgroundColor: "rgba(220, 53, 69, 0.05)",
|
||||
"&:hover": { backgroundColor: "rgba(220, 53, 69, 0.15)" }
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(220, 53, 69, 0.15)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
@@ -663,23 +880,35 @@ export default function OrganizationEdit() {
|
||||
))}
|
||||
</AnimatePresence>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
style={{ textAlign: "center", padding: "60px 20px", backgroundColor: "#fff", borderRadius: "15px", border: "1px dashed #ccc" }}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "60px 20px",
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: "15px",
|
||||
border: "1px dashed #ccc",
|
||||
}}
|
||||
>
|
||||
<SearchOffIcon sx={{ fontSize: "64px", color: "#ccc", marginBottom: "15px" }} />
|
||||
<SearchOffIcon
|
||||
sx={{ fontSize: "64px", color: "#ccc", marginBottom: "15px" }}
|
||||
/>
|
||||
<h2 style={{ margin: 0, color: "#999" }}>Sin Pagos</h2>
|
||||
<p style={{ color: "#aaa" }}>No se encontraron pagos registrados para este período.</p>
|
||||
<p style={{ color: "#aaa" }}>
|
||||
No se encontraron pagos registrados para este período.
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{payments && payments.pages > 1 && (
|
||||
<div style={{ display: "flex", justifyContent: "center", marginTop: "40px" }}>
|
||||
<Pagination
|
||||
count={payments.pages}
|
||||
page={payments.page}
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "center", marginTop: "40px" }}
|
||||
>
|
||||
<Pagination
|
||||
count={payments.pages}
|
||||
page={payments.page}
|
||||
onChange={(e, value) => loadPayments(value, pageSize)}
|
||||
color="primary"
|
||||
/>
|
||||
|
||||
@@ -17,6 +17,7 @@ import Avatar from "@components/Avatar/Avatar";
|
||||
import Button from "@core/app/components/Button/Button";
|
||||
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
||||
import { getMondayWeekStart } from "@core/helpers/week";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
dayjs.locale("es");
|
||||
|
||||
@@ -28,6 +29,7 @@ export default function Schedules() {
|
||||
const data = useParams<UrlData>();
|
||||
const id = data.id;
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const SessionInfo = useSessionStore();
|
||||
const alert = useAlert();
|
||||
@@ -38,6 +40,8 @@ export default function Schedules() {
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
location.add({
|
||||
url: "/admin/org",
|
||||
title: "Organizaciones",
|
||||
@@ -55,6 +59,10 @@ export default function Schedules() {
|
||||
title: "Horarios Habilitados",
|
||||
icon: null,
|
||||
});
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -118,7 +126,7 @@ export default function Schedules() {
|
||||
},
|
||||
() => {
|
||||
return;
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+22
-11
@@ -80,7 +80,7 @@ export default function OrganizationService() {
|
||||
const [serviceDiscountName, setServiceDiscountName] = useState<string | undefined>(undefined);
|
||||
|
||||
const [servicePublished, setServicePublished] = useState<SERVICE_PUBLISHED_STATUS | undefined>(
|
||||
undefined
|
||||
undefined,
|
||||
);
|
||||
|
||||
const [savePublishedStatusAvailable, setSavePublishedStatusAvailable] = useState(false);
|
||||
@@ -149,7 +149,7 @@ export default function OrganizationService() {
|
||||
>
|
||||
Aplicar Descuento
|
||||
</ButtonMaterial>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
}, []);
|
||||
|
||||
@@ -271,8 +271,12 @@ export default function OrganizationService() {
|
||||
setServiceDiscountType(response.discountType);
|
||||
setServiceDiscountValue(response.discountValue);
|
||||
setServiceDiscountName(response.discountName);
|
||||
setShowPublicScores(response.showPublicScores !== undefined ? response.showPublicScores : true);
|
||||
setShowPublicOpinions(response.showPublicOpinions !== undefined ? response.showPublicOpinions : true);
|
||||
setShowPublicScores(
|
||||
response.showPublicScores !== undefined ? response.showPublicScores : true,
|
||||
);
|
||||
setShowPublicOpinions(
|
||||
response.showPublicOpinions !== undefined ? response.showPublicOpinions : true,
|
||||
);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
@@ -283,7 +287,7 @@ export default function OrganizationService() {
|
||||
const formatDiscount = (
|
||||
value: number | undefined,
|
||||
type: string | undefined,
|
||||
name: string | undefined
|
||||
name: string | undefined,
|
||||
): string => {
|
||||
if (value === undefined || value === null || type === undefined || type === null) {
|
||||
return "";
|
||||
@@ -464,7 +468,7 @@ export default function OrganizationService() {
|
||||
setServicePublished(
|
||||
checked
|
||||
? SERVICE_PUBLISHED_STATUS.PUBLISHED
|
||||
: SERVICE_PUBLISHED_STATUS.PRIVATE
|
||||
: SERVICE_PUBLISHED_STATUS.PRIVATE,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -506,10 +510,15 @@ export default function OrganizationService() {
|
||||
/>
|
||||
|
||||
<MaterialAlert severity="info" sx={{ marginTop: "20px" }}>
|
||||
<b>Visibilidad pública:</b> Elegí si querés mostrar puntuaciones y opiniones de este servicio en páginas públicas. Disponible para planes pagos.
|
||||
<b>Visibilidad pública:</b> Elegí si querés mostrar puntuaciones y opiniones de este
|
||||
servicio en páginas públicas. Disponible para planes pagos.
|
||||
</MaterialAlert>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "10px", marginTop: "10px" }}
|
||||
>
|
||||
<label
|
||||
style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}
|
||||
>
|
||||
<Switch
|
||||
checked={showPublicScores}
|
||||
disabled={!canEditPublicRatingFlags}
|
||||
@@ -520,7 +529,9 @@ export default function OrganizationService() {
|
||||
/>
|
||||
Mostrar puntuaciones públicas
|
||||
</label>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}>
|
||||
<label
|
||||
style={{ display: "flex", alignItems: "center", gap: "10px", fontWeight: 600 }}
|
||||
>
|
||||
<Switch
|
||||
checked={showPublicOpinions}
|
||||
disabled={!canEditPublicRatingFlags}
|
||||
@@ -567,7 +578,7 @@ export default function OrganizationService() {
|
||||
() => {
|
||||
deleteDiscountHandler();
|
||||
},
|
||||
() => {}
|
||||
() => {},
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getServiceImage } from "@core/helpers/getServiceImage";
|
||||
import ServiceRatingSummary from "@components/ServiceRatingSummary/ServiceRatingSummary";
|
||||
|
||||
import ShareOutlinedIcon from "@mui/icons-material/ShareOutlined";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
@@ -31,8 +32,9 @@ import {
|
||||
WhatsappIcon as WhatsAppShareIcon,
|
||||
TwitterIcon,
|
||||
EmailIcon,
|
||||
TelegramIcon
|
||||
TelegramIcon,
|
||||
} from "react-share";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
type UrlData = {
|
||||
id: string;
|
||||
@@ -42,6 +44,7 @@ export default function Org() {
|
||||
const urlData = useParams<UrlData>();
|
||||
const id = urlData.id;
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const alert = useAlert();
|
||||
@@ -57,6 +60,8 @@ export default function Org() {
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
location.add({
|
||||
url: "/admin/org",
|
||||
title: "Mis organizaciones",
|
||||
@@ -74,6 +79,10 @@ export default function Org() {
|
||||
title: "Mis servicios",
|
||||
icon: <StorefrontOutlinedIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||
});
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -104,7 +113,7 @@ export default function Org() {
|
||||
<div
|
||||
className={style.serviceCard}
|
||||
style={{
|
||||
borderLeft: `6px solid ${data.color}`
|
||||
borderLeft: `6px solid ${data.color}`,
|
||||
}}
|
||||
onClick={() => handleServiceClick(data.id)}
|
||||
>
|
||||
@@ -114,22 +123,38 @@ export default function Org() {
|
||||
<div className={style.serviceInfo}>
|
||||
<h1>{data.name}</h1>
|
||||
<p>{data.description}</p>
|
||||
<ServiceRatingSummary serviceId={data.id} variant="row" showOpinionCount={false} />
|
||||
<ServiceRatingSummary
|
||||
serviceId={data.id}
|
||||
variant="row"
|
||||
showOpinionCount={false}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.serviceActions}>
|
||||
<span className={style.servicePrice}>{formatPrice(data.price)}</span>
|
||||
<IconButton
|
||||
className={style.shareButton}
|
||||
sx={{ position: "absolute", top: "15px", right: "15px" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShareService(data);
|
||||
}}
|
||||
size="small"
|
||||
title="Compartir servicio"
|
||||
>
|
||||
<ShareOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<div className={style.iconsRow}>
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goTo(`/landing/service/${data.id}`);
|
||||
}}
|
||||
size="small"
|
||||
title="Ver perfil público del servicio"
|
||||
className={style.iconButton}
|
||||
>
|
||||
<OpenInNewIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShareService(data);
|
||||
}}
|
||||
size="small"
|
||||
title="Compartir servicio"
|
||||
className={style.iconButton}
|
||||
>
|
||||
<ShareOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -146,8 +171,21 @@ export default function Org() {
|
||||
<div style={{ marginBottom: "20px" }}> </div>
|
||||
|
||||
{shareService && (
|
||||
<Dialog open={!!shareService} onClose={() => setShareService(null)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ m: 0, p: 2, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Dialog
|
||||
open={!!shareService}
|
||||
onClose={() => setShareService(null)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
m: 0,
|
||||
p: 2,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: "bold" }}>Compartir Servicio</span>
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
@@ -158,23 +196,57 @@ export default function Org() {
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<div style={{ textAlign: "center", marginBottom: "30px", fontSize: "16px", color: "var(--black-light)" }}>
|
||||
Compartí tu servicio <strong>{shareService.name}</strong> para que tus clientes puedan reservarlo directamente.
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
marginBottom: "30px",
|
||||
fontSize: "16px",
|
||||
color: "var(--black-light)",
|
||||
}}
|
||||
>
|
||||
Compartí tu servicio <strong>{shareService.name}</strong> para que tus
|
||||
clientes puedan reservarlo directamente.
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "center", flexDirection: "row", alignItems: "center", gap: "20px", paddingBottom: "20px" }}>
|
||||
<WhatsappShareButton title={"Reserva este servicio: " + shareService.name} separator=":: " url={window.location.origin + "/landing/service/" + shareService.id}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: "20px",
|
||||
paddingBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<WhatsappShareButton
|
||||
title={"Reserva este servicio: " + shareService.name}
|
||||
separator=":: "
|
||||
url={window.location.origin + "/landing/service/" + shareService.id}
|
||||
>
|
||||
<WhatsAppShareIcon size={54} round />
|
||||
</WhatsappShareButton>
|
||||
<FacebookShareButton title={"Reserva este servicio: " + shareService.name} url={window.location.origin + "/landing/service/" + shareService.id}>
|
||||
<FacebookShareButton
|
||||
title={"Reserva este servicio: " + shareService.name}
|
||||
url={window.location.origin + "/landing/service/" + shareService.id}
|
||||
>
|
||||
<FacebookIcon size={54} round />
|
||||
</FacebookShareButton>
|
||||
<EmailShareButton title={"Reserva este servicio: " + shareService.name} separator=":: " url={window.location.origin + "/landing/service/" + shareService.id}>
|
||||
<EmailShareButton
|
||||
title={"Reserva este servicio: " + shareService.name}
|
||||
separator=":: "
|
||||
url={window.location.origin + "/landing/service/" + shareService.id}
|
||||
>
|
||||
<EmailIcon size={54} round />
|
||||
</EmailShareButton>
|
||||
<TelegramShareButton title={"Reserva este servicio: " + shareService.name} url={window.location.origin + "/landing/service/" + shareService.id}>
|
||||
<TelegramShareButton
|
||||
title={"Reserva este servicio: " + shareService.name}
|
||||
url={window.location.origin + "/landing/service/" + shareService.id}
|
||||
>
|
||||
<TelegramIcon size={54} round />
|
||||
</TelegramShareButton>
|
||||
<TwitterShareButton title={"Reserva este servicio: " + shareService.name} url={window.location.origin + "/landing/service/" + shareService.id}>
|
||||
<TwitterShareButton
|
||||
title={"Reserva este servicio: " + shareService.name}
|
||||
url={window.location.origin + "/landing/service/" + shareService.id}
|
||||
>
|
||||
<TwitterIcon size={54} round />
|
||||
</TwitterShareButton>
|
||||
</div>
|
||||
|
||||
+11
-8
@@ -71,26 +71,29 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end; /* changed from space-between */
|
||||
min-width: 100px;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.servicePrice {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0; /* removed bottom margin */
|
||||
}
|
||||
|
||||
.shareButton {
|
||||
.iconsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
color: #94a3b8;
|
||||
transition: color 0.2s ease;
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
}
|
||||
|
||||
.shareButton:hover {
|
||||
.iconButton:hover {
|
||||
color: var(--wine-dark);
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -54,7 +54,7 @@ export default function TemplateCreate() {
|
||||
|
||||
const insertAtCursor = (
|
||||
ref: React.RefObject<HTMLInputElement | HTMLTextAreaElement>,
|
||||
insertText: string
|
||||
insertText: string,
|
||||
) => {
|
||||
const el = ref.current;
|
||||
if (!el) return; // todavía no está montado
|
||||
@@ -126,14 +126,14 @@ export default function TemplateCreate() {
|
||||
},
|
||||
() => {
|
||||
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
|
||||
}
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
Eliminar
|
||||
</ButtonMaterial>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
loadTemplate();
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@models/Templates.model";
|
||||
import { findTemplatesByOrganization } from "./Templates.Service";
|
||||
import { useNavigation } from "@core/app/hooks/goto";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
type UrlData = {
|
||||
id: string;
|
||||
@@ -29,6 +30,7 @@ export default function TemplatesGrid() {
|
||||
const oid = data.id;
|
||||
const { goTo } = useNavigation();
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
|
||||
const formatType = (type: string) => {
|
||||
@@ -91,8 +93,8 @@ export default function TemplatesGrid() {
|
||||
parseInt(
|
||||
process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
|
||||
? process.env.NEXT_PUBLIC_DATAGRID_PAGE_SIZE
|
||||
: "10"
|
||||
)
|
||||
: "10",
|
||||
),
|
||||
);
|
||||
const [templates, setTemplates] = useState<PaginateTemplatesResults>();
|
||||
|
||||
@@ -103,6 +105,8 @@ export default function TemplatesGrid() {
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
location.add({
|
||||
url: "/admin/org",
|
||||
title: "Mis organizaciones",
|
||||
@@ -122,6 +126,10 @@ export default function TemplatesGrid() {
|
||||
});
|
||||
|
||||
loadTemplates(1, pageSize);
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadTemplates = (page: number = 1, pageSize: number = 30) => {
|
||||
|
||||
@@ -26,6 +26,7 @@ import Image from "next/image";
|
||||
import Switch from "@core/app/components/Switch/Switch";
|
||||
import { useConfirmStore } from "@core/Store/Confirm.Store";
|
||||
import SecureByPlan, { SECURE_CONTENTS } from "@core/app/components/SecureByPlan/SecureByPlan";
|
||||
import { useBottomToolbarStore } from "@core/Store/BottomToolbar.Store";
|
||||
|
||||
type UrlData = {
|
||||
id: string;
|
||||
@@ -35,6 +36,7 @@ export default function OrganizationEdit() {
|
||||
const data = useParams<UrlData>();
|
||||
const id = data.id;
|
||||
const location = useLocationStore();
|
||||
const bottomToolbar = useBottomToolbarStore();
|
||||
const eventHandler = useEventHandlerStore();
|
||||
const SessionInfo = useSessionStore();
|
||||
const alert = useAlert();
|
||||
@@ -46,6 +48,8 @@ export default function OrganizationEdit() {
|
||||
useEffect(() => {
|
||||
location.clear();
|
||||
|
||||
bottomToolbar.set(<></>);
|
||||
|
||||
location.add({
|
||||
url: "/admin/org",
|
||||
title: "Organizaciones",
|
||||
@@ -63,6 +67,10 @@ export default function OrganizationEdit() {
|
||||
title: "Whatsapp",
|
||||
icon: <OrgConfigIcon sx={{ fontSize: "32px", mr: "10px" }} />,
|
||||
});
|
||||
|
||||
return () => {
|
||||
bottomToolbar.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -420,7 +428,7 @@ export default function OrganizationEdit() {
|
||||
eventHandler.setEventType(EVENT_TYPES.SLEEP);
|
||||
});
|
||||
},
|
||||
() => { }
|
||||
() => {},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -206,6 +206,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
const [wapError, setWapError] = useState<string | null>(null);
|
||||
|
||||
// State for ARCA onboarding flow
|
||||
const [arcaIsBlocked, setArcaIsBlocked] = useState(false);
|
||||
const [arcaCuit, setArcaCuit] = useState("");
|
||||
const [arcaLegalName, setArcaLegalName] = useState("");
|
||||
const [arcaTaxCondition, setArcaTaxCondition] = useState<"MONOTRIBUTO" | "EXENTO">("MONOTRIBUTO");
|
||||
@@ -277,6 +278,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
};
|
||||
|
||||
const loadOrganizationContext = (org: any) => {
|
||||
setArcaIsBlocked(false);
|
||||
setCreatedCompanyId(org.id || org._id);
|
||||
setSelectedOrganization(org);
|
||||
setOrgName(org.name || "");
|
||||
@@ -499,6 +501,18 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
setWapQr(null);
|
||||
setWapError(null);
|
||||
} else if (flowId === "arca-onboarding") {
|
||||
const orgSub = SessionInfo.organizationSubscriptions[org.id || org._id];
|
||||
const planSupportsPayments = orgSub ? orgSub.plan?.payments : false;
|
||||
|
||||
if (!planSupportsPayments) {
|
||||
setArcaIsBlocked(true);
|
||||
setCurrentStepIndex(0);
|
||||
setArcaError(null);
|
||||
setArcaLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setArcaIsBlocked(false);
|
||||
setCurrentStepIndex(0);
|
||||
setArcaError(null);
|
||||
setArcaLoading(false);
|
||||
@@ -527,6 +541,19 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
setArcaStatusLabel(getArcaStatusLabel(credential.status));
|
||||
}).catch(console.error);
|
||||
} else if (flowId === "arca-test-connection") {
|
||||
const orgSub = SessionInfo.organizationSubscriptions[org.id || org._id];
|
||||
const planSupportsPayments = orgSub ? orgSub.plan?.payments : false;
|
||||
|
||||
if (!planSupportsPayments) {
|
||||
setArcaIsBlocked(true);
|
||||
setCurrentStepIndex(0);
|
||||
setArcaError(null);
|
||||
setArcaLoading(false);
|
||||
setArcaTestResult(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setArcaIsBlocked(false);
|
||||
setCurrentStepIndex(0);
|
||||
setArcaError(null);
|
||||
setArcaLoading(false);
|
||||
@@ -2415,8 +2442,42 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
if (flowId === "public-link") return renderPublicLinkFlow();
|
||||
if (flowId === "notifications") return renderNotificationsFlow();
|
||||
if (flowId === "whatsapp-bot") return renderWhatsAppBotFlow();
|
||||
if (flowId === "arca-onboarding") return renderArcaOnboardingFlow();
|
||||
if (flowId === "arca-test-connection") return renderArcaTestConnectionFlow();
|
||||
if (flowId === "arca-onboarding") {
|
||||
if (arcaIsBlocked) {
|
||||
return (
|
||||
<QuestionCard
|
||||
isActive
|
||||
title="Módulo ARCA no disponible"
|
||||
description="Este flujo requiere un plan con el módulo de pagos habilitado para emitir Factura C."
|
||||
onNext={() => router.push("/landing/pricing")}
|
||||
nextLabel="Ver planes"
|
||||
>
|
||||
<div style={{ color: "rgba(255,255,255,0.75)", textAlign: "center", lineHeight: 1.6 }}>
|
||||
La organización seleccionada necesita un plan con el módulo de pagos activo para usar ARCA. Actualizá tu plan o volvé al asistente y elegí otra opción.
|
||||
</div>
|
||||
</QuestionCard>
|
||||
);
|
||||
}
|
||||
return renderArcaOnboardingFlow();
|
||||
}
|
||||
if (flowId === "arca-test-connection") {
|
||||
if (arcaIsBlocked) {
|
||||
return (
|
||||
<QuestionCard
|
||||
isActive
|
||||
title="Test de conexión ARCA no disponible"
|
||||
description="Este flujo requiere un plan con el módulo de pagos habilitado para probar la conexión con ARCA."
|
||||
onNext={() => router.push("/landing/pricing")}
|
||||
nextLabel="Ver planes"
|
||||
>
|
||||
<div style={{ color: "rgba(255,255,255,0.75)", textAlign: "center", lineHeight: 1.6 }}>
|
||||
La organización seleccionada necesita un plan con el módulo de pagos activo para usar ARCA. Actualizá tu plan o volvé al asistente y elegí otra opción.
|
||||
</div>
|
||||
</QuestionCard>
|
||||
);
|
||||
}
|
||||
return renderArcaTestConnectionFlow();
|
||||
}
|
||||
if (flowId === "disable-schedule") return renderDisableScheduleFlow();
|
||||
if (flowId === "open-reservation-periods") return renderReservationPeriodFlow();
|
||||
if (flowId === "override-schedule") return renderOverrideScheduleFlow();
|
||||
|
||||
+521
-359
@@ -39,13 +39,47 @@ type OnboardingServiceFlowProps = {
|
||||
};
|
||||
|
||||
export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps) {
|
||||
const { heatMapFraction, currentStepIndex, handleNext, serviceName, setServiceName, serviceDescription, setServiceDescription, handleKeyDown, serviceLength, setServiceLength, handleCreateService, isCreating, servicePrice, setServicePrice, serviceLimit, setServiceLimit, serviceCreateError, handleUploadServiceImage, serviceImage, setServiceImage, isStandaloneNewService, handleAssignCreatedServiceToMe, handleShowCollaboratorAssignment, handleToggleServiceCollaborator, handleAssignCreatedServiceToCollaborators, handleFinishWithoutAssignment, serviceAssignmentError, serviceAssignmentWarning, serviceAssignmentMode, serviceAssignmentCollaborators, selectedServiceCollaboratorIds, isLoadingServiceCollaborators } = props;
|
||||
const {
|
||||
heatMapFraction,
|
||||
currentStepIndex,
|
||||
handleNext,
|
||||
serviceName,
|
||||
setServiceName,
|
||||
serviceDescription,
|
||||
setServiceDescription,
|
||||
handleKeyDown,
|
||||
serviceLength,
|
||||
setServiceLength,
|
||||
handleCreateService,
|
||||
isCreating,
|
||||
servicePrice,
|
||||
setServicePrice,
|
||||
serviceLimit,
|
||||
setServiceLimit,
|
||||
serviceCreateError,
|
||||
handleUploadServiceImage,
|
||||
serviceImage,
|
||||
setServiceImage,
|
||||
isStandaloneNewService,
|
||||
handleAssignCreatedServiceToMe,
|
||||
handleShowCollaboratorAssignment,
|
||||
handleToggleServiceCollaborator,
|
||||
handleAssignCreatedServiceToCollaborators,
|
||||
handleFinishWithoutAssignment,
|
||||
serviceAssignmentError,
|
||||
serviceAssignmentWarning,
|
||||
serviceAssignmentMode,
|
||||
serviceAssignmentCollaborators,
|
||||
selectedServiceCollaboratorIds,
|
||||
isLoadingServiceCollaborators,
|
||||
} = props;
|
||||
const [isCustomDuration, setIsCustomDuration] = React.useState(false);
|
||||
const [customDurationValue, setCustomDurationValue] = React.useState("");
|
||||
const fraction = heatMapFraction || 60;
|
||||
const durationOptions = [fraction, fraction * 2, fraction * 3, fraction * 4];
|
||||
const customDurationMinutes = Number(customDurationValue);
|
||||
const isValidCustomDuration = Number.isInteger(customDurationMinutes) && customDurationMinutes > 0;
|
||||
const isValidCustomDuration =
|
||||
Number.isInteger(customDurationMinutes) && customDurationMinutes > 0;
|
||||
|
||||
const selectDurationOption = (duration: number) => {
|
||||
setIsCustomDuration(false);
|
||||
@@ -65,390 +99,518 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 0}
|
||||
title="Configura tus Servicios"
|
||||
description="Ya creaste tu negocio. Ahora necesitas configurar al menos un servicio para que tus clientes puedan empezar a reservar."
|
||||
onNext={handleNext}
|
||||
nextLabel="Comenzar"
|
||||
<>
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 0}
|
||||
title="Configura tus Servicios"
|
||||
description="Ya creaste tu negocio. Ahora necesitas configurar al menos un servicio para que tus clientes puedan empezar a reservar."
|
||||
onNext={handleNext}
|
||||
nextLabel="Comenzar"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: "rgba(255, 255, 255, 0.7)",
|
||||
textAlign: "center",
|
||||
padding: "10px",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "rgba(255, 255, 255, 0.7)", textAlign: "center", padding: "10px" }}>
|
||||
Vamos a crear el primer servicio de tu negocio. ¡Es muy fácil!
|
||||
</div>
|
||||
</QuestionCard>
|
||||
Vamos a crear el primer servicio de tu negocio. ¡Es muy fácil!
|
||||
</div>
|
||||
</QuestionCard>
|
||||
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 1}
|
||||
title="1. Nombre y Descripción"
|
||||
description="Elige un nombre atractivo y describe de qué trata el servicio."
|
||||
onNext={handleNext}
|
||||
disableNext={serviceName.trim().length < 3 || serviceDescription.trim().length < 5}
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 1}
|
||||
title="1. Nombre y Descripción"
|
||||
description="Elige un nombre atractivo y describe de qué trata el servicio."
|
||||
onNext={handleNext}
|
||||
disableNext={serviceName.trim().length < 3 || serviceDescription.trim().length < 5}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "15px", width: "100%" }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px', width: '100%' }}>
|
||||
<QuestionInput
|
||||
placeholder="Ej. Corte clásico con lavado"
|
||||
value={serviceName}
|
||||
onChange={(e) => setServiceName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<QuestionInput
|
||||
placeholder="Breve descripción del servicio..."
|
||||
value={serviceDescription}
|
||||
onChange={(e) => setServiceDescription(e.target.value)}
|
||||
<QuestionInput
|
||||
placeholder="Ej. Corte clásico con lavado"
|
||||
value={serviceName}
|
||||
onChange={(e) => setServiceName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<QuestionInput
|
||||
placeholder="Breve descripción del servicio..."
|
||||
value={serviceDescription}
|
||||
onChange={(e) => setServiceDescription(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
serviceName.trim().length >= 3 &&
|
||||
serviceDescription.trim().length >= 5
|
||||
) {
|
||||
handleKeyDown(e, handleNext);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</QuestionCard>
|
||||
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 2}
|
||||
title="2. Duración del Servicio"
|
||||
description={`Elige cuánto tiempo tomará. Las opciones están basadas en tu fracción de agenda (${fraction} min).`}
|
||||
onNext={handleNext}
|
||||
disableNext={!serviceLength || (isCustomDuration && !isValidCustomDuration)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "10px",
|
||||
flexWrap: "wrap",
|
||||
width: "100%",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{durationOptions.map((val) => {
|
||||
const hours = Math.floor(val / 60);
|
||||
const minutes = val % 60;
|
||||
const label =
|
||||
hours > 0
|
||||
? `${hours}h ${minutes > 0 ? minutes + "m" : ""}`.trim()
|
||||
: `${minutes} min`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={val}
|
||||
onClick={() => selectDurationOption(val)}
|
||||
style={{
|
||||
padding: "12px 24px",
|
||||
borderRadius: "12px",
|
||||
cursor: "pointer",
|
||||
background:
|
||||
!isCustomDuration && serviceLength === val
|
||||
? "var(--wine-red)"
|
||||
: "rgba(255,255,255,0.05)",
|
||||
border: `2px solid ${!isCustomDuration && serviceLength === val ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`,
|
||||
color: "white",
|
||||
fontWeight:
|
||||
!isCustomDuration && serviceLength === val
|
||||
? "bold"
|
||||
: "normal",
|
||||
transition: "all 0.2s ease",
|
||||
fontSize: "16px",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
onClick={selectCustomDuration}
|
||||
style={{
|
||||
padding: "12px 24px",
|
||||
borderRadius: "12px",
|
||||
cursor: "pointer",
|
||||
background: isCustomDuration
|
||||
? "var(--wine-red)"
|
||||
: "rgba(255,255,255,0.05)",
|
||||
border: `2px solid ${isCustomDuration ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`,
|
||||
color: "white",
|
||||
fontWeight: isCustomDuration ? "bold" : "normal",
|
||||
transition: "all 0.2s ease",
|
||||
fontSize: "16px",
|
||||
}}
|
||||
>
|
||||
Otro
|
||||
</div>
|
||||
</div>
|
||||
{isCustomDuration && (
|
||||
<div style={{ width: "100%", marginTop: "16px" }}>
|
||||
<QuestionInput
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="Duración en minutos"
|
||||
value={customDurationValue}
|
||||
onChange={(e) => updateCustomDuration(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (serviceName.trim().length >= 3 && serviceDescription.trim().length >= 5) {
|
||||
if (isValidCustomDuration) {
|
||||
handleKeyDown(e, handleNext);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</QuestionCard>
|
||||
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 2}
|
||||
title="2. Duración del Servicio"
|
||||
description={`Elige cuánto tiempo tomará. Las opciones están basadas en tu fracción de agenda (${fraction} min).`}
|
||||
onNext={handleNext}
|
||||
disableNext={!serviceLength || (isCustomDuration && !isValidCustomDuration)}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap', width: '100%', justifyContent: 'center' }}>
|
||||
{durationOptions.map(val => {
|
||||
const hours = Math.floor(val / 60);
|
||||
const minutes = val % 60;
|
||||
const label = hours > 0
|
||||
? `${hours}h ${minutes > 0 ? minutes + 'm' : ''}`.trim()
|
||||
: `${minutes} min`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={val}
|
||||
onClick={() => selectDurationOption(val)}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
borderRadius: '12px',
|
||||
cursor: 'pointer',
|
||||
background: !isCustomDuration && serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)',
|
||||
border: `2px solid ${!isCustomDuration && serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`,
|
||||
color: 'white',
|
||||
fontWeight: !isCustomDuration && serviceLength === val ? 'bold' : 'normal',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
onClick={selectCustomDuration}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
borderRadius: '12px',
|
||||
cursor: 'pointer',
|
||||
background: isCustomDuration ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)',
|
||||
border: `2px solid ${isCustomDuration ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`,
|
||||
color: 'white',
|
||||
fontWeight: isCustomDuration ? 'bold' : 'normal',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '16px'
|
||||
<div
|
||||
style={{
|
||||
color: "rgba(255,255,255,0.6)",
|
||||
fontSize: "13px",
|
||||
marginTop: "8px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Otro
|
||||
Ingresá la duración total del servicio expresada en minutos.
|
||||
</div>
|
||||
</div>
|
||||
{isCustomDuration && (
|
||||
<div style={{ width: '100%', marginTop: '16px' }}>
|
||||
<QuestionInput
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="Duración en minutos"
|
||||
value={customDurationValue}
|
||||
onChange={(e) => updateCustomDuration(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isValidCustomDuration) {
|
||||
handleKeyDown(e, handleNext);
|
||||
}
|
||||
)}
|
||||
</QuestionCard>
|
||||
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 3}
|
||||
title="3. Precio y Cupos"
|
||||
description="¿Cuánto cuesta y a cuántas personas puede atender cada profesional a la vez en este servicio?"
|
||||
onNext={handleCreateService}
|
||||
disableNext={isCreating}
|
||||
nextLabel={isCreating ? "Guardando..." : "Crear Servicio"}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "20px", width: "100%" }}
|
||||
>
|
||||
<div>
|
||||
<div style={{ color: "white", marginBottom: "10px", fontSize: "14px" }}>
|
||||
Precio Original
|
||||
</div>
|
||||
<div style={{ position: "relative" }}>
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "15px",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: "white",
|
||||
fontSize: "18px",
|
||||
}}
|
||||
>
|
||||
$
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
value={servicePrice}
|
||||
onChange={(e) => setServicePrice(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "15px 15px 15px 35px",
|
||||
borderRadius: "12px",
|
||||
border: "1px solid rgba(255,255,255,0.2)",
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
color: "white",
|
||||
fontSize: "18px",
|
||||
outline: "none",
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: '13px', marginTop: '8px', textAlign: 'center' }}>
|
||||
Ingresá la duración total del servicio expresada en minutos.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ color: "white", marginBottom: "10px", fontSize: "14px" }}>
|
||||
Cupos Simultáneos (Por defecto 1)
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
|
||||
{[1, 2, 3, 5, 10].map((val) => (
|
||||
<div
|
||||
key={val}
|
||||
onClick={() => setServiceLimit(val)}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
borderRadius: "8px",
|
||||
cursor: "pointer",
|
||||
background:
|
||||
serviceLimit === val
|
||||
? "var(--wine-red)"
|
||||
: "rgba(255,255,255,0.1)",
|
||||
border: `1px solid ${serviceLimit === val ? "var(--wine-red)" : "rgba(255,255,255,0.2)"}`,
|
||||
color: "white",
|
||||
fontWeight: serviceLimit === val ? "bold" : "normal",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{val}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{serviceCreateError && (
|
||||
<div
|
||||
style={{
|
||||
padding: "14px 16px",
|
||||
borderRadius: "12px",
|
||||
background: "rgba(255, 99, 132, 0.12)",
|
||||
border: "1px solid rgba(255, 99, 132, 0.35)",
|
||||
color: "#fecdd3",
|
||||
fontSize: "14px",
|
||||
lineHeight: 1.5,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{serviceCreateError}
|
||||
</div>
|
||||
)}
|
||||
</QuestionCard>
|
||||
</div>
|
||||
</QuestionCard>
|
||||
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 3}
|
||||
title="3. Precio y Cupos"
|
||||
description="¿Cuánto cuesta y a cuántas personas puedes atender a la vez en este servicio?"
|
||||
onNext={handleCreateService}
|
||||
disableNext={isCreating}
|
||||
nextLabel={isCreating ? "Guardando..." : "Crear Servicio"}
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 4}
|
||||
title="4. Imagen del Servicio"
|
||||
description="Sube una foto representativa para que los clientes sepan qué ofreces. (Opcional)"
|
||||
onNext={handleUploadServiceImage}
|
||||
disableNext={isCreating}
|
||||
nextLabel={
|
||||
isCreating
|
||||
? "Subiendo..."
|
||||
: serviceImage
|
||||
? isStandaloneNewService
|
||||
? "Subir y continuar"
|
||||
: "Subir y Finalizar"
|
||||
: "Omitir por ahora"
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "20px",
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', width: '100%' }}>
|
||||
<div>
|
||||
<div style={{ color: 'white', marginBottom: '10px', fontSize: '14px' }}>Precio Original</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<span style={{ position: 'absolute', left: '15px', top: '50%', transform: 'translateY(-50%)', color: 'white', fontSize: '18px' }}>$</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
value={servicePrice}
|
||||
onChange={(e) => setServicePrice(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '15px 15px 15px 35px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
color: 'white',
|
||||
fontSize: '18px',
|
||||
outline: 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ color: 'white', marginBottom: '10px', fontSize: '14px' }}>Cupos Simultáneos (Por defecto 1)</div>
|
||||
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
|
||||
{[1, 2, 3, 5, 10].map(val => (
|
||||
<div
|
||||
key={val}
|
||||
onClick={() => setServiceLimit(val)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
background: serviceLimit === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)',
|
||||
border: `1px solid ${serviceLimit === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.2)'}`,
|
||||
color: 'white',
|
||||
fontWeight: serviceLimit === val ? 'bold' : 'normal',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
{val}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{serviceCreateError && (
|
||||
<div style={{
|
||||
padding: '14px 16px',
|
||||
borderRadius: '12px',
|
||||
background: 'rgba(255, 99, 132, 0.12)',
|
||||
border: '1px solid rgba(255, 99, 132, 0.35)',
|
||||
color: '#fecdd3',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.5,
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{serviceCreateError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</QuestionCard>
|
||||
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 4}
|
||||
title="4. Imagen del Servicio"
|
||||
description="Sube una foto representativa para que los clientes sepan qué ofreces. (Opcional)"
|
||||
onNext={handleUploadServiceImage}
|
||||
disableNext={isCreating}
|
||||
nextLabel={isCreating ? "Subiendo..." : (serviceImage ? (isStandaloneNewService ? "Subir y continuar" : "Subir y Finalizar") : "Omitir por ahora")}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', width: '100%', alignItems: 'center' }}>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '150px',
|
||||
border: '2px dashed rgba(255,255,255,0.3)',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
background: serviceImage ? 'transparent' : 'rgba(255,255,255,0.05)'
|
||||
}}>
|
||||
{serviceImage ? (
|
||||
<img
|
||||
src={URL.createObjectURL(serviceImage)}
|
||||
alt="Preview"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ color: 'rgba(255,255,255,0.6)' }}>Toca para seleccionar imagen</span>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
setServiceImage(e.target.files[0]);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
opacity: 0,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{serviceCreateError && (
|
||||
<div style={{
|
||||
padding: '14px 16px',
|
||||
borderRadius: '12px',
|
||||
background: 'rgba(255, 99, 132, 0.12)',
|
||||
border: '1px solid rgba(255, 99, 132, 0.35)',
|
||||
color: '#fecdd3',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.5,
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{serviceCreateError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</QuestionCard>
|
||||
|
||||
{isStandaloneNewService && (
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 5}
|
||||
title="¿Quién brinda este servicio?"
|
||||
description="Asigná este servicio a un colaborador para que pueda recibir reservas. Si todavía no querés hacerlo, podés configurarlo después."
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "150px",
|
||||
border: "2px dashed rgba(255,255,255,0.3)",
|
||||
borderRadius: "12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
background: serviceImage ? "transparent" : "rgba(255,255,255,0.05)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px', width: '100%' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAssignCreatedServiceToMe}
|
||||
disabled={isCreating}
|
||||
{serviceImage ? (
|
||||
<img
|
||||
src={URL.createObjectURL(serviceImage)}
|
||||
alt="Preview"
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ color: "rgba(255,255,255,0.6)" }}>
|
||||
Toca para seleccionar imagen
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
setServiceImage(e.target.files[0]);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
opacity: 0,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{serviceCreateError && (
|
||||
<div
|
||||
style={{
|
||||
padding: "14px 16px",
|
||||
borderRadius: "12px",
|
||||
background: "rgba(255, 99, 132, 0.12)",
|
||||
border: "1px solid rgba(255, 99, 132, 0.35)",
|
||||
color: "#fecdd3",
|
||||
fontSize: "14px",
|
||||
lineHeight: 1.5,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{serviceCreateError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</QuestionCard>
|
||||
|
||||
{isStandaloneNewService && (
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 5}
|
||||
title="¿Quién brinda este servicio?"
|
||||
description="Asigná este servicio a un colaborador para que pueda recibir reservas. Si todavía no querés hacerlo, podés configurarlo después."
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "14px",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAssignCreatedServiceToMe}
|
||||
disabled={isCreating}
|
||||
style={{
|
||||
padding: "14px 18px",
|
||||
borderRadius: "12px",
|
||||
border: "1px solid var(--wine-red)",
|
||||
background: "var(--wine-red)",
|
||||
color: "white",
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold",
|
||||
cursor: isCreating ? "not-allowed" : "pointer",
|
||||
opacity: isCreating ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{isCreating ? "Asignando..." : "Yo"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShowCollaboratorAssignment}
|
||||
disabled={isCreating || isLoadingServiceCollaborators}
|
||||
style={{
|
||||
padding: "14px 18px",
|
||||
borderRadius: "12px",
|
||||
border:
|
||||
serviceAssignmentMode === "collaborators"
|
||||
? "1px solid var(--wine-red)"
|
||||
: "1px solid rgba(255,255,255,0.2)",
|
||||
background:
|
||||
serviceAssignmentMode === "collaborators"
|
||||
? "rgba(128, 0, 64, 0.35)"
|
||||
: "rgba(255,255,255,0.05)",
|
||||
color: "white",
|
||||
fontSize: "16px",
|
||||
cursor:
|
||||
isCreating || isLoadingServiceCollaborators
|
||||
? "not-allowed"
|
||||
: "pointer",
|
||||
opacity: isCreating || isLoadingServiceCollaborators ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{isLoadingServiceCollaborators
|
||||
? "Cargando colaboradores..."
|
||||
: "Uno o más colaboradores"}
|
||||
</button>
|
||||
{serviceAssignmentMode === "collaborators" && (
|
||||
<div
|
||||
style={{
|
||||
padding: '14px 18px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid var(--wine-red)',
|
||||
background: 'var(--wine-red)',
|
||||
color: 'white',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
cursor: isCreating ? 'not-allowed' : 'pointer',
|
||||
opacity: isCreating ? 0.7 : 1
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "10px",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{isCreating ? "Asignando..." : "Yo"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShowCollaboratorAssignment}
|
||||
disabled={isCreating || isLoadingServiceCollaborators}
|
||||
style={{
|
||||
padding: '14px 18px',
|
||||
borderRadius: '12px',
|
||||
border: serviceAssignmentMode === "collaborators" ? '1px solid var(--wine-red)' : '1px solid rgba(255,255,255,0.2)',
|
||||
background: serviceAssignmentMode === "collaborators" ? 'rgba(128, 0, 64, 0.35)' : 'rgba(255,255,255,0.05)',
|
||||
color: 'white',
|
||||
fontSize: '16px',
|
||||
cursor: isCreating || isLoadingServiceCollaborators ? 'not-allowed' : 'pointer',
|
||||
opacity: isCreating || isLoadingServiceCollaborators ? 0.7 : 1
|
||||
}}
|
||||
>
|
||||
{isLoadingServiceCollaborators ? "Cargando colaboradores..." : "Uno o más colaboradores"}
|
||||
</button>
|
||||
{serviceAssignmentMode === "collaborators" && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', width: '100%' }}>
|
||||
{serviceAssignmentCollaborators.length === 0 && !isLoadingServiceCollaborators && (
|
||||
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', textAlign: 'center' }}>
|
||||
No encontramos colaboradores activos para esta organización.
|
||||
</div>
|
||||
)}
|
||||
<CollaboratorPicker
|
||||
employees={serviceAssignmentCollaborators}
|
||||
selectedIds={selectedServiceCollaboratorIds}
|
||||
multiple
|
||||
disabled={isCreating}
|
||||
onToggle={handleToggleServiceCollaborator}
|
||||
/>
|
||||
{serviceAssignmentCollaborators.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAssignCreatedServiceToCollaborators}
|
||||
disabled={isCreating || selectedServiceCollaboratorIds.length === 0}
|
||||
style={{
|
||||
padding: '14px 18px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid var(--wine-red)',
|
||||
background: 'var(--wine-red)',
|
||||
color: 'white',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
cursor: isCreating || selectedServiceCollaboratorIds.length === 0 ? 'not-allowed' : 'pointer',
|
||||
opacity: isCreating || selectedServiceCollaboratorIds.length === 0 ? 0.7 : 1
|
||||
}}
|
||||
>
|
||||
{isCreating ? "Asignando..." : "Asignar servicio"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!serviceAssignmentWarning && (
|
||||
<>
|
||||
>
|
||||
{serviceAssignmentCollaborators.length === 0 &&
|
||||
!isLoadingServiceCollaborators && (
|
||||
<div
|
||||
style={{
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
fontSize: "14px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
No encontramos colaboradores activos para esta
|
||||
organización.
|
||||
</div>
|
||||
)}
|
||||
<CollaboratorPicker
|
||||
employees={serviceAssignmentCollaborators}
|
||||
selectedIds={selectedServiceCollaboratorIds}
|
||||
multiple
|
||||
disabled={isCreating}
|
||||
onToggle={handleToggleServiceCollaborator}
|
||||
/>
|
||||
{serviceAssignmentCollaborators.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFinishWithoutAssignment}
|
||||
disabled={isCreating}
|
||||
onClick={handleAssignCreatedServiceToCollaborators}
|
||||
disabled={
|
||||
isCreating ||
|
||||
selectedServiceCollaboratorIds.length === 0
|
||||
}
|
||||
style={{
|
||||
padding: '14px 18px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
color: 'white',
|
||||
fontSize: '16px',
|
||||
cursor: isCreating ? 'not-allowed' : 'pointer',
|
||||
opacity: isCreating ? 0.7 : 1
|
||||
padding: "14px 18px",
|
||||
borderRadius: "12px",
|
||||
border: "1px solid var(--wine-red)",
|
||||
background: "var(--wine-red)",
|
||||
color: "white",
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold",
|
||||
cursor:
|
||||
isCreating ||
|
||||
selectedServiceCollaboratorIds.length === 0
|
||||
? "not-allowed"
|
||||
: "pointer",
|
||||
opacity:
|
||||
isCreating ||
|
||||
selectedServiceCollaboratorIds.length === 0
|
||||
? 0.7
|
||||
: 1,
|
||||
}}
|
||||
>
|
||||
Lo configuro después
|
||||
{isCreating ? "Asignando..." : "Asignar servicio"}
|
||||
</button>
|
||||
<div style={{
|
||||
padding: '12px 14px',
|
||||
borderRadius: '12px',
|
||||
background: 'rgba(245, 158, 11, 0.12)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.35)',
|
||||
color: '#fde68a',
|
||||
fontSize: '14px',
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!serviceAssignmentWarning && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFinishWithoutAssignment}
|
||||
disabled={isCreating}
|
||||
style={{
|
||||
padding: "14px 18px",
|
||||
borderRadius: "12px",
|
||||
border: "1px solid rgba(255,255,255,0.2)",
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
color: "white",
|
||||
fontSize: "16px",
|
||||
cursor: isCreating ? "not-allowed" : "pointer",
|
||||
opacity: isCreating ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
Lo configuro después
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 14px",
|
||||
borderRadius: "12px",
|
||||
background: "rgba(245, 158, 11, 0.12)",
|
||||
border: "1px solid rgba(245, 158, 11, 0.35)",
|
||||
color: "#fde68a",
|
||||
fontSize: "14px",
|
||||
lineHeight: 1.5,
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
Si lo configurás después, el servicio no va a estar disponible para reservas hasta que lo asignes a un colaborador con horarios cargados.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{serviceAssignmentError && (
|
||||
<div style={{
|
||||
padding: '12px 14px',
|
||||
borderRadius: '12px',
|
||||
background: 'rgba(255, 99, 132, 0.12)',
|
||||
border: '1px solid rgba(255, 99, 132, 0.35)',
|
||||
color: '#fecdd3',
|
||||
fontSize: '14px',
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Si lo configurás después, el servicio no va a estar disponible
|
||||
para reservas hasta que lo asignes a un colaborador con horarios
|
||||
cargados.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{serviceAssignmentError && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 14px",
|
||||
borderRadius: "12px",
|
||||
background: "rgba(255, 99, 132, 0.12)",
|
||||
border: "1px solid rgba(255, 99, 132, 0.35)",
|
||||
color: "#fecdd3",
|
||||
fontSize: "14px",
|
||||
lineHeight: 1.5,
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{serviceAssignmentError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</QuestionCard>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{serviceAssignmentError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</QuestionCard>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface CalendarRowViewProps {
|
||||
|
||||
type CalendarRowViewData = {
|
||||
name: string;
|
||||
startTime: number;
|
||||
events: TXCalendarEvent[];
|
||||
};
|
||||
|
||||
@@ -167,11 +168,13 @@ export default function CalendarRowView(props: CalendarRowViewProps) {
|
||||
} else {
|
||||
auxData.push({
|
||||
name: time,
|
||||
startTime: item.startTime,
|
||||
events: [item],
|
||||
});
|
||||
}
|
||||
}
|
||||
setData(auxData.sort((a, b) => a.name.localeCompare(b.name)));
|
||||
// Sort by numeric hour, never by the formatted label: as strings "11:00 PM" < "5:00 PM".
|
||||
setData(auxData.sort((a, b) => a.startTime - b.startTime));
|
||||
}, [daySchedules]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
.buttonFilter {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
top: 5px;
|
||||
right: -10px;
|
||||
background-color: transparent;
|
||||
color: var(--gray-dark);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: width 0.25s ease-in-out, height 0.25s ease-in-out;
|
||||
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.buttonFilter > svg {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
background: transparent;
|
||||
animation: buttonFilterPulseShadow 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes buttonFilterPulseShadow {
|
||||
0%,
|
||||
100% {
|
||||
filter: drop-shadow(0px 2px 1px var(--gray-light));
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0px 2px 4px var(--gray-dark));
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
"use client";
|
||||
import { ReactNode } from "react";
|
||||
import style from "./Filterable.module.css";
|
||||
import FilterAltOutlinedIcon from "@mui/icons-material/FilterAltOutlined";
|
||||
import { useFilterStore } from "@core/Store/Filter.Store";
|
||||
|
||||
interface FilterableProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export default function Filterable(props: FilterableProps): React.ReactElement {
|
||||
const filter = useFilterStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.children}
|
||||
<div
|
||||
className={style.buttonFilter}
|
||||
onClick={() => {
|
||||
filter.toggle();
|
||||
}}
|
||||
>
|
||||
<FilterAltOutlinedIcon sx={{ fontSize: "32px" }} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -27,15 +27,28 @@
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.headerToggle {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--green-darkestX1);
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toggleIcon {
|
||||
@@ -45,13 +58,16 @@
|
||||
flex: 0 0 auto;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--white);
|
||||
color: var(--green-darkestX1);
|
||||
border: 1px solid var(--green-darkestX1);
|
||||
font-family: inherit;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filters {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { LineChart } from "@mui/x-charts/LineChart";
|
||||
import { MyOranizationsView } from "@core/Models/Company.model";
|
||||
import { DashboardFinancialsResult, getDashboardFinancials } from "../../landing/dashboard/Dashboard.Service";
|
||||
import {
|
||||
DashboardFinancialsResult,
|
||||
getDashboardFinancials,
|
||||
} from "../../landing/dashboard/Dashboard.Service";
|
||||
import { loadCollaboratorsByCompanyId } from "../../admin/(organization-profile)/org/profile/[id]/collaborators/Collaborators.Service";
|
||||
import { CompanyEmployeesView } from "@models/Collaborators.model";
|
||||
import UnpaidIncomeCard from "../UnpaidIncomeCard/UnpaidIncomeCard";
|
||||
import styles from "./FinancialWidget.module.css";
|
||||
import dayjs from "dayjs";
|
||||
import OpenInNewOutlinedIcon from "@mui/icons-material/OpenInNewOutlined";
|
||||
import { useNavigation } from "@core/app/hooks/goto";
|
||||
|
||||
export interface FinancialWidgetProps {
|
||||
sessionUser: string;
|
||||
@@ -15,11 +19,16 @@ export interface FinancialWidgetProps {
|
||||
organizationName?: string;
|
||||
}
|
||||
|
||||
export default function FinancialWidget({ sessionUser, selectedOrgId, organizationName }: FinancialWidgetProps) {
|
||||
export default function FinancialWidget({
|
||||
sessionUser,
|
||||
selectedOrgId,
|
||||
organizationName,
|
||||
}: FinancialWidgetProps) {
|
||||
const { goTo } = useNavigation();
|
||||
const [selectedTimeframe, setSelectedTimeframe] = useState<"week" | "month" | "year">("month");
|
||||
const [selectedEmployeeId, setSelectedEmployeeId] = useState<string>("");
|
||||
const [collaborators, setCollaborators] = useState<CompanyEmployeesView[]>([]);
|
||||
|
||||
|
||||
const [financials, setFinancials] = useState<DashboardFinancialsResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -27,10 +36,12 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedOrgId) {
|
||||
loadCollaboratorsByCompanyId({ companyId: selectedOrgId }).then((res) => {
|
||||
setCollaborators(res);
|
||||
setSelectedEmployeeId(""); // reset employee filter when org changes
|
||||
}).catch(console.error);
|
||||
loadCollaboratorsByCompanyId({ companyId: selectedOrgId })
|
||||
.then((res) => {
|
||||
setCollaborators(res);
|
||||
setSelectedEmployeeId(""); // reset employee filter when org changes
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [selectedOrgId, sessionUser]);
|
||||
|
||||
@@ -43,14 +54,17 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
|
||||
sessionUser,
|
||||
companyId: selectedOrgId,
|
||||
timeframe: selectedTimeframe,
|
||||
employeeId: selectedEmployeeId || undefined
|
||||
}).then((res) => {
|
||||
setFinancials(res);
|
||||
}).catch((err) => {
|
||||
const errMsg = err.format ? err.format() : err.message;
|
||||
console.warn("Acceso denegado a métricas financieras: ", errMsg);
|
||||
setError("No tienes permisos de administrador para esta organización.");
|
||||
}).finally(() => setLoading(false));
|
||||
employeeId: selectedEmployeeId || undefined,
|
||||
})
|
||||
.then((res) => {
|
||||
setFinancials(res);
|
||||
})
|
||||
.catch((err) => {
|
||||
const errMsg = err.format ? err.format() : err.message;
|
||||
console.warn("Acceso denegado a métricas financieras: ", errMsg);
|
||||
setError("No tienes permisos de administrador para esta organización.");
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [selectedOrgId, selectedTimeframe, selectedEmployeeId, sessionUser]);
|
||||
|
||||
@@ -62,34 +76,89 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
|
||||
if (!financials) return null;
|
||||
const current = financials.currentPeriodTotal;
|
||||
const previous = financials.previousPeriodTotal;
|
||||
|
||||
|
||||
if (previous === 0) {
|
||||
if (current > 0) return <span className={styles.positive}>+100% vs periodo anterior</span>;
|
||||
if (current > 0)
|
||||
return <span className={styles.positive}>+100% vs periodo anterior</span>;
|
||||
return <span className={styles.neutral}>Sin datos previos</span>;
|
||||
}
|
||||
|
||||
const diff = current - previous;
|
||||
const percent = (diff / previous) * 100;
|
||||
|
||||
if (percent > 0) return <span className={styles.positive}>+{percent.toFixed(1)}% vs periodo anterior</span>;
|
||||
if (percent < 0) return <span className={styles.negative}>{percent.toFixed(1)}% vs periodo anterior</span>;
|
||||
|
||||
if (percent > 0)
|
||||
return (
|
||||
<span className={styles.positive}>+{percent.toFixed(1)}% vs periodo anterior</span>
|
||||
);
|
||||
if (percent < 0)
|
||||
return (
|
||||
<span className={styles.negative}>{percent.toFixed(1)}% vs periodo anterior</span>
|
||||
);
|
||||
return <span className={styles.neutral}>0% vs periodo anterior</span>;
|
||||
};
|
||||
|
||||
if (!selectedOrgId) return null;
|
||||
|
||||
const header = (
|
||||
<div className={styles.headerButton}>
|
||||
<h3 className={styles.title}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.headerToggle}
|
||||
onClick={() => setIsOpen((value) => !value)}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
Rendimiento Financiero {organizationName ? `- ${organizationName}` : ""}
|
||||
</button>
|
||||
</h3>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goTo(`/landing/org/${selectedOrgId}`)}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "6px 12px",
|
||||
borderRadius: "999px",
|
||||
color: "var(--green-darkestX1)",
|
||||
border: "1px solid var(--green-darkestX1)",
|
||||
background: "var(--white)",
|
||||
textDecoration: "none",
|
||||
fontSize: "14px",
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
title="Ver perfil público de la organización"
|
||||
>
|
||||
<OpenInNewOutlinedIcon fontSize="small" />
|
||||
Ver perfil
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggleIcon}
|
||||
onClick={() => setIsOpen((value) => !value)}
|
||||
aria-expanded={isOpen}
|
||||
aria-label={
|
||||
isOpen ? "Colapsar métricas financieras" : "Expandir métricas financieras"
|
||||
}
|
||||
>
|
||||
{isOpen ? "−" : "+"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<button className={styles.headerButton} type="button" onClick={() => setIsOpen((value) => !value)}>
|
||||
<h3 className={styles.title}>
|
||||
Rendimiento Financiero {organizationName ? `- ${organizationName}` : ''}
|
||||
</h3>
|
||||
<span className={styles.toggleIcon}>{isOpen ? "−" : "+"}</span>
|
||||
</button>
|
||||
{header}
|
||||
{isOpen && (
|
||||
<div style={{ padding: '40px 20px', textAlign: 'center', color: '#6b7280' }}>
|
||||
<p style={{ fontWeight: '600', marginBottom: '8px', color: '#374151' }}>Acceso Restringido</p>
|
||||
<div style={{ padding: "40px 20px", textAlign: "center", color: "#6b7280" }}>
|
||||
<p style={{ fontWeight: "600", marginBottom: "8px", color: "#374151" }}>
|
||||
Acceso Restringido
|
||||
</p>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -98,67 +167,74 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
|
||||
}
|
||||
|
||||
const chartData = financials?.currentPeriodData || [];
|
||||
const xAxisData = chartData.map(d => {
|
||||
const xAxisData = chartData.map((d) => {
|
||||
// format label nicer depending on timeframe
|
||||
if (selectedTimeframe === "year") return dayjs(d.date).format("MMM YYYY");
|
||||
return dayjs(d.date).format("DD/MM");
|
||||
});
|
||||
const seriesData = chartData.map(d => d.amount);
|
||||
const hasChartData = chartData.some(d => d.amount > 0);
|
||||
const seriesData = chartData.map((d) => d.amount);
|
||||
const hasChartData = chartData.some((d) => d.amount > 0);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<button className={styles.headerButton} type="button" onClick={() => setIsOpen((value) => !value)}>
|
||||
<h3 className={styles.title}>
|
||||
Rendimiento Financiero {organizationName ? `- ${organizationName}` : ''}
|
||||
</h3>
|
||||
<span className={styles.toggleIcon}>{isOpen ? "−" : "+"}</span>
|
||||
</button>
|
||||
{header}
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className={styles.filters}>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={selectedTimeframe}
|
||||
onChange={e => setSelectedTimeframe(e.target.value as "week" | "month" | "year")}
|
||||
>
|
||||
<option value="week">Última Semana</option>
|
||||
<option value="month">Último Mes</option>
|
||||
<option value="year">Último Año</option>
|
||||
</select>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={selectedTimeframe}
|
||||
onChange={(e) =>
|
||||
setSelectedTimeframe(e.target.value as "week" | "month" | "year")
|
||||
}
|
||||
>
|
||||
<option value="week">Última Semana</option>
|
||||
<option value="month">Último Mes</option>
|
||||
<option value="year">Último Año</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className={styles.select}
|
||||
value={selectedEmployeeId}
|
||||
onChange={e => setSelectedEmployeeId(e.target.value)}
|
||||
>
|
||||
<option value="">Todos los Profesionales</option>
|
||||
{collaborators.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.fullName}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={selectedEmployeeId}
|
||||
onChange={(e) => setSelectedEmployeeId(e.target.value)}
|
||||
>
|
||||
<option value="">Todos los Profesionales</option>
|
||||
{collaborators.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.fullName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.metricsRow}>
|
||||
<div className={styles.metricBox}>
|
||||
<span className={styles.metricLabel}>Ingresos del Periodo</span>
|
||||
<span className={styles.metricValue}>
|
||||
{loading ? "..." : formatCurrency(financials?.currentPeriodTotal || 0)}
|
||||
</span>
|
||||
<div className={styles.metricComparison}>
|
||||
{!loading && renderComparison()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{financials && (
|
||||
<UnpaidIncomeCard value={financials.unpaidToday} />
|
||||
)}
|
||||
<div className={styles.metricBox}>
|
||||
<span className={styles.metricLabel}>Ingresos del Periodo</span>
|
||||
<span className={styles.metricValue}>
|
||||
{loading
|
||||
? "..."
|
||||
: formatCurrency(financials?.currentPeriodTotal || 0)}
|
||||
</span>
|
||||
<div className={styles.metricComparison}>
|
||||
{!loading && renderComparison()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{financials && <UnpaidIncomeCard value={financials.unpaidToday} />}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className={styles.chartContainer}>
|
||||
<div style={{display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', color: '#9ca3af'}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
color: "#9ca3af",
|
||||
}}
|
||||
>
|
||||
Cargando datos...
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,57 +244,68 @@ export default function FinancialWidget({ sessionUser, selectedOrgId, organizati
|
||||
<div className={styles.chartContainer}>
|
||||
<LineChart
|
||||
height={350}
|
||||
xAxis={[{
|
||||
scaleType: 'point',
|
||||
data: xAxisData,
|
||||
}]}
|
||||
xAxis={[
|
||||
{
|
||||
scaleType: "point",
|
||||
data: xAxisData,
|
||||
},
|
||||
]}
|
||||
series={[
|
||||
{
|
||||
data: seriesData,
|
||||
color: 'rgb(36, 169, 53)',
|
||||
color: "rgb(36, 169, 53)",
|
||||
area: true,
|
||||
showMark: true,
|
||||
curve: 'monotoneX',
|
||||
valueFormatter: (val: number | null) => val ? formatCurrency(val) : "0",
|
||||
curve: "monotoneX",
|
||||
valueFormatter: (val: number | null) =>
|
||||
val ? formatCurrency(val) : "0",
|
||||
},
|
||||
]}
|
||||
grid={{ horizontal: true }}
|
||||
margin={{ top: 20, bottom: 40, left: 70, right: 20 }}
|
||||
sx={{
|
||||
'.MuiLineElement-root': {
|
||||
".MuiLineElement-root": {
|
||||
strokeWidth: 3,
|
||||
},
|
||||
'.MuiAreaElement-root': {
|
||||
fill: 'url(#gradient)',
|
||||
".MuiAreaElement-root": {
|
||||
fill: "url(#gradient)",
|
||||
},
|
||||
'.MuiChartsGrid-line': {
|
||||
strokeDasharray: '5 5',
|
||||
stroke: '#e5e7eb',
|
||||
".MuiChartsGrid-line": {
|
||||
strokeDasharray: "5 5",
|
||||
stroke: "#e5e7eb",
|
||||
},
|
||||
'.MuiChartsAxis-line': {
|
||||
stroke: 'transparent',
|
||||
".MuiChartsAxis-line": {
|
||||
stroke: "transparent",
|
||||
},
|
||||
'.MuiChartsAxis-tick': {
|
||||
stroke: 'transparent',
|
||||
".MuiChartsAxis-tick": {
|
||||
stroke: "transparent",
|
||||
},
|
||||
'.MuiChartsAxis-tickLabel': {
|
||||
fill: '#9ca3af',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '12px',
|
||||
".MuiChartsAxis-tickLabel": {
|
||||
fill: "#9ca3af",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "12px",
|
||||
fontWeight: 500,
|
||||
},
|
||||
'.MuiMarkElement-root': {
|
||||
stroke: 'rgb(36, 169, 53)',
|
||||
".MuiMarkElement-root": {
|
||||
stroke: "rgb(36, 169, 53)",
|
||||
strokeWidth: 2,
|
||||
fill: '#ffffff',
|
||||
scale: '1.2',
|
||||
}
|
||||
fill: "#ffffff",
|
||||
scale: "1.2",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="rgb(36, 169, 53)" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="rgb(36, 169, 53)" stopOpacity={0.0}/>
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="rgb(36, 169, 53)"
|
||||
stopOpacity={0.3}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="rgb(36, 169, 53)"
|
||||
stopOpacity={0.0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</LineChart>
|
||||
|
||||
@@ -74,6 +74,9 @@ export default function Footer() {
|
||||
<Link href="/admin/org" style={{ display: "block" }}>
|
||||
Mis organizaciones
|
||||
</Link>
|
||||
<Link href="/landing/help" style={{ display: "block" }}>
|
||||
Obtener ayuda
|
||||
</Link>
|
||||
<h1 style={{ marginTop: "5px" }}>Contacto</h1>
|
||||
<Link href="mailto:info@turnosxpress.com.ar" style={{ display: "block" }}>
|
||||
info@turnosxpress.com.ar
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
right: 0px;
|
||||
height: 70px;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -24,8 +24,34 @@
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.locationTitleFilter {
|
||||
padding-right: 64px;
|
||||
}
|
||||
|
||||
.filterButton {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 12px;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background-color: transparent;
|
||||
color: var(--gray-dark);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filterButton:hover {
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.locationContainer {
|
||||
left: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,22 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import style from "./Location.module.css";
|
||||
import { useLocationStore } from "@store/Location.Store";
|
||||
import { useFilterStore } from "@store/Filter.Store";
|
||||
import Breadcrumbs from "@mui/material/Breadcrumbs";
|
||||
import Link from "@mui/material/Link";
|
||||
import HomeIcon from "@mui/icons-material/Home";
|
||||
import FilterAltOutlinedIcon from "@mui/icons-material/FilterAltOutlined";
|
||||
|
||||
export interface LocationProps {
|
||||
bottomSeperation?: string;
|
||||
showFilter?: boolean;
|
||||
}
|
||||
|
||||
export default function Location(props: LocationProps) {
|
||||
const { bottomSeperation = "55px" } = props;
|
||||
const { bottomSeperation = "55px", showFilter = false } = props;
|
||||
const router = useRouter();
|
||||
const location = useLocationStore();
|
||||
const filter = useFilterStore();
|
||||
|
||||
const goTo = (path: string) => {
|
||||
router.push(path);
|
||||
@@ -48,10 +52,26 @@ export default function Location(props: LocationProps) {
|
||||
))}
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
<div className={style.locationTitle}>
|
||||
<div
|
||||
className={
|
||||
showFilter
|
||||
? `${style.locationTitle} ${style.locationTitleFilter}`
|
||||
: style.locationTitle
|
||||
}
|
||||
>
|
||||
{location.last()?.icon}
|
||||
<h1>{location.last()?.title}</h1>
|
||||
</div>
|
||||
{showFilter && (
|
||||
<button
|
||||
className={style.filterButton}
|
||||
onClick={() => filter.toggle()}
|
||||
aria-label="Filtrar"
|
||||
title="Filtrar"
|
||||
>
|
||||
<FilterAltOutlinedIcon sx={{ fontSize: "32px" }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -63,4 +83,4 @@ export default function Location(props: LocationProps) {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import classNames from "classnames";
|
||||
import { useNavigation } from "@core/app/hooks/goto";
|
||||
|
||||
import ShareOutlinedIcon from "@mui/icons-material/ShareOutlined";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
@@ -69,6 +70,14 @@ export default function OrganizationHeader(props: OrganizationHeaderParams): Rea
|
||||
icon: <ShareOutlinedIcon />,
|
||||
});
|
||||
|
||||
menu.add({
|
||||
text: "Ver perfil",
|
||||
onClick: () => {
|
||||
goTo(`/landing/org/${props.data.id}`);
|
||||
},
|
||||
icon: <OpenInNewIcon />,
|
||||
});
|
||||
|
||||
menu.add({
|
||||
text: "Notificaciones",
|
||||
onClick: () => {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Grid2, IconButton, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import turnosXpressTheme from "@core/app/theme/turnosXpress";
|
||||
import Button from "@mui/material/Button";
|
||||
import { ThemeProvider } from "@emotion/react";
|
||||
import { PopoverPicker } from "@components/ColorPicker/ColorPicker";
|
||||
import ImagePicker from "@components/ImagePicker/ImagePicker";
|
||||
import { useAlert } from "@core/Store/Alert.Store";
|
||||
import { useSessionStore } from "@core/Store/Sesion.Store";
|
||||
import { useOrganizationHeaderStore } from "@core/Store/OrganizationHeader.Store";
|
||||
import { updateOrganizationColors, uploadHeaderImage } from "@services/Header.Edit.Service";
|
||||
import { UpdateCompanyParams } from "@models/Company.model";
|
||||
|
||||
interface HeaderEditPopupProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
companyId: string;
|
||||
headerColor: string;
|
||||
headerFontColor: string;
|
||||
headerFontShadowColor: string;
|
||||
headerImage: string;
|
||||
onSaved: () => void;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export default function HeaderEditPopup(props: HeaderEditPopupProps) {
|
||||
const { open, onClose, companyId, headerColor, headerFontColor, headerFontShadowColor, headerImage, onSaved, onRefresh } = props;
|
||||
const alert = useAlert();
|
||||
const SessionInfo = useSessionStore();
|
||||
const organizationHeader = useOrganizationHeaderStore();
|
||||
|
||||
const [headerColorState, setHeaderColorState] = useState("");
|
||||
const [headerFontColorState, setHeaderFontColorState] = useState("");
|
||||
const [headerFontShadowColorState, setHeaderFontShadowColorState] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Sync local state when values change externally
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setHeaderColorState(headerColor || "");
|
||||
setHeaderFontColorState(headerFontColor || "");
|
||||
setHeaderFontShadowColorState(headerFontShadowColor || "");
|
||||
setLoading(false);
|
||||
}
|
||||
}, [open, headerColor, headerFontColor, headerFontShadowColor]);
|
||||
|
||||
const handleSaveColors = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const updData: UpdateCompanyParams = {
|
||||
id: companyId,
|
||||
headerColor: headerColorState,
|
||||
headerFontColor: headerFontColorState,
|
||||
headerFontShadowColor: headerFontShadowColorState,
|
||||
sessionUser: SessionInfo.userId,
|
||||
};
|
||||
|
||||
await updateOrganizationColors(updData);
|
||||
|
||||
organizationHeader.setHeaderColor(headerColorState);
|
||||
organizationHeader.setHeaderFontColor(headerFontColorState);
|
||||
organizationHeader.setHeaderFontShadowColor(headerFontShadowColorState);
|
||||
|
||||
alert.showSuccess("Colores actualizados");
|
||||
onSaved();
|
||||
onRefresh?.();
|
||||
} catch (error) {
|
||||
const formatted = error instanceof Error ? error.message : "Error al guardar los colores";
|
||||
alert.showError(formatted || "Error al guardar los colores");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("companyId", companyId);
|
||||
formData.append("sessionUser", SessionInfo.userId);
|
||||
formData.append("file", file);
|
||||
|
||||
uploadHeaderImage(formData).then(() => {
|
||||
alert.showSuccess("Imagen actualizada");
|
||||
onSaved();
|
||||
onRefresh?.();
|
||||
}).catch((error) => {
|
||||
const formatted = error instanceof Error ? error.message : "Error al guardar la imagen";
|
||||
alert.showError(formatted || "Error al guardar la imagen");
|
||||
}).finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
} catch (error) {
|
||||
const formatted = error instanceof Error ? error.message : "Error al guardar la imagen";
|
||||
alert.showError(formatted || "Error al guardar la imagen");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={turnosXpressTheme}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 2,
|
||||
height: 500,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
Personalizar
|
||||
<IconButton onClick={onClose} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent dividers sx={{ p: 3 }}>
|
||||
{/* Colores */}
|
||||
<Grid2 container spacing={1} mb={3}>
|
||||
<Grid2 size={4}>
|
||||
<PopoverPicker
|
||||
placeholder="Fondo"
|
||||
color={headerColorState}
|
||||
onChange={(color: string) => {
|
||||
setHeaderColorState(color);
|
||||
}}
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 size={4}>
|
||||
<PopoverPicker
|
||||
placeholder="Fuente"
|
||||
color={headerFontColorState}
|
||||
onChange={(color: string) => {
|
||||
setHeaderFontColorState(color);
|
||||
}}
|
||||
/>
|
||||
</Grid2>
|
||||
<Grid2 size={4}>
|
||||
<PopoverPicker
|
||||
placeholder="Sombra"
|
||||
color={headerFontShadowColorState}
|
||||
onChange={(color: string) => {
|
||||
setHeaderFontShadowColorState(color);
|
||||
}}
|
||||
/>
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
|
||||
{/* Imagen */}
|
||||
<ImagePicker
|
||||
src={headerImage}
|
||||
alt="Cabecera"
|
||||
style={{ width: "100%", height: 120, borderRadius: 8 }}
|
||||
readOnly={false}
|
||||
onChange={handleImageChange}
|
||||
imageSizeSrc="/org-header-size.webp"
|
||||
mbLimit={5}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={onClose}>Cancelar</Button>
|
||||
<Button
|
||||
onClick={handleSaveColors}
|
||||
autoFocus
|
||||
color="success"
|
||||
variant="contained"
|
||||
disabled={loading}
|
||||
>
|
||||
Guardar
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import HeaderEditPopup from "./HeaderEditPopup";
|
||||
import { useSessionStore } from "@core/Store/Sesion.Store";
|
||||
import { checkOrgAdmin } from "@services/Header.Edit.Service";
|
||||
|
||||
interface OrganizationHeaderEditProps {
|
||||
companyId: string;
|
||||
headerColor?: string;
|
||||
headerFontColor?: string;
|
||||
headerFontShadowColor?: string;
|
||||
headerImage?: string;
|
||||
}
|
||||
|
||||
export default function OrganizationHeaderEdit({
|
||||
companyId,
|
||||
headerColor = "",
|
||||
headerFontColor = "",
|
||||
headerFontShadowColor = "",
|
||||
headerImage = "",
|
||||
}: OrganizationHeaderEditProps) {
|
||||
const router = useRouter();
|
||||
const SessionInfo = useSessionStore();
|
||||
const [isOrgAdmin, setIsOrgAdmin] = useState(false);
|
||||
const [showEditPopup, setShowEditPopup] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!SessionInfo.userId || !companyId) {
|
||||
setIsOrgAdmin(false);
|
||||
const existing = document.getElementById("edit-header-btn");
|
||||
if (existing) existing.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
checkOrgAdmin(companyId, SessionInfo.userId)
|
||||
.then((isAdmin) => {
|
||||
if (cancelled) return;
|
||||
setIsOrgAdmin(isAdmin);
|
||||
|
||||
const wrapper = document.getElementById("header-wrapper");
|
||||
if (!wrapper) return;
|
||||
|
||||
if (isAdmin) {
|
||||
const existingBtn = document.getElementById("edit-header-btn");
|
||||
if (existingBtn) existingBtn.remove();
|
||||
|
||||
const btn = document.createElement("div");
|
||||
btn.id = "edit-header-btn";
|
||||
btn.innerHTML = `
|
||||
<button style="
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0,0,0,0.45);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
transition: background 0.2s;
|
||||
" onmouseover="this.style.background='rgba(0,0,0,0.7)'" onmouseout="this.style.background='rgba(0,0,0,0.45)'">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
||||
</button>
|
||||
`;
|
||||
btn.querySelector("button")!.addEventListener("click", () =>
|
||||
setShowEditPopup(true),
|
||||
);
|
||||
wrapper.appendChild(btn);
|
||||
} else {
|
||||
const existingBtn = document.getElementById("edit-header-btn");
|
||||
if (existingBtn) existingBtn.remove();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setIsOrgAdmin(false);
|
||||
|
||||
const existingBtn = document.getElementById("edit-header-btn");
|
||||
if (existingBtn) existingBtn.remove();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
const existing = document.getElementById("edit-header-btn");
|
||||
if (existing) existing.remove();
|
||||
};
|
||||
}, [SessionInfo.userId, companyId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOrgAdmin && (
|
||||
<HeaderEditPopup
|
||||
open={showEditPopup}
|
||||
onClose={() => setShowEditPopup(false)}
|
||||
companyId={companyId}
|
||||
headerColor={headerColor}
|
||||
headerFontColor={headerFontColor}
|
||||
headerFontShadowColor={headerFontShadowColor}
|
||||
headerImage={headerImage}
|
||||
onSaved={() => {
|
||||
setShowEditPopup(false);
|
||||
}}
|
||||
onRefresh={() => router.refresh()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,10 +9,11 @@ export interface PaginationControlProps {
|
||||
current: number;
|
||||
size: number;
|
||||
max: number;
|
||||
inheritColor?: boolean;
|
||||
}
|
||||
|
||||
export default function PaginationControls(props: PaginationControlProps) {
|
||||
const { current, size, max, onChange = () => {} } = props;
|
||||
const { current, size, max, onChange = () => {}, inheritColor = false } = props;
|
||||
|
||||
useEffect(() => {}, [current]);
|
||||
|
||||
@@ -36,14 +37,20 @@ export default function PaginationControls(props: PaginationControlProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.paginationControls}>
|
||||
<IconButton onClick={handlePrevious}>
|
||||
<div
|
||||
className={style.paginationControls}
|
||||
style={inheritColor ? { color: "var(--white)" } : undefined}
|
||||
>
|
||||
<IconButton color={inheritColor ? "inherit" : undefined} onClick={handlePrevious}>
|
||||
<ArrowBackwardIcon sx={{ fontSize: 24 }} />
|
||||
</IconButton>
|
||||
<span className={style.paginationLabel}>
|
||||
<span
|
||||
className={style.paginationLabel}
|
||||
style={inheritColor ? { color: "var(--white)" } : undefined}
|
||||
>
|
||||
{current} / {max}
|
||||
</span>
|
||||
<IconButton onClick={handleNext}>
|
||||
<IconButton color={inheritColor ? "inherit" : undefined} onClick={handleNext}>
|
||||
<ArrowForwardIcon sx={{ fontSize: 24 }} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PublicServiceView } from "@models/Service.model";
|
||||
import style from "./style.module.css";
|
||||
import { getServiceImage } from "@core/helpers/getServiceImage";
|
||||
import ServiceRatingSummary from "@components/ServiceRatingSummary/ServiceRatingSummary";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
|
||||
export type FindResultsViewMode = "grid" | "card";
|
||||
|
||||
@@ -14,6 +15,8 @@ export interface ProductItemProps {
|
||||
width?: number | string;
|
||||
viewMode?: FindResultsViewMode;
|
||||
dragging?: boolean;
|
||||
isAdmin?: boolean;
|
||||
onImageEdit?: () => void;
|
||||
}
|
||||
|
||||
export default function ProductItem(props: ProductItemProps) {
|
||||
@@ -21,6 +24,8 @@ export default function ProductItem(props: ProductItemProps) {
|
||||
const { product } = props;
|
||||
const { viewMode = "card" } = props;
|
||||
const { dragging = false } = props;
|
||||
const { isAdmin = false } = props;
|
||||
const { onImageEdit } = props;
|
||||
const { goTo } = useNavigation();
|
||||
|
||||
return (
|
||||
@@ -38,7 +43,7 @@ export default function ProductItem(props: ProductItemProps) {
|
||||
textAlign: "center",
|
||||
padding: "10px",
|
||||
border: "solid 1px rgba(0, 0, 0, 0.1)",
|
||||
cursor: "pointer",
|
||||
cursor: isAdmin ? "default" : "pointer",
|
||||
position: "relative",
|
||||
}}
|
||||
onClick={() => {
|
||||
@@ -58,6 +63,11 @@ export default function ProductItem(props: ProductItemProps) {
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<span className={style.adminEditButton} onClick={(e) => { e.stopPropagation(); onImageEdit?.(); }}>
|
||||
<EditIcon fontSize="small" />
|
||||
</span>
|
||||
)}
|
||||
<ServiceRatingSummary
|
||||
serviceId={product.id}
|
||||
variant="card"
|
||||
|
||||
@@ -8,6 +8,32 @@
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
.adminEditButton {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.adminEditButton:hover {
|
||||
background: var(--wine-red);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.adminEditButton:hover svg {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
margin: 10px 0;
|
||||
text-align: center;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Metadata } from "next";
|
||||
import OfferContent from "../offer-appointments/OfferContent";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Agenda Online para Profesionales | Reservas Automáticas - TurnosXpress",
|
||||
description: "Agendá citas online y eliminá el ida y vuelta de mensajes. Una agenda online automática, con recordatorios y reservas 24/7 para tu negocio.",
|
||||
keywords: ["agenda online", "agenda digital", "agenda de citas online", "reservas online", "agenda para profesionales"],
|
||||
openGraph: {
|
||||
title: "Agenda Online para Profesionales | Reservas - TurnosXpress",
|
||||
description: "Tu agenda online con reservas automáticas, recordatorios y gestión simple. ¡Empezá gratis!",
|
||||
type: "website",
|
||||
}
|
||||
};
|
||||
|
||||
export default function AgendaOnlinePage() {
|
||||
return (
|
||||
<OfferContent
|
||||
heroTitle="Tu agenda online trabaja mientras vos trabajás"
|
||||
heroSubtitle="Una agenda online que recibe reservas automáticamente, avisa a tus clientes y respeta tus horarios. Sin papeles ni mensajes a deshora."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,39 @@
|
||||
import { Metadata } from "next";
|
||||
import AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
||||
import CategoriesExplorer from "@core/app/components/Home/CategoriesExplorer/page";
|
||||
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
|
||||
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
||||
import style from "./style.module.css";
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Explorá categorías y encontrá tu próximo turno | TurnosXpress",
|
||||
description:
|
||||
"Categorías de servicios por rubro en TurnosXpress. Elegí el tipo de servicio que necesitás y filtrá por ubicación para encontrar opciones cercanas, rápidas y organizadas.",
|
||||
alternates: {
|
||||
canonical: `${baseUrl}/landing/categories`,
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
title: "Explorá categorías y encontrá tu próximo turno | TurnosXpress",
|
||||
description:
|
||||
"Categorías de servicios por rubro en TurnosXpress. Elegí el tipo de servicio que necesitás y filtrá por ubicación.",
|
||||
url: `${baseUrl}/landing/categories`,
|
||||
siteName: "TurnosXpress",
|
||||
locale: "es_AR",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: "Explorá categorías y encontrá tu próximo turno | TurnosXpress",
|
||||
description: "Categorías de servicios por rubro en TurnosXpress.",
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default function CategoriesPage() {
|
||||
return (
|
||||
<AnimatedContainer
|
||||
|
||||
@@ -41,6 +41,7 @@ import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNone
|
||||
import ChatBubbleOutlineOutlinedIcon from "@mui/icons-material/ChatBubbleOutlineOutlined";
|
||||
import AddCircleOutlineOutlinedIcon from "@mui/icons-material/AddCircleOutlineOutlined";
|
||||
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
|
||||
import HelpOutlineIcon from "@mui/icons-material/HelpOutline";
|
||||
import FinancialWidget from "@components/FinancialWidget/FinancialWidget";
|
||||
import PlanMetricsWidget from "@components/PlanMetricsWidget/PlanMetricsWidget";
|
||||
import PendingRepeatsCard from "@core/app/components/PendingRepeatsCard/PendingRepeatsCard";
|
||||
@@ -442,6 +443,9 @@ function DashboardContent() {
|
||||
<div className={classNames(style.actionButton)} style={{backgroundColor: "var(--wine-red)"}} onClick={handleNewAppointmentClick}>
|
||||
<AddCircleOutlineOutlinedIcon /> Nuevo Turno
|
||||
</div>
|
||||
<div className={classNames(style.actionButton, style.buttonHelp)} onClick={() => goTo("/landing/help")}>
|
||||
<HelpOutlineIcon /> Obtener ayuda
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,29 +1,42 @@
|
||||
.dashboardGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 30px auto;
|
||||
padding: 0 20px;
|
||||
max-width: 1280px;
|
||||
margin: 40px auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
@media (min-width: 850px) {
|
||||
.dashboardGrid {
|
||||
grid-template-columns: 350px 1fr;
|
||||
grid-template-columns: 380px 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Welcome Card - Modern gradient design */
|
||||
.welcomeCard {
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.05);
|
||||
background: linear-gradient(135deg, #ffffff 0%, #faf7fb 100%);
|
||||
border-radius: 20px;
|
||||
padding: 36px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
gap: 20px;
|
||||
grid-column: 1 / -1;
|
||||
position: relative;
|
||||
border: 1px solid rgba(127, 42, 255, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.welcomeCard::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, var(--wine-red) 0%, var(--wine-dark) 50%, var(--wine-darkest) 100%);
|
||||
}
|
||||
|
||||
.welcomeContainer {
|
||||
@@ -35,24 +48,35 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.welcomeContainer h3 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
color: var(--wine-superdark);
|
||||
margin: 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.metricsCard {
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.05);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* Agenda Card */
|
||||
.agendaCard {
|
||||
background-color: white;
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.05);
|
||||
border-radius: 20px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
gap: 16px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.agendaHeader {
|
||||
@@ -68,10 +92,16 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.agendaHeader h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--wine-superdark);
|
||||
}
|
||||
|
||||
.agendaHeaderTitleWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -79,14 +109,15 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 9px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--wine-red);
|
||||
background: linear-gradient(135deg, var(--wine-red) 0%, var(--wine-dark) 100%);
|
||||
color: var(--white);
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 4px 12px rgba(255, 42, 127, 0.3);
|
||||
}
|
||||
|
||||
.agendaToggleIcon {
|
||||
@@ -94,21 +125,27 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
background: var(--white);
|
||||
color: var(--wine-red);
|
||||
border: 1px solid var(--wine-red);
|
||||
border: 2px solid var(--wine-red);
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.agendaToggleIcon:hover {
|
||||
background: var(--wine-red);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.agendaList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -116,22 +153,25 @@
|
||||
.agendaItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 15px;
|
||||
border-radius: 12px;
|
||||
background-color: #f9f9f9;
|
||||
padding: 16px 18px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #faf7fb 0%, #ffffff 100%);
|
||||
border-left: 4px solid var(--wine-red);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid rgba(127, 42, 255, 0.08);
|
||||
}
|
||||
|
||||
.agendaItem:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.05);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
|
||||
border-left-color: var(--wine-dark);
|
||||
}
|
||||
|
||||
/* Quick Actions - Modern button grid */
|
||||
.quickActions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 15px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
@@ -139,37 +179,75 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 20px 10px;
|
||||
border-radius: 16px;
|
||||
gap: 12px;
|
||||
padding: 24px 16px;
|
||||
border-radius: 18px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.actionButton::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.actionButton:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.actionButton:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0px 6px 15px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.buttonOrganizations { background-color: var(--organization-card); }
|
||||
.buttonNotification { background-color: var(--red); }
|
||||
.buttonMessenger { background-color: var(--orange); }
|
||||
.actionButton:active {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.actionButton svg {
|
||||
font-size: 28px !important;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
.buttonOrganizations {
|
||||
background: linear-gradient(135deg, var(--organization-card) 0%, var(--organization-flag) 100%);
|
||||
}
|
||||
.buttonNotification {
|
||||
background: linear-gradient(135deg, var(--red) 0%, var(--red-darkest) 100%);
|
||||
}
|
||||
.buttonMessenger {
|
||||
background: linear-gradient(135deg, var(--orange) 0%, #cc7a00 100%);
|
||||
}
|
||||
.buttonHelp {
|
||||
background: linear-gradient(135deg, var(--wine-dark) 0%, var(--wine-darkest) 100%);
|
||||
}
|
||||
|
||||
/* Payment Cards */
|
||||
.pendingPaymentCard {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 26px 30px;
|
||||
border-radius: 18px;
|
||||
background: #fff8e8;
|
||||
border: 1px solid rgba(255, 151, 36, 0.28);
|
||||
box-shadow: 0 12px 28px rgba(255, 151, 36, 0.1);
|
||||
padding: 28px 32px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(135deg, #fff8e8 0%, #fff3d6 100%);
|
||||
border: 1px solid rgba(255, 151, 36, 0.3);
|
||||
box-shadow: 0 12px 32px rgba(255, 151, 36, 0.12);
|
||||
}
|
||||
|
||||
.failedPaymentCard {
|
||||
@@ -178,11 +256,11 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 26px 30px;
|
||||
border-radius: 18px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid rgba(100, 116, 139, 0.24);
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
|
||||
padding: 28px 32px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border: 1px solid rgba(100, 116, 139, 0.25);
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.expiredDowngradeCard {
|
||||
@@ -191,11 +269,11 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 26px 30px;
|
||||
border-radius: 18px;
|
||||
background: #fff7ed;
|
||||
border: 1px solid rgba(194, 65, 12, 0.24);
|
||||
box-shadow: 0 12px 28px rgba(194, 65, 12, 0.08);
|
||||
padding: 28px 32px;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(135deg, #fff7ed 0%, #ffedd5 100%);
|
||||
border: 1px solid rgba(194, 65, 12, 0.25);
|
||||
box-shadow: 0 12px 32px rgba(194, 65, 12, 0.1);
|
||||
color: #7c2d12;
|
||||
}
|
||||
|
||||
@@ -210,78 +288,82 @@
|
||||
.pendingPaymentContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.pendingPaymentEyebrow {
|
||||
width: fit-content;
|
||||
padding: 5px 11px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 151, 36, 0.16);
|
||||
background: rgba(255, 151, 36, 0.18);
|
||||
color: #9a4b00;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.failedPaymentEyebrow {
|
||||
width: fit-content;
|
||||
padding: 5px 11px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(100, 116, 139, 0.12);
|
||||
background: rgba(100, 116, 139, 0.14);
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.expiredDowngradeEyebrow {
|
||||
width: fit-content;
|
||||
padding: 5px 11px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(194, 65, 12, 0.12);
|
||||
background: rgba(194, 65, 12, 0.14);
|
||||
color: #9a3412;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.expiredDowngradeButton {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgba(124, 45, 18, 0.22);
|
||||
border-radius: 14px;
|
||||
background: #7c2d12;
|
||||
border: 1px solid rgba(124, 45, 18, 0.25);
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #7c2d12 0%, #9a3412 100%);
|
||||
color: #fff7ed;
|
||||
cursor: pointer;
|
||||
font-weight: 900;
|
||||
padding: 14px 18px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
font-weight: 800;
|
||||
padding: 16px 20px;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.expiredDowngradeButton:hover {
|
||||
box-shadow: 0 10px 24px rgba(124, 45, 18, 0.18);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 28px rgba(124, 45, 18, 0.2);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.pendingPaymentContent h2 {
|
||||
margin: 0;
|
||||
color: var(--black);
|
||||
font-size: clamp(22px, 3vw, 30px);
|
||||
font-size: clamp(22px, 3vw, 28px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.pendingPaymentContent p {
|
||||
margin: 0;
|
||||
color: var(--gray-dark);
|
||||
line-height: 1.55;
|
||||
line-height: 1.6;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.pendingPaymentLink {
|
||||
color: #9a4b00;
|
||||
font-weight: 800;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
@@ -295,31 +377,32 @@
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
/* Starter Cards */
|
||||
.starterCard {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 32px;
|
||||
border-radius: 20px;
|
||||
padding: 36px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(135deg, rgba(128, 0, 32, 0.96), rgba(58, 0, 120, 0.94));
|
||||
color: var(--white);
|
||||
box-shadow: 0 18px 45px rgba(58, 0, 120, 0.18);
|
||||
box-shadow: 0 20px 50px rgba(58, 0, 120, 0.2);
|
||||
}
|
||||
|
||||
.starterContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.starterEyebrow {
|
||||
width: fit-content;
|
||||
padding: 6px 12px;
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
@@ -329,14 +412,15 @@
|
||||
.starterContent h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(26px, 4vw, 38px);
|
||||
line-height: 1.05;
|
||||
line-height: 1.1;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.starterContent p {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.starterActions {
|
||||
@@ -349,69 +433,79 @@
|
||||
.starterPrimaryButton,
|
||||
.starterSecondaryButton {
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
padding: 14px 18px;
|
||||
font-weight: 900;
|
||||
border-radius: 16px;
|
||||
padding: 16px 20px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.starterPrimaryButton:hover,
|
||||
.starterSecondaryButton:hover {
|
||||
transform: translateY(-2px);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.starterPrimaryButton {
|
||||
background: var(--white);
|
||||
color: var(--wine-red);
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16);
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.starterSecondaryButton {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: var(--white);
|
||||
border: 1px solid rgba(255, 255, 255, 0.32);
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.starterGuideGrid {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.starterGuideCard {
|
||||
min-height: 220px;
|
||||
padding: 26px;
|
||||
border-radius: 18px;
|
||||
padding: 28px;
|
||||
border-radius: 20px;
|
||||
background: var(--white);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.07);
|
||||
border: 1px solid rgba(128, 0, 32, 0.08);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.starterGuideCard:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.starterGuideNumber {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 999px;
|
||||
background: rgba(128, 0, 32, 0.1);
|
||||
color: var(--wine-red);
|
||||
background: linear-gradient(135deg, var(--wine-red) 0%, var(--wine-dark) 100%);
|
||||
color: var(--white);
|
||||
font-weight: 900;
|
||||
margin-bottom: 18px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 6px 16px rgba(255, 42, 127, 0.3);
|
||||
}
|
||||
|
||||
.starterGuideCard h3 {
|
||||
margin: 0 0 10px;
|
||||
margin: 0 0 12px;
|
||||
color: var(--black);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.starterGuideCard p {
|
||||
margin: 0;
|
||||
color: var(--gray-dark);
|
||||
line-height: 1.55;
|
||||
line-height: 1.6;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
@@ -419,32 +513,34 @@
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 420px);
|
||||
gap: 28px;
|
||||
gap: 32px;
|
||||
align-items: start;
|
||||
padding: 34px;
|
||||
border-radius: 20px;
|
||||
padding: 36px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #faf7fb 100%);
|
||||
box-shadow: 0 12px 34px rgba(0, 0, 0, 0.055);
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.07);
|
||||
border: 1px solid rgba(128, 0, 32, 0.08);
|
||||
}
|
||||
|
||||
.starterNextStepsPanel h3 {
|
||||
margin: 14px 0 10px;
|
||||
margin: 16px 0 12px;
|
||||
color: var(--black);
|
||||
font-size: clamp(24px, 3vw, 34px);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.starterNextStepsPanel p {
|
||||
margin: 0;
|
||||
max-width: 680px;
|
||||
color: var(--gray-dark);
|
||||
line-height: 1.65;
|
||||
line-height: 1.7;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.starterChecklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
gap: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
@@ -452,49 +548,81 @@
|
||||
|
||||
.starterChecklist li {
|
||||
position: relative;
|
||||
padding: 14px 16px 14px 44px;
|
||||
border-radius: 14px;
|
||||
padding: 16px 18px 16px 48px;
|
||||
border-radius: 16px;
|
||||
background: var(--white);
|
||||
color: var(--black);
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.045);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.starterChecklist li:hover {
|
||||
transform: translateX(4px);
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.starterChecklist li::before {
|
||||
content: "✓";
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
left: 18px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: var(--green);
|
||||
background: linear-gradient(135deg, var(--green) 0%, var(--green-darkestX1) 100%);
|
||||
color: var(--white);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
box-shadow: 0 3px 8px rgba(42, 255, 42, 0.3);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 760px) {
|
||||
.dashboardGrid {
|
||||
margin: 24px auto;
|
||||
padding: 0 16px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.welcomeCard {
|
||||
padding: 28px 24px;
|
||||
}
|
||||
|
||||
.welcomeContainer h3 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.quickActions {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
padding: 20px 12px;
|
||||
}
|
||||
|
||||
.pendingPaymentCard {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
padding: 22px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.failedPaymentCard {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
padding: 22px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.expiredDowngradeCard {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
padding: 22px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.expiredDowngradeButton {
|
||||
@@ -510,7 +638,7 @@
|
||||
.starterCard {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.starterActions {
|
||||
@@ -523,7 +651,13 @@
|
||||
|
||||
.starterNextStepsPanel {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 24px;
|
||||
padding: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.quickActions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 AnimatedContainer from "@components/AnimatedConainer/AnimatedContainer";
|
||||
import ExploreCategories from "@core/app/components/ExploreCategories/ExploreCategories";
|
||||
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
|
||||
import useContainerHeight from "@core/app/hooks/ContainerHeight";
|
||||
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
||||
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
|
||||
import { Metadata } from "next";
|
||||
import GetAppointmentsContent from "./GetAppointmentsContent";
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://turnosxpress.com.ar";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Quiero reservar turno | TurnosXpress",
|
||||
description:
|
||||
"Reservá tu turno en TurnosXpress: explorá categorías y buscá el servicio más adecuado para vos. Registrate y reservá en minutos.",
|
||||
alternates: {
|
||||
canonical: `${baseUrl}/landing/get-appointments`,
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
title: "Quiero reservar turno | TurnosXpress",
|
||||
description:
|
||||
"Explorá categorías, buscá el servicio más adecuado para vos y reservá tu turno en TurnosXpress.",
|
||||
url: `${baseUrl}/landing/get-appointments`,
|
||||
siteName: "TurnosXpress",
|
||||
locale: "es_AR",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
title: "Quiero reservar turno | TurnosXpress",
|
||||
description: "Explorá categorías y reservá tu turno en TurnosXpress.",
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default function GetAppointmentsPage() {
|
||||
const containerHeight = useContainerHeight();
|
||||
|
||||
return (
|
||||
<AnimatedContainer
|
||||
color="#aeaeae"
|
||||
minSize={150}
|
||||
maxSize={500}
|
||||
items={10}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<ScrollToTop />
|
||||
<HeaderConfProvider />
|
||||
<DefaultLoginAction />
|
||||
<div className="homeCentered">
|
||||
<h1 className="homeTitleHeader">Quiero reservar turno</h1>
|
||||
<p className="homeResumeHeader">
|
||||
Reservar un turno es muy fácil. Solo tenés que explorar nuestras categorías y
|
||||
buscar el servicio más adecuado para vos. Una vez que ingreses al servicio,
|
||||
podrás realizar la reserva. Tené en cuenta que, para poder reservar, debés estar
|
||||
registrado en nuestro sitio web.
|
||||
</p>
|
||||
</div>
|
||||
<div className="homeCentered">
|
||||
<ExploreCategories containerHeight={containerHeight} />
|
||||
|
||||
<div style={{ height: "100px" }}> </div>
|
||||
</div>
|
||||
</AnimatedContainer>
|
||||
);
|
||||
}
|
||||
return <GetAppointmentsContent />;
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import style from "./help.module.css";
|
||||
import { videoCategories, Video, getTotalVideoCount } from "./videoData";
|
||||
import VideoModal from "./components/VideoModal";
|
||||
import AnimatedContainer from "@core/app/components/AnimatedConainer/AnimatedContainer";
|
||||
import HeaderConfProvider from "@core/app/components/HeaderConfProvider/HeaderConfProvider";
|
||||
import DefaultLoginAction from "@core/app/components/LoginAction/DefaultLoginAction";
|
||||
import { ScrollToTop } from "@core/app/components/ScrollTop";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function HelpContent() {
|
||||
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [activeCategory, setActiveCategory] = useState<string>("all");
|
||||
const [watchedVideos, setWatchedVideos] = useState<Set<string>>(new Set());
|
||||
|
||||
const totalVideos = getTotalVideoCount();
|
||||
|
||||
useEffect(() => {
|
||||
const watched = localStorage.getItem("tx-help-watched");
|
||||
if (watched) {
|
||||
setWatchedVideos(new Set(JSON.parse(watched)));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleVideoClick = (video: Video) => {
|
||||
setSelectedVideo(video);
|
||||
setIsModalOpen(true);
|
||||
const newWatched = new Set(watchedVideos);
|
||||
newWatched.add(video.id);
|
||||
setWatchedVideos(newWatched);
|
||||
localStorage.setItem("tx-help-watched", JSON.stringify([...newWatched]));
|
||||
};
|
||||
|
||||
const handleVideoNavigate = (video: Video) => {
|
||||
// Mark as watched without opening the modal, then let the Link navigate
|
||||
const newWatched = new Set(watchedVideos);
|
||||
newWatched.add(video.id);
|
||||
setWatchedVideos(newWatched);
|
||||
localStorage.setItem("tx-help-watched", JSON.stringify([...newWatched]));
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setTimeout(() => setSelectedVideo(null), 200);
|
||||
};
|
||||
|
||||
const handleCategoryClick = (categoryId: string) => {
|
||||
setActiveCategory(categoryId);
|
||||
if (categoryId !== "all") {
|
||||
const element = document.getElementById(`category-${categoryId}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const progressPercent = Math.round((watchedVideos.size / totalVideos) * 100);
|
||||
|
||||
return (
|
||||
<AnimatedContainer
|
||||
color="#aeaeae"
|
||||
minSize={150}
|
||||
maxSize={500}
|
||||
items={10}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
background: "var(--white)",
|
||||
}}
|
||||
>
|
||||
<ScrollToTop />
|
||||
<HeaderConfProvider />
|
||||
<DefaultLoginAction />
|
||||
|
||||
<div className={style.container}>
|
||||
{/* Hero Section */}
|
||||
<div className={style.hero}>
|
||||
<div className={style.heroIcon}>
|
||||
<svg
|
||||
width="36"
|
||||
height="36"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className={style.heroTitle}>Centro de Ayuda</h1>
|
||||
<p className={style.heroSubtitle}>
|
||||
Aprende a usar TurnosXpress paso a paso con nuestros tutoriales en video.
|
||||
Desde la configuración inicial hasta las funciones más avanzadas,
|
||||
tenemos todo lo que necesitas para aprovechar al máximo la plataforma.
|
||||
</p>
|
||||
<div className={style.heroStats}>
|
||||
<div className={style.statItem}>
|
||||
<span className={style.statNumber}>{totalVideos}</span>
|
||||
<span className={style.statLabel}>Video tutoriales</span>
|
||||
</div>
|
||||
<div className={style.statItem}>
|
||||
<span className={style.statNumber}>{videoCategories.length}</span>
|
||||
<span className={style.statLabel}>Categorías</span>
|
||||
</div>
|
||||
<div className={style.statItem}>
|
||||
<span className={style.statNumber}>{watchedVideos.size}</span>
|
||||
<span className={style.statLabel}>Vistos</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Section */}
|
||||
{watchedVideos.size > 0 && (
|
||||
<div className={style.progressSection}>
|
||||
<h2 className={style.progressTitle}>
|
||||
Tu progreso de aprendizaje
|
||||
</h2>
|
||||
<p className={style.progressText}>
|
||||
Has visto {watchedVideos.size} de {totalVideos} tutoriales.
|
||||
{progressPercent === 100
|
||||
? " ¡Felicidades! Has completado todos los tutoriales."
|
||||
: ` ¡Sigue así, te faltan ${totalVideos - watchedVideos.size}!`}
|
||||
</p>
|
||||
<div className={style.progressBar}>
|
||||
<div
|
||||
className={style.progressFill}
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category Navigation */}
|
||||
<div className={style.categoryNav}>
|
||||
<button
|
||||
className={
|
||||
activeCategory === "all"
|
||||
? style.categoryBtnActive
|
||||
: style.categoryBtn
|
||||
}
|
||||
onClick={() => {
|
||||
setActiveCategory("all");
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
>
|
||||
Todos
|
||||
</button>
|
||||
{videoCategories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={
|
||||
activeCategory === cat.id
|
||||
? style.categoryBtnActive
|
||||
: style.categoryBtn
|
||||
}
|
||||
onClick={() => handleCategoryClick(cat.id)}
|
||||
>
|
||||
{cat.icon} {cat.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Learning Path */}
|
||||
<div className={style.learningPath}>
|
||||
<span className={style.learningPathIcon}>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4" />
|
||||
<path d="M12 8h.01" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className={style.learningPathText}>
|
||||
<strong>Recomendación:</strong> Sigue el orden de las categorías para
|
||||
un aprendizaje progresivo. Comienza con "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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useCallback } from "react";
|
||||
import style from "../help.module.css";
|
||||
import { Video, videoCategories } from "../videoData";
|
||||
|
||||
interface VideoModalProps {
|
||||
video: Video | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelectVideo: (video: Video) => void;
|
||||
}
|
||||
|
||||
export default function VideoModal({
|
||||
video,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelectVideo,
|
||||
}: VideoModalProps) {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen, handleKeyDown]);
|
||||
|
||||
if (!isOpen || !video) return null;
|
||||
|
||||
const getCategoryName = (categoryId: string) => {
|
||||
const cat = videoCategories.find((c) => c.id === categoryId);
|
||||
return cat ? cat.name : "";
|
||||
};
|
||||
|
||||
const allVideos = videoCategories.flatMap((cat) => cat.videos);
|
||||
const currentVideoIndex = allVideos.findIndex((v) => v.id === video.id);
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentVideoIndex < allVideos.length - 1) {
|
||||
onSelectVideo(allVideos[currentVideoIndex + 1]);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (currentVideoIndex > 0) {
|
||||
onSelectVideo(allVideos[currentVideoIndex - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.modalOverlay} onClick={onClose}>
|
||||
<div className={style.modalContent} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={style.modalHeader}>
|
||||
<h3 className={style.modalTitle}>{video.title}</h3>
|
||||
<button className={style.modalClose} onClick={onClose} aria-label="Cerrar">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={style.modalBody}>
|
||||
<div className={style.modalVideo}>
|
||||
<div className={style.videoWrapper}>
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${video.id}?autoplay=1&rel=0&modestbranding=1`}
|
||||
title={video.title}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
<div style={{ padding: "1rem 1.5rem", borderBottom: "1px solid var(--white-darkest)" }}>
|
||||
<p style={{ color: "var(--gray-dark)", fontSize: "0.9rem", lineHeight: 1.6 }}>
|
||||
{video.description}
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: "0.75rem", marginTop: "0.75rem" }}>
|
||||
{currentVideoIndex > 0 && (
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid var(--white-darkest)",
|
||||
background: "var(--white)",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--black)",
|
||||
}}
|
||||
>
|
||||
← Anterior
|
||||
</button>
|
||||
)}
|
||||
{currentVideoIndex < allVideos.length - 1 && (
|
||||
<button
|
||||
onClick={handleNext}
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
borderRadius: "8px",
|
||||
border: "none",
|
||||
background: "linear-gradient(135deg, var(--wine-red), var(--wine-dark))",
|
||||
color: "white",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Siguiente →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={style.modalSidebar}>
|
||||
<div className={style.sidebarTitle}>Todos los tutoriales</div>
|
||||
{videoCategories.map((cat) => (
|
||||
<div key={cat.id}>
|
||||
<div
|
||||
style={{
|
||||
padding: "0.6rem 1.25rem",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--wine-dark)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.5px",
|
||||
background: "rgba(127, 42, 255, 0.04)",
|
||||
borderTop: "1px solid var(--white-darkest)",
|
||||
}}
|
||||
>
|
||||
{cat.icon} {cat.name}
|
||||
</div>
|
||||
{cat.videos.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className={
|
||||
v.id === video.id
|
||||
? style.sidebarVideoActive
|
||||
: style.sidebarVideo
|
||||
}
|
||||
onClick={() => onSelectVideo(v)}
|
||||
>
|
||||
<div className={style.sidebarThumb}>
|
||||
<img src={v.thumbnail} alt={v.title} />
|
||||
</div>
|
||||
<div className={style.sidebarVideoInfo}>
|
||||
<div className={style.sidebarVideoTitle}>{v.title}</div>
|
||||
<div className={style.sidebarVideoCategory}>
|
||||
{getCategoryName(v.category)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user