feat: add CollaboratorPicker component and integrate into CollaboratorScheduleSummaryFlow
- 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.
This commit is contained in:
@@ -8,6 +8,34 @@ export type ScheduleItem = {
|
||||
serviceIds?: string[];
|
||||
};
|
||||
|
||||
export type ScheduleConflictStrategy = "reject" | "overwrite-conflicts";
|
||||
|
||||
export type ScheduleConflictDetail = {
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
weekDay: number;
|
||||
weekDayLabel: string;
|
||||
attempted: ScheduleItem;
|
||||
existing: ScheduleItem;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ScheduleApplyParams = {
|
||||
companyId: string;
|
||||
employeeIds: string[];
|
||||
weekDays: number[];
|
||||
schedules: ScheduleItem[];
|
||||
sessionUser: string;
|
||||
conflictStrategy: ScheduleConflictStrategy;
|
||||
};
|
||||
|
||||
export type ScheduleApplyResult = {
|
||||
valid: boolean;
|
||||
applied: boolean;
|
||||
conflicts: ScheduleConflictDetail[];
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type FindSchedulesParams = {
|
||||
_id?: string;
|
||||
companyId?: string;
|
||||
@@ -100,6 +128,7 @@ export interface ISchedulesManager {
|
||||
findAllSchedulesByCollaborator(data: FindSchedulesParams): Promise<CollaboratorSchedulesView>;
|
||||
delete(data: DeleteScheduleParams): Promise<void>;
|
||||
isAvailable(data: AvailableSchedulesParams): Promise<boolean>;
|
||||
applyScheduleChanges(data: ScheduleApplyParams): Promise<ScheduleApplyResult>;
|
||||
deleteSchedulesByCompany(data: DeleteScheulesByCompanyParams): Promise<void>;
|
||||
deleteSchedulesByEmployee(data: DeleteSchedulesByEmployeeParams): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
DeleteScheulesByCompanyParams,
|
||||
DeleteSchedulesByEmployeeParams,
|
||||
ScheduleItem,
|
||||
ScheduleApplyParams,
|
||||
ScheduleApplyResult,
|
||||
ScheduleConflictDetail,
|
||||
} from "./Schedules.Interface";
|
||||
import { EmployeeRoles } from "../Employees/Employees.Interface";
|
||||
import UsersManager from "../Users/Users";
|
||||
@@ -24,6 +27,9 @@ 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;
|
||||
@@ -173,6 +179,169 @@ class SchedulesManager implements ISchedulesManager {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user