diff --git a/server/src/Models/AdminAssistant/AdminAssistant.Interface.ts b/server/src/Models/AdminAssistant/AdminAssistant.Interface.ts new file mode 100644 index 0000000..558412b --- /dev/null +++ b/server/src/Models/AdminAssistant/AdminAssistant.Interface.ts @@ -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; + weeklySchedules: CollaboratorWeeklySchedule[]; + futureDisabledSchedules: ISchedulesDisabled[]; + futureOverrides: ISchedulesOverrides[]; +}; + +export interface IAdminAssistantManager { + getCollaboratorScheduleDetails( + data: CollaboratorScheduleDetailsParams + ): Promise; +} diff --git a/server/src/Models/AdminAssistant/AdminAssistant.ts b/server/src/Models/AdminAssistant/AdminAssistant.ts new file mode 100644 index 0000000..cd6018c --- /dev/null +++ b/server/src/Models/AdminAssistant/AdminAssistant.ts @@ -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 { + 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; diff --git a/server/src/Models/Schedules/Schedules.Interface.ts b/server/src/Models/Schedules/Schedules.Interface.ts index 2ee5243..f59e80c 100644 --- a/server/src/Models/Schedules/Schedules.Interface.ts +++ b/server/src/Models/Schedules/Schedules.Interface.ts @@ -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; delete(data: DeleteScheduleParams): Promise; isAvailable(data: AvailableSchedulesParams): Promise; + applyScheduleChanges(data: ScheduleApplyParams): Promise; deleteSchedulesByCompany(data: DeleteScheulesByCompanyParams): Promise; deleteSchedulesByEmployee(data: DeleteSchedulesByEmployeeParams): Promise; } diff --git a/server/src/Models/Schedules/Schedules.ts b/server/src/Models/Schedules/Schedules.ts index e0469b2..827cd13 100644 --- a/server/src/Models/Schedules/Schedules.ts +++ b/server/src/Models/Schedules/Schedules.ts @@ -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 { + 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; diff --git a/server/src/Models/SchedulesDisabled/SchedulesDisabled.Adapter.Mongoose.ts b/server/src/Models/SchedulesDisabled/SchedulesDisabled.Adapter.Mongoose.ts index 9045aa9..1e0a13d 100644 --- a/server/src/Models/SchedulesDisabled/SchedulesDisabled.Adapter.Mongoose.ts +++ b/server/src/Models/SchedulesDisabled/SchedulesDisabled.Adapter.Mongoose.ts @@ -32,6 +32,12 @@ export class SchedulesDisabledAdapterMongoose implements ISchedulesDisabledAdapt await this.schedulesDisabledList.deleteOne({ _id: id }).exec(); } + public async find( + filters: Omit + ): Promise { + return this.schedulesDisabledList.find(filters).exec(); + } + public async findOne( filters: Omit ): Promise { diff --git a/server/src/Models/SchedulesDisabled/SchedulesDisabled.Interface.ts b/server/src/Models/SchedulesDisabled/SchedulesDisabled.Interface.ts index 6cc0f4c..cc49e60 100644 --- a/server/src/Models/SchedulesDisabled/SchedulesDisabled.Interface.ts +++ b/server/src/Models/SchedulesDisabled/SchedulesDisabled.Interface.ts @@ -90,6 +90,7 @@ export interface SchedulesDisabledByCollaboratorView { export interface ISchedulesDisabledAdapter { create(data: CreateSchedulesDisabledParams): Promise; delete(id: string): Promise; + find(filters: Omit): Promise; findOne(filters: FindSchedulesDisabledParams): Promise; } @@ -98,6 +99,7 @@ export interface ISchedulesDisabledManager { createSchedulesDisabled(data: CreateSchedulesDisabledParams): Promise; disableSchedule(data: DisableScheduleParams): Promise; enableSchedule(data: DisableScheduleParams): Promise; + find(data: FindSchedulesDisabledParams): Promise; findOne(data: FindSchedulesDisabledParams): Promise; findSchedulesDisabledByCollaborator( data: FindSchedulesDisabledParams diff --git a/server/src/Models/SchedulesDisabled/SchedulesDisabled.ts b/server/src/Models/SchedulesDisabled/SchedulesDisabled.ts index 744721f..bd188da 100644 --- a/server/src/Models/SchedulesDisabled/SchedulesDisabled.ts +++ b/server/src/Models/SchedulesDisabled/SchedulesDisabled.ts @@ -271,6 +271,17 @@ class SchedulesDisabledManager implements ISchedulesDisabledManager { return await this.schedulesDisabled.findOne(data); } + public async find(data: FindSchedulesDisabledParams): Promise { + 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 { await validateSessionUser({ sessionUser: data.sessionUser }); diff --git a/server/src/api/AdminAssistant/AdminAssistant.Controller.ts b/server/src/api/AdminAssistant/AdminAssistant.Controller.ts new file mode 100644 index 0000000..1f6eb06 --- /dev/null +++ b/server/src/api/AdminAssistant/AdminAssistant.Controller.ts @@ -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(500, "Ha ocurrido un error") + @SuccessResponse(200, "Done") + @Post() + public async getCollaboratorScheduleDetails( + @Body() requestBody: CollaboratorScheduleDetailsParams + ): Promise { + 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); + } + } +} diff --git a/server/src/api/AdminAssistant/AdminAssistant.Service.ts b/server/src/api/AdminAssistant/AdminAssistant.Service.ts new file mode 100644 index 0000000..238cc76 --- /dev/null +++ b/server/src/api/AdminAssistant/AdminAssistant.Service.ts @@ -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 { + await connect(`${process.env.DATABASE_CONNECTION}`); + + return await AdminAssistantList.getCollaboratorScheduleDetails(data); + } +} diff --git a/server/src/api/Schedules/Schedules.Controller.ts b/server/src/api/Schedules/Schedules.Controller.ts index d5d8be2..1bde90f 100644 --- a/server/src/api/Schedules/Schedules.Controller.ts +++ b/server/src/api/Schedules/Schedules.Controller.ts @@ -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(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(500, "Ha ocurrido un error") + @SuccessResponse(200, "Done") + @Post() + public async applyScheduleChanges( + @Body() requestBody: ScheduleApplyParams + ): Promise { + 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 { diff --git a/server/src/api/Schedules/Schedules.Service.ts b/server/src/api/Schedules/Schedules.Service.ts index 27b53d2..0ec02a2 100644 --- a/server/src/api/Schedules/Schedules.Service.ts +++ b/server/src/api/Schedules/Schedules.Service.ts @@ -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 { + await connect(`${process.env.DATABASE_CONNECTION}`); + + return await ScheduleList.applyScheduleChanges(data); + } + public async deleteSchedule(data: DeleteScheduleParams): Promise { await connect(`${process.env.DATABASE_CONNECTION}`); await ScheduleList.delete(data); diff --git a/server/uploads/services/6a899a56edf89c333b2e626f/image.png b/server/uploads/services/6a899a56edf89c333b2e626f/image.png new file mode 100644 index 0000000..e05b6f7 Binary files /dev/null and b/server/uploads/services/6a899a56edf89c333b2e626f/image.png differ diff --git a/txclient/src/app/admin/assistant/components/AssistantHome/AssistantHome.tsx b/txclient/src/app/admin/assistant/components/AssistantHome/AssistantHome.tsx index 69fe5fd..fcf73b1 100644 --- a/txclient/src/app/admin/assistant/components/AssistantHome/AssistantHome.tsx +++ b/txclient/src/app/admin/assistant/components/AssistantHome/AssistantHome.tsx @@ -9,6 +9,7 @@ import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; import EventBusyIcon from '@mui/icons-material/EventBusy'; import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; import GroupAddIcon from '@mui/icons-material/GroupAdd'; +import ManageSearchIcon from '@mui/icons-material/ManageSearch'; import SearchIcon from "@mui/icons-material/Search"; import BlockIcon from "@mui/icons-material/Block"; import EventRepeatIcon from "@mui/icons-material/EventRepeat"; @@ -20,6 +21,8 @@ import NotificationsIcon from '@mui/icons-material/Notifications'; import WhatsAppIcon from '@mui/icons-material/WhatsApp'; import QrCode2Icon from '@mui/icons-material/QrCode2'; import ReceiptLongIcon from '@mui/icons-material/ReceiptLong'; +import PublicIcon from '@mui/icons-material/Public'; +import PublicOffIcon from '@mui/icons-material/PublicOff'; import API from "@services/Api.Service"; import { ReservationPeriodsSummary } from "@models/SchedulesEnabled.type"; @@ -134,6 +137,24 @@ const AVAILABLE_FLOWS: FlowDefinition[] = [ keywords: ["nuevo servicio", "prestacion", "ofrecer", "crear servicio", "precio", "duracion"], icon: AddCircleOutlineIcon }, + { + id: "publish-services", + flowId: "publish-services", + title: "Publicar Servicios", + description: "Hacer visibles uno o más servicios en la página pública.", + category: "Servicios", + keywords: ["publicar", "servicios", "visible", "publico", "mostrar", "habilitar reservas"], + icon: PublicIcon + }, + { + id: "unpublish-services", + flowId: "unpublish-services", + title: "Retirar Servicios", + description: "Quitar uno o más servicios de la página pública.", + category: "Servicios", + keywords: ["despublicar", "ocultar", "servicios", "privado", "no mostrar", "quitar publicacion"], + icon: PublicOffIcon + }, { id: "update-schedule", flowId: "update-schedule", @@ -178,6 +199,15 @@ const AVAILABLE_FLOWS: FlowDefinition[] = [ category: "Colaboradores", keywords: ["invitar", "colaborador", "profesional", "empleado", "staff", "equipo"], icon: GroupAddIcon + }, + { + id: "collaborator-schedule-summary", + flowId: "collaborator-schedule-summary", + title: "Ver horarios de colaborador", + description: "Consultá horarios, excepciones y restricciones configuradas.", + category: "Colaboradores", + keywords: ["horarios", "colaborador", "agenda", "restricciones", "excepciones", "cerrada", "consulta"], + icon: ManageSearchIcon } ]; diff --git a/txclient/src/app/admin/assistant/components/QuestionCard/QuestionCard.module.css b/txclient/src/app/admin/assistant/components/QuestionCard/QuestionCard.module.css index 8119f3c..4bb2e20 100644 --- a/txclient/src/app/admin/assistant/components/QuestionCard/QuestionCard.module.css +++ b/txclient/src/app/admin/assistant/components/QuestionCard/QuestionCard.module.css @@ -72,6 +72,19 @@ transform: translateY(-2px); box-shadow: 0 6px 20px rgba(128, 0, 32, 0.3); } + +.nextButton:disabled { + cursor: not-allowed; + opacity: 0.55; + transform: none; + box-shadow: none; +} + +.nextButton:disabled:hover { + transform: none; + box-shadow: none; +} + .pressEnterText { font-size: 13px; color: #6b7280; diff --git a/txclient/src/app/admin/assistant/components/QuestionCard/QuestionCard.tsx b/txclient/src/app/admin/assistant/components/QuestionCard/QuestionCard.tsx index 9d92886..acf65f6 100644 --- a/txclient/src/app/admin/assistant/components/QuestionCard/QuestionCard.tsx +++ b/txclient/src/app/admin/assistant/components/QuestionCard/QuestionCard.tsx @@ -34,7 +34,7 @@ export default function QuestionCard({
{topAccessory &&
{topAccessory}
} -

{title}

+ {title &&

{title}

} {description &&

{description}

}
diff --git a/txclient/src/app/admin/assistant/components/StepEngine/StepEngine.tsx b/txclient/src/app/admin/assistant/components/StepEngine/StepEngine.tsx index 7689485..940fc23 100644 --- a/txclient/src/app/admin/assistant/components/StepEngine/StepEngine.tsx +++ b/txclient/src/app/admin/assistant/components/StepEngine/StepEngine.tsx @@ -14,6 +14,7 @@ dayjs.locale("es"); import { NotificationChannel, ReminderRule } from "@models/NotificationPreferences.model"; import { OrganizationClientView } from "@models/Clients.model"; import { ReservationPeriodDuration, ReservationPeriodsSummary } from "@models/SchedulesEnabled.type"; +import { CompanyServiceView, SERVICE_PUBLISHED_STATUS } from "@models/Service.model"; import OnboardingOrgFlow from "./flows/OnboardingOrgFlow"; import OnboardingServiceFlow from "./flows/OnboardingServiceFlow"; import OnboardingScheduleFlow from "./flows/OnboardingScheduleFlow"; @@ -23,6 +24,8 @@ import UpdateScheduleFlow from "./flows/UpdateScheduleFlow"; import OverrideScheduleFlow from "./flows/OverrideScheduleFlow"; import DisableScheduleFlow from "./flows/DisableScheduleFlow"; import PublicVisibilityFlow from "./flows/PublicVisibilityFlow"; +import ServicePublicationFlow from "./flows/ServicePublicationFlow"; +import CollaboratorScheduleSummaryFlow, { CollaboratorScheduleDetails } from "./flows/CollaboratorScheduleSummaryFlow"; import NotificationsFlow from "./flows/NotificationsFlow"; import NewCollaboratorFlow from "./flows/NewCollaboratorFlow"; import SetupCollaboratorFlow from "./flows/SetupCollaboratorFlow"; @@ -31,6 +34,8 @@ import ArcaOnboardingFlow from "./flows/ArcaOnboardingFlow"; import ArcaTestConnectionFlow, { ArcaWsaaTestResult, ArcaWsfeDiagnosticsResult } from "./flows/ArcaTestConnectionFlow"; import SuccessScreen from "./components/SuccessScreen"; import OrganizationPicker from "./components/OrganizationPicker"; +import QuestionCard from "../QuestionCard/QuestionCard"; +import { getCollaboratorLabel } from "./components/CollaboratorPicker"; import { reservationPeriodOptions } from "./constants/reservationPeriods"; import { NOTIF_CHANNELS, NOTIF_REMINDER_PRESETS } from "./constants/notifications"; import { getEmployeeId, getEmployeeUserId, getTargetEmployees } from "./helpers/employees"; @@ -96,11 +101,18 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm const [serviceLimit, setServiceLimit] = useState(1); const [serviceImage, setServiceImage] = useState(null); const [createdServiceId, setCreatedServiceId] = useState(null); + const [serviceCreateError, setServiceCreateError] = useState(""); + const [serviceAssignmentError, setServiceAssignmentError] = useState(""); + const [serviceAssignmentMode, setServiceAssignmentMode] = useState<"options" | "collaborators">("options"); + const [serviceAssignmentWarning, setServiceAssignmentWarning] = useState(""); + const [isLoadingServiceCollaborators, setIsLoadingServiceCollaborators] = useState(false); + const [selectedServiceCollaboratorIds, setSelectedServiceCollaboratorIds] = useState([]); const [employeeId, setEmployeeId] = useState(null); const [doesProvideServices, setDoesProvideServices] = useState(null); // State for Schedule Flow const [workingDaysMode, setWorkingDaysMode] = useState(""); // "mon-fri", "mon-sat", "mon-sun", "custom" + const [specificWeekDay, setSpecificWeekDay] = useState(null); const [openTime, setOpenTime] = useState("09:00"); const [closeTime, setCloseTime] = useState("18:00"); const [hasBreak, setHasBreak] = useState(null); @@ -116,7 +128,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm const [isSuccess, setIsSuccess] = useState(false); const [isAddingAnotherService, setIsAddingAnotherService] = useState(false); - const [isResuming, setIsResuming] = useState(false); + const [isResuming, setIsResuming] = useState(true); const [headerImage, setHeaderImage] = useState(null); const [headerColor, setHeaderColor] = useState("#250525"); @@ -133,6 +145,13 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm const [servicesShowPublicScores, setServicesShowPublicScores] = useState(true); const [servicesShowPublicOpinions, setServicesShowPublicOpinions] = useState(true); const [publicLinkCopied, setPublicLinkCopied] = useState(false); + const [publicationServices, setPublicationServices] = useState([]); + const [selectedPublicationServiceIds, setSelectedPublicationServiceIds] = useState([]); + const [publicationLoading, setPublicationLoading] = useState(false); + const [publicationMessage, setPublicationMessage] = useState(""); + const [collaboratorScheduleDetails, setCollaboratorScheduleDetails] = useState(null); + const [collaboratorScheduleLoading, setCollaboratorScheduleLoading] = useState(false); + const [collaboratorScheduleMessage, setCollaboratorScheduleMessage] = useState(""); // State for Notifications Flow const [notificationsScope, setNotificationsScope] = useState<"org" | "client" | null>(null); @@ -149,6 +168,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm // State for standalone update schedule flow const [scheduleScope, setScheduleScope] = useState<"me" | "specific" | "all" | null>(null); const [targetEmployeeId, setTargetEmployeeId] = useState(null); + const [selectedScheduleEmployeeIds, setSelectedScheduleEmployeeIds] = useState([]); const [allEmployees, setAllEmployees] = useState([]); const [overrideDate, setOverrideDate] = useState(""); const [allOrganizations, setAllOrganizations] = useState([]); @@ -163,6 +183,11 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm const [reservationPeriodSummary, setReservationPeriodSummary] = useState(null); const [reservationPeriodEmployeesLoading, setReservationPeriodEmployeesLoading] = useState(false); const [reservationPeriodEmployeesError, setReservationPeriodEmployeesError] = useState(null); + const [scheduleServiceScope, setScheduleServiceScope] = useState<"all" | "specific">("all"); + const [scheduleServiceIds, setScheduleServiceIds] = useState([]); + const [scheduleAvailableServices, setScheduleAvailableServices] = useState([]); + const [scheduleConflictMessage, setScheduleConflictMessage] = useState(""); + const [pendingScheduleOverwrite, setPendingScheduleOverwrite] = useState(false); // State for New Collaborator Flow const [inviteEmail, setInviteEmail] = useState(""); @@ -208,6 +233,49 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm return !employee.removed && (isCurrentUser || employee.guestOk === true); }; + const requiresOrganizationContext = () => { + return flowId === "onboarding-service" + || flowId === "onboarding-schedule" + || flowId === "setup-collaborator" + || flowId === "public-visibility" + || flowId === "publish-services" + || flowId === "unpublish-services" + || flowId === "collaborator-schedule-summary" + || flowId === "public-link" + || flowId === "notifications" + || flowId === "whatsapp-bot" + || flowId === "arca-onboarding" + || flowId === "arca-test-connection" + || flowId === "update-schedule" + || flowId === "override-schedule" + || flowId === "disable-schedule" + || flowId === "open-reservation-periods"; + }; + + const loadPublicationServices = async (companyId: string) => { + const publishMode = flowId === "publish-services"; + setPublicationLoading(true); + setPublicationMessage(""); + setSelectedPublicationServiceIds([]); + + try { + const services = await API.post("services/get-by-company", { companyId }); + const filteredServices = (services || []).filter(service => { + if (publishMode) { + return service.published !== SERVICE_PUBLISHED_STATUS.PUBLISHED && !service.banned; + } + return service.published === SERVICE_PUBLISHED_STATUS.PUBLISHED; + }); + setPublicationServices(filteredServices); + } catch (error: any) { + console.error("Error cargando servicios para publicación:", error); + setPublicationServices([]); + setPublicationMessage(error?.message || "No pudimos cargar los servicios. Reintentá en unos segundos."); + } finally { + setPublicationLoading(false); + } + }; + const loadOrganizationContext = (org: any) => { setCreatedCompanyId(org.id || org._id); setSelectedOrganization(org); @@ -299,7 +367,24 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm }).catch(console.error); } }).catch(console.error); - } else if (flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule" || flowId === "open-reservation-periods") { + } else if (flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule" || flowId === "open-reservation-periods" || flowId === "collaborator-schedule-summary") { + if (flowId === "collaborator-schedule-summary") { + setCurrentStepIndex(0); + setAllEmployees([]); + setTargetEmployeeId(null); + setCollaboratorScheduleDetails(null); + setCollaboratorScheduleMessage(""); + } + + if (flowId === "update-schedule") { + setSelectedScheduleEmployeeIds([]); + setScheduleServiceScope("all"); + setScheduleServiceIds([]); + setScheduleAvailableServices([]); + setScheduleConflictMessage(""); + setPendingScheduleOverwrite(false); + } + if (flowId === "open-reservation-periods") { setCurrentStepIndex(0); setReservationPeriodScope(null); @@ -313,7 +398,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm API.post("employees/get-by-company", { companyId: org.id || org._id }) .then(empRes => { - const availableEmployees = flowId === "open-reservation-periods" + const availableEmployees = flowId === "open-reservation-periods" || flowId === "collaborator-schedule-summary" ? (empRes || []).filter((employee: any) => !employee.removed) : (empRes || []).filter(canManageEmployeeSchedules); setAllEmployees(availableEmployees); @@ -360,6 +445,9 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm setServicesShowPublicScores(services.every(service => service.showPublicScores !== false)); setServicesShowPublicOpinions(services.every(service => service.showPublicOpinions !== false)); }).catch(console.error); + } else if (flowId === "publish-services" || flowId === "unpublish-services") { + setCurrentStepIndex(0); + loadPublicationServices(org.id || org._id); } else if (flowId === "public-link") { setCurrentStepIndex(0); setPublicLinkCopied(false); @@ -448,7 +536,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm }; React.useEffect(() => { - if (flowId === "new-collaborator" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId === "arca-onboarding" || flowId === "arca-test-connection" || flowId.startsWith("onboarding") || flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule" || flowId === "open-reservation-periods") { + if (flowId === "new-collaborator" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "publish-services" || flowId === "unpublish-services" || flowId === "collaborator-schedule-summary" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId === "arca-onboarding" || flowId === "arca-test-connection" || flowId.startsWith("onboarding") || flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule" || flowId === "open-reservation-periods") { setIsResuming(true); API.post("companies/get-by-user", { sessionUser: SessionInfo.userId }) .then(response => { @@ -469,7 +557,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm .finally(() => setIsResuming(false)); } - if (flowId === "onboarding-service" || flowId === "onboarding-schedule" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId === "arca-onboarding" || flowId === "arca-test-connection" || flowId === "open-reservation-periods" || (flowId === "onboarding-org" && action === "new-org")) { + if (flowId === "onboarding-service" || flowId === "onboarding-schedule" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "publish-services" || flowId === "unpublish-services" || flowId === "collaborator-schedule-summary" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId === "arca-onboarding" || flowId === "arca-test-connection" || flowId === "open-reservation-periods" || (flowId === "onboarding-org" && action === "new-org")) { setCurrentStepIndex(0); } }, [flowId, SessionInfo.metrics.organizationsCount, SessionInfo.userId, router]); @@ -1060,15 +1148,55 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm ); const handleCreateService = async () => { - if (!createdCompanyId || !serviceName || !serviceDescription || !serviceLength || isCreating) return; + setServiceCreateError(""); + const defaultOrganization = selectedOrganization || (allOrganizations.length === 1 ? allOrganizations[0] : null); + let companyId = createdCompanyId || defaultOrganization?.id || defaultOrganization?._id; + const normalizedPrice = typeof servicePrice === "string" ? servicePrice.replace(",", ".") : servicePrice; + const parsedPrice = Number(normalizedPrice); + + if (isCreating) return; + + if (!companyId) { + try { + const organizations = await API.post("companies/get-by-user", { sessionUser: SessionInfo.userId }); + const availableOrganizations = organizations || []; + setAllOrganizations(availableOrganizations); + + if (availableOrganizations.length === 1) { + const organization = availableOrganizations[0]; + companyId = organization.id || organization._id; + loadOrganizationContext(organization); + } + } catch (error) { + console.error("Error cargando organizaciones para crear servicio:", error); + setServiceCreateError("No pudimos cargar tus organizaciones para crear el servicio. Reintentá en unos segundos."); + return; + } + } + + if (!companyId) { + setServiceCreateError("Para crear un servicio primero necesitás tener una organización asociada. Volvé al inicio del asistente y creá una organización."); + return; + } + + if (!serviceName || !serviceDescription || !serviceLength) { + setServiceCreateError("Completá el nombre, la descripción y la duración antes de crear el servicio."); + return; + } + + if (servicePrice === "" || Number.isNaN(parsedPrice)) { + setServiceCreateError("Ingresá el precio del servicio. Si es gratis, cargá 0."); + return; + } + setIsCreating(true); try { const service = await API.post("services/create", { - companyId: createdCompanyId, + companyId, name: serviceName, description: serviceDescription, length: serviceLength, - originalPrice: Number(servicePrice) || 0, + originalPrice: parsedPrice, limit: serviceLimit, sessionUser: SessionInfo.userId }); @@ -1080,7 +1208,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm handleNext(); // Move to Step 4 (Service Image) } catch (error: any) { console.error("Error creando servicio:", error); - alert(error?.message || "Hubo un error al guardar el servicio."); + setServiceCreateError(error?.message || "Hubo un error al guardar el servicio."); } finally { setIsCreating(false); } @@ -1098,6 +1226,8 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm .then(res => { setPreviewServices(res || []); }); + } else if (isStandaloneAction && action === "new-service") { + setCurrentStepIndex(5); } else if (isStandaloneAction) { handleActionSuccess(); } else { @@ -1123,7 +1253,139 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm finalizeService(); } catch (error: any) { console.error("Error subiendo imagen del servicio:", error); - alert(error?.message || "Hubo un error al subir la imagen. Puedes intentarlo más tarde desde tu panel."); + setServiceCreateError(error?.message || "Hubo un error al subir la imagen. Podés intentarlo más tarde desde tu panel."); + } finally { + setIsCreating(false); + } + }; + + const handleAssignCreatedServiceToMe = async () => { + if (!createdCompanyId || !createdServiceId || isCreating) return; + + setIsCreating(true); + setServiceAssignmentError(""); + setServiceAssignmentWarning(""); + try { + const employees = await API.post("employees/get-by-company", { companyId: createdCompanyId }); + const currentEmployee = (employees || []).find((employee: any) => String(getEmployeeUserId(employee)) === String(SessionInfo.userId)); + let finalEmployeeId = currentEmployee ? getEmployeeId(currentEmployee) : null; + + if (!finalEmployeeId) { + const newEmp = await API.post("employees/create", { + companyId: createdCompanyId, + userId: SessionInfo.userId, + roles: ["owner", "employee"], + sessionUser: SessionInfo.userId + }); + finalEmployeeId = newEmp?.id || newEmp?._id; + } + + if (!finalEmployeeId) { + throw new Error("No pudimos inicializar tu perfil de colaborador."); + } + + setEmployeeId(finalEmployeeId); + await API.post("employees/update-collaborator-services", { + companyId: createdCompanyId, + employeeId: finalEmployeeId, + services: [{ serviceId: createdServiceId, active: true }], + sessionUser: SessionInfo.userId + }); + + const schedulesRes = await API.post("schedules/get-all-by-collaborator", { + companyId: createdCompanyId, + employeeId: finalEmployeeId + }); + + if (schedulesRes?.data?.length > 0) { + handleActionSuccess(); + return; + } + + router.push(`/admin/assistant?flow=update-schedule&companyId=${createdCompanyId}`); + } catch (error: any) { + console.error("Error asignando servicio al colaborador:", error); + setServiceAssignmentError(error?.message || "No pudimos asignarte el servicio. Reintentá en unos segundos."); + } finally { + setIsCreating(false); + } + }; + + const loadServiceAssignmentCollaborators = async () => { + if (!createdCompanyId || isCreating || isLoadingServiceCollaborators) return; + + setServiceAssignmentMode("collaborators"); + setServiceAssignmentError(""); + setServiceAssignmentWarning(""); + setIsLoadingServiceCollaborators(true); + try { + const employees = await API.post("employees/get-by-company", { companyId: createdCompanyId }); + const activeEmployees = (employees || []).filter((employee: any) => !employee.removed); + setAllEmployees(activeEmployees); + } catch (error: any) { + console.error("Error cargando colaboradores:", error); + setServiceAssignmentError(error?.message || "No pudimos cargar los colaboradores. Reintentá en unos segundos."); + } finally { + setIsLoadingServiceCollaborators(false); + } + }; + + const toggleServiceCollaboratorSelection = (employee: any) => { + const id = getEmployeeId(employee); + if (!id || isCreating) return; + + setSelectedServiceCollaboratorIds(prev => ( + prev.includes(id) + ? prev.filter(selectedId => selectedId !== id) + : [...prev, id] + )); + }; + + const handleAssignCreatedServiceToCollaborators = async () => { + if (!createdCompanyId || !createdServiceId || isCreating) return; + if (selectedServiceCollaboratorIds.length === 0) { + setServiceAssignmentError("Seleccioná al menos un colaborador para asignar el servicio."); + return; + } + + const selectedEmployees = allEmployees.filter((employee: any) => selectedServiceCollaboratorIds.includes(getEmployeeId(employee))); + if (selectedEmployees.length === 0) { + setServiceAssignmentError("No encontramos los colaboradores seleccionados. Volvé a cargarlos e intentá de nuevo."); + return; + } + + setIsCreating(true); + setServiceAssignmentError(""); + setServiceAssignmentWarning(""); + try { + await Promise.all(selectedEmployees.map((employee: any) => API.post("employees/update-collaborator-services", { + companyId: createdCompanyId, + employeeId: getEmployeeId(employee), + services: [{ serviceId: createdServiceId, active: true }], + sessionUser: SessionInfo.userId + }))); + + const scheduleResults = await Promise.all(selectedEmployees.map(async (employee: any) => { + const employeeId = getEmployeeId(employee); + const schedulesRes = await API.post("schedules/get-all-by-collaborator", { + companyId: createdCompanyId, + employeeId + }); + return { employee, hasSchedules: (schedulesRes?.data || []).length > 0 }; + })); + + const missingSchedules = scheduleResults.filter(result => !result.hasSchedules); + if (missingSchedules.length > 0) { + const names = missingSchedules.map(({ employee }: any) => getCollaboratorLabel(employee)).join(", "); + setServiceAssignmentWarning(`Servicio asignado. Todavía necesitás cargar horarios para: ${names}. Hasta entonces no van a poder recibir reservas.`); + handleActionSuccess(); + return; + } + + handleActionSuccess(); + } catch (error: any) { + console.error("Error asignando servicio a colaboradores:", error); + setServiceAssignmentError(error?.message || "No pudimos asignar el servicio a los colaboradores. Reintentá en unos segundos."); } finally { setIsCreating(false); } @@ -1147,9 +1409,22 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm setServicePrice={setServicePrice} serviceLimit={serviceLimit} setServiceLimit={setServiceLimit} + serviceCreateError={serviceCreateError} handleUploadServiceImage={handleUploadServiceImage} serviceImage={serviceImage} setServiceImage={setServiceImage} + isStandaloneNewService={isStandaloneAction && action === "new-service"} + handleAssignCreatedServiceToMe={handleAssignCreatedServiceToMe} + handleShowCollaboratorAssignment={loadServiceAssignmentCollaborators} + handleToggleServiceCollaborator={toggleServiceCollaboratorSelection} + handleAssignCreatedServiceToCollaborators={handleAssignCreatedServiceToCollaborators} + handleFinishWithoutAssignment={handleActionSuccess} + serviceAssignmentError={serviceAssignmentError} + serviceAssignmentWarning={serviceAssignmentWarning} + serviceAssignmentMode={serviceAssignmentMode} + serviceAssignmentCollaborators={allEmployees} + selectedServiceCollaboratorIds={selectedServiceCollaboratorIds} + isLoadingServiceCollaborators={isLoadingServiceCollaborators} /> ); @@ -1321,57 +1596,160 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm ); - const handleUpdateScheduleSubmit = async () => { + const formatScheduleConflicts = (conflicts: any[]) => { + if (!conflicts || conflicts.length === 0) return ""; + + return conflicts.map((conflict) => conflict.message).join("\n"); + }; + + const getUpdateScheduleTargetEmployees = () => { + if (scheduleScope === "specific") { + return allEmployees.filter((employee: any) => selectedScheduleEmployeeIds.includes(String(getEmployeeId(employee)))); + } + + return getTargetEmployees(allEmployees, scheduleScope, targetEmployeeId, SessionInfo.userId); + }; + + const loadScheduleServicesForEmployees = async (employeeIdsToLoad: string[]) => { + if (!createdCompanyId || employeeIdsToLoad.length === 0) { + setScheduleAvailableServices([]); + return; + } + + const results = await Promise.all(employeeIdsToLoad.map((employeeIdToLoad) => API.post("employees/get-all-services", { + companyId: createdCompanyId, + employeeId: employeeIdToLoad, + sessionUser: SessionInfo.userId + }))); + const serviceGroups = results.map((result) => (result?.services || []).filter((service: any) => service.active)); + + if (serviceGroups.length === 0) { + setScheduleAvailableServices([]); + return; + } + + const commonServiceIds = serviceGroups.slice(1).reduce((commonIds, services) => { + const currentIds = new Set(services.map((service: any) => String(service.id || service._id))); + return commonIds.filter((serviceId: string) => currentIds.has(serviceId)); + }, serviceGroups[0].map((service: any) => String(service.id || service._id))); + const commonServiceIdSet = new Set(commonServiceIds); + const commonServices = serviceGroups[0].filter((service: any) => commonServiceIdSet.has(String(service.id || service._id))); + + setScheduleAvailableServices(commonServices); + setScheduleServiceIds((currentIds) => currentIds.filter((serviceId) => commonServiceIdSet.has(String(serviceId)))); + }; + + const handleToggleScheduleEmployee = (employee: any) => { + const employeeIdToToggle = String(getEmployeeId(employee)); + + setSelectedScheduleEmployeeIds((currentIds) => { + const nextIds = currentIds.includes(employeeIdToToggle) + ? currentIds.filter((id) => id !== employeeIdToToggle) + : [...currentIds, employeeIdToToggle]; + + setScheduleServiceScope("all"); + setScheduleServiceIds([]); + setScheduleAvailableServices([]); + setScheduleConflictMessage(""); + + return nextIds; + }); + }; + + const handleSetUpdateScheduleScope = (scope: "me" | "specific" | "all") => { + setScheduleScope(scope); + setScheduleServiceScope("all"); + setScheduleServiceIds([]); + setScheduleAvailableServices([]); + setScheduleConflictMessage(""); + setPendingScheduleOverwrite(false); + }; + + const handleUpdateScheduleSubmit = async (conflictStrategy: "reject" | "overwrite-conflicts" = "reject") => { if (!createdCompanyId || isCreating || !scheduleScope) return; setIsCreating(true); + setScheduleConflictMessage(""); + setPendingScheduleOverwrite(false); try { - const targetEmployees = getTargetEmployees(allEmployees, scheduleScope, targetEmployeeId, SessionInfo.userId); + const targetEmployees = getUpdateScheduleTargetEmployees(); if (targetEmployees.length === 0) { - alert("No se encontró ningún colaborador seleccionado."); + setScheduleConflictMessage("Seleccioná al menos un colaborador para actualizar sus horarios."); setIsCreating(false); return; } - const workingDays = getWorkingDays(workingDaysMode); + const employeeIds = targetEmployees.map((employee) => getEmployeeId(employee)).filter(Boolean); + const weekDays = workingDaysMode === "specific-day" && specificWeekDay !== null + ? [specificWeekDay] + : getWorkingDays(workingDaysMode); + const canUseSpecificServices = workingDaysMode === "specific-day" && employeeIds.length > 0; + const effectiveServiceScope = canUseSpecificServices ? scheduleServiceScope : "all"; + const schedules = buildScheduleBlocks( + openTime, + closeTime, + hasBreak, + breakStart, + breakEnd, + effectiveServiceScope, + effectiveServiceScope === "specific" ? scheduleServiceIds : [] + ); - for (const emp of targetEmployees) { - const empId = getEmployeeId(emp); - if (!empId) continue; - - for (let i = 0; i <= 6; i++) { - const isWorkingDay = workingDays.includes(i); - const schedules = isWorkingDay ? buildScheduleBlocks(openTime, closeTime, hasBreak, breakStart, breakEnd) : []; - - await API.post("schedules/update", { - companyId: createdCompanyId, - employeeId: empId, - weekDay: i, - schedules: schedules, - sessionUser: SessionInfo.userId - }); - } + if (weekDays.length === 0) { + throw new Error("Seleccioná al menos un día de trabajo."); } + + if (effectiveServiceScope === "specific" && scheduleServiceIds.length === 0) { + throw new Error("Seleccioná al menos un servicio."); + } + + if (effectiveServiceScope === "specific" && scheduleAvailableServices.length === 0) { + throw new Error("Los colaboradores seleccionados no tienen servicios activos en común."); + } + + const result = await API.post("schedules/apply", { + companyId: createdCompanyId, + employeeIds, + weekDays, + schedules, + sessionUser: SessionInfo.userId, + conflictStrategy + }); + + if (result?.valid === false && !result?.applied) { + setScheduleConflictMessage(formatScheduleConflicts(result.conflicts) || result.message); + setPendingScheduleOverwrite(true); + return; + } + handleActionSuccess(); } catch (error: any) { console.error("Error actualizando horarios:", error); - alert("Hubo un error al guardar los horarios: " + error?.message); + setScheduleConflictMessage("Hubo un error al guardar los horarios: " + error?.message); } finally { setIsCreating(false); } }; - const renderUpdateScheduleFlow = () => ( - { + const scheduleTargetEmployees = getUpdateScheduleTargetEmployees(); + const scheduleTargetEmployeeIds = scheduleTargetEmployees.map((employee) => String(getEmployeeId(employee))).filter(Boolean); + const canUseSpecificScheduleServices = scheduleTargetEmployeeIds.length > 0; + + return - ); + scheduleServiceScope={scheduleServiceScope} + setScheduleServiceScope={setScheduleServiceScope} + scheduleServiceIds={scheduleServiceIds} + setScheduleServiceIds={setScheduleServiceIds} + scheduleAvailableServices={scheduleAvailableServices} + loadScheduleServicesForEmployees={loadScheduleServicesForEmployees} + scheduleTargetEmployeeIds={scheduleTargetEmployeeIds} + canUseSpecificScheduleServices={canUseSpecificScheduleServices} + scheduleConflictMessage={scheduleConflictMessage} + pendingScheduleOverwrite={pendingScheduleOverwrite} + setScheduleConflictMessage={setScheduleConflictMessage} + setPendingScheduleOverwrite={setPendingScheduleOverwrite} + />; + }; @@ -1747,6 +2137,45 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm } }; + const togglePublicationService = (serviceId: string) => { + if (isCreating) return; + setSelectedPublicationServiceIds(prev => ( + prev.includes(serviceId) + ? prev.filter(selectedId => selectedId !== serviceId) + : [...prev, serviceId] + )); + }; + + const handleSaveServicePublication = async () => { + if (!createdCompanyId || isCreating) return; + if (selectedPublicationServiceIds.length === 0) { + setPublicationMessage("Seleccioná al menos un servicio para continuar."); + return; + } + + const nextStatus = flowId === "publish-services" + ? SERVICE_PUBLISHED_STATUS.PUBLISHED + : SERVICE_PUBLISHED_STATUS.PRIVATE; + + setIsCreating(true); + setPublicationMessage(""); + try { + await Promise.all(selectedPublicationServiceIds.map(serviceId => + API.post("services/set-published-status", { + serviceId, + published: nextStatus, + sessionUser: SessionInfo.userId + }) + )); + setIsSuccess(true); + } catch (error: any) { + console.error("Error actualizando publicación de servicios:", error); + setPublicationMessage(error?.message || "No pudimos guardar el cambio de publicación. Reintentá en unos segundos."); + } finally { + setIsCreating(false); + } + }; + const renderPublicVisibilityFlow = () => ( ); + const renderServicePublicationFlow = () => ( + + ); + + const handleLoadCollaboratorScheduleDetails = async () => { + if (!createdCompanyId || !targetEmployeeId || collaboratorScheduleLoading) return; + + setCollaboratorScheduleLoading(true); + setCollaboratorScheduleMessage(""); + try { + const details = await API.post("admin-assistant/collaborator-schedule-details", { + companyId: createdCompanyId, + employeeId: targetEmployeeId, + sessionUser: SessionInfo.userId, + }); + setCollaboratorScheduleDetails(details); + setCurrentStepIndex(1); + } catch (error: any) { + console.error("Error consultando horarios del colaborador:", error); + setCollaboratorScheduleMessage(error?.message || "No pudimos cargar los horarios del colaborador. Reintentá en unos segundos."); + } finally { + setCollaboratorScheduleLoading(false); + } + }; + + const renderCollaboratorScheduleSummaryFlow = () => ( + router.push("/admin/assistant")} + /> + ); + const getSelectedOrganizationSlug = () => { return buildSelectedOrganizationSlug(selectedOrganization, orgName); }; @@ -1905,13 +2387,31 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm /> ); const renderFlow = () => { + if (requiresOrganizationContext() && !isResuming && allOrganizations.length === 0 && !createdCompanyId) { + return ( + router.push("/admin/assistant")} + nextLabel="Volver al inicio del asistente" + > +
+ Creá una organización desde el inicio del asistente y después volvé a intentar esta acción. +
+
+ ); + } + if (allOrganizations.length > 1 && !createdCompanyId && action !== "new-org") { return ; } if (flowId === "setup-collaborator") return renderSetupCollaboratorFlow(); if (flowId === "new-collaborator") return renderNewCollaboratorFlow(); + if (flowId === "collaborator-schedule-summary") return renderCollaboratorScheduleSummaryFlow(); if (flowId === "public-visibility") return renderPublicVisibilityFlow(); + if (flowId === "publish-services" || flowId === "unpublish-services") return renderServicePublicationFlow(); if (flowId === "public-link") return renderPublicLinkFlow(); if (flowId === "notifications") return renderNotificationsFlow(); if (flowId === "whatsapp-bot") return renderWhatsAppBotFlow(); @@ -1952,6 +2452,8 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm ); } + const shouldHideFlowTitle = flowId === "collaborator-schedule-summary" && currentStepIndex === 1; + return (
@@ -1960,7 +2462,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm Volver
- Configuración de Negocio + {!shouldHideFlowTitle && "Configuración de Negocio"}
{/* Placeholder for progress dots or percentage */} diff --git a/txclient/src/app/admin/assistant/components/StepEngine/components/CollaboratorPicker.tsx b/txclient/src/app/admin/assistant/components/StepEngine/components/CollaboratorPicker.tsx new file mode 100644 index 0000000..4789f76 --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/components/CollaboratorPicker.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +type Props = { + employees: any[]; + selectedIds?: string[]; + selectedId?: string | null; + multiple?: boolean; + disabled?: boolean; + onToggle?: (employee: any) => void; + onSelect?: (employeeId: string) => void; +}; + +const getEmployeeId = (employee: any) => employee.id || employee._id || employee.employeeId; + +export const getCollaboratorLabel = (employee: any) => { + const fullName = employee.fullName || [employee.firstName, employee.lastName].filter(Boolean).join(" ").trim(); + return fullName || employee.email || employee.userId?.email || employee.userId?.name || "Colaborador"; +}; + +export default function CollaboratorPicker({ employees, selectedIds = [], selectedId = null, multiple = false, disabled = false, onToggle, onSelect }: Props) { + return ( +
+ {employees.map((employee: any) => { + const employeeId = getEmployeeId(employee); + const isSelected = multiple ? selectedIds.includes(employeeId) : selectedId === employeeId; + const label = getCollaboratorLabel(employee); + + return ( + + ); + })} +
+ ); +} diff --git a/txclient/src/app/admin/assistant/components/StepEngine/components/SuccessScreen.tsx b/txclient/src/app/admin/assistant/components/StepEngine/components/SuccessScreen.tsx index 03b5118..4e3d092 100644 --- a/txclient/src/app/admin/assistant/components/StepEngine/components/SuccessScreen.tsx +++ b/txclient/src/app/admin/assistant/components/StepEngine/components/SuccessScreen.tsx @@ -14,6 +14,8 @@ export default function SuccessScreen(props: Props) { if (flowId === "override-schedule" || flowId === "disable-schedule") return "El horario especial ha sido guardado exitosamente."; if (flowId === "new-collaborator") return "El colaborador fue invitado exitosamente."; if (flowId === "setup-collaborator") return "El colaborador ha sido configurado exitosamente y ya puede comenzar a recibir reservas."; + if (flowId === "publish-services") return "Los servicios seleccionados ya están publicados."; + if (flowId === "unpublish-services") return "Los servicios seleccionados dejaron de estar publicados."; if (flowId === "notifications") return "La configuración de notificaciones se ha guardado exitosamente."; if (flowId === "whatsapp-bot") return "Tu bot de WhatsApp está configurado y funcionando. Tus clientes recibirán notificaciones automáticas por WhatsApp."; if (flowId === "open-reservation-periods") return "El período de reservas fue extendido exitosamente."; diff --git a/txclient/src/app/admin/assistant/components/StepEngine/flows/CollaboratorScheduleSummaryFlow.tsx b/txclient/src/app/admin/assistant/components/StepEngine/flows/CollaboratorScheduleSummaryFlow.tsx new file mode 100644 index 0000000..71a1cee --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/flows/CollaboratorScheduleSummaryFlow.tsx @@ -0,0 +1,234 @@ +import React from "react"; +import dayjs from "dayjs"; +import QuestionCard from "../../QuestionCard/QuestionCard"; +import CollaboratorPicker from "../components/CollaboratorPicker"; + +type ScheduleItem = { + from: string; + to: string; + disabled: boolean; + serviceScope?: "all" | "specific"; + serviceIds?: string[]; +}; + +type WeeklySchedule = { + scheduleId: string; + weekDay: number; + schedules: ScheduleItem[]; +}; + +type DisabledSchedule = { + id?: string; + _id?: string; + startDate: Date | string; + endDate: Date | string; +}; + +type ScheduleOverride = { + id?: string; + _id?: string; + date: Date | string; + schedules: ScheduleItem[]; +}; + +export type CollaboratorScheduleDetails = { + collaborator: { + employeeId: string; + fullName: string; + email: string; + avatar: string; + userId: string; + }; + servicesById: Record; + weeklySchedules: WeeklySchedule[]; + futureDisabledSchedules: DisabledSchedule[]; + futureOverrides: ScheduleOverride[]; +}; + +type Props = { + currentStepIndex: number; + employees: any[]; + selectedEmployeeId: string | null; + setSelectedEmployeeId: (employeeId: string) => void; + details: CollaboratorScheduleDetails | null; + loading: boolean; + message: string; + selectedOrganization?: any; + orgName: string; + loadDetails: () => void; + goToAssistantHome: () => void; +}; + +const weekDays = ["Domingo", "Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado"]; +const weekDayDisplayOrder = [1, 2, 3, 4, 5, 6, 0]; + +const cardStyle: React.CSSProperties = { + padding: "14px", + borderRadius: "14px", + background: "rgba(255,255,255,0.05)", + border: "1px solid rgba(255,255,255,0.12)", + color: "white", +}; + +const mutedStyle: React.CSSProperties = { + color: "rgba(255,255,255,0.7)", + fontSize: "14px", + lineHeight: 1.45, +}; + +const formatDate = (date: Date | string) => dayjs(date).format("DD/MM/YYYY"); + +const formatSchedule = (schedule: ScheduleItem, servicesById: CollaboratorScheduleDetails["servicesById"] = {}) => { + const selectedServices = (schedule.serviceIds || []) + .map(serviceId => servicesById[serviceId]?.name || `Servicio ${serviceId}`) + .filter(Boolean); + const scope = schedule.serviceScope === "specific" + ? selectedServices.length > 0 ? selectedServices.join(", ") : "servicios específicos sin nombre disponible" + : "todos los servicios"; + return `${schedule.from} a ${schedule.to}${schedule.disabled ? " (deshabilitado)" : ""} · ${scope}`; +}; + +function ExpandableItem({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) { + const [open, setOpen] = React.useState(false); + + return ( + + ); +} + +export default function CollaboratorScheduleSummaryFlow(props: Props) { + const { + currentStepIndex, + employees, + selectedEmployeeId, + setSelectedEmployeeId, + details, + loading, + message, + selectedOrganization, + orgName, + loadDetails, + goToAssistantHome, + } = props; + const configuredWeeklySchedules = details?.weeklySchedules + .filter(day => day.schedules.length > 0) + .sort((a, b) => weekDayDisplayOrder.indexOf(a.weekDay) - weekDayDisplayOrder.indexOf(b.weekDay)) || []; + + if (currentStepIndex === 0) { + return ( + +
+
+ Organización: {selectedOrganization?.name || orgName || "seleccionada"} +
+ {message &&
{message}
} + {employees.length === 0 ? ( +
No encontramos colaboradores activos en esta organización.
+ ) : ( + + )} +
+
+ ); + } + + return ( + +
+ {message &&
{message}
} + {!details ? ( +
No hay datos para mostrar.
+ ) : ( + <> +
+ {details.collaborator.fullName} +
+ {details.collaborator.fullName} +
{details.collaborator.email || "Sin email registrado"}
+
+
+ +
+

Horarios semanales

+ {configuredWeeklySchedules.length === 0 ? ( +
Este colaborador no tiene horarios semanales configurados.
+ ) : configuredWeeklySchedules.map(day => ( +
+ {weekDays[day.weekDay] || `Día ${day.weekDay}`} +
+ {day.schedules.map((schedule, index) => ( + {formatSchedule(schedule, details.servicesById)} + ))} +
+
+ ))} +
+ +
+

Excepciones futuras

+ {details.futureOverrides.length === 0 ? ( +
No hay excepciones futuras configuradas.
+ ) : details.futureOverrides.map((override, index) => ( + +
+ {override.schedules.map((schedule, scheduleIndex) => ( + {formatSchedule(schedule, details.servicesById)} + ))} +
+
+ ))} +
+ +
+

Restricciones / agenda cerrada

+ {details.futureDisabledSchedules.length === 0 ? ( +
No hay cierres de agenda futuros configurados.
+ ) : details.futureDisabledSchedules.map((disabled, index) => ( + +
Durante este período no se muestran turnos disponibles para el colaborador.
+
+ ))} +
+ + )} +
+
+ ); +} diff --git a/txclient/src/app/admin/assistant/components/StepEngine/flows/OnboardingServiceFlow.tsx b/txclient/src/app/admin/assistant/components/StepEngine/flows/OnboardingServiceFlow.tsx index ecc3b8c..adcd600 100644 --- a/txclient/src/app/admin/assistant/components/StepEngine/flows/OnboardingServiceFlow.tsx +++ b/txclient/src/app/admin/assistant/components/StepEngine/flows/OnboardingServiceFlow.tsx @@ -1,6 +1,7 @@ import React from "react"; import QuestionCard from "../../QuestionCard/QuestionCard"; import QuestionInput from "../../QuestionCard/QuestionInput"; +import CollaboratorPicker from "../components/CollaboratorPicker"; type OnboardingServiceFlowProps = { heatMapFraction: any; @@ -19,17 +20,51 @@ type OnboardingServiceFlowProps = { setServicePrice: any; serviceLimit: any; setServiceLimit: any; + serviceCreateError: any; handleUploadServiceImage: any; serviceImage: any; setServiceImage: any; + isStandaloneNewService: any; + handleAssignCreatedServiceToMe: any; + handleShowCollaboratorAssignment: any; + handleToggleServiceCollaborator: any; + handleAssignCreatedServiceToCollaborators: any; + handleFinishWithoutAssignment: any; + serviceAssignmentError: any; + serviceAssignmentWarning: any; + serviceAssignmentMode: any; + serviceAssignmentCollaborators: any; + selectedServiceCollaboratorIds: any; + isLoadingServiceCollaborators: any; }; export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps) { - const { heatMapFraction, currentStepIndex, handleNext, serviceName, setServiceName, serviceDescription, setServiceDescription, handleKeyDown, serviceLength, setServiceLength, handleCreateService, isCreating, servicePrice, setServicePrice, serviceLimit, setServiceLimit, handleUploadServiceImage, serviceImage, setServiceImage } = props; - const fraction = heatMapFraction || 60; - const durationOptions = [fraction, fraction * 2, fraction * 3, fraction * 4]; + const { heatMapFraction, currentStepIndex, handleNext, serviceName, setServiceName, serviceDescription, setServiceDescription, handleKeyDown, serviceLength, setServiceLength, handleCreateService, isCreating, servicePrice, setServicePrice, serviceLimit, setServiceLimit, serviceCreateError, handleUploadServiceImage, serviceImage, setServiceImage, isStandaloneNewService, handleAssignCreatedServiceToMe, handleShowCollaboratorAssignment, handleToggleServiceCollaborator, handleAssignCreatedServiceToCollaborators, handleFinishWithoutAssignment, serviceAssignmentError, serviceAssignmentWarning, serviceAssignmentMode, serviceAssignmentCollaborators, selectedServiceCollaboratorIds, isLoadingServiceCollaborators } = props; + const [isCustomDuration, setIsCustomDuration] = React.useState(false); + const [customDurationValue, setCustomDurationValue] = React.useState(""); + const fraction = heatMapFraction || 60; + const durationOptions = [fraction, fraction * 2, fraction * 3, fraction * 4]; + const customDurationMinutes = Number(customDurationValue); + const isValidCustomDuration = Number.isInteger(customDurationMinutes) && customDurationMinutes > 0; - return ( + const selectDurationOption = (duration: number) => { + setIsCustomDuration(false); + setCustomDurationValue(""); + setServiceLength(duration); + }; + + const selectCustomDuration = () => { + setIsCustomDuration(true); + setServiceLength(isValidCustomDuration ? customDurationMinutes : null); + }; + + const updateCustomDuration = (value: string) => { + setCustomDurationValue(value); + const parsedValue = Number(value); + setServiceLength(Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null); + }; + + return ( <>
{durationOptions.map(val => { @@ -88,15 +123,15 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps) return (
setServiceLength(val)} + onClick={() => selectDurationOption(val)} style={{ padding: '12px 24px', borderRadius: '12px', cursor: 'pointer', - background: serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`, + background: !isCustomDuration && serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', + border: `2px solid ${!isCustomDuration && serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`, color: 'white', - fontWeight: serviceLength === val ? 'bold' : 'normal', + fontWeight: !isCustomDuration && serviceLength === val ? 'bold' : 'normal', transition: 'all 0.2s ease', fontSize: '16px' }} @@ -105,7 +140,44 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
); })} +
+ Otro +
+ {isCustomDuration && ( +
+ updateCustomDuration(e.target.value)} + onKeyDown={(e) => { + if (isValidCustomDuration) { + handleKeyDown(e, handleNext); + } + }} + autoFocus + /> +
+ Ingresá la duración total del servicio expresada en minutos. +
+
+ )}
@@ -122,7 +194,8 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
$ setServicePrice(e.target.value)} @@ -163,6 +236,20 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps) ))}
+ {serviceCreateError && ( +
+ {serviceCreateError} +
+ )}
@@ -172,7 +259,7 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps) description="Sube una foto representativa para que los clientes sepan qué ofreces. (Opcional)" onNext={handleUploadServiceImage} disableNext={isCreating} - nextLabel={isCreating ? "Subiendo..." : (serviceImage ? "Subir y Finalizar" : "Omitir por ahora")} + nextLabel={isCreating ? "Subiendo..." : (serviceImage ? (isStandaloneNewService ? "Subir y continuar" : "Subir y Finalizar") : "Omitir por ahora")} >
+ {serviceCreateError && ( +
+ {serviceCreateError} +
+ )}
+ + {isStandaloneNewService && ( + +
+ + + {serviceAssignmentMode === "collaborators" && ( +
+ {serviceAssignmentCollaborators.length === 0 && !isLoadingServiceCollaborators && ( +
+ No encontramos colaboradores activos para esta organización. +
+ )} + + {serviceAssignmentCollaborators.length > 0 && ( + + )} +
+ )} + {!serviceAssignmentWarning && ( + <> + +
+ Si lo configurás después, el servicio no va a estar disponible para reservas hasta que lo asignes a un colaborador con horarios cargados. +
+ + )} + {serviceAssignmentError && ( +
+ {serviceAssignmentError} +
+ )} +
+
+ )} ); diff --git a/txclient/src/app/admin/assistant/components/StepEngine/flows/ServicePublicationFlow.tsx b/txclient/src/app/admin/assistant/components/StepEngine/flows/ServicePublicationFlow.tsx new file mode 100644 index 0000000..d0a2bf5 --- /dev/null +++ b/txclient/src/app/admin/assistant/components/StepEngine/flows/ServicePublicationFlow.tsx @@ -0,0 +1,99 @@ +import React from "react"; +import { CompanyServiceView } from "@models/Service.model"; +import { getServiceImage } from "@core/helpers/getServiceImage"; +import QuestionCard from "../../QuestionCard/QuestionCard"; + +type Props = { + mode: "publish" | "unpublish"; + currentStepIndex: number; + services: CompanyServiceView[]; + selectedServiceIds: string[]; + toggleService: (serviceId: string) => void; + handleSave: () => void; + isCreating: boolean; + isLoading: boolean; + message: string; + selectedOrganization?: any; + orgName: string; +}; + +export default function ServicePublicationFlow(props: Props) { + const { mode, currentStepIndex, services, selectedServiceIds, toggleService, handleSave, isCreating, isLoading, message, selectedOrganization, orgName } = props; + const isPublish = mode === "publish"; + const title = isPublish ? "Publicá tus servicios" : "Retirá servicios de la página pública"; + const description = isPublish + ? "Elegí uno o más servicios privados para mostrarlos en tu página pública. Los servicios bloqueados no se pueden publicar desde acá." + : "Elegí uno o más servicios publicados para dejar de mostrarlos en la página pública."; + const emptyMessage = isPublish + ? "No encontramos servicios privados disponibles para publicar. Puede que ya estén publicados o que estén bloqueados." + : "No encontramos servicios publicados para retirar."; + const nextLabel = isCreating + ? (isPublish ? "Publicando..." : "Quitando publicación...") + : (isPublish ? "Publicar servicios" : "Quitar publicación"); + + return ( + +
+
+ Organización: {selectedOrganization?.name || orgName || "seleccionada"} +
+ + {message && ( +
+ {message} +
+ )} + + {!isLoading && services.length === 0 && ( +
+ {emptyMessage} +
+ )} + + {services.map(service => { + const selected = selectedServiceIds.includes(service.id); + return ( + + ); + })} +
+
+ ); +} diff --git a/txclient/src/app/admin/assistant/components/StepEngine/flows/UpdateScheduleFlow.tsx b/txclient/src/app/admin/assistant/components/StepEngine/flows/UpdateScheduleFlow.tsx index 0cdf792..7525595 100644 --- a/txclient/src/app/admin/assistant/components/StepEngine/flows/UpdateScheduleFlow.tsx +++ b/txclient/src/app/admin/assistant/components/StepEngine/flows/UpdateScheduleFlow.tsx @@ -1,234 +1,241 @@ -import React from "react"; import QuestionCard from "../../QuestionCard/QuestionCard"; +import CollaboratorPicker from "../components/CollaboratorPicker"; type Props = any; +const weekDays = [ + { id: 1, label: "Lunes" }, + { id: 2, label: "Martes" }, + { id: 3, label: "Miércoles" }, + { id: 4, label: "Jueves" }, + { id: 5, label: "Viernes" }, + { id: 6, label: "Sábado" }, + { id: 0, label: "Domingo" }, +]; + +const optionStyle = (active: boolean) => ({ + padding: "15px", + borderRadius: "12px", + cursor: "pointer", + background: active ? "var(--wine-red)" : "rgba(255,255,255,0.05)", + border: `2px solid ${active ? "var(--wine-red)" : "transparent"}`, + color: "white", + textAlign: "center" as const, + transition: "all 0.2s ease", +}); + export default function UpdateScheduleFlow(props: Props) { - const { currentStepIndex, scheduleScope, setScheduleScope, targetEmployeeId, setTargetEmployeeId, allEmployees, handleNext, workingDaysMode, setWorkingDaysMode, openTime, setOpenTime, closeTime, setCloseTime, hasBreak, setHasBreak, breakStart, setBreakStart, breakEnd, setBreakEnd, isCreating, handleUpdateScheduleSubmit, handleOverrideScheduleSubmit, disableDuration, setDisableDuration, disableStartDate, setDisableStartDate, disableEndDate, setDisableEndDate, isFullDayDisabled, setIsFullDayDisabled, disableStartTime, setDisableStartTime, disableEndTime, setDisableEndTime, handleDisableScheduleSubmit } = props; + const { + currentStepIndex, + scheduleScope, + setScheduleScope, + selectedScheduleEmployeeIds, + handleToggleScheduleEmployee, + allEmployees, + handleNext, + workingDaysMode, + setWorkingDaysMode, + specificWeekDay, + setSpecificWeekDay, + openTime, + setOpenTime, + closeTime, + setCloseTime, + hasBreak, + setHasBreak, + breakStart, + setBreakStart, + breakEnd, + setBreakEnd, + isCreating, + handleUpdateScheduleSubmit, + scheduleServiceScope, + setScheduleServiceScope, + scheduleServiceIds, + setScheduleServiceIds, + scheduleAvailableServices, + loadScheduleServicesForEmployees, + scheduleTargetEmployeeIds, + canUseSpecificScheduleServices, + scheduleConflictMessage, + pendingScheduleOverwrite, + setScheduleConflictMessage, + setPendingScheduleOverwrite, + } = props; + + const isSpecificDay = workingDaysMode === "specific-day"; + const weekDayStep = 2; + const timeStep = isSpecificDay ? 3 : 2; + const breakStep = isSpecificDay ? 4 : 3; + const breakTimeStep = isSpecificDay ? 5 : 4; + const serviceScopeStep = hasBreak ? 6 : 5; + const hasNoCommonServices = scheduleServiceScope === "specific" && scheduleAvailableServices.length === 0; + + const continueAfterBreak = () => { + if (isSpecificDay) { + handleNext(); + return; + } + + handleUpdateScheduleSubmit(); + }; + + const handleServiceScopeSelect = async (scope: "all" | "specific") => { + setScheduleConflictMessage(""); + setScheduleServiceScope(scope); + + if (scope === "specific" && canUseSpecificScheduleServices) { + await loadScheduleServicesForEmployees(scheduleTargetEmployeeIds); + } + }; + + const toggleService = (serviceId: string) => { + setScheduleServiceIds( + scheduleServiceIds.includes(serviceId) + ? scheduleServiceIds.filter((id: string) => id !== serviceId) + : [...scheduleServiceIds, serviceId] + ); + }; + + const renderConflictActions = () => { + if (!scheduleConflictMessage) return null; + return ( <> - { - if (scheduleScope === "specific" && !targetEmployeeId) { - alert("Por favor selecciona un colaborador."); - return; - } - handleNext(); - }} - disableNext={!scheduleScope || (scheduleScope === "specific" && !targetEmployeeId)} - > -
-
setScheduleScope("me")} - style={{ - padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: scheduleScope === "me" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${scheduleScope === "me" ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center', transition: 'all 0.2s ease' - }} - > -

Solo a mí

-

Aplica esta rutina únicamente a tus horarios.

-
-
setScheduleScope("specific")} - style={{ - padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: scheduleScope === "specific" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${scheduleScope === "specific" ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center', transition: 'all 0.2s ease' - }} - > -

A un colaborador en particular

-

Aplica esta rutina a un integrante específico de tu equipo.

-
-
setScheduleScope("all")} - style={{ - padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: scheduleScope === "all" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${scheduleScope === "all" ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center', transition: 'all 0.2s ease' - }} - > -

A todos mis colaboradores

-

Sobrescribe masivamente los horarios de todo tu equipo.

-
- - {scheduleScope === "specific" && ( -
- - -
- )} +
+ {scheduleConflictMessage} +
+ {pendingScheduleOverwrite && ( +
+ +
- - - -
- {[ - { id: 'mon-fri', label: 'Lunes a Viernes' }, - { id: 'mon-sat', label: 'Lunes a Sábado' }, - { id: 'mon-sun', label: 'Todos los días' } - ].map(mode => ( -
setWorkingDaysMode(mode.id)} - style={{ - padding: '15px', - borderRadius: '12px', - cursor: 'pointer', - background: workingDaysMode === mode.id ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${workingDaysMode === mode.id ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', - textAlign: 'center', - transition: 'all 0.2s ease' - }} - > - {mode.label} -
- ))} -
-
- - -
-
- - setOpenTime(e.target.value)} - style={{ - padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', - fontSize: '18px', fontFamily: 'monospace' - }} - /> -
-
- - setCloseTime(e.target.value)} - style={{ - padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', - fontSize: '18px', fontFamily: 'monospace' - }} - /> -
-
-
- - { - if (hasBreak) { - handleNext(); - } else { - handleUpdateScheduleSubmit(); - } - }} - disableNext={hasBreak === null} - nextLabel={hasBreak ? "Siguiente" : (isCreating ? "Guardando..." : "Guardar Horarios")} - > -
-
setHasBreak(true)} - style={{ - flex: 1, padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: hasBreak === true ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${hasBreak === true ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center' - }} - > - Sí, hay descanso -
-
setHasBreak(false)} - style={{ - flex: 1, padding: '15px', borderRadius: '12px', cursor: 'pointer', - background: hasBreak === false ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', - border: `2px solid ${hasBreak === false ? 'var(--wine-red)' : 'transparent'}`, - color: 'white', textAlign: 'center' - }} - > - No, horario corrido -
-
-
- - -
-
- - setBreakStart(e.target.value)} - style={{ - padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', - fontSize: '18px', fontFamily: 'monospace' - }} - /> -
-
- - setBreakEnd(e.target.value)} - style={{ - padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', - background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', - fontSize: '18px', fontFamily: 'monospace' - }} - /> -
-
-
+ )} ); - } + }; + + return ( + <> + +
+
setScheduleScope("me")} style={optionStyle(scheduleScope === "me")}> +

Solo a mí

+

Aplica esta rutina únicamente a tus horarios.

+
+
setScheduleScope("specific")} style={optionStyle(scheduleScope === "specific")}> +

A uno o más colaboradores

+

Aplica esta rutina a uno o varios integrantes específicos de tu equipo.

+
+
setScheduleScope("all")} style={optionStyle(scheduleScope === "all")}> +

A todos mis colaboradores

+

Actualiza masivamente los horarios de todo tu equipo.

+
+ + {scheduleScope === "specific" && ( +
+ + +
+ )} +
+
+ + +
+ {[ + { id: "mon-fri", label: "Lunes a Viernes" }, + { id: "mon-sat", label: "Lunes a Sábado" }, + { id: "mon-sun", label: "Todos los días" }, + { id: "specific-day", label: "Día específico" }, + ].map((mode) => ( +
setWorkingDaysMode(mode.id)} style={optionStyle(workingDaysMode === mode.id)}> + {mode.label} +
+ ))} +
+
+ + +
+ {weekDays.map((day) => ( +
setSpecificWeekDay(day.id)} style={optionStyle(specificWeekDay === day.id)}> + {day.label} +
+ ))} +
+
+ + +
+
+ + setOpenTime(e.target.value)} style={{ padding: "10px", borderRadius: "8px", border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)", color: "white", outline: "none", fontSize: "18px", fontFamily: "monospace" }} /> +
+
+ + setCloseTime(e.target.value)} style={{ padding: "10px", borderRadius: "8px", border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)", color: "white", outline: "none", fontSize: "18px", fontFamily: "monospace" }} /> +
+
+
+ + +
+
+
setHasBreak(true)} style={{ ...optionStyle(hasBreak === true), flex: 1 }}>Sí, hay descanso
+
setHasBreak(false)} style={{ ...optionStyle(hasBreak === false), flex: 1 }}>No, horario corrido
+
+ {renderConflictActions()} +
+
+ + +
+
+
+ + setBreakStart(e.target.value)} style={{ padding: "10px", borderRadius: "8px", border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)", color: "white", outline: "none", fontSize: "18px", fontFamily: "monospace" }} /> +
+
+ + setBreakEnd(e.target.value)} style={{ padding: "10px", borderRadius: "8px", border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)", color: "white", outline: "none", fontSize: "18px", fontFamily: "monospace" }} /> +
+
+ {renderConflictActions()} +
+
+ + handleUpdateScheduleSubmit()} disableNext={isCreating || (scheduleServiceScope === "specific" && (scheduleServiceIds.length === 0 || hasNoCommonServices))} nextLabel={isCreating ? "Guardando..." : "Guardar Horarios"}> +
+ {!canUseSpecificScheduleServices && ( +

Seleccioná al menos un colaborador para configurar servicios específicos.

+ )} +
handleServiceScopeSelect("all")} style={optionStyle(scheduleServiceScope === "all")}>Todos los servicios
+
canUseSpecificScheduleServices && handleServiceScopeSelect("specific")} style={{ ...optionStyle(scheduleServiceScope === "specific"), opacity: canUseSpecificScheduleServices ? 1 : 0.5, cursor: canUseSpecificScheduleServices ? "pointer" : "not-allowed" }}>Servicios específicos
+ + {scheduleServiceScope === "specific" && canUseSpecificScheduleServices && ( +
+ {hasNoCommonServices && ( +

Los colaboradores seleccionados no tienen servicios activos en común. Usá todos los servicios o cambiá la selección.

+ )} + {scheduleAvailableServices.map((service: any) => ( + + ))} +
+ )} + + {renderConflictActions()} +
+
+ + ); +} diff --git a/txclient/src/app/admin/assistant/components/StepEngine/helpers/schedules.ts b/txclient/src/app/admin/assistant/components/StepEngine/helpers/schedules.ts index 5d948aa..860121a 100644 --- a/txclient/src/app/admin/assistant/components/StepEngine/helpers/schedules.ts +++ b/txclient/src/app/admin/assistant/components/StepEngine/helpers/schedules.ts @@ -1,4 +1,10 @@ -type ScheduleBlock = { from: string; to: string; disabled: boolean }; +type ScheduleBlock = { + from: string; + to: string; + disabled: boolean; + serviceScope?: "all" | "specific"; + serviceIds?: string[]; +}; export const getWorkingDays = (workingDaysMode: string, customDays: number[] = []) => { if (workingDaysMode === "mon-fri") return [1, 2, 3, 4, 5]; @@ -13,14 +19,18 @@ export const buildScheduleBlocks = ( closeTime: string, hasBreak: boolean | null, breakStart: string, - breakEnd: string + breakEnd: string, + serviceScope: "all" | "specific" = "all", + serviceIds: string[] = [] ): ScheduleBlock[] => { + const serviceData = serviceScope === "specific" ? { serviceScope, serviceIds } : { serviceScope: "all" as const, serviceIds: [] }; + if (hasBreak) { return [ - { from: openTime, to: breakStart, disabled: false }, - { from: breakEnd, to: closeTime, disabled: false } + { from: openTime, to: breakStart, disabled: false, ...serviceData }, + { from: breakEnd, to: closeTime, disabled: false, ...serviceData } ]; } - return [{ from: openTime, to: closeTime, disabled: false }]; + return [{ from: openTime, to: closeTime, disabled: false, ...serviceData }]; }; diff --git a/txclient/src/app/components/ServicePrivate/ServicePrivate.module.css b/txclient/src/app/components/ServicePrivate/ServicePrivate.module.css new file mode 100644 index 0000000..4cfb597 --- /dev/null +++ b/txclient/src/app/components/ServicePrivate/ServicePrivate.module.css @@ -0,0 +1,217 @@ +.page { + width: 100vw; + min-height: 100dvh; + margin-left: calc(50% - 50vw); + margin-right: calc(50% - 50vw); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: + radial-gradient(circle at 20% 20%, rgba(255, 42, 127, 0.18), transparent 34%), + linear-gradient(180deg, var(--wine-darkest) 0%, var(--wine-dark) 100%); +} + +.card { + width: min(860px, calc(100% - 48px)); + display: grid; + grid-template-columns: minmax(260px, 0.9fr) minmax(300px, 1fr); + gap: 28px; + align-items: center; + padding: clamp(22px, 3vw, 36px); + border-radius: 28px; + background: rgba(255, 255, 255, 0.9); + box-shadow: 0 18px 54px rgba(0, 0, 0, 0.2); + border: 1px solid rgba(255, 255, 255, 0.42); + backdrop-filter: blur(8px); +} + +.illustrationPanel { + display: flex; + align-items: center; + justify-content: center; + min-height: 300px; + border-radius: 24px; + background: transparent; +} + +.illustration { + width: min(100%, 360px); + height: auto; + object-fit: contain; +} + +.content { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; + max-width: 430px; +} + +.badge { + display: inline-flex; + padding: 8px 14px; + border-radius: 999px; + color: var(--wine-red); + background: rgba(255, 42, 127, 0.1); + font-size: 0.85rem; + font-weight: 800; + letter-spacing: 0.02em; +} + +.content h1 { + margin: 0; + color: var(--wine-darkest); + font-size: clamp(2rem, 4vw, 3.15rem); + line-height: 1.04; + letter-spacing: -0.04em; +} + +.content p { + margin: 0; + color: rgba(37, 5, 37, 0.72); + font-size: 1.05rem; + line-height: 1.65; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 8px; +} + +.primaryAction, +.secondaryAction { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 46px; + padding: 0 22px; + border-radius: 999px; + font-weight: 800; + text-decoration: none; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.primaryAction { + color: white; + background: var(--wine-red); + box-shadow: 0 12px 26px rgba(255, 42, 127, 0.28); +} + +.secondaryAction { + color: var(--wine-red); + background: rgba(255, 42, 127, 0.08); +} + +.primaryAction:hover, +.secondaryAction:hover { + transform: translateY(-2px); +} + +@media (max-width: 760px) { + .page { + align-items: flex-start; + padding: 88px 16px 24px; + } + + .card { + grid-template-columns: 1fr; + gap: 18px; + text-align: center; + width: min(420px, calc(100% - 32px)); + padding: 22px 20px 24px; + border-radius: 24px; + background: rgba(255, 255, 255, 0.9); + box-shadow: 0 14px 38px rgba(0, 0, 0, 0.18); + } + + .content { + align-items: center; + max-width: 100%; + } + + .illustrationPanel { + min-height: 0; + padding: 16px; + border-radius: 20px; + } + + .illustration { + width: min(100%, 260px); + max-height: 230px; + } + + .content { + gap: 12px; + } + + .content h1 { + max-width: 12ch; + font-size: clamp(1.8rem, 8vw, 2.35rem); + line-height: 1.05; + } + + .content p { + max-width: 27ch; + font-size: 0.98rem; + line-height: 1.55; + } + + .actions { + width: 100%; + flex-direction: column; + align-items: center; + justify-content: center; + margin-top: 4px; + } + + .primaryAction, + .secondaryAction { + width: min(100%, 220px); + } +} + +@media (max-width: 420px) { + .page { + padding: 78px 12px 18px; + } + + .card { + width: min(390px, calc(100% - 24px)); + padding: 18px 18px 22px; + gap: 14px; + border-radius: 22px; + } + + .illustrationPanel { + padding: 12px; + } + + .illustration { + width: min(100%, 230px); + max-height: 205px; + } + + .badge { + padding: 7px 12px; + font-size: 0.78rem; + } + + .content h1 { + font-size: clamp(1.65rem, 7.5vw, 2rem); + } + + .content p { + font-size: 0.92rem; + } + + .primaryAction, + .secondaryAction { + min-height: 42px; + padding: 0 18px; + font-size: 0.92rem; + } +} diff --git a/txclient/src/app/components/ServicePrivate/ServicePrivate.tsx b/txclient/src/app/components/ServicePrivate/ServicePrivate.tsx index 91cd902..8927c25 100644 --- a/txclient/src/app/components/ServicePrivate/ServicePrivate.tsx +++ b/txclient/src/app/components/ServicePrivate/ServicePrivate.tsx @@ -1,13 +1,28 @@ +import Link from "next/link"; +import styles from "./ServicePrivate.module.css"; + export default function ServicePrivate() { return ( -
-

- Este servicio aun no ha sido publicado! -

-

- Este servicio no ha sido publicado. Por favor, intenta volver a - cargar la página o regresa más tarde. -

+
+
+ + +
+ Servicio no disponible +

Este servicio todavía no está publicado

+

+ El perfil público existe, pero el servicio aún no fue habilitado para recibir reservas. + Puede estar en preparación o haber sido retirado temporalmente. +

+ +
+ Ir al inicio + Explorar servicios +
+
+
); }