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:
2026-08-22 12:34:09 -03:00
parent b6399d28fc
commit 33743f12b9
25 changed files with 2145 additions and 287 deletions
@@ -0,0 +1,45 @@
import { ScheduleItem } from "../Schedules/Schedules.Interface";
import { ISchedulesDisabled } from "../SchedulesDisabled/SchedulesDisabled.Interface";
import { ISchedulesOverrides } from "../SchedulesOverrides/SchedulesOverrides.Interface";
export type CollaboratorScheduleDetailsParams = {
companyId: string;
employeeId: string;
sessionUser: string;
fromDate?: Date;
};
export type CollaboratorScheduleDetailsCollaborator = {
employeeId: string;
fullName: string;
email: string;
avatar: string;
userId: string;
};
export type CollaboratorWeeklySchedule = {
companyId: string;
employeeId: string;
weekDay: number;
scheduleId: string;
schedules: ScheduleItem[];
};
export type CollaboratorScheduleServiceSummary = {
id: string;
name: string;
};
export type CollaboratorScheduleDetailsView = {
collaborator: CollaboratorScheduleDetailsCollaborator;
servicesById: Record<string, CollaboratorScheduleServiceSummary>;
weeklySchedules: CollaboratorWeeklySchedule[];
futureDisabledSchedules: ISchedulesDisabled[];
futureOverrides: ISchedulesOverrides[];
};
export interface IAdminAssistantManager {
getCollaboratorScheduleDetails(
data: CollaboratorScheduleDetailsParams
): Promise<CollaboratorScheduleDetailsView>;
}
@@ -0,0 +1,98 @@
import dayjs from "dayjs";
import EmployeesList from "../Employees/Employee";
import ServicesList from "../Services/Service";
import SchedulesList from "../Schedules/Schedules";
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
import SchedulesOverridesList from "../SchedulesOverrides/SchedulesOverrides";
import UsersManager from "../Users/Users";
import getAvatar from "../../helpers/getAvatar";
import { joinStrings } from "../../helpers/String";
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
import {
CollaboratorScheduleDetailsParams,
CollaboratorScheduleDetailsView,
IAdminAssistantManager,
} from "./AdminAssistant.Interface";
class AdminAssistantManager implements IAdminAssistantManager {
public async getCollaboratorScheduleDetails(
data: CollaboratorScheduleDetailsParams
): Promise<CollaboratorScheduleDetailsView> {
await validateSessionUser({ sessionUser: data.sessionUser });
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
const employee = await EmployeesList.employees.findOne({
_id: data.employeeId,
companyId: data.companyId,
});
if (!employee) {
throw new Error("El colaborador no existe o no pertenece a la organización.");
}
const user = await UsersManager.users.findOne({ _id: String(employee.userId) });
const snapshot = employee.profileSnapshot || {};
const fullName = user
? joinStrings([user.firstName, user.lastName], " ")
: joinStrings([snapshot.firstName, snapshot.lastName], " ");
const email = user?.email || snapshot.email || "";
const avatar = user
? getAvatar(String(user.id), user.avatar, fullName)
: getAvatar(String(employee.userId), snapshot.avatar || "", fullName);
const fromDate = data.fromDate ? dayjs(data.fromDate).startOf("day").toDate() : dayjs().startOf("day").toDate();
const weeklySchedules = await SchedulesList.find({
companyId: data.companyId,
employeeId: data.employeeId,
});
const futureDisabledSchedules = await SchedulesDisabledList.find({
companyId: data.companyId,
employeeId: data.employeeId,
endDate: { $gte: fromDate } as any,
sessionUser: data.sessionUser,
});
const futureOverrides = await SchedulesOverridesList.find({
companyId: data.companyId,
employeeId: data.employeeId,
date: { $gte: fromDate } as any,
});
const companyServices = await ServicesList.findByCompanyId({ companyId: data.companyId });
const servicesById = companyServices.reduce((index, service) => {
index[service.id] = {
id: service.id,
name: service.name,
};
return index;
}, {} as CollaboratorScheduleDetailsView["servicesById"]);
return {
collaborator: {
employeeId: String(employee.id || employee._id),
fullName: fullName || email || "Colaborador",
email,
avatar,
userId: String(employee.userId),
},
servicesById,
weeklySchedules: weeklySchedules
.sort((a, b) => a.weekDay - b.weekDay)
.map((schedule) => ({
companyId: String(schedule.companyId),
employeeId: String(schedule.employeeId),
scheduleId: String(schedule.id || (schedule as any)._id || ""),
weekDay: schedule.weekDay,
schedules: schedule.schedules || [],
})),
futureDisabledSchedules: futureDisabledSchedules.sort((a, b) =>
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
),
futureOverrides: futureOverrides.sort((a, b) =>
new Date(a.date).getTime() - new Date(b.date).getTime()
),
};
}
}
const AdminAssistantList = new AdminAssistantManager();
export default AdminAssistantList;
@@ -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>;
}
+169
View File
@@ -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;
@@ -32,6 +32,12 @@ export class SchedulesDisabledAdapterMongoose implements ISchedulesDisabledAdapt
await this.schedulesDisabledList.deleteOne({ _id: id }).exec();
}
public async find(
filters: Omit<FindSchedulesDisabledParams, "sessionUser">
): Promise<ISchedulesDisabledDocument[]> {
return this.schedulesDisabledList.find(filters).exec();
}
public async findOne(
filters: Omit<FindSchedulesDisabledParams, "sessionUser">
): Promise<ISchedulesDisabledDocument | null> {
@@ -90,6 +90,7 @@ export interface SchedulesDisabledByCollaboratorView {
export interface ISchedulesDisabledAdapter {
create(data: CreateSchedulesDisabledParams): Promise<ISchedulesDisabled>;
delete(id: string): Promise<void>;
find(filters: Omit<FindSchedulesDisabledParams, "sessionUser">): Promise<ISchedulesDisabled[]>;
findOne(filters: FindSchedulesDisabledParams): Promise<ISchedulesDisabled | null>;
}
@@ -98,6 +99,7 @@ export interface ISchedulesDisabledManager {
createSchedulesDisabled(data: CreateSchedulesDisabledParams): Promise<ISchedulesDisabled>;
disableSchedule(data: DisableScheduleParams): Promise<void>;
enableSchedule(data: DisableScheduleParams): Promise<void>;
find(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled[]>;
findOne(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled | null>;
findSchedulesDisabledByCollaborator(
data: FindSchedulesDisabledParams
@@ -271,6 +271,17 @@ class SchedulesDisabledManager implements ISchedulesDisabledManager {
return await this.schedulesDisabled.findOne(data);
}
public async find(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled[]> {
await validateSessionUser({ sessionUser: data.sessionUser });
await validatePermissionsByCompany({
sessionUser: data.sessionUser,
companyId: data.companyId,
});
const { sessionUser, ...filters } = data;
return await this.schedulesDisabled.find(filters);
}
public async delete(data: DeleteSchedulesDisabledParams): Promise<void> {
await validateSessionUser({ sessionUser: data.sessionUser });
@@ -0,0 +1,29 @@
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
import { ApiValidationError } from "../../Models/Server.Error.model";
import {
CollaboratorScheduleDetailsParams,
CollaboratorScheduleDetailsView,
} from "../../Models/AdminAssistant/AdminAssistant.Interface";
import { authenticateMiddleware } from "../../middleware/authentication";
import { AdminAssistantService } from "./AdminAssistant.Service";
@Route("admin-assistant/collaborator-schedule-details")
@Middlewares(authenticateMiddleware)
export class GetCollaboratorScheduleDetailsController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async getCollaboratorScheduleDetails(
@Body() requestBody: CollaboratorScheduleDetailsParams
): Promise<CollaboratorScheduleDetailsView | ApiValidationError> {
try {
const details = await new AdminAssistantService().getCollaboratorScheduleDetails(requestBody);
this.setStatus(200);
return details;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -0,0 +1,16 @@
import { connect } from "mongoose";
import AdminAssistantList from "../../Models/AdminAssistant/AdminAssistant";
import {
CollaboratorScheduleDetailsParams,
CollaboratorScheduleDetailsView,
} from "../../Models/AdminAssistant/AdminAssistant.Interface";
export class AdminAssistantService {
public async getCollaboratorScheduleDetails(
data: CollaboratorScheduleDetailsParams
): Promise<CollaboratorScheduleDetailsView> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await AdminAssistantList.getCollaboratorScheduleDetails(data);
}
}
@@ -10,6 +10,8 @@ import {
PaginateSchedulesResults,
FindSchedulesParams,
CollaboratorSchedulesView,
ScheduleApplyParams,
ScheduleApplyResult,
} from "../../Models/Schedules/Schedules.Interface";
import { authenticateMiddleware } from "../../middleware/authentication";
import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
@@ -36,6 +38,7 @@ export class CreateScheduleController extends Controller {
}
@Route("schedules/get-all-by-collaborator")
@Middlewares(authenticateMiddleware)
export class GetAllSchedulsByCollaboratoreController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "El empleado fue creado con exito")
@@ -81,6 +84,27 @@ export class UpdateScheduleController extends Controller {
}
}
@Route("schedules/apply")
@Middlewares(authenticateMiddleware)
export class ApplyScheduleController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async applyScheduleChanges(
@Body() requestBody: ScheduleApplyParams
): Promise<ScheduleApplyResult | ApiValidationError> {
try {
const result = await new SchedulesService().applyScheduleChanges(requestBody);
this.setStatus(200);
return result;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@Route("schedules/delete")
@Middlewares(authenticateMiddleware)
export class DeleteScheduleController extends Controller {
@@ -9,6 +9,8 @@ import {
PaginateSchedulesResults,
CollaboratorSchedulesView,
FindSchedulesParams,
ScheduleApplyParams,
ScheduleApplyResult,
} from "../../Models/Schedules/Schedules.Interface";
export class SchedulesService {
@@ -26,6 +28,12 @@ export class SchedulesService {
await ScheduleList.updateSchedule(data);
}
public async applyScheduleChanges(data: ScheduleApplyParams): Promise<ScheduleApplyResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await ScheduleList.applyScheduleChanges(data);
}
public async deleteSchedule(data: DeleteScheduleParams): Promise<void> {
await connect(`${process.env.DATABASE_CONNECTION}`);
await ScheduleList.delete(data);