feat: implement backend and frontend support for managing reservation period extensions and summaries
This commit is contained in:
@@ -682,7 +682,11 @@ class CompaniesManager implements ICompaniesManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isAdmin = await EmployeesList.checkPermission(companyDoc, employ.id, EmployeeRoles.ADMIN);
|
||||
const isAdmin = await EmployeesList.checkPermission(
|
||||
companyDoc,
|
||||
data.sessionUser,
|
||||
EmployeeRoles.ADMIN
|
||||
);
|
||||
|
||||
returnData.push({
|
||||
id: isNull<string>(company.id, ""),
|
||||
|
||||
@@ -49,6 +49,49 @@ export type EnableScheduleParams = {
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type ReservationPeriodScope = "employee" | "all";
|
||||
|
||||
export type ReservationPeriodDurationUnit = "week" | "month";
|
||||
|
||||
export type ReservationPeriodDuration = {
|
||||
unit: ReservationPeriodDurationUnit;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type ReservationPeriodsSummaryParams = {
|
||||
companyId: string;
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type ExtendReservationPeriodsParams = {
|
||||
companyId: string;
|
||||
scope: ReservationPeriodScope;
|
||||
employeeId?: string;
|
||||
duration?: ReservationPeriodDuration;
|
||||
weeks?: number;
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type ReservationPeriodEmployeeSummary = {
|
||||
employeeId: string;
|
||||
employeeFullName: string;
|
||||
employeeEmail: string;
|
||||
employeeAvatar: string;
|
||||
employeeUserId: string;
|
||||
isOpen: boolean;
|
||||
currentOpenUntil: Date | null;
|
||||
daysRemaining: number | null;
|
||||
};
|
||||
|
||||
export type ReservationPeriodsSummary = {
|
||||
companyId: string;
|
||||
status: "missing" | "expired" | "near-ending" | "open";
|
||||
worstDaysRemaining: number | null;
|
||||
currentOpenUntil: Date | null;
|
||||
nearEndingThresholdDays: number;
|
||||
employees: ReservationPeriodEmployeeSummary[];
|
||||
};
|
||||
|
||||
export type DeleteSchedulesEnabledByCompanyParams = {
|
||||
companyId: string;
|
||||
};
|
||||
@@ -94,4 +137,6 @@ export interface ISchedulesEnabledManager {
|
||||
exists(data: CheckSchedulesEnabledParams): Promise<boolean>;
|
||||
deleteSchedulesEnabledByCompany(data: DeleteSchedulesEnabledByCompanyParams): Promise<void>;
|
||||
deleteSchedulesEnabledByEmployee(data: DeleteSchedulesEnabledByEmployeeParams): Promise<void>;
|
||||
summarizeReservationPeriods(data: ReservationPeriodsSummaryParams): Promise<ReservationPeriodsSummary>;
|
||||
extendReservationPeriods(data: ExtendReservationPeriodsParams): Promise<ReservationPeriodsSummary>;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
FindSchedulesEnabledParams,
|
||||
ISchedulesEnabled,
|
||||
ISchedulesEnabledManager,
|
||||
ReservationPeriodEmployeeSummary,
|
||||
ReservationPeriodsSummary,
|
||||
ReservationPeriodsSummaryParams,
|
||||
ExtendReservationPeriodsParams,
|
||||
SchedulesEnabledView,
|
||||
} from "./SchedulesEnabled.Interface";
|
||||
import { EmployeeRoles } from "../Employees/Employees.Interface";
|
||||
@@ -22,9 +26,19 @@ import getAvatar from "../../helpers/getAvatar";
|
||||
import { NotificationsManager } from "../Notifications/Notifications";
|
||||
import dayjs from "dayjs";
|
||||
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
|
||||
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
|
||||
|
||||
dayjs.locale("es");
|
||||
|
||||
const NEAR_ENDING_THRESHOLD_DAYS = 14;
|
||||
const ALLOWED_RESERVATION_PERIOD_DURATIONS = [
|
||||
{ unit: "week", value: 1 },
|
||||
{ unit: "week", value: 2 },
|
||||
{ unit: "week", value: 3 },
|
||||
{ unit: "month", value: 1 },
|
||||
{ unit: "month", value: 2 },
|
||||
] as const;
|
||||
|
||||
class SchedulesEnabledManager implements ISchedulesEnabledManager {
|
||||
schedulesEnabled: SchedulesEnabledAdapterMongoose;
|
||||
|
||||
@@ -233,7 +247,7 @@ class SchedulesEnabledManager implements ISchedulesEnabledManager {
|
||||
if (data.employeeId) {
|
||||
employeeFilter = {
|
||||
...employeeFilter,
|
||||
employeeId: data.employeeId,
|
||||
_id: data.employeeId,
|
||||
};
|
||||
}
|
||||
const employees = await EmployeesList.employees.find(employeeFilter);
|
||||
@@ -297,6 +311,137 @@ class SchedulesEnabledManager implements ISchedulesEnabledManager {
|
||||
|
||||
await this.schedulesEnabled.delete(String(data.id));
|
||||
}
|
||||
|
||||
public async summarizeReservationPeriods(
|
||||
data: ReservationPeriodsSummaryParams
|
||||
): Promise<ReservationPeriodsSummary> {
|
||||
await validateSessionUser({ sessionUser: data.sessionUser });
|
||||
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
|
||||
|
||||
return this.buildReservationPeriodsSummary(data.companyId);
|
||||
}
|
||||
|
||||
public async extendReservationPeriods(
|
||||
data: ExtendReservationPeriodsParams
|
||||
): Promise<ReservationPeriodsSummary> {
|
||||
await validateSessionUser({ sessionUser: data.sessionUser });
|
||||
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
|
||||
|
||||
const duration = data.duration || (data.weeks ? { unit: "week" as const, value: Number(data.weeks) } : null);
|
||||
|
||||
if (!duration || !ALLOWED_RESERVATION_PERIOD_DURATIONS.some(option => option.unit === duration.unit && option.value === Number(duration.value))) {
|
||||
throw new Error("El período seleccionado no es válido");
|
||||
}
|
||||
|
||||
const employees = await EmployeesList.employees.find({
|
||||
companyId: data.companyId,
|
||||
removed: { $ne: true },
|
||||
} as any);
|
||||
|
||||
let targetEmployees = employees;
|
||||
|
||||
if (data.scope === "employee") {
|
||||
if (!data.employeeId) {
|
||||
throw new Error("Debe seleccionar un colaborador");
|
||||
}
|
||||
|
||||
targetEmployees = employees.filter(employee => String(employee.id) === String(data.employeeId));
|
||||
|
||||
if (targetEmployees.length === 0) {
|
||||
throw new Error("El colaborador no existe o no pertenece a la organización");
|
||||
}
|
||||
} else if (data.scope !== "all") {
|
||||
throw new Error("El alcance seleccionado no es válido");
|
||||
}
|
||||
|
||||
if (targetEmployees.length === 0) {
|
||||
throw new Error("No hay colaboradores disponibles para extender el período de reservas");
|
||||
}
|
||||
|
||||
const today = dayjs().startOf("day");
|
||||
const summary = await this.buildReservationPeriodsSummary(data.companyId);
|
||||
const operations = targetEmployees.map(employee => {
|
||||
const employeeSummary = summary.employees.find(item => String(item.employeeId) === String(employee.id));
|
||||
const startDate = employeeSummary?.currentOpenUntil && dayjs(employeeSummary.currentOpenUntil).isAfter(today)
|
||||
? dayjs(employeeSummary.currentOpenUntil).add(1, "day").startOf("day")
|
||||
: today;
|
||||
const endDate = startDate.add(Number(duration.value), duration.unit).subtract(1, "day").endOf("day");
|
||||
|
||||
return {
|
||||
updateOne: {
|
||||
filter: {
|
||||
employeeId: employee.id,
|
||||
companyId: data.companyId,
|
||||
startDate: startDate.toDate(),
|
||||
endDate: endDate.toDate(),
|
||||
},
|
||||
update: {
|
||||
$setOnInsert: {
|
||||
employeeId: employee.id,
|
||||
companyId: data.companyId,
|
||||
startDate: startDate.toDate(),
|
||||
endDate: endDate.toDate(),
|
||||
},
|
||||
},
|
||||
upsert: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await this.schedulesEnabled.schedulesEnabledList.bulkWrite(operations, { ordered: true });
|
||||
|
||||
return this.buildReservationPeriodsSummary(data.companyId);
|
||||
}
|
||||
|
||||
private async buildReservationPeriodsSummary(companyId: string): Promise<ReservationPeriodsSummary> {
|
||||
const today = dayjs().startOf("day");
|
||||
const employees = await EmployeesList.findByCompanyId({ companyId });
|
||||
const employeeSummaries: ReservationPeriodEmployeeSummary[] = [];
|
||||
|
||||
for (const employee of employees) {
|
||||
const latestPeriod = await this.schedulesEnabled.schedulesEnabledList
|
||||
.findOne({ companyId, employeeId: employee.id })
|
||||
.sort({ endDate: -1 })
|
||||
.exec();
|
||||
|
||||
const currentOpenUntil = latestPeriod ? latestPeriod.endDate : null;
|
||||
const daysRemaining = currentOpenUntil
|
||||
? dayjs(currentOpenUntil).endOf("day").diff(today, "day")
|
||||
: null;
|
||||
|
||||
employeeSummaries.push({
|
||||
employeeId: employee.id,
|
||||
employeeFullName: employee.fullName,
|
||||
employeeEmail: employee.email,
|
||||
employeeAvatar: employee.avatar,
|
||||
employeeUserId: employee.userId,
|
||||
isOpen: daysRemaining !== null && daysRemaining >= 0,
|
||||
currentOpenUntil,
|
||||
daysRemaining,
|
||||
});
|
||||
}
|
||||
|
||||
const currentOpenUntilDates = employeeSummaries
|
||||
.map(employee => employee.currentOpenUntil)
|
||||
.filter((date): date is Date => Boolean(date));
|
||||
const remainingValues = employeeSummaries
|
||||
.map(employee => employee.daysRemaining)
|
||||
.filter((days): days is number => days !== null);
|
||||
const hasMissing = employeeSummaries.some(employee => employee.currentOpenUntil === null);
|
||||
const hasExpired = employeeSummaries.some(employee => employee.daysRemaining !== null && employee.daysRemaining < 0);
|
||||
const hasNearEnding = employeeSummaries.some(employee => employee.daysRemaining !== null && employee.daysRemaining <= NEAR_ENDING_THRESHOLD_DAYS);
|
||||
|
||||
return {
|
||||
companyId,
|
||||
status: hasMissing ? "missing" : hasExpired ? "expired" : hasNearEnding ? "near-ending" : "open",
|
||||
worstDaysRemaining: remainingValues.length > 0 ? Math.min(...remainingValues) : null,
|
||||
currentOpenUntil: currentOpenUntilDates.length > 0
|
||||
? currentOpenUntilDates.reduce((max, date) => dayjs(date).isAfter(max) ? date : max, currentOpenUntilDates[0])
|
||||
: null,
|
||||
nearEndingThresholdDays: NEAR_ENDING_THRESHOLD_DAYS,
|
||||
employees: employeeSummaries,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const SchedulesEnabledList = new SchedulesEnabledManager();
|
||||
|
||||
@@ -5,8 +5,11 @@ import {
|
||||
CreateSchedulesEnabledParams,
|
||||
DeleteSchedulesEnabledParams,
|
||||
EnableScheduleParams,
|
||||
ExtendReservationPeriodsParams,
|
||||
FindSchedulesEnabledParams,
|
||||
ISchedulesEnabled,
|
||||
ReservationPeriodsSummary,
|
||||
ReservationPeriodsSummaryParams,
|
||||
SchedulesEnabledView,
|
||||
} from "../../Models/SchedulesEnabled/SchedulesEnabled.Interface";
|
||||
import { authenticateMiddleware } from "../../middleware/authentication";
|
||||
@@ -150,3 +153,45 @@ export class DisableSchedulesEnabledController extends Controller {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Route("schedules-enabled/reservation-periods/summary")
|
||||
@Middlewares(authenticateMiddleware)
|
||||
export class ReservationPeriodsSummaryController extends Controller {
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async summarizeReservationPeriods(
|
||||
@Body() requestBody: ReservationPeriodsSummaryParams
|
||||
): Promise<ReservationPeriodsSummary | ApiValidationError> {
|
||||
try {
|
||||
const summary = await new SchedulesEnabledService().summarizeReservationPeriods(requestBody);
|
||||
this.setStatus(200);
|
||||
return summary;
|
||||
} catch (e) {
|
||||
const errorOccurred: Error = e as Error;
|
||||
this.setStatus(500);
|
||||
return new ApiValidationError(500, errorOccurred.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Route("schedules-enabled/reservation-periods/extend")
|
||||
@Middlewares(authenticateMiddleware)
|
||||
export class ExtendReservationPeriodsController extends Controller {
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async extendReservationPeriods(
|
||||
@Body() requestBody: ExtendReservationPeriodsParams
|
||||
): Promise<ReservationPeriodsSummary | ApiValidationError> {
|
||||
try {
|
||||
const summary = await new SchedulesEnabledService().extendReservationPeriods(requestBody);
|
||||
this.setStatus(200);
|
||||
return summary;
|
||||
} catch (e) {
|
||||
const errorOccurred: Error = e as Error;
|
||||
this.setStatus(500);
|
||||
return new ApiValidationError(500, errorOccurred.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
FindSchedulesEnabledParams,
|
||||
SchedulesEnabledView,
|
||||
EnableScheduleParams,
|
||||
ReservationPeriodsSummaryParams,
|
||||
ReservationPeriodsSummary,
|
||||
ExtendReservationPeriodsParams,
|
||||
} from "../../Models/SchedulesEnabled/SchedulesEnabled.Interface";
|
||||
|
||||
export class SchedulesEnabledService {
|
||||
@@ -52,4 +55,18 @@ export class SchedulesEnabledService {
|
||||
|
||||
return schedulesEnabled;
|
||||
}
|
||||
|
||||
public async summarizeReservationPeriods(
|
||||
data: ReservationPeriodsSummaryParams
|
||||
): Promise<ReservationPeriodsSummary> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
return SchedulesEnabledList.summarizeReservationPeriods(data);
|
||||
}
|
||||
|
||||
public async extendReservationPeriods(
|
||||
data: ExtendReservationPeriodsParams
|
||||
): Promise<ReservationPeriodsSummary> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
return SchedulesEnabledList.extendReservationPeriods(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,49 @@ export type EnableScheduleParams = {
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type ReservationPeriodScope = "employee" | "all";
|
||||
|
||||
export type ReservationPeriodDurationUnit = "week" | "month";
|
||||
|
||||
export type ReservationPeriodDuration = {
|
||||
unit: ReservationPeriodDurationUnit;
|
||||
value: number;
|
||||
};
|
||||
|
||||
export type ReservationPeriodsSummaryParams = {
|
||||
companyId: string;
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type ExtendReservationPeriodsParams = {
|
||||
companyId: string;
|
||||
scope: ReservationPeriodScope;
|
||||
employeeId?: string;
|
||||
duration?: ReservationPeriodDuration;
|
||||
weeks?: number;
|
||||
sessionUser: string;
|
||||
};
|
||||
|
||||
export type ReservationPeriodEmployeeSummary = {
|
||||
employeeId: string;
|
||||
employeeFullName: string;
|
||||
employeeEmail: string;
|
||||
employeeAvatar: string;
|
||||
employeeUserId: string;
|
||||
isOpen: boolean;
|
||||
currentOpenUntil: string | null;
|
||||
daysRemaining: number | null;
|
||||
};
|
||||
|
||||
export type ReservationPeriodsSummary = {
|
||||
companyId: string;
|
||||
status: "missing" | "expired" | "near-ending" | "open";
|
||||
worstDaysRemaining: number | null;
|
||||
currentOpenUntil: string | null;
|
||||
nearEndingThresholdDays: number;
|
||||
employees: ReservationPeriodEmployeeSummary[];
|
||||
};
|
||||
|
||||
export interface SchedulesEnabledView {
|
||||
id: string;
|
||||
employeeId: string;
|
||||
|
||||
@@ -8,7 +8,10 @@ import { ApiVoidResult } from "@models/Api.VoidResult.type";
|
||||
import ApiRequest from "@services/Api.Service";
|
||||
import {
|
||||
EnableScheduleParams,
|
||||
ExtendReservationPeriodsParams,
|
||||
FindSchedulesEnabledParams,
|
||||
ReservationPeriodsSummary,
|
||||
ReservationPeriodsSummaryParams,
|
||||
SchedulesEnabledView,
|
||||
} from "@models/SchedulesEnabled.type";
|
||||
import {
|
||||
@@ -56,6 +59,18 @@ export const disableSchedule = async (data: EnableScheduleParams): Promise<ApiVo
|
||||
return ApiRequest.post<ApiVoidResult>("schedules-enabled/disable", data);
|
||||
};
|
||||
|
||||
export const summarizeReservationPeriods = async (
|
||||
data: ReservationPeriodsSummaryParams
|
||||
): Promise<ReservationPeriodsSummary> => {
|
||||
return ApiRequest.post<ReservationPeriodsSummary>("schedules-enabled/reservation-periods/summary", data);
|
||||
};
|
||||
|
||||
export const extendReservationPeriods = async (
|
||||
data: ExtendReservationPeriodsParams
|
||||
): Promise<ReservationPeriodsSummary> => {
|
||||
return ApiRequest.post<ReservationPeriodsSummary>("schedules-enabled/reservation-periods/extend", data);
|
||||
};
|
||||
|
||||
export const uploadHeaderImage = async (data: FormData): Promise<ApiVoidResult> => {
|
||||
return ApiRequest.postFile<ApiVoidResult>("companies/upload-header", data);
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||
import WhatsAppIcon from '@mui/icons-material/WhatsApp';
|
||||
import QrCode2Icon from '@mui/icons-material/QrCode2';
|
||||
import API from "@services/Api.Service";
|
||||
import { ReservationPeriodsSummary } from "@models/SchedulesEnabled.type";
|
||||
|
||||
|
||||
type FlowDefinition = {
|
||||
@@ -123,6 +124,15 @@ const AVAILABLE_FLOWS: FlowDefinition[] = [
|
||||
keywords: ["horarios", "disponibilidad", "semana", "apertura", "cierre", "jornada"],
|
||||
icon: CalendarMonthIcon
|
||||
},
|
||||
{
|
||||
id: "open-reservation-periods",
|
||||
flowId: "open-reservation-periods",
|
||||
title: "Extender Reservas",
|
||||
description: "Abrir más semanas para que tus clientes puedan reservar turnos.",
|
||||
category: "Horarios",
|
||||
keywords: ["reservas", "periodo", "agenda", "extender", "habilitar", "semanas"],
|
||||
icon: EventBusyIcon
|
||||
},
|
||||
{
|
||||
id: "override-schedule",
|
||||
flowId: "override-schedule",
|
||||
@@ -165,6 +175,7 @@ export default function AssistantHome() {
|
||||
|
||||
const [incompleteOrgs, setIncompleteOrgs] = React.useState<IncompleteOrgData[]>([]);
|
||||
const [incompleteCollaborators, setIncompleteCollaborators] = React.useState<any[]>([]);
|
||||
const [reservationPeriodAlerts, setReservationPeriodAlerts] = React.useState<Array<{ org: any; summary: ReservationPeriodsSummary }>>([]);
|
||||
const [isLoadingFlow, setIsLoadingFlow] = React.useState(true);
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
|
||||
@@ -174,9 +185,25 @@ export default function AssistantHome() {
|
||||
const response = await API.post<any[]>("companies/get-by-user", { sessionUser: SessionInfo.userId });
|
||||
const orgs = response || [];
|
||||
const newIncompleteOrgs: IncompleteOrgData[] = [];
|
||||
const reservationAlerts: Array<{ org: any; summary: ReservationPeriodsSummary }> = [];
|
||||
|
||||
if (orgs.length > 0) {
|
||||
for (const org of orgs) {
|
||||
if (org.isAdmin) {
|
||||
try {
|
||||
const summary = await API.post<ReservationPeriodsSummary>("schedules-enabled/reservation-periods/summary", {
|
||||
companyId: org.id || org._id,
|
||||
sessionUser: SessionInfo.userId
|
||||
});
|
||||
|
||||
if (summary && summary.status !== "open") {
|
||||
reservationAlerts.push({ org, summary });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error checking reservation periods", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (org.onboardingCompleted) continue;
|
||||
|
||||
const isNameMissing = !org.name || org.name.trim() === "";
|
||||
@@ -239,6 +266,7 @@ export default function AssistantHome() {
|
||||
}
|
||||
|
||||
setIncompleteOrgs(newIncompleteOrgs);
|
||||
setReservationPeriodAlerts(reservationAlerts);
|
||||
} else {
|
||||
setIncompleteOrgs([{
|
||||
org: null,
|
||||
@@ -246,6 +274,7 @@ export default function AssistantHome() {
|
||||
currentStepName: "Detalles del Negocio",
|
||||
flowToTrigger: "onboarding-org"
|
||||
}]);
|
||||
setReservationPeriodAlerts([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching organizations:", error);
|
||||
@@ -255,6 +284,7 @@ export default function AssistantHome() {
|
||||
currentStepName: "Detalles del Negocio",
|
||||
flowToTrigger: "onboarding-org"
|
||||
}]);
|
||||
setReservationPeriodAlerts([]);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -273,7 +303,7 @@ export default function AssistantHome() {
|
||||
}, [SessionInfo.metrics, SessionInfo.userId, router]);
|
||||
|
||||
const isSetupIncomplete = () => {
|
||||
return incompleteOrgs.length > 0 || incompleteCollaborators.length > 0;
|
||||
return incompleteOrgs.length > 0 || incompleteCollaborators.length > 0 || reservationPeriodAlerts.length > 0;
|
||||
};
|
||||
|
||||
const handleStartFlow = (flowId: string, companyId?: string) => {
|
||||
@@ -397,6 +427,46 @@ export default function AssistantHome() {
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{reservationPeriodAlerts.map(({ org, summary }, index) => {
|
||||
const orgId = org.id || org._id;
|
||||
const reservationPeriodCopy = summary.status === "missing"
|
||||
? {
|
||||
title: "Falta abrir el período de reservas",
|
||||
description: "Uno o más colaboradores todavía no tienen un período abierto para recibir reservas. Abrilo para que tus clientes puedan sacar turnos.",
|
||||
}
|
||||
: summary.status === "expired"
|
||||
? {
|
||||
title: "El período de reservas está vencido",
|
||||
description: "Uno o más colaboradores tienen el período de reservas vencido. Extendelo para que tus clientes puedan volver a reservar turnos.",
|
||||
}
|
||||
: {
|
||||
title: "El período de reservas está por finalizar",
|
||||
description: "El período abierto para reservas de uno o más colaboradores está por llegar a su fin. Extendelo para que tus clientes puedan seguir reservando turnos.",
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={`reservation-period-${orgId || index}`} className={style.onboardingCard}>
|
||||
<div className={style.onboardingContent}>
|
||||
<div className={style.onboardingIcon}>
|
||||
<EventBusyIcon fontSize="large" style={{ color: "#fff" }} />
|
||||
</div>
|
||||
<div className={style.onboardingTexts} style={{ flex: 1 }}>
|
||||
<h3>{reservationPeriodCopy.title}</h3>
|
||||
<p>{reservationPeriodCopy.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<AssistantButton
|
||||
text={isLoadingFlow ? "Cargando..." : "Extender período"}
|
||||
onClick={() => {
|
||||
if (!isLoadingFlow) {
|
||||
handleStartFlow("open-reservation-periods", orgId);
|
||||
}
|
||||
}}
|
||||
style={{ width: "100%", marginTop: "20px", height: "48px" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import { OrganizationClientView } from "@models/Clients.model";
|
||||
import WhatsAppIcon from "@mui/icons-material/WhatsApp";
|
||||
import QrCode2Icon from "@mui/icons-material/QrCode2";
|
||||
import { getSlug } from "@core/app/helpers/Slug";
|
||||
import { ReservationPeriodDuration, ReservationPeriodsSummary } from "@models/SchedulesEnabled.type";
|
||||
|
||||
interface StepEngineProps {
|
||||
flowId: string;
|
||||
@@ -143,6 +144,18 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
const [isFullDayDisabled, setIsFullDayDisabled] = useState<boolean>(true);
|
||||
const [disableStartTime, setDisableStartTime] = useState<string>("");
|
||||
const [disableEndTime, setDisableEndTime] = useState<string>("");
|
||||
const [reservationPeriodScope, setReservationPeriodScope] = useState<"employee" | "all" | null>(null);
|
||||
const reservationPeriodOptions: Array<ReservationPeriodDuration & { label: string }> = [
|
||||
{ unit: "week", value: 1, label: "1 semana" },
|
||||
{ unit: "week", value: 2, label: "2 semanas" },
|
||||
{ unit: "week", value: 3, label: "3 semanas" },
|
||||
{ unit: "month", value: 1, label: "1 mes" },
|
||||
{ unit: "month", value: 2, label: "2 meses" },
|
||||
];
|
||||
const [reservationPeriodDuration, setReservationPeriodDuration] = useState<ReservationPeriodDuration | null>({ unit: "month", value: 1 });
|
||||
const [reservationPeriodSummary, setReservationPeriodSummary] = useState<ReservationPeriodsSummary | null>(null);
|
||||
const [reservationPeriodEmployeesLoading, setReservationPeriodEmployeesLoading] = useState(false);
|
||||
const [reservationPeriodEmployeesError, setReservationPeriodEmployeesError] = useState<string | null>(null);
|
||||
|
||||
// State for New Collaborator Flow
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
@@ -265,18 +278,56 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
}).catch(console.error);
|
||||
}
|
||||
}).catch(console.error);
|
||||
} else if (flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule") {
|
||||
} else if (flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule" || flowId === "open-reservation-periods") {
|
||||
if (flowId === "open-reservation-periods") {
|
||||
setCurrentStepIndex(0);
|
||||
setReservationPeriodScope(null);
|
||||
setTargetEmployeeId(null);
|
||||
setReservationPeriodDuration({ unit: "month", value: 1 });
|
||||
setReservationPeriodSummary(null);
|
||||
setAllEmployees([]);
|
||||
setReservationPeriodEmployeesError(null);
|
||||
setReservationPeriodEmployeesLoading(true);
|
||||
}
|
||||
|
||||
API.post<any[]>("employees/get-by-company", { companyId: org.id || org._id })
|
||||
.then(empRes => {
|
||||
if (empRes && empRes.length > 0) {
|
||||
const availableEmployees = empRes.filter(canManageEmployeeSchedules);
|
||||
setAllEmployees(availableEmployees);
|
||||
const me = availableEmployees.find((e: any) => String(getEmployeeUserId(e)) === String(SessionInfo.userId));
|
||||
if (me) {
|
||||
setEmployeeId(me.id || me._id || me.employeeId);
|
||||
const availableEmployees = flowId === "open-reservation-periods"
|
||||
? (empRes || []).filter((employee: any) => !employee.removed)
|
||||
: (empRes || []).filter(canManageEmployeeSchedules);
|
||||
setAllEmployees(availableEmployees);
|
||||
|
||||
const me = availableEmployees.find((e: any) => String(getEmployeeUserId(e)) === String(SessionInfo.userId));
|
||||
if (me) {
|
||||
setEmployeeId(me.id || me._id || me.employeeId);
|
||||
}
|
||||
if (flowId === "open-reservation-periods") {
|
||||
if (availableEmployees.length === 0) {
|
||||
setReservationPeriodEmployeesError("No encontramos colaboradores activos para extender el período de reservas.");
|
||||
return;
|
||||
}
|
||||
if (availableEmployees.length === 1) {
|
||||
setReservationPeriodScope("employee");
|
||||
setTargetEmployeeId(availableEmployees[0].id || availableEmployees[0]._id || availableEmployees[0].employeeId);
|
||||
}
|
||||
}
|
||||
}).catch(console.error);
|
||||
}).catch(error => {
|
||||
console.error(error);
|
||||
if (flowId === "open-reservation-periods") {
|
||||
setReservationPeriodEmployeesError("No pudimos cargar los colaboradores. Reintentá antes de extender el período.");
|
||||
}
|
||||
}).finally(() => {
|
||||
if (flowId === "open-reservation-periods") {
|
||||
setReservationPeriodEmployeesLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
if (flowId === "open-reservation-periods") {
|
||||
API.post<ReservationPeriodsSummary>("schedules-enabled/reservation-periods/summary", {
|
||||
companyId: org.id || org._id,
|
||||
sessionUser: SessionInfo.userId
|
||||
}).then(setReservationPeriodSummary).catch(console.error);
|
||||
}
|
||||
} else if (flowId === "new-collaborator") {
|
||||
setCurrentStepIndex(1);
|
||||
} else if (flowId === "public-visibility") {
|
||||
@@ -342,7 +393,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (flowId === "new-collaborator" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId.startsWith("onboarding") || flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule") {
|
||||
if (flowId === "new-collaborator" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId.startsWith("onboarding") || flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule" || flowId === "open-reservation-periods") {
|
||||
setIsResuming(true);
|
||||
API.post<any[]>("companies/get-by-user", { sessionUser: SessionInfo.userId })
|
||||
.then(response => {
|
||||
@@ -363,7 +414,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
.finally(() => setIsResuming(false));
|
||||
}
|
||||
|
||||
if (flowId === "onboarding-service" || flowId === "onboarding-schedule" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || (flowId === "onboarding-org" && action === "new-org")) {
|
||||
if (flowId === "onboarding-service" || flowId === "onboarding-schedule" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId === "open-reservation-periods" || (flowId === "onboarding-org" && action === "new-org")) {
|
||||
setCurrentStepIndex(0);
|
||||
}
|
||||
}, [flowId, SessionInfo.metrics.organizationsCount, SessionInfo.userId, router]);
|
||||
@@ -4556,6 +4607,253 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
);
|
||||
};
|
||||
|
||||
const handleExtendReservationPeriods = async () => {
|
||||
if (!createdCompanyId || !reservationPeriodScope || !reservationPeriodDuration || isCreating) return;
|
||||
if (reservationPeriodEmployeesLoading || reservationPeriodEmployeesError || allEmployees.length === 0) return;
|
||||
if (reservationPeriodScope === "employee" && !targetEmployeeId) return;
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await API.post<ReservationPeriodsSummary>("schedules-enabled/reservation-periods/extend", {
|
||||
companyId: createdCompanyId,
|
||||
scope: reservationPeriodScope,
|
||||
employeeId: reservationPeriodScope === "employee" ? targetEmployeeId : undefined,
|
||||
duration: reservationPeriodDuration,
|
||||
sessionUser: SessionInfo.userId
|
||||
});
|
||||
setIsSuccess(true);
|
||||
} catch (error: any) {
|
||||
console.error("Error extendiendo período de reservas:", error);
|
||||
alert(error?.message || "Hubo un error al extender el período de reservas.");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderReservationPeriodFlow = () => {
|
||||
if (reservationPeriodEmployeesLoading) {
|
||||
return (
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 0}
|
||||
title="Cargando colaboradores"
|
||||
description="Estamos buscando los colaboradores disponibles para extender el período de reservas."
|
||||
disableNext
|
||||
nextLabel="Cargando..."
|
||||
>
|
||||
<div />
|
||||
</QuestionCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (reservationPeriodEmployeesError || allEmployees.length === 0) {
|
||||
return (
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 0}
|
||||
title="No pudimos preparar la extensión"
|
||||
description={reservationPeriodEmployeesError || "No encontramos colaboradores activos para extender el período de reservas."}
|
||||
disableNext
|
||||
nextLabel="Extensión bloqueada"
|
||||
>
|
||||
<div />
|
||||
</QuestionCard>
|
||||
);
|
||||
}
|
||||
|
||||
const needsEmployeeSelection = allEmployees.length > 1 && reservationPeriodScope === "employee";
|
||||
const selectedEmployee = allEmployees.find((employee: any) => String(employee.id || employee._id || employee.employeeId) === String(targetEmployeeId));
|
||||
const reservationPeriodEmployees = reservationPeriodSummary?.employees || [];
|
||||
const getReservationPeriodEmployeeSummary = (employee: any) => {
|
||||
const employeeKey = employee.id || employee._id || employee.employeeId;
|
||||
return reservationPeriodEmployees.find(summary => String(summary.employeeId) === String(employeeKey));
|
||||
};
|
||||
const getReservationPeriodStatus = (employee: any) => {
|
||||
const summary = getReservationPeriodEmployeeSummary(employee);
|
||||
const daysRemaining = summary?.daysRemaining;
|
||||
const safeDaysRemaining = typeof daysRemaining === "number" ? Math.max(daysRemaining, 0) : 0;
|
||||
const progress = Math.min(Math.round((safeDaysRemaining / 30) * 100), 100);
|
||||
const color = !summary?.isOpen || safeDaysRemaining === 0
|
||||
? "#ef4444"
|
||||
: safeDaysRemaining <= 7
|
||||
? "#f59e0b"
|
||||
: "#22c55e";
|
||||
|
||||
let label = "Sin período abierto";
|
||||
if (summary?.isOpen && typeof daysRemaining === "number") {
|
||||
if (daysRemaining < 0) {
|
||||
label = "Vencido";
|
||||
} else if (daysRemaining === 1) {
|
||||
label = "1 día restante";
|
||||
} else {
|
||||
label = `${daysRemaining} días restantes`;
|
||||
}
|
||||
} else if (typeof daysRemaining === "number" && daysRemaining < 0) {
|
||||
label = "Vencido";
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
color,
|
||||
progress,
|
||||
openUntilLabel: summary?.currentOpenUntil ? `Abierto hasta ${dayjs(summary.currentOpenUntil).format("D [de] MMMM")}` : null
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{allEmployees.length > 1 && (
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 0}
|
||||
title="Elegí a quién extenderle el período"
|
||||
description="Podés abrir reservas para todos los colaboradores o para uno específico."
|
||||
onNext={handleNext}
|
||||
disableNext={!reservationPeriodScope || (needsEmployeeSelection && !targetEmployeeId)}
|
||||
nextLabel="Siguiente"
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px", width: "100%" }}>
|
||||
<div
|
||||
onClick={() => {
|
||||
setReservationPeriodScope("all");
|
||||
setTargetEmployeeId(null);
|
||||
}}
|
||||
style={{
|
||||
padding: "18px", borderRadius: "12px", cursor: "pointer",
|
||||
background: reservationPeriodScope === "all" ? "var(--wine-red)" : "rgba(255,255,255,0.05)",
|
||||
border: `2px solid ${reservationPeriodScope === "all" ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`,
|
||||
color: "white", textAlign: "center", transition: "all 0.2s ease"
|
||||
}}
|
||||
>
|
||||
<h4 style={{ margin: "0 0 5px 0", fontSize: "16px", fontWeight: reservationPeriodScope === "all" ? "bold" : "normal" }}>Todos los colaboradores</h4>
|
||||
<p style={{ margin: 0, fontSize: "13px", color: "rgba(255,255,255,0.75)" }}>Extiende el período de reservas para todo el equipo.</p>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setReservationPeriodScope("employee")}
|
||||
style={{
|
||||
padding: "18px", borderRadius: "12px", cursor: "pointer",
|
||||
background: reservationPeriodScope === "employee" ? "var(--wine-red)" : "rgba(255,255,255,0.05)",
|
||||
border: `2px solid ${reservationPeriodScope === "employee" ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`,
|
||||
color: "white", textAlign: "center", transition: "all 0.2s ease"
|
||||
}}
|
||||
>
|
||||
<h4 style={{ margin: "0 0 5px 0", fontSize: "16px", fontWeight: reservationPeriodScope === "employee" ? "bold" : "normal" }}>Un colaborador específico</h4>
|
||||
<p style={{ margin: 0, fontSize: "13px", color: "rgba(255,255,255,0.75)" }}>Elige una persona del equipo.</p>
|
||||
</div>
|
||||
{needsEmployeeSelection && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px", marginTop: "8px" }}>
|
||||
{allEmployees.map((employee: any) => {
|
||||
const employeeKey = employee.id || employee._id || employee.employeeId;
|
||||
const isSelected = String(targetEmployeeId) === String(employeeKey);
|
||||
const periodStatus = getReservationPeriodStatus(employee);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={employeeKey}
|
||||
onClick={() => setTargetEmployeeId(employeeKey)}
|
||||
style={{
|
||||
padding: "12px 14px", borderRadius: "10px", cursor: "pointer",
|
||||
background: isSelected ? "rgba(255,42,127,0.18)" : "rgba(255,255,255,0.04)",
|
||||
border: `1px solid ${isSelected ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`,
|
||||
color: "white"
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: "12px", alignItems: "flex-start" }}>
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
{employee.fullName || `${employee.firstName || ""} ${employee.lastName || ""}`.trim() || employee.email || "Colaborador"}
|
||||
</span>
|
||||
<span style={{ color: periodStatus.color, fontSize: "12px", fontWeight: 700, whiteSpace: "nowrap" }}>
|
||||
{periodStatus.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: "7px", borderRadius: "999px", background: "rgba(255,255,255,0.12)", overflow: "hidden" }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${periodStatus.progress}%`,
|
||||
minWidth: periodStatus.progress > 0 ? "8px" : 0,
|
||||
height: "100%",
|
||||
borderRadius: "999px",
|
||||
background: periodStatus.color,
|
||||
transition: "width 0.2s ease"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{periodStatus.openUntilLabel && (
|
||||
<span style={{ color: "rgba(255,255,255,0.62)", fontSize: "12px" }}>
|
||||
{periodStatus.openUntilLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</QuestionCard>
|
||||
)}
|
||||
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === (allEmployees.length > 1 ? 1 : 0)}
|
||||
title="Elegí cuánto extender"
|
||||
description="Abriremos un nuevo bloque continuo desde el último día actualmente disponible."
|
||||
onNext={handleNext}
|
||||
disableNext={!reservationPeriodDuration}
|
||||
nextLabel="Siguiente"
|
||||
>
|
||||
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap", width: "100%", justifyContent: "center" }}>
|
||||
{reservationPeriodOptions.map(option => {
|
||||
const isSelected = reservationPeriodDuration?.unit === option.unit && reservationPeriodDuration.value === option.value;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${option.unit}-${option.value}`}
|
||||
onClick={() => setReservationPeriodDuration({ unit: option.unit, value: option.value })}
|
||||
style={{
|
||||
padding: "12px 24px", borderRadius: "12px", cursor: "pointer",
|
||||
background: isSelected ? "var(--wine-red)" : "rgba(255,255,255,0.05)",
|
||||
border: `2px solid ${isSelected ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`,
|
||||
color: "white", fontWeight: isSelected ? "bold" : "normal",
|
||||
transition: "all 0.2s ease", fontSize: "16px"
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</QuestionCard>
|
||||
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === (allEmployees.length > 1 ? 2 : 1)}
|
||||
title="Confirmar extensión"
|
||||
description="Revisá la selección antes de abrir el nuevo período de reservas."
|
||||
onNext={handleExtendReservationPeriods}
|
||||
disableNext={isCreating || reservationPeriodEmployeesLoading || Boolean(reservationPeriodEmployeesError) || allEmployees.length === 0 || !reservationPeriodScope || !reservationPeriodDuration || (reservationPeriodScope === "employee" && !targetEmployeeId)}
|
||||
nextLabel={isCreating ? "Extendiendo..." : "Extender período"}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px", width: "100%", maxWidth: "500px", margin: "0 auto", background: "rgba(255,255,255,0.05)", borderRadius: "12px", padding: "18px", border: "1px solid rgba(255,255,255,0.1)", color: "white" }}>
|
||||
<div>
|
||||
<div style={{ color: "rgba(255,255,255,0.65)", fontSize: "13px" }}>Organización</div>
|
||||
<div style={{ fontWeight: 700 }}>{selectedOrganization?.name || orgName}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: "rgba(255,255,255,0.65)", fontSize: "13px" }}>Aplicar a</div>
|
||||
<div style={{ fontWeight: 700 }}>{reservationPeriodScope === "all" ? "Todos los colaboradores" : (selectedEmployee?.fullName || selectedEmployee?.email || "Colaborador seleccionado")}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ color: "rgba(255,255,255,0.65)", fontSize: "13px" }}>Duración</div>
|
||||
<div style={{ fontWeight: 700 }}>{reservationPeriodOptions.find(option => option.unit === reservationPeriodDuration?.unit && option.value === reservationPeriodDuration.value)?.label}</div>
|
||||
</div>
|
||||
{reservationPeriodSummary?.worstDaysRemaining !== null && reservationPeriodSummary?.worstDaysRemaining !== undefined && (
|
||||
<div style={{ color: "rgba(255,255,255,0.7)", fontSize: "13px" }}>
|
||||
Menor disponibilidad actual: {reservationPeriodSummary.worstDaysRemaining} días restantes.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</QuestionCard>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFlow = () => {
|
||||
if (allOrganizations.length > 1 && !createdCompanyId && action !== "new-org") {
|
||||
return (
|
||||
@@ -4598,6 +4896,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
if (flowId === "notifications") return renderNotificationsFlow();
|
||||
if (flowId === "whatsapp-bot") return renderWhatsAppBotFlow();
|
||||
if (flowId === "disable-schedule") return renderDisableScheduleFlow();
|
||||
if (flowId === "open-reservation-periods") return renderReservationPeriodFlow();
|
||||
if (flowId === "override-schedule") return renderOverrideScheduleFlow();
|
||||
if (flowId === "update-schedule") return renderUpdateScheduleFlow();
|
||||
if (isAddingAnotherService || flowId === "onboarding-service") return renderOnboardingServiceFlow();
|
||||
@@ -4617,6 +4916,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
if (flowId === "setup-collaborator") return "El colaborador ha sido configurado exitosamente y ya puede comenzar a recibir reservas.";
|
||||
if (flowId === "notifications") return "La configuración de notificaciones se ha guardado exitosamente.";
|
||||
if (flowId === "whatsapp-bot") return "Tu bot de WhatsApp está configurado y funcionando. Tus clientes recibirán notificaciones automáticas por WhatsApp.";
|
||||
if (flowId === "open-reservation-periods") return "El período de reservas fue extendido exitosamente.";
|
||||
if (action) return "Tus cambios se han guardado exitosamente.";
|
||||
return "Has configurado exitosamente tu negocio. Todo está listo para que empieces a recibir reservas y gestionar tu agenda como un profesional.";
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user