feat: introduce service-based availability filtering for collaborator schedules and overrides

This commit is contained in:
2026-07-19 23:36:18 -03:00
parent f017f7e9aa
commit b01149a19e
10 changed files with 493 additions and 31 deletions
+37 -3
View File
@@ -70,7 +70,7 @@ import { DiscountType } from "../Discounts/Discounts.Interface";
import Discounts from "../Discounts/Discounts";
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
import Templates from "../Templates/Templates";
import { CollaboratorSchedulesView } from "../Schedules/Schedules.Interface";
import { CollaboratorSchedulesView, ScheduleItem } from "../Schedules/Schedules.Interface";
import ClientAccount from "../ClientAccounts/ClientAccount";
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
import DiscountsEmail from "../DiscountsEmail/DiscountsEmail";
@@ -89,6 +89,14 @@ class AppointmentManager implements IAppointmentsManager {
this.Appointments = new AppointmentsAdapterMongoose();
}
private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean {
if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") {
return true;
}
return schedule.serviceScope === "specific" && (schedule.serviceIds || []).includes(serviceId);
}
public async setPaymentStatus(data: SetPaymentStatusParams): Promise<void> {
const sessionUser = await UsersManager.users.findOne({
_id: data.sessionUser,
@@ -935,6 +943,7 @@ class AppointmentManager implements IAppointmentsManager {
dateDay: dayjs(data.start).startOf("day").toDate(),
from: checkFrom,
to: checkTo,
serviceId: data.serviceId,
});
} else {
//chequear que el colaborador este disponible.
@@ -944,6 +953,7 @@ class AppointmentManager implements IAppointmentsManager {
weekDay: weekDay,
from: checkFrom,
to: checkTo,
serviceId: data.serviceId,
});
}
@@ -2289,6 +2299,10 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
continue;
}
if (!this.isScheduleAvailableForService(schedule, data.serviceId)) {
continue;
}
const scheduleDataFrom = schedule.from.split(":");
const scheduleDataTo = schedule.to.split(":");
const hourFrom = parseInt(scheduleDataFrom[0]);
@@ -2761,7 +2775,17 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
date: currentDate.toDate(),
});
if (hasOverride && hasOverride.schedules.length > 0) {
if (
hasOverride &&
hasOverride.schedules.some(
(schedule) =>
!schedule.disabled &&
(!schedule.serviceScope ||
schedule.serviceScope === "all" ||
(schedule.serviceScope === "specific" &&
(schedule.serviceIds || []).includes(data.serviceId)))
)
) {
returnAvailableDates.push(currentDate.toDate());
} else if (!hasOverride) {
// Check if the current date is within any enabled schedule range
@@ -2784,7 +2808,17 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
weekDay: currentDate.day(),
});
if (schedule && schedule.schedules.length > 0) {
if (
schedule &&
schedule.schedules.some(
(scheduleItem) =>
!scheduleItem.disabled &&
(!scheduleItem.serviceScope ||
scheduleItem.serviceScope === "all" ||
(scheduleItem.serviceScope === "specific" &&
(scheduleItem.serviceIds || []).includes(data.serviceId)))
)
) {
returnAvailableDates.push(currentDate.toDate());
}
}
@@ -4,6 +4,8 @@ export type ScheduleItem = {
from: string;
to: string;
disabled: boolean;
serviceScope?: "all" | "specific";
serviceIds?: string[];
};
export type FindSchedulesParams = {
@@ -19,6 +21,7 @@ export type AvailableSchedulesParams = {
weekDay: number;
from: string;
to: string;
serviceId?: string;
};
export type PaginateSchedulesParams = FindSchedulesParams & {
+12
View File
@@ -173,6 +173,14 @@ class SchedulesManager implements ISchedulesManager {
return await this.schedules.find(filters);
}
private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean {
if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") {
return true;
}
return schedule.serviceScope === "specific" && (schedule.serviceIds || []).includes(serviceId);
}
private async getScheduleByFrom(data: AvailableSchedulesParams): Promise<ScheduleItem | null> {
const schedule = await this.schedules.findOne({
employeeId: data.employeeId,
@@ -185,6 +193,9 @@ class SchedulesManager implements ISchedulesManager {
if (horario.disabled) {
continue;
}
if (!this.isScheduleAvailableForService(horario, data.serviceId)) {
continue;
}
if (horario.from == data.from) {
return horario;
}
@@ -236,6 +247,7 @@ class SchedulesManager implements ISchedulesManager {
weekDay: data.weekDay,
from: scheduleFrom.to,
to: data.to,
serviceId: data.serviceId,
});
if (!scheduleTo) {
@@ -14,6 +14,7 @@ export type AvailableSchedulesOverridesParams = {
dateDay: Date;
from: string;
to: string;
serviceId?: string;
};
export type PaginateSchedulesOverridesParams = FindSchedulesOverridesParams & {
@@ -154,6 +154,14 @@ class SchedulesOverridesManager implements ISchedulesOverridesManager {
return await this.schedulesOverrides.findOne(filters);
}
private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean {
if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") {
return true;
}
return schedule.serviceScope === "specific" && (schedule.serviceIds || []).includes(serviceId);
}
private async getScheduleByFrom(data: AvailableSchedulesOverridesParams): Promise<ScheduleItem | null> {
const exactDate = dayjs(data.dateDay).startOf("day").toDate();
const schedule = await this.schedulesOverrides.findOne({
@@ -165,6 +173,7 @@ class SchedulesOverridesManager implements ISchedulesOverridesManager {
if (schedule) {
for (const horario of schedule.schedules) {
if (horario.disabled) continue;
if (!this.isScheduleAvailableForService(horario, data.serviceId)) continue;
if (horario.from == data.from) return horario;
if (horario.from < data.from && horario.to > data.from) return horario;
}
@@ -140,6 +140,8 @@ export type ScheduleItem = {
from: string;
to: string;
disabled: boolean;
serviceScope?: "all" | "specific";
serviceIds?: string[];
};
export type FindSchedulesParams = {
@@ -10,8 +10,8 @@ import { useAlert } from "@store/Alert.Store";
import { useConfirmStore } from "@store/Confirm.Store";
import { useEffect, useState } from "react";
import MaterialAlert from "@mui/material/Alert";
import { ScheduleItem } from "@core/Models/Collaborators.model";
import { createSchedulesOverrides } from "../../../Collaborators.Service";
import { CollaboratorServiceItems, ScheduleItem } from "@core/Models/Collaborators.model";
import { createSchedulesOverrides, findCollaboratorsServices } from "../../../Collaborators.Service";
import { Grid2 } from "@mui/material";
import TextTime from "@core/app/components/TextTime/TextTime";
import TextDate from "@core/app/components/TextDate/TextDate";
@@ -51,11 +51,24 @@ export default function OverridesCreate() {
const [from, setFrom] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(8).minute(0));
const [to, setTo] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(12).minute(0));
const [schedules, setSchedules] = useState<Array<SchedulesDateItem>>([]);
const [services, setServices] = useState<CollaboratorServiceItems[]>([]);
useEffect(() => {
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
}, []);
useEffect(() => {
findCollaboratorsServices({
companyId: id,
employeeId: cid,
sessionUser: SessionInfo.userId,
})
.then((collaboratorServices) => {
setServices(collaboratorServices?.services.filter((service) => service.active) || []);
})
.catch((error) => alert.showError(error.format()));
}, [id, cid, SessionInfo.userId]);
useEffect(() => {
location.clear();
@@ -109,6 +122,8 @@ export default function OverridesCreate() {
from: schedule.from.format("HH:mm"),
to: schedule.to.format("HH:mm"),
disabled: schedule.data.disabled || false,
serviceScope: schedule.data.serviceScope || "all",
serviceIds: schedule.data.serviceScope === "specific" ? schedule.data.serviceIds || [] : [],
};
});
};
@@ -121,6 +136,8 @@ export default function OverridesCreate() {
from: from.format("HH:mm"),
to: to.format("HH:mm"),
disabled: false,
serviceScope: "all",
serviceIds: [],
},
};
@@ -174,6 +191,30 @@ export default function OverridesCreate() {
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
};
const serviceScopeChangeHandler = (
from: string,
serviceScope: "all" | "specific",
serviceIds: string[]
) => {
setSchedules(
schedules.map((schedule) => {
if (schedule.data.from == from) {
return {
...schedule,
data: {
...schedule.data,
serviceScope,
serviceIds,
},
};
}
return schedule;
})
);
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
};
return (
<>
<Location />
@@ -225,7 +266,11 @@ export default function OverridesCreate() {
from={schedule.data.from}
to={schedule.data.to}
disabled={schedule.data.disabled}
serviceScope={schedule.data.serviceScope}
serviceIds={schedule.data.serviceIds}
serviceOptions={services}
onCheckChange={checkChangeHandler}
onServiceScopeChange={serviceScopeChangeHandler}
onDelete={deleteScheduleHandler}
/>
))}
@@ -10,8 +10,9 @@ import { useAlert } from "@store/Alert.Store";
import { useConfirmStore } from "@store/Confirm.Store";
import { useEffect, useState } from "react";
import MaterialAlert from "@mui/material/Alert";
import { CollaboratorSchedulesView, ScheduleItem } from "@core/Models/Collaborators.model";
import { CollaboratorSchedulesView, CollaboratorServiceItems, ScheduleItem } from "@core/Models/Collaborators.model";
import {
findCollaboratorsServices,
findCollaboratorsSchedules,
updateCollaboratorsSchedules,
} from "../../Collaborators.Service";
@@ -51,6 +52,7 @@ export default function CollaboratorSchedules() {
const [from, setFrom] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(8).minute(0));
const [to, setTo] = useState<dayjs.Dayjs>(dayjs(new Date()).hour(12).minute(0));
const [schedules, setSchedules] = useState<Array<SchedulesDateItem>>([]);
const [services, setServices] = useState<CollaboratorServiceItems[]>([]);
useEffect(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
@@ -86,6 +88,18 @@ export default function CollaboratorSchedules() {
loadCollaboratorSchedules();
}, [weekDay]);
useEffect(() => {
findCollaboratorsServices({
companyId: id,
employeeId: cid,
sessionUser: SessionInfo.userId,
})
.then((collaboratorServices) => {
setServices(collaboratorServices?.services.filter((service) => service.active) || []);
})
.catch((error) => alert.showError(error.format()));
}, [id, cid, SessionInfo.userId]);
useEffect(() => {
if (eventHandler.eventType === EVENT_TYPES.SAVE) {
updateCollaboratorsSchedules({
@@ -158,6 +172,8 @@ export default function CollaboratorSchedules() {
from: schedule.from.format("HH:mm"),
to: schedule.to.format("HH:mm"),
disabled: schedule.data.disabled || false,
serviceScope: schedule.data.serviceScope || "all",
serviceIds: schedule.data.serviceScope === "specific" ? schedule.data.serviceIds || [] : [],
};
});
};
@@ -170,6 +186,8 @@ export default function CollaboratorSchedules() {
from: from.format("HH:mm"),
to: to.format("HH:mm"),
disabled: false,
serviceScope: "all",
serviceIds: [],
},
};
@@ -248,6 +266,30 @@ export default function CollaboratorSchedules() {
};
const serviceScopeChangeHandler = (
from: string,
serviceScope: "all" | "specific",
serviceIds: string[]
) => {
setSchedules(
schedules.map((schedule) => {
if (schedule.data.from == from) {
return {
...schedule,
data: {
...schedule.data,
serviceScope,
serviceIds,
},
};
}
return schedule;
})
);
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
};
return (
@@ -291,7 +333,11 @@ export default function CollaboratorSchedules() {
from={schedule.data.from}
to={schedule.data.to}
disabled={schedule.data.disabled}
serviceScope={schedule.data.serviceScope}
serviceIds={schedule.data.serviceIds}
serviceOptions={services}
onCheckChange={checkChangeHandler}
onServiceScopeChange={serviceScopeChangeHandler}
onDelete={deleteScheduleHandler}
/>
))}
@@ -1,5 +1,205 @@
.scheduleItemRow {
color: var(--black);
border-bottom: solid 1px var(--white-dark);
height: 45px;
min-height: 64px;
display: grid;
grid-template-columns: 58px 58px minmax(0, 1fr) 88px;
align-items: center;
gap: 8px;
padding: 8px 0;
}
.timeCell {
display: flex;
align-items: center;
justify-content: center;
}
.servicesCell {
display: flex;
align-items: center;
min-width: 0;
gap: 6px;
}
.actionsCell {
display: flex;
align-items: center;
justify-content: flex-end;
}
.serviceTags {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
flex: 1;
padding: 4px 0;
}
.serviceTags :global(.MuiChip-root) {
max-width: 100%;
height: 26px;
}
.customizeButton {
color: var(--primary) !important;
background: color-mix(in srgb, var(--primary) 10%, white) !important;
}
.servicePanelBackdrop {
position: fixed;
inset: 0;
z-index: 1300;
display: flex;
align-items: flex-end;
justify-content: center;
padding: 16px;
background: rgba(0, 0, 0, 0.36);
}
.servicePanel {
width: min(100%, 440px);
max-height: min(84vh, 680px);
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: 28px;
padding: 10px 16px 16px;
background: #fff;
box-shadow: 0 -12px 36px rgba(0, 0, 0, 0.24);
}
.servicePanelHandle {
width: 48px;
height: 5px;
margin: 0 auto 14px;
border-radius: 999px;
background: var(--white-dark);
}
.servicePanelHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.servicePanelHeader h3,
.servicePanelEyebrow {
margin: 0;
}
.servicePanelEyebrow {
color: var(--primary);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.scopeCard,
.servicePickerItem,
.servicePanelDone {
width: 100%;
border: 0;
font: inherit;
cursor: pointer;
}
.scopeCard {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-bottom: 10px;
padding: 14px;
border: 1px solid var(--white-dark);
border-radius: 18px;
color: var(--black);
text-align: left;
background: #fff;
}
.scopeCard small {
display: block;
margin-top: 4px;
color: var(--gray);
line-height: 1.3;
}
.scopeCardActive {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 9%, white);
}
.scopeCardActive svg,
.servicePickerItemActive svg {
color: var(--primary);
}
.servicePickerList {
display: grid;
gap: 8px;
margin: 14px 0;
overflow-y: auto;
max-height: min(42vh, 320px);
padding-right: 2px;
}
.servicePickerItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 48px;
padding: 12px 14px;
border: 1px solid var(--white-dark);
border-radius: 16px;
color: var(--black);
text-align: left;
background: #fff;
}
.servicePickerItemActive {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 8%, white);
font-weight: 700;
}
.servicePanelDone {
min-height: 48px;
margin-top: 8px;
border-radius: 16px;
color: white;
font-weight: 700;
background: var(--primary);
}
@media (min-width: 720px) {
.servicePanelBackdrop {
align-items: center;
}
.servicePanel {
border-radius: 24px;
}
}
@media (max-width: 520px) {
.scheduleItemRow {
grid-template-columns: 52px 52px minmax(0, 1fr) 82px;
gap: 6px;
}
.servicesCell {
align-items: flex-start;
}
.serviceTags {
max-height: 72px;
overflow-y: auto;
}
}
@@ -1,42 +1,155 @@
import IconButton from "@mui/material/IconButton";
import DeleteIcon from "@mui/icons-material/Delete";
import { Grid2, ThemeProvider } from "@mui/material";
import TuneIcon from "@mui/icons-material/Tune";
import CloseIcon from "@mui/icons-material/Close";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked";
import { Box, Chip, ThemeProvider } from "@mui/material";
import style from "./ScheduleItemRow.module.css";
import turnosXpressTheme from "@core/app/theme/turnosXpress";
import Switch from "@mui/material/Switch";
import { useState } from "react";
export type ScheduleServiceOption = {
id: string;
name: string;
};
export interface ScheduleItemRowProps {
from: string;
to: string;
disabled: boolean;
serviceScope?: "all" | "specific";
serviceIds?: string[];
serviceOptions?: ScheduleServiceOption[];
onDelete: (from: string) => void;
onCheckChange: (from: string, data: boolean) => void;
onServiceScopeChange?: (from: string, serviceScope: "all" | "specific", serviceIds: string[]) => void;
}
export default function ScheduleItemRow(props: ScheduleItemRowProps): React.ReactElement {
const [isServicesPanelOpen, setIsServicesPanelOpen] = useState(false);
const selectedServiceIds = props.serviceIds || [];
const serviceScope = props.serviceScope || "all";
const serviceOptions = props.serviceOptions || [];
const selectedServices = serviceOptions.filter((service) => selectedServiceIds.includes(service.id));
const handleServiceScopeChange = (nextScope: "all" | "specific") => {
props.onServiceScopeChange?.(props.from, nextScope, nextScope === "all" ? [] : selectedServiceIds);
};
const handleServiceToggle = (serviceId: string) => {
const nextServiceIds = selectedServiceIds.includes(serviceId)
? selectedServiceIds.filter((selectedServiceId) => selectedServiceId !== serviceId)
: [...selectedServiceIds, serviceId];
props.onServiceScopeChange?.(
props.from,
"specific",
nextServiceIds
);
};
return (
<>
<Grid2 className={style.scheduleItemRow} container>
<Grid2
size={3}
sx={{ display: "flex", justifyContent: "center", alignItems: "center" }}
>
<div className={style.scheduleItemRow}>
<div className={style.timeCell}>
{props.from}
</Grid2>
<Grid2 size={1}></Grid2>
<Grid2
size={3}
sx={{ display: "flex", justifyContent: "center", alignItems: "center" }}
>
</div>
<div className={style.timeCell}>
{props.to}
</Grid2>
</div>
<Grid2 size={1}></Grid2>
<Grid2
size={4}
sx={{ display: "flex", justifyContent: "center", alignItems: "center" }}
>
<div>
<div className={style.servicesCell}>
<Box className={style.serviceTags}>
{serviceScope === "all" ? (
<Chip label="Todos los servicios" size="small" />
) : selectedServices.length > 0 ? (
selectedServices.map((service) => (
<Chip key={service.id} label={service.name} size="small" />
))
) : (
<Chip label="Sin servicios" size="small" variant="outlined" />
)}
</Box>
<IconButton
aria-label="Configurar servicios"
size="small"
className={style.customizeButton}
onClick={() => setIsServicesPanelOpen(true)}
>
<TuneIcon fontSize="small" />
</IconButton>
{isServicesPanelOpen && (
<div className={style.servicePanelBackdrop} onClick={() => setIsServicesPanelOpen(false)}>
<section className={style.servicePanel} onClick={(event) => event.stopPropagation()}>
<div className={style.servicePanelHandle} />
<div className={style.servicePanelHeader}>
<div>
<p className={style.servicePanelEyebrow}>Disponibilidad</p>
<h3>Servicios del horario</h3>
</div>
<IconButton aria-label="Cerrar" onClick={() => setIsServicesPanelOpen(false)}>
<CloseIcon />
</IconButton>
</div>
<button
type="button"
className={`${style.scopeCard} ${serviceScope === "all" ? style.scopeCardActive : ""}`}
onClick={() => handleServiceScopeChange("all")}
>
<span>
<strong>Todos los servicios</strong>
<small>Este horario queda disponible para cualquier servicio asignado.</small>
</span>
{serviceScope === "all" ? <CheckCircleIcon /> : <RadioButtonUncheckedIcon />}
</button>
<button
type="button"
className={`${style.scopeCard} ${serviceScope === "specific" ? style.scopeCardActive : ""}`}
onClick={() => handleServiceScopeChange("specific")}
>
<span>
<strong>Servicios específicos</strong>
<small>Elegí exactamente para qué servicios aplica este horario.</small>
</span>
{serviceScope === "specific" ? <CheckCircleIcon /> : <RadioButtonUncheckedIcon />}
</button>
{serviceScope === "specific" && (
<div className={style.servicePickerList}>
{serviceOptions.map((service) => {
const isSelected = selectedServiceIds.includes(service.id);
return (
<button
key={service.id}
type="button"
className={`${style.servicePickerItem} ${isSelected ? style.servicePickerItemActive : ""}`}
onClick={() => handleServiceToggle(service.id)}
>
<span>{service.name}</span>
{isSelected ? <CheckCircleIcon /> : <RadioButtonUncheckedIcon />}
</button>
);
})}
</div>
)}
<button
type="button"
className={style.servicePanelDone}
onClick={() => setIsServicesPanelOpen(false)}
>
Listo
</button>
</section>
</div>
)}
</div>
<div className={style.actionsCell}>
<ThemeProvider theme={turnosXpressTheme}>
<Switch
checked={!props.disabled}
@@ -49,11 +162,8 @@ export default function ScheduleItemRow(props: ScheduleItemRowProps): React.Reac
<IconButton aria-label="delete" color="error">
<DeleteIcon onClick={() => props.onDelete(props.from)} />
</IconButton>
</div>
</Grid2>
</Grid2 >
</div>
</div>
</>
);
}