diff --git a/server/src/Models/Appointments/Appointments.ts b/server/src/Models/Appointments/Appointments.ts index d9119f3..3d9dce7 100644 --- a/server/src/Models/Appointments/Appointments.ts +++ b/server/src/Models/Appointments/Appointments.ts @@ -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 { 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 { 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 { 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 { 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()); } } diff --git a/server/src/Models/Schedules/Schedules.Interface.ts b/server/src/Models/Schedules/Schedules.Interface.ts index 5e4fce9..2ee5243 100644 --- a/server/src/Models/Schedules/Schedules.Interface.ts +++ b/server/src/Models/Schedules/Schedules.Interface.ts @@ -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 & { diff --git a/server/src/Models/Schedules/Schedules.ts b/server/src/Models/Schedules/Schedules.ts index 861848d..e0469b2 100644 --- a/server/src/Models/Schedules/Schedules.ts +++ b/server/src/Models/Schedules/Schedules.ts @@ -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 { 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) { diff --git a/server/src/Models/SchedulesOverrides/SchedulesOverrides.Interface.ts b/server/src/Models/SchedulesOverrides/SchedulesOverrides.Interface.ts index 2450303..ac95588 100644 --- a/server/src/Models/SchedulesOverrides/SchedulesOverrides.Interface.ts +++ b/server/src/Models/SchedulesOverrides/SchedulesOverrides.Interface.ts @@ -14,6 +14,7 @@ export type AvailableSchedulesOverridesParams = { dateDay: Date; from: string; to: string; + serviceId?: string; }; export type PaginateSchedulesOverridesParams = FindSchedulesOverridesParams & { diff --git a/server/src/Models/SchedulesOverrides/SchedulesOverrides.ts b/server/src/Models/SchedulesOverrides/SchedulesOverrides.ts index 8b34bf4..fa92eb8 100644 --- a/server/src/Models/SchedulesOverrides/SchedulesOverrides.ts +++ b/server/src/Models/SchedulesOverrides/SchedulesOverrides.ts @@ -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 { 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; } diff --git a/txclient/src/Models/Collaborators.model.ts b/txclient/src/Models/Collaborators.model.ts index f519653..a1d51b4 100644 --- a/txclient/src/Models/Collaborators.model.ts +++ b/txclient/src/Models/Collaborators.model.ts @@ -140,6 +140,8 @@ export type ScheduleItem = { from: string; to: string; disabled: boolean; + serviceScope?: "all" | "specific"; + serviceIds?: string[]; }; export type FindSchedulesParams = { diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/collaborators/[cid]/overrides/new/page.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/collaborators/[cid]/overrides/new/page.tsx index 305d65f..3c0bcf5 100644 --- a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/collaborators/[cid]/overrides/new/page.tsx +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/collaborators/[cid]/overrides/new/page.tsx @@ -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(new Date()).hour(8).minute(0)); const [to, setTo] = useState(dayjs(new Date()).hour(12).minute(0)); const [schedules, setSchedules] = useState>([]); + const [services, setServices] = useState([]); 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 ( <> @@ -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} /> ))} diff --git a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/collaborators/[cid]/schedules/page.tsx b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/collaborators/[cid]/schedules/page.tsx index 5d94bf2..61f1bc2 100644 --- a/txclient/src/app/admin/(organization-profile)/org/profile/[id]/collaborators/[cid]/schedules/page.tsx +++ b/txclient/src/app/admin/(organization-profile)/org/profile/[id]/collaborators/[cid]/schedules/page.tsx @@ -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(new Date()).hour(8).minute(0)); const [to, setTo] = useState(dayjs(new Date()).hour(12).minute(0)); const [schedules, setSchedules] = useState>([]); + const [services, setServices] = useState([]); 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} /> ))} diff --git a/txclient/src/app/components/ScheduleItemRow/ScheduleItemRow.module.css b/txclient/src/app/components/ScheduleItemRow/ScheduleItemRow.module.css index e8cc11f..062a99d 100644 --- a/txclient/src/app/components/ScheduleItemRow/ScheduleItemRow.module.css +++ b/txclient/src/app/components/ScheduleItemRow/ScheduleItemRow.module.css @@ -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; + } } diff --git a/txclient/src/app/components/ScheduleItemRow/ScheduleItemRow.tsx b/txclient/src/app/components/ScheduleItemRow/ScheduleItemRow.tsx index 78a6e60..7ff3bfa 100644 --- a/txclient/src/app/components/ScheduleItemRow/ScheduleItemRow.tsx +++ b/txclient/src/app/components/ScheduleItemRow/ScheduleItemRow.tsx @@ -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 ( <> - - +
+
{props.from} - - - +
+
{props.to} - +
- - -
+
+ + {serviceScope === "all" ? ( + + ) : selectedServices.length > 0 ? ( + selectedServices.map((service) => ( + + )) + ) : ( + + )} + + setIsServicesPanelOpen(true)} + > + + + {isServicesPanelOpen && ( +
setIsServicesPanelOpen(false)}> +
event.stopPropagation()}> +
+
+
+

Disponibilidad

+

Servicios del horario

+
+ setIsServicesPanelOpen(false)}> + + +
+ + + + + + {serviceScope === "specific" && ( +
+ {serviceOptions.map((service) => { + const isSelected = selectedServiceIds.includes(service.id); + + return ( + + ); + })} +
+ )} + + +
+
+ )} +
+
props.onDelete(props.from)} /> -
- - +
+
); } - -