feat: implement backend and frontend support for managing reservation period extensions and summaries

This commit is contained in:
2026-07-28 17:10:20 -03:00
parent 68d98e8b40
commit ece3879c8d
9 changed files with 697 additions and 13 deletions
+5 -1
View File
@@ -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);
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(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);
}
}).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.";
};