From ece3879c8d92880197c770c8a8df13414b5d5d1b Mon Sep 17 00:00:00 2001 From: Horacio Daniel Ros Date: Tue, 28 Jul 2026 17:10:20 -0300 Subject: [PATCH] feat: implement backend and frontend support for managing reservation period extensions and summaries --- server/src/Models/Companies/Companies.ts | 6 +- .../SchedulesEnabled.Interface.ts | 45 +++ .../SchedulesEnabled/SchedulesEnabled.ts | 147 +++++++- .../SchedulesEnabled/Schedules.Controller.ts | 45 +++ .../SchedulesEnabled.Service.ts | 17 + txclient/src/Models/SchedulesEnabled.type.ts | 43 +++ .../org/profile/[id]/Org.Service.tsx | 15 + .../AssistantHome/AssistantHome.tsx | 72 +++- .../components/StepEngine/StepEngine.tsx | 320 +++++++++++++++++- 9 files changed, 697 insertions(+), 13 deletions(-) diff --git a/server/src/Models/Companies/Companies.ts b/server/src/Models/Companies/Companies.ts index 8308c7b..f852b0b 100644 --- a/server/src/Models/Companies/Companies.ts +++ b/server/src/Models/Companies/Companies.ts @@ -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(company.id, ""), diff --git a/server/src/Models/SchedulesEnabled/SchedulesEnabled.Interface.ts b/server/src/Models/SchedulesEnabled/SchedulesEnabled.Interface.ts index 0e80411..609bf18 100644 --- a/server/src/Models/SchedulesEnabled/SchedulesEnabled.Interface.ts +++ b/server/src/Models/SchedulesEnabled/SchedulesEnabled.Interface.ts @@ -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; deleteSchedulesEnabledByCompany(data: DeleteSchedulesEnabledByCompanyParams): Promise; deleteSchedulesEnabledByEmployee(data: DeleteSchedulesEnabledByEmployeeParams): Promise; + summarizeReservationPeriods(data: ReservationPeriodsSummaryParams): Promise; + extendReservationPeriods(data: ExtendReservationPeriodsParams): Promise; } diff --git a/server/src/Models/SchedulesEnabled/SchedulesEnabled.ts b/server/src/Models/SchedulesEnabled/SchedulesEnabled.ts index d4be844..d9b0b01 100644 --- a/server/src/Models/SchedulesEnabled/SchedulesEnabled.ts +++ b/server/src/Models/SchedulesEnabled/SchedulesEnabled.ts @@ -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 { + await validateSessionUser({ sessionUser: data.sessionUser }); + await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser }); + + return this.buildReservationPeriodsSummary(data.companyId); + } + + public async extendReservationPeriods( + data: ExtendReservationPeriodsParams + ): Promise { + 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 { + 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(); diff --git a/server/src/api/SchedulesEnabled/Schedules.Controller.ts b/server/src/api/SchedulesEnabled/Schedules.Controller.ts index c9ed76b..e122ddc 100644 --- a/server/src/api/SchedulesEnabled/Schedules.Controller.ts +++ b/server/src/api/SchedulesEnabled/Schedules.Controller.ts @@ -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(500, "Ha ocurrido un error") + @SuccessResponse(200, "Done") + @Post() + public async summarizeReservationPeriods( + @Body() requestBody: ReservationPeriodsSummaryParams + ): Promise { + 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(500, "Ha ocurrido un error") + @SuccessResponse(200, "Done") + @Post() + public async extendReservationPeriods( + @Body() requestBody: ExtendReservationPeriodsParams + ): Promise { + 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); + } + } +} diff --git a/server/src/api/SchedulesEnabled/SchedulesEnabled.Service.ts b/server/src/api/SchedulesEnabled/SchedulesEnabled.Service.ts index be85683..6f26dfd 100644 --- a/server/src/api/SchedulesEnabled/SchedulesEnabled.Service.ts +++ b/server/src/api/SchedulesEnabled/SchedulesEnabled.Service.ts @@ -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 { + await connect(`${process.env.DATABASE_CONNECTION}`); + return SchedulesEnabledList.summarizeReservationPeriods(data); + } + + public async extendReservationPeriods( + data: ExtendReservationPeriodsParams + ): Promise { + await connect(`${process.env.DATABASE_CONNECTION}`); + return SchedulesEnabledList.extendReservationPeriods(data); + } } diff --git a/txclient/src/Models/SchedulesEnabled.type.ts b/txclient/src/Models/SchedulesEnabled.type.ts index d5d8d68..ec6405c 100644 --- a/txclient/src/Models/SchedulesEnabled.type.ts +++ b/txclient/src/Models/SchedulesEnabled.type.ts @@ -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; diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/Org.Service.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/Org.Service.tsx index 836c40a..ae99501 100644 --- a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/Org.Service.tsx +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/Org.Service.tsx @@ -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("schedules-enabled/disable", data); }; +export const summarizeReservationPeriods = async ( + data: ReservationPeriodsSummaryParams +): Promise => { + return ApiRequest.post("schedules-enabled/reservation-periods/summary", data); +}; + +export const extendReservationPeriods = async ( + data: ExtendReservationPeriodsParams +): Promise => { + return ApiRequest.post("schedules-enabled/reservation-periods/extend", data); +}; + export const uploadHeaderImage = async (data: FormData): Promise => { return ApiRequest.postFile("companies/upload-header", data); }; diff --git a/txclient/src/app/admin/assistant/components/AssistantHome/AssistantHome.tsx b/txclient/src/app/admin/assistant/components/AssistantHome/AssistantHome.tsx index 65c5051..cdf4bb0 100644 --- a/txclient/src/app/admin/assistant/components/AssistantHome/AssistantHome.tsx +++ b/txclient/src/app/admin/assistant/components/AssistantHome/AssistantHome.tsx @@ -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([]); const [incompleteCollaborators, setIncompleteCollaborators] = React.useState([]); + const [reservationPeriodAlerts, setReservationPeriodAlerts] = React.useState>([]); const [isLoadingFlow, setIsLoadingFlow] = React.useState(true); const [searchQuery, setSearchQuery] = React.useState(""); @@ -174,9 +185,25 @@ export default function AssistantHome() { const response = await API.post("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("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() { /> ))} + {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 ( +
+
+
+ +
+
+

{reservationPeriodCopy.title}

+

{reservationPeriodCopy.description}

+
+
+ { + if (!isLoadingFlow) { + handleStartFlow("open-reservation-periods", orgId); + } + }} + style={{ width: "100%", marginTop: "20px", height: "48px" }} + /> +
+ ); + })} )} diff --git a/txclient/src/app/admin/assistant/components/StepEngine/StepEngine.tsx b/txclient/src/app/admin/assistant/components/StepEngine/StepEngine.tsx index cef602b..a55aa5f 100644 --- a/txclient/src/app/admin/assistant/components/StepEngine/StepEngine.tsx +++ b/txclient/src/app/admin/assistant/components/StepEngine/StepEngine.tsx @@ -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(true); const [disableStartTime, setDisableStartTime] = useState(""); const [disableEndTime, setDisableEndTime] = useState(""); + const [reservationPeriodScope, setReservationPeriodScope] = useState<"employee" | "all" | null>(null); + const reservationPeriodOptions: Array = [ + { 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({ unit: "month", value: 1 }); + const [reservationPeriodSummary, setReservationPeriodSummary] = useState(null); + const [reservationPeriodEmployeesLoading, setReservationPeriodEmployeesLoading] = useState(false); + const [reservationPeriodEmployeesError, setReservationPeriodEmployeesError] = useState(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("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("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("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("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 ( + +
+ + ); + } + + if (reservationPeriodEmployeesError || allEmployees.length === 0) { + return ( + +
+ + ); + } + + 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 && ( + +
+
{ + 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" + }} + > +

Todos los colaboradores

+

Extiende el período de reservas para todo el equipo.

+
+
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" + }} + > +

Un colaborador específico

+

Elige una persona del equipo.

+
+ {needsEmployeeSelection && ( +
+ {allEmployees.map((employee: any) => { + const employeeKey = employee.id || employee._id || employee.employeeId; + const isSelected = String(targetEmployeeId) === String(employeeKey); + const periodStatus = getReservationPeriodStatus(employee); + + return ( +
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" + }} + > +
+
+ + {employee.fullName || `${employee.firstName || ""} ${employee.lastName || ""}`.trim() || employee.email || "Colaborador"} + + + {periodStatus.label} + +
+
+
0 ? "8px" : 0, + height: "100%", + borderRadius: "999px", + background: periodStatus.color, + transition: "width 0.2s ease" + }} + /> +
+ {periodStatus.openUntilLabel && ( + + {periodStatus.openUntilLabel} + + )} +
+
+ ); + })} +
+ )} +
+ + )} + + 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" + > +
+ {reservationPeriodOptions.map(option => { + const isSelected = reservationPeriodDuration?.unit === option.unit && reservationPeriodDuration.value === option.value; + + return ( +
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} +
+ ); + })} +
+
+ + 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"} + > +
+
+
Organización
+
{selectedOrganization?.name || orgName}
+
+
+
Aplicar a
+
{reservationPeriodScope === "all" ? "Todos los colaboradores" : (selectedEmployee?.fullName || selectedEmployee?.email || "Colaborador seleccionado")}
+
+
+
Duración
+
{reservationPeriodOptions.find(option => option.unit === reservationPeriodDuration?.unit && option.value === reservationPeriodDuration.value)?.label}
+
+ {reservationPeriodSummary?.worstDaysRemaining !== null && reservationPeriodSummary?.worstDaysRemaining !== undefined && ( +
+ Menor disponibilidad actual: {reservationPeriodSummary.worstDaysRemaining} días restantes. +
+ )} +
+
+ + ); + }; + 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."; };