33743f12b9
- Implemented CollaboratorPicker for selecting employees with support for single and multiple selections. - Created CollaboratorScheduleSummaryFlow to display collaborator schedules and exceptions. - Added ServicePublicationFlow for managing service visibility on public pages. - Introduced CSS styles for ServicePrivate component to enhance UI.
536 lines
19 KiB
TypeScript
536 lines
19 KiB
TypeScript
import { SchedulesAdapterMongoose } from "./Schedules.Adapter.Mongoose";
|
|
import {
|
|
CreateScheduleParams,
|
|
UpdateScheduleParams,
|
|
DeleteScheduleParams,
|
|
FindSchedulesParams,
|
|
ISchedule,
|
|
ISchedulesManager,
|
|
PaginateSchedulesParams,
|
|
PaginateSchedulesResults,
|
|
CollaboratorSchedulesView,
|
|
AvailableSchedulesParams,
|
|
DeleteScheulesByCompanyParams,
|
|
DeleteSchedulesByEmployeeParams,
|
|
ScheduleItem,
|
|
ScheduleApplyParams,
|
|
ScheduleApplyResult,
|
|
ScheduleConflictDetail,
|
|
} from "./Schedules.Interface";
|
|
import { EmployeeRoles } from "../Employees/Employees.Interface";
|
|
import UsersManager from "../Users/Users";
|
|
import CompaniesManager from "../Companies/Companies";
|
|
import EmployeesList from "../Employees/Employee";
|
|
import { NoPermissionMessage } from "../../helpers/NoPermissionMessage";
|
|
import { joinStrings } from "../../helpers/String";
|
|
import { isNull } from "../../helpers/IsNull";
|
|
import getAvatar from "../../helpers/getAvatar";
|
|
import { NotificationsManager } from "../Notifications/Notifications";
|
|
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
|
|
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
|
|
|
|
const WEEK_DAY_LABELS = ["Domingo", "Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado"];
|
|
|
|
class SchedulesManager implements ISchedulesManager {
|
|
schedules: SchedulesAdapterMongoose;
|
|
|
|
constructor() {
|
|
this.schedules = new SchedulesAdapterMongoose();
|
|
}
|
|
|
|
public async deleteSchedulesByEmployee(data: DeleteSchedulesByEmployeeParams): Promise<void> {
|
|
await this.schedules.schedulesList.deleteMany({ employeeId: data.employeeId });
|
|
}
|
|
|
|
public async deleteSchedulesByCompany(data: DeleteScheulesByCompanyParams): Promise<void> {
|
|
await this.schedules.schedulesList.deleteMany({ companyId: data.companyId });
|
|
}
|
|
|
|
public async createSchedule(data: CreateScheduleParams): Promise<ISchedule> {
|
|
const sessionUser = await UsersManager.users.findOne({ _id: data.sessionUser });
|
|
|
|
if (!sessionUser) {
|
|
throw new Error(
|
|
"No se ha encontrado el usuario o no tiene permisos para realizar esta acción"
|
|
);
|
|
}
|
|
|
|
const companyCheck = await CompaniesManager.companies.findOne({ _id: data.companyId });
|
|
|
|
if (!companyCheck) {
|
|
throw new Error("La compañia no existe");
|
|
}
|
|
|
|
if (
|
|
!(await EmployeesList.checkPermission(
|
|
companyCheck,
|
|
data.sessionUser,
|
|
EmployeeRoles.ADMIN
|
|
))
|
|
) {
|
|
throw new Error(NoPermissionMessage());
|
|
}
|
|
|
|
const employeeCheck = await EmployeesList.employees.findOne({
|
|
companyId: data.companyId,
|
|
_id: data.employeeId,
|
|
});
|
|
|
|
if (!employeeCheck) {
|
|
throw new Error("El empleado no existe o no pertenece a la compañia.");
|
|
}
|
|
|
|
const ScheduleCheck = await this.schedules.findOne({
|
|
employeeId: data.employeeId,
|
|
companyId: data.companyId,
|
|
weekDay: data.weekDay,
|
|
});
|
|
|
|
if (ScheduleCheck) {
|
|
throw new Error("El servicio ya fue asignado para este empleado");
|
|
}
|
|
|
|
const newEmplyeeService = await this.schedules.create(data);
|
|
|
|
const strHorarios = data.schedules.map((horario) => {
|
|
return `De ${horario.from} a ${horario.to}`;
|
|
});
|
|
|
|
await NotificationsManager.sendSystemNotification({
|
|
userId: String(employeeCheck.userId),
|
|
subject: "Se han agregado horarios de trabajo",
|
|
message: `Se han registrado nuevos horarios en la organización ${companyCheck.name
|
|
}. Horarios: ${strHorarios.join(", ")}`,
|
|
type: NotificationType.SCHEDULE,
|
|
code: String(employeeCheck._id)
|
|
});
|
|
|
|
return newEmplyeeService;
|
|
}
|
|
|
|
public async updateSchedule(data: UpdateScheduleParams): Promise<void> {
|
|
const sessionUser = await UsersManager.users.findOne({ _id: data.sessionUser });
|
|
|
|
if (!sessionUser) {
|
|
throw new Error(
|
|
"No se ha encontrado el usuario o no tiene permisos para realizar esta acción"
|
|
);
|
|
}
|
|
|
|
const companyCheck = await CompaniesManager.companies.findOne({ _id: data.companyId });
|
|
|
|
if (!companyCheck) {
|
|
throw new Error("La compañia no existe");
|
|
}
|
|
|
|
const employeeCheck = await EmployeesList.employees.findOne({
|
|
companyId: data.companyId,
|
|
_id: data.employeeId,
|
|
});
|
|
|
|
if (!employeeCheck) {
|
|
throw new Error("El empleado no existe o no pertenece a la compañia.");
|
|
}
|
|
|
|
if (
|
|
!(await EmployeesList.checkPermission(
|
|
companyCheck,
|
|
data.sessionUser,
|
|
EmployeeRoles.ADMIN
|
|
))
|
|
) {
|
|
throw new Error(NoPermissionMessage());
|
|
}
|
|
|
|
const scheduleUpd = await this.schedules.findOne({
|
|
employeeId: data.employeeId,
|
|
companyId: data.companyId,
|
|
weekDay: data.weekDay,
|
|
});
|
|
|
|
if (!scheduleUpd) {
|
|
this.createSchedule(data);
|
|
return;
|
|
}
|
|
|
|
scheduleUpd.schedules = data.schedules;
|
|
|
|
await scheduleUpd.save();
|
|
|
|
const strHorarios = data.schedules.map((horario) => {
|
|
return `De ${horario.from} a ${horario.to}`;
|
|
});
|
|
|
|
await NotificationsManager.sendSystemNotification({
|
|
userId: String(employeeCheck.userId),
|
|
subject: "Se han agregado horarios de trabajo",
|
|
message: `Se han registrado nuevos horarios en la organización ${companyCheck.name
|
|
}. Horarios: ${strHorarios.join(", ")}`,
|
|
type: NotificationType.SCHEDULE,
|
|
code: String(employeeCheck._id)
|
|
});
|
|
}
|
|
|
|
public async paginate(filters: PaginateSchedulesParams): Promise<PaginateSchedulesResults> {
|
|
return await this.schedules.paginate(filters);
|
|
}
|
|
|
|
public async find(filters: FindSchedulesParams): Promise<ISchedule[]> {
|
|
return await this.schedules.find(filters);
|
|
}
|
|
|
|
private parseTimeToMinutes(time: string): number {
|
|
const [hours, minutes] = time.split(":").map((part) => Number(part));
|
|
|
|
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) {
|
|
throw new Error("El horario indicado no es válido");
|
|
}
|
|
|
|
return hours * 60 + minutes;
|
|
}
|
|
|
|
private hasTimeOverlap(first: ScheduleItem, second: ScheduleItem): boolean {
|
|
return this.parseTimeToMinutes(first.from) < this.parseTimeToMinutes(second.to)
|
|
&& this.parseTimeToMinutes(second.from) < this.parseTimeToMinutes(first.to);
|
|
}
|
|
|
|
private getServiceScope(schedule: ScheduleItem): "all" | "specific" {
|
|
return schedule.serviceScope || "all";
|
|
}
|
|
|
|
private hasServiceOverlap(first: ScheduleItem, second: ScheduleItem): boolean {
|
|
const firstScope = this.getServiceScope(first);
|
|
const secondScope = this.getServiceScope(second);
|
|
|
|
if (firstScope === "all" || secondScope === "all") {
|
|
return true;
|
|
}
|
|
|
|
const secondServices = new Set(second.serviceIds || []);
|
|
return (first.serviceIds || []).some((serviceId) => secondServices.has(serviceId));
|
|
}
|
|
|
|
private hasScheduleConflict(first: ScheduleItem, second: ScheduleItem): boolean {
|
|
if (first.disabled || second.disabled) {
|
|
return false;
|
|
}
|
|
|
|
return this.hasTimeOverlap(first, second) && this.hasServiceOverlap(first, second);
|
|
}
|
|
|
|
private formatSchedule(schedule: ScheduleItem): string {
|
|
if (this.getServiceScope(schedule) === "all") {
|
|
return `${schedule.from} a ${schedule.to} para todos los servicios`;
|
|
}
|
|
|
|
return `${schedule.from} a ${schedule.to} para servicios específicos`;
|
|
}
|
|
|
|
private getEmployeeName(employee: any): string {
|
|
const snapshotName = [employee.profileSnapshot?.firstName, employee.profileSnapshot?.lastName].filter(Boolean).join(" ").trim();
|
|
return snapshotName || employee.fullName || employee.name || String(employee._id);
|
|
}
|
|
|
|
private buildConflict(employee: any, weekDay: number, attempted: ScheduleItem, existing: ScheduleItem): ScheduleConflictDetail {
|
|
const employeeId = String(employee._id);
|
|
const employeeName = this.getEmployeeName(employee);
|
|
const weekDayLabel = WEEK_DAY_LABELS[weekDay] || String(weekDay);
|
|
|
|
return {
|
|
employeeId,
|
|
employeeName,
|
|
weekDay,
|
|
weekDayLabel,
|
|
attempted,
|
|
existing,
|
|
message: `${employeeName} ya tiene un horario el ${weekDayLabel} de ${this.formatSchedule(existing)} que se superpone con ${this.formatSchedule(attempted)}.`,
|
|
};
|
|
}
|
|
|
|
private async findScheduleDocument(companyId: string, employeeId: string, weekDay: number) {
|
|
return this.schedules.findOne({ companyId, employeeId, weekDay });
|
|
}
|
|
|
|
public async applyScheduleChanges(data: ScheduleApplyParams): Promise<ScheduleApplyResult> {
|
|
await validateSessionUser({ sessionUser: data.sessionUser });
|
|
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
|
|
|
|
if (!Array.isArray(data.employeeIds) || data.employeeIds.length === 0) {
|
|
throw new Error("Tenés que seleccionar al menos un colaborador.");
|
|
}
|
|
|
|
if (!Array.isArray(data.weekDays) || data.weekDays.length === 0 || data.weekDays.some((day) => !Number.isInteger(day) || day < 0 || day > 6)) {
|
|
throw new Error("Tenés que seleccionar días válidos entre 0 y 6.");
|
|
}
|
|
|
|
if (data.conflictStrategy !== "reject" && data.conflictStrategy !== "overwrite-conflicts") {
|
|
throw new Error("La estrategia de conflictos no es válida.");
|
|
}
|
|
|
|
const employees = await Promise.all(data.employeeIds.map(async (employeeId) => {
|
|
const employee = await EmployeesList.employees.findOne({ companyId: data.companyId, _id: employeeId });
|
|
|
|
if (!employee) {
|
|
throw new Error("El empleado no existe o no pertenece a la compañia.");
|
|
}
|
|
|
|
return employee;
|
|
}));
|
|
|
|
const conflicts: ScheduleConflictDetail[] = [];
|
|
|
|
for (const employee of employees) {
|
|
const employeeId = String(employee._id);
|
|
|
|
for (const weekDay of data.weekDays) {
|
|
const scheduleDocument = await this.findScheduleDocument(data.companyId, employeeId, weekDay);
|
|
const existingSchedules = scheduleDocument?.schedules || [];
|
|
|
|
for (const attempted of data.schedules) {
|
|
const existing = existingSchedules.find((schedule) => this.hasScheduleConflict(attempted, schedule));
|
|
|
|
if (existing) {
|
|
conflicts.push(this.buildConflict(employee, weekDay, attempted, existing));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (conflicts.length > 0 && data.conflictStrategy === "reject") {
|
|
return {
|
|
valid: false,
|
|
applied: false,
|
|
conflicts,
|
|
message: "Encontramos horarios superpuestos. Revisalos antes de guardar o elegí sobrescribir solo esos conflictos.",
|
|
};
|
|
}
|
|
|
|
for (const employee of employees) {
|
|
const employeeId = String(employee._id);
|
|
|
|
for (const weekDay of data.weekDays) {
|
|
const scheduleDocument = await this.findScheduleDocument(data.companyId, employeeId, weekDay);
|
|
const existingSchedules = scheduleDocument?.schedules || [];
|
|
const nextSchedules = data.conflictStrategy === "overwrite-conflicts"
|
|
? existingSchedules.filter((existing) => !data.schedules.some((attempted) => this.hasScheduleConflict(attempted, existing)))
|
|
: existingSchedules;
|
|
|
|
const schedules = [...nextSchedules, ...data.schedules];
|
|
|
|
if (scheduleDocument) {
|
|
scheduleDocument.schedules = schedules;
|
|
await scheduleDocument.save();
|
|
} else {
|
|
await this.schedules.create({
|
|
companyId: data.companyId,
|
|
employeeId,
|
|
weekDay,
|
|
schedules,
|
|
sessionUser: data.sessionUser,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
valid: conflicts.length === 0,
|
|
applied: true,
|
|
conflicts,
|
|
message: conflicts.length > 0
|
|
? "Se sobrescribieron solo los horarios en conflicto."
|
|
: "Los horarios se guardaron correctamente.",
|
|
};
|
|
}
|
|
|
|
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,
|
|
companyId: data.companyId,
|
|
weekDay: data.weekDay,
|
|
});
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private checkScheduleTo(schedule: ScheduleItem, to: string): boolean {
|
|
const toSchedule = schedule.to.split(":");
|
|
|
|
const toHoras = parseInt(toSchedule[0]);
|
|
const toMinutos = parseInt(toSchedule[1]);
|
|
const toTime = toHoras + toMinutos / 60;
|
|
|
|
const dataTo = to.split(":");
|
|
const dataToHoras = parseInt(dataTo[0]);
|
|
const dataToMinutos = parseInt(dataTo[1]);
|
|
const dataToTime = dataToHoras + dataToMinutos / 60;
|
|
|
|
// Si este rango cubre el from to del potencial turno retorna true.
|
|
if (dataToTime <= toTime) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public async isAvailable(data: AvailableSchedulesParams): Promise<boolean> {
|
|
// Buscar un rango horario que coincida con el from.
|
|
const scheduleFrom = await this.getScheduleByFrom(data);
|
|
|
|
if (scheduleFrom) {
|
|
// Si este rango cubre el from to del potencial turno retorna true.
|
|
if (this.checkScheduleTo(scheduleFrom, data.to)) {
|
|
return true;
|
|
}
|
|
|
|
// En caso de que no lo cubra, lo que hacemos es chequear si en ese rango que comienza con el from
|
|
// es posible convinarlo con otro rango chequeando que exista un rango que comience donde termina el primero.
|
|
// Entonces podemos crear un nuevo rango que convine los dos encontrados para formar uno mas grande.
|
|
const scheduleTo = await this.getScheduleByFrom({
|
|
employeeId: data.employeeId,
|
|
companyId: data.companyId,
|
|
weekDay: data.weekDay,
|
|
from: scheduleFrom.to,
|
|
to: data.to,
|
|
serviceId: data.serviceId,
|
|
});
|
|
|
|
if (!scheduleTo) {
|
|
return false;
|
|
}
|
|
|
|
if (this.checkScheduleTo(scheduleTo, data.to)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public async findAllSchedulesByCollaborator(
|
|
filters: FindSchedulesParams
|
|
): Promise<CollaboratorSchedulesView> {
|
|
const employeeCheck = await EmployeesList.employees.findOne({
|
|
_id: filters.employeeId,
|
|
companyId: filters.companyId,
|
|
});
|
|
|
|
if (!employeeCheck) {
|
|
throw new Error("El colaborador no existe o no pertenece a la compañia.");
|
|
}
|
|
|
|
const userCheck = await UsersManager.users.findOne({
|
|
_id: employeeCheck.userId,
|
|
});
|
|
|
|
if (!userCheck) {
|
|
throw new Error("El colaborador no existe");
|
|
}
|
|
|
|
const schedule = await SchedulesList.schedules.findOne({
|
|
companyId: filters.companyId,
|
|
employeeId: filters.employeeId,
|
|
weekDay: filters.weekDay,
|
|
});
|
|
|
|
const fullName = joinStrings([userCheck.firstName, userCheck.lastName], " ");
|
|
|
|
return {
|
|
companyId: isNull<string>(filters.companyId, ""),
|
|
employeeId: isNull<string>(filters.employeeId, ""),
|
|
weekDay: isNull<number>(filters.weekDay, 1),
|
|
scheduleId: schedule ? schedule.id : "",
|
|
schedules: schedule ? schedule.schedules : [],
|
|
fullName: fullName,
|
|
avatar: getAvatar(userCheck.id, userCheck.avatar, fullName),
|
|
};
|
|
}
|
|
|
|
public async delete(data: DeleteScheduleParams): Promise<void> {
|
|
const sessionUser = await UsersManager.users.findOne({ _id: data.sessionUser });
|
|
|
|
if (!sessionUser) {
|
|
throw new Error(
|
|
"No se ha encontrado el usuario o no tiene permisos para realizar esta acción"
|
|
);
|
|
}
|
|
|
|
const companyCheck = await CompaniesManager.companies.findOne({ _id: data.companyId });
|
|
|
|
if (!companyCheck) {
|
|
throw new Error("La compañia no existe");
|
|
}
|
|
|
|
if (
|
|
!(await EmployeesList.checkPermission(
|
|
companyCheck,
|
|
data.sessionUser,
|
|
EmployeeRoles.ADMIN
|
|
))
|
|
) {
|
|
throw new Error(NoPermissionMessage());
|
|
}
|
|
|
|
const deleteItem = await this.schedules.findOne({
|
|
_id: data.id,
|
|
});
|
|
|
|
if (!deleteItem) {
|
|
throw new Error("Ha ocurrido un error al borrar el horario");
|
|
}
|
|
|
|
const emplyeeId = deleteItem.employeeId;
|
|
|
|
const employeeCheck = await EmployeesList.employees.findOne({
|
|
companyId: data.companyId,
|
|
_id: emplyeeId,
|
|
});
|
|
if (!employeeCheck) {
|
|
throw new Error("El empleado no existe o no pertenece a la compañia.");
|
|
}
|
|
|
|
const strHorarios = deleteItem.schedules.map((horario) => {
|
|
return `De ${horario.from} a ${horario.to}`;
|
|
});
|
|
|
|
await this.schedules.delete(String(deleteItem._id));
|
|
|
|
await NotificationsManager.sendSystemNotification({
|
|
userId: String(employeeCheck.userId),
|
|
subject: "Se han agregado horarios de trabajo",
|
|
message: `Se han registrado nuevos horarios en la organización ${companyCheck.name
|
|
}. Horarios: ${strHorarios.join(", ")}`,
|
|
type: NotificationType.SCHEDULE,
|
|
code: String(employeeCheck._id)
|
|
});
|
|
}
|
|
}
|
|
|
|
const SchedulesList = new SchedulesManager();
|
|
|
|
export default SchedulesList;
|