feat: add CollaboratorPicker component and integrate into CollaboratorScheduleSummaryFlow

- Implemented CollaboratorPicker for selecting employees with support for single and multiple selections.
- Created CollaboratorScheduleSummaryFlow to display collaborator schedules and exceptions.
- Added ServicePublicationFlow for managing service visibility on public pages.
- Introduced CSS styles for ServicePrivate component to enhance UI.
This commit is contained in:
2026-08-22 12:34:09 -03:00
parent b6399d28fc
commit 33743f12b9
25 changed files with 2145 additions and 287 deletions
@@ -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
}
];
@@ -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;
@@ -34,7 +34,7 @@ export default function QuestionCard({
<div className={classNames(style.cardContainer, style.animateEnter)}>
<div className={style.contentWrapper}>
{topAccessory && <div className={style.topAccessory}>{topAccessory}</div>}
<h1 className={style.title}>{title}</h1>
{title && <h1 className={style.title}>{title}</h1>}
{description && <p className={style.description}>{description}</p>}
<div className={style.inputArea}>
@@ -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<number>(1);
const [serviceImage, setServiceImage] = useState<File | null>(null);
const [createdServiceId, setCreatedServiceId] = useState<string | null>(null);
const [serviceCreateError, setServiceCreateError] = useState<string>("");
const [serviceAssignmentError, setServiceAssignmentError] = useState<string>("");
const [serviceAssignmentMode, setServiceAssignmentMode] = useState<"options" | "collaborators">("options");
const [serviceAssignmentWarning, setServiceAssignmentWarning] = useState<string>("");
const [isLoadingServiceCollaborators, setIsLoadingServiceCollaborators] = useState(false);
const [selectedServiceCollaboratorIds, setSelectedServiceCollaboratorIds] = useState<string[]>([]);
const [employeeId, setEmployeeId] = useState<string | null>(null);
const [doesProvideServices, setDoesProvideServices] = useState<boolean | null>(null);
// State for Schedule Flow
const [workingDaysMode, setWorkingDaysMode] = useState<string>(""); // "mon-fri", "mon-sat", "mon-sun", "custom"
const [specificWeekDay, setSpecificWeekDay] = useState<number | null>(null);
const [openTime, setOpenTime] = useState<string>("09:00");
const [closeTime, setCloseTime] = useState<string>("18:00");
const [hasBreak, setHasBreak] = useState<boolean | null>(null);
@@ -116,7 +128,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
const [isSuccess, setIsSuccess] = useState<boolean>(false);
const [isAddingAnotherService, setIsAddingAnotherService] = useState<boolean>(false);
const [isResuming, setIsResuming] = useState(false);
const [isResuming, setIsResuming] = useState(true);
const [headerImage, setHeaderImage] = useState<File | null>(null);
const [headerColor, setHeaderColor] = useState<string>("#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<CompanyServiceView[]>([]);
const [selectedPublicationServiceIds, setSelectedPublicationServiceIds] = useState<string[]>([]);
const [publicationLoading, setPublicationLoading] = useState(false);
const [publicationMessage, setPublicationMessage] = useState("");
const [collaboratorScheduleDetails, setCollaboratorScheduleDetails] = useState<CollaboratorScheduleDetails | null>(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<string | null>(null);
const [selectedScheduleEmployeeIds, setSelectedScheduleEmployeeIds] = useState<string[]>([]);
const [allEmployees, setAllEmployees] = useState<any[]>([]);
const [overrideDate, setOverrideDate] = useState<string>("");
const [allOrganizations, setAllOrganizations] = useState<any[]>([]);
@@ -163,6 +183,11 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
const [reservationPeriodSummary, setReservationPeriodSummary] = useState<ReservationPeriodsSummary | null>(null);
const [reservationPeriodEmployeesLoading, setReservationPeriodEmployeesLoading] = useState(false);
const [reservationPeriodEmployeesError, setReservationPeriodEmployeesError] = useState<string | null>(null);
const [scheduleServiceScope, setScheduleServiceScope] = useState<"all" | "specific">("all");
const [scheduleServiceIds, setScheduleServiceIds] = useState<string[]>([]);
const [scheduleAvailableServices, setScheduleAvailableServices] = useState<any[]>([]);
const [scheduleConflictMessage, setScheduleConflictMessage] = useState<string>("");
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<CompanyServiceView[]>("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<any[]>("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<any[]>("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<any[]>("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<any>("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<any[]>("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<any>("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<any>("employees/update-collaborator-services", {
companyId: createdCompanyId,
employeeId: finalEmployeeId,
services: [{ serviceId: createdServiceId, active: true }],
sessionUser: SessionInfo.userId
});
const schedulesRes = await API.post<any>("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<any[]>("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<any>("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<any>("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<any>("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<any>("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<any>("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 = () => (
<UpdateScheduleFlow
const renderUpdateScheduleFlow = () => {
const scheduleTargetEmployees = getUpdateScheduleTargetEmployees();
const scheduleTargetEmployeeIds = scheduleTargetEmployees.map((employee) => String(getEmployeeId(employee))).filter(Boolean);
const canUseSpecificScheduleServices = scheduleTargetEmployeeIds.length > 0;
return <UpdateScheduleFlow
currentStepIndex={currentStepIndex}
scheduleScope={scheduleScope}
setScheduleScope={setScheduleScope}
setScheduleScope={handleSetUpdateScheduleScope}
targetEmployeeId={targetEmployeeId}
setTargetEmployeeId={setTargetEmployeeId}
selectedScheduleEmployeeIds={selectedScheduleEmployeeIds}
handleToggleScheduleEmployee={handleToggleScheduleEmployee}
allEmployees={allEmployees}
handleNext={handleNext}
workingDaysMode={workingDaysMode}
setWorkingDaysMode={setWorkingDaysMode}
specificWeekDay={specificWeekDay}
setSpecificWeekDay={setSpecificWeekDay}
openTime={openTime}
setOpenTime={setOpenTime}
closeTime={closeTime}
@@ -1384,8 +1762,20 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
setBreakEnd={setBreakEnd}
isCreating={isCreating}
handleUpdateScheduleSubmit={handleUpdateScheduleSubmit}
/>
);
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<any>("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 = () => (
<PublicVisibilityFlow
isSelectedOrganizationPaidPlan={isSelectedOrganizationPaidPlan}
@@ -1768,6 +2197,59 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
/>
);
const renderServicePublicationFlow = () => (
<ServicePublicationFlow
mode={flowId === "publish-services" ? "publish" : "unpublish"}
currentStepIndex={currentStepIndex}
services={publicationServices}
selectedServiceIds={selectedPublicationServiceIds}
toggleService={togglePublicationService}
handleSave={handleSaveServicePublication}
isCreating={isCreating}
isLoading={publicationLoading}
message={publicationMessage}
selectedOrganization={selectedOrganization}
orgName={orgName}
/>
);
const handleLoadCollaboratorScheduleDetails = async () => {
if (!createdCompanyId || !targetEmployeeId || collaboratorScheduleLoading) return;
setCollaboratorScheduleLoading(true);
setCollaboratorScheduleMessage("");
try {
const details = await API.post<CollaboratorScheduleDetails>("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 = () => (
<CollaboratorScheduleSummaryFlow
currentStepIndex={currentStepIndex}
employees={allEmployees}
selectedEmployeeId={targetEmployeeId}
setSelectedEmployeeId={setTargetEmployeeId}
details={collaboratorScheduleDetails}
loading={collaboratorScheduleLoading}
message={collaboratorScheduleMessage}
selectedOrganization={selectedOrganization}
orgName={orgName}
loadDetails={handleLoadCollaboratorScheduleDetails}
goToAssistantHome={() => 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 (
<QuestionCard
isActive={true}
title="Necesitás una organización"
description="Para continuar con este flujo primero tenés que crear o tener asociada una organización. Sin una organización no podemos aplicar esta configuración."
onNext={() => router.push("/admin/assistant")}
nextLabel="Volver al inicio del asistente"
>
<div style={{ color: "rgba(255,255,255,0.75)", textAlign: "center", lineHeight: 1.6 }}>
Creá una organización desde el inicio del asistente y después volvé a intentar esta acción.
</div>
</QuestionCard>
);
}
if (allOrganizations.length > 1 && !createdCompanyId && action !== "new-org") {
return <OrganizationPicker allOrganizations={allOrganizations} loadOrganizationContext={loadOrganizationContext} />;
}
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 (
<div className={style.engineContainer}>
<div className={style.header}>
@@ -1960,7 +2462,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
<span>Volver</span>
</div>
<div className={style.flowTitle}>
Configuración de Negocio
{!shouldHideFlowTitle && "Configuración de Negocio"}
</div>
<div className={style.progressIndicator}>
{/* Placeholder for progress dots or percentage */}
@@ -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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', width: '100%' }}>
{employees.map((employee: any) => {
const employeeId = getEmployeeId(employee);
const isSelected = multiple ? selectedIds.includes(employeeId) : selectedId === employeeId;
const label = getCollaboratorLabel(employee);
return (
<label
key={employeeId}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 14px',
borderRadius: '12px',
border: `1px solid ${isSelected ? 'var(--wine-red)' : 'rgba(255,255,255,0.16)'}`,
background: isSelected ? 'rgba(128, 0, 64, 0.3)' : 'rgba(255,255,255,0.05)',
color: 'white',
cursor: disabled ? 'not-allowed' : 'pointer'
}}
>
<input
type={multiple ? "checkbox" : "radio"}
checked={isSelected}
disabled={disabled}
onChange={() => {
if (disabled) return;
if (multiple) {
onToggle?.(employee);
} else if (employeeId) {
onSelect?.(employeeId);
}
}}
/>
<span style={{ fontSize: '15px' }}>{label}</span>
</label>
);
})}
</div>
);
}
@@ -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.";
@@ -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<string, { id: string; name: string }>;
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 (
<button
type="button"
onClick={() => setOpen(prev => !prev)}
style={{ ...cardStyle, width: "100%", textAlign: "left", cursor: "pointer" }}
>
<span style={{ display: "flex", justifyContent: "space-between", gap: "12px", alignItems: "center" }}>
<span style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<strong>{title}</strong>
<span style={mutedStyle}>{subtitle}</span>
</span>
<span style={{ color: "rgba(255,255,255,0.72)", fontSize: "13px" }}>{open ? "Ocultar" : "Ver detalle"}</span>
</span>
{open && (
<span style={{ display: "block", marginTop: "12px", paddingTop: "12px", borderTop: "1px solid rgba(255,255,255,0.1)" }}>
{children}
</span>
)}
</button>
);
}
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 (
<QuestionCard
isActive={true}
title="Elegí el colaborador"
description="Seleccioná a quién querés consultar. Esta acción es sólo de lectura."
onNext={loadDetails}
disableNext={!selectedEmployeeId || loading || employees.length === 0}
nextLabel={loading ? "Consultando..." : "Ver horarios"}
>
<div style={{ display: "flex", flexDirection: "column", gap: "14px", width: "100%" }}>
<div style={{ ...mutedStyle, textAlign: "center" }}>
Organización: <strong style={{ color: "white" }}>{selectedOrganization?.name || orgName || "seleccionada"}</strong>
</div>
{message && <div style={cardStyle}>{message}</div>}
{employees.length === 0 ? (
<div style={{ ...cardStyle, ...mutedStyle, textAlign: "center" }}>No encontramos colaboradores activos en esta organización.</div>
) : (
<CollaboratorPicker employees={employees} selectedId={selectedEmployeeId} onSelect={setSelectedEmployeeId} disabled={loading} />
)}
</div>
</QuestionCard>
);
}
return (
<QuestionCard
isActive={true}
title=""
onNext={goToAssistantHome}
nextLabel="Volver al asistente"
>
<div style={{ display: "flex", flexDirection: "column", gap: "16px", width: "100%", paddingTop: "150px" }}>
{message && <div style={cardStyle}>{message}</div>}
{!details ? (
<div style={{ ...cardStyle, ...mutedStyle, textAlign: "center" }}>No hay datos para mostrar.</div>
) : (
<>
<div style={{ ...cardStyle, display: "flex", alignItems: "center", justifyContent: "center", gap: "12px", textAlign: "left", padding: "18px 14px", marginTop: "4px" }}>
<img
src={details.collaborator.avatar}
alt={details.collaborator.fullName}
style={{ width: "46px", height: "46px", borderRadius: "50%", objectFit: "cover", border: "2px solid rgba(255,255,255,0.18)", flex: "0 0 auto" }}
/>
<div>
<strong style={{ display: "block", fontSize: "18px", lineHeight: 1.35 }}>{details.collaborator.fullName}</strong>
<div style={mutedStyle}>{details.collaborator.email || "Sin email registrado"}</div>
</div>
</div>
<section style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
<h3 style={{ margin: 0, fontSize: "16px", color: "white" }}>Horarios semanales</h3>
{configuredWeeklySchedules.length === 0 ? (
<div style={{ ...cardStyle, ...mutedStyle }}>Este colaborador no tiene horarios semanales configurados.</div>
) : configuredWeeklySchedules.map(day => (
<div key={day.scheduleId || day.weekDay} style={cardStyle}>
<strong>{weekDays[day.weekDay] || `Día ${day.weekDay}`}</strong>
<div style={{ display: "flex", flexDirection: "column", gap: "6px", marginTop: "8px" }}>
{day.schedules.map((schedule, index) => (
<span key={`${day.weekDay}-${index}`} style={mutedStyle}>{formatSchedule(schedule, details.servicesById)}</span>
))}
</div>
</div>
))}
</section>
<section style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
<h3 style={{ margin: 0, fontSize: "16px", color: "white" }}>Excepciones futuras</h3>
{details.futureOverrides.length === 0 ? (
<div style={{ ...cardStyle, ...mutedStyle }}>No hay excepciones futuras configuradas.</div>
) : details.futureOverrides.map((override, index) => (
<ExpandableItem key={override.id || override._id || index} title={formatDate(override.date)} subtitle={`${override.schedules.length} bloque(s) configurado(s)`}>
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
{override.schedules.map((schedule, scheduleIndex) => (
<span key={scheduleIndex} style={mutedStyle}>{formatSchedule(schedule, details.servicesById)}</span>
))}
</div>
</ExpandableItem>
))}
</section>
<section style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
<h3 style={{ margin: 0, fontSize: "16px", color: "white" }}>Restricciones / agenda cerrada</h3>
{details.futureDisabledSchedules.length === 0 ? (
<div style={{ ...cardStyle, ...mutedStyle }}>No hay cierres de agenda futuros configurados.</div>
) : details.futureDisabledSchedules.map((disabled, index) => (
<ExpandableItem
key={disabled.id || disabled._id || index}
title={`${formatDate(disabled.startDate)} al ${formatDate(disabled.endDate)}`}
subtitle="Agenda cerrada para este colaborador"
>
<div style={mutedStyle}>Durante este período no se muestran turnos disponibles para el colaborador.</div>
</ExpandableItem>
))}
</section>
</>
)}
</div>
</QuestionCard>
);
}
@@ -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 (
<>
<QuestionCard
isActive={currentStepIndex === 0}
@@ -75,7 +110,7 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
title="2. Duración del Servicio"
description={`Elige cuánto tiempo tomará. Las opciones están basadas en tu fracción de agenda (${fraction} min).`}
onNext={handleNext}
disableNext={!serviceLength}
disableNext={!serviceLength || (isCustomDuration && !isValidCustomDuration)}
>
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap', width: '100%', justifyContent: 'center' }}>
{durationOptions.map(val => {
@@ -88,15 +123,15 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
return (
<div
key={val}
onClick={() => 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)
</div>
);
})}
<div
onClick={selectCustomDuration}
style={{
padding: '12px 24px',
borderRadius: '12px',
cursor: 'pointer',
background: isCustomDuration ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)',
border: `2px solid ${isCustomDuration ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`,
color: 'white',
fontWeight: isCustomDuration ? 'bold' : 'normal',
transition: 'all 0.2s ease',
fontSize: '16px'
}}
>
Otro
</div>
</div>
{isCustomDuration && (
<div style={{ width: '100%', marginTop: '16px' }}>
<QuestionInput
type="number"
min="1"
step="1"
placeholder="Duración en minutos"
value={customDurationValue}
onChange={(e) => updateCustomDuration(e.target.value)}
onKeyDown={(e) => {
if (isValidCustomDuration) {
handleKeyDown(e, handleNext);
}
}}
autoFocus
/>
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: '13px', marginTop: '8px', textAlign: 'center' }}>
Ingresá la duración total del servicio expresada en minutos.
</div>
</div>
)}
</QuestionCard>
<QuestionCard
@@ -113,7 +185,7 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
title="3. Precio y Cupos"
description="¿Cuánto cuesta y a cuántas personas puedes atender a la vez en este servicio?"
onNext={handleCreateService}
disableNext={isCreating || !servicePrice}
disableNext={isCreating}
nextLabel={isCreating ? "Guardando..." : "Crear Servicio"}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', width: '100%' }}>
@@ -122,7 +194,8 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
<div style={{ position: 'relative' }}>
<span style={{ position: 'absolute', left: '15px', top: '50%', transform: 'translateY(-50%)', color: 'white', fontSize: '18px' }}>$</span>
<input
type="number"
type="text"
inputMode="decimal"
placeholder="0.00"
value={servicePrice}
onChange={(e) => setServicePrice(e.target.value)}
@@ -163,6 +236,20 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
))}
</div>
</div>
{serviceCreateError && (
<div style={{
padding: '14px 16px',
borderRadius: '12px',
background: 'rgba(255, 99, 132, 0.12)',
border: '1px solid rgba(255, 99, 132, 0.35)',
color: '#fecdd3',
fontSize: '14px',
lineHeight: 1.5,
textAlign: 'center'
}}>
{serviceCreateError}
</div>
)}
</div>
</QuestionCard>
@@ -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")}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', width: '100%', alignItems: 'center' }}>
<div style={{
@@ -216,8 +303,151 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
}}
/>
</div>
{serviceCreateError && (
<div style={{
padding: '14px 16px',
borderRadius: '12px',
background: 'rgba(255, 99, 132, 0.12)',
border: '1px solid rgba(255, 99, 132, 0.35)',
color: '#fecdd3',
fontSize: '14px',
lineHeight: 1.5,
textAlign: 'center'
}}>
{serviceCreateError}
</div>
)}
</div>
</QuestionCard>
{isStandaloneNewService && (
<QuestionCard
isActive={currentStepIndex === 5}
title="¿Quién brinda este servicio?"
description="Asigná este servicio a un colaborador para que pueda recibir reservas. Si todavía no querés hacerlo, podés configurarlo después."
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px', width: '100%' }}>
<button
type="button"
onClick={handleAssignCreatedServiceToMe}
disabled={isCreating}
style={{
padding: '14px 18px',
borderRadius: '12px',
border: '1px solid var(--wine-red)',
background: 'var(--wine-red)',
color: 'white',
fontSize: '16px',
fontWeight: 'bold',
cursor: isCreating ? 'not-allowed' : 'pointer',
opacity: isCreating ? 0.7 : 1
}}
>
{isCreating ? "Asignando..." : "Yo"}
</button>
<button
type="button"
onClick={handleShowCollaboratorAssignment}
disabled={isCreating || isLoadingServiceCollaborators}
style={{
padding: '14px 18px',
borderRadius: '12px',
border: serviceAssignmentMode === "collaborators" ? '1px solid var(--wine-red)' : '1px solid rgba(255,255,255,0.2)',
background: serviceAssignmentMode === "collaborators" ? 'rgba(128, 0, 64, 0.35)' : 'rgba(255,255,255,0.05)',
color: 'white',
fontSize: '16px',
cursor: isCreating || isLoadingServiceCollaborators ? 'not-allowed' : 'pointer',
opacity: isCreating || isLoadingServiceCollaborators ? 0.7 : 1
}}
>
{isLoadingServiceCollaborators ? "Cargando colaboradores..." : "Uno o más colaboradores"}
</button>
{serviceAssignmentMode === "collaborators" && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', width: '100%' }}>
{serviceAssignmentCollaborators.length === 0 && !isLoadingServiceCollaborators && (
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', textAlign: 'center' }}>
No encontramos colaboradores activos para esta organización.
</div>
)}
<CollaboratorPicker
employees={serviceAssignmentCollaborators}
selectedIds={selectedServiceCollaboratorIds}
multiple
disabled={isCreating}
onToggle={handleToggleServiceCollaborator}
/>
{serviceAssignmentCollaborators.length > 0 && (
<button
type="button"
onClick={handleAssignCreatedServiceToCollaborators}
disabled={isCreating || selectedServiceCollaboratorIds.length === 0}
style={{
padding: '14px 18px',
borderRadius: '12px',
border: '1px solid var(--wine-red)',
background: 'var(--wine-red)',
color: 'white',
fontSize: '16px',
fontWeight: 'bold',
cursor: isCreating || selectedServiceCollaboratorIds.length === 0 ? 'not-allowed' : 'pointer',
opacity: isCreating || selectedServiceCollaboratorIds.length === 0 ? 0.7 : 1
}}
>
{isCreating ? "Asignando..." : "Asignar servicio"}
</button>
)}
</div>
)}
{!serviceAssignmentWarning && (
<>
<button
type="button"
onClick={handleFinishWithoutAssignment}
disabled={isCreating}
style={{
padding: '14px 18px',
borderRadius: '12px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: 'white',
fontSize: '16px',
cursor: isCreating ? 'not-allowed' : 'pointer',
opacity: isCreating ? 0.7 : 1
}}
>
Lo configuro después
</button>
<div style={{
padding: '12px 14px',
borderRadius: '12px',
background: 'rgba(245, 158, 11, 0.12)',
border: '1px solid rgba(245, 158, 11, 0.35)',
color: '#fde68a',
fontSize: '14px',
lineHeight: 1.5,
textAlign: 'center'
}}>
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.
</div>
</>
)}
{serviceAssignmentError && (
<div style={{
padding: '12px 14px',
borderRadius: '12px',
background: 'rgba(255, 99, 132, 0.12)',
border: '1px solid rgba(255, 99, 132, 0.35)',
color: '#fecdd3',
fontSize: '14px',
lineHeight: 1.5,
textAlign: 'center'
}}>
{serviceAssignmentError}
</div>
)}
</div>
</QuestionCard>
)}
</>
);
@@ -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 (
<QuestionCard
isActive={currentStepIndex === 0}
title={title}
description={description}
onNext={handleSave}
disableNext={isLoading || isCreating || selectedServiceIds.length === 0 || services.length === 0}
nextLabel={isLoading ? "Cargando servicios..." : nextLabel}
>
<div style={{ display: "flex", flexDirection: "column", gap: "14px", width: "100%" }}>
<div style={{ color: "rgba(255,255,255,0.72)", fontSize: "14px", textAlign: "center" }}>
Organización: <strong style={{ color: "white" }}>{selectedOrganization?.name || orgName || "seleccionada"}</strong>
</div>
{message && (
<div style={{ padding: "12px 14px", borderRadius: "12px", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.12)", color: "rgba(255,255,255,0.82)", textAlign: "center", lineHeight: 1.4 }}>
{message}
</div>
)}
{!isLoading && services.length === 0 && (
<div style={{ padding: "18px", borderRadius: "14px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "rgba(255,255,255,0.75)", textAlign: "center", lineHeight: 1.5 }}>
{emptyMessage}
</div>
)}
{services.map(service => {
const selected = selectedServiceIds.includes(service.id);
return (
<button
key={service.id}
type="button"
onClick={() => toggleService(service.id)}
disabled={isCreating}
style={{
display: "flex",
gap: "14px",
alignItems: "center",
width: "100%",
padding: "12px",
borderRadius: "14px",
border: `2px solid ${selected ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`,
background: selected ? "rgba(255,42,127,0.18)" : "rgba(255,255,255,0.05)",
color: "white",
cursor: isCreating ? "not-allowed" : "pointer",
textAlign: "left",
transition: "all 0.2s ease"
}}
>
<img
src={getServiceImage(service.image)}
alt={service.name}
style={{ width: "62px", height: "62px", objectFit: "cover", borderRadius: "12px", background: "rgba(255,255,255,0.08)", flex: "0 0 auto" }}
/>
<span style={{ display: "flex", flexDirection: "column", gap: "4px", flex: 1 }}>
<span style={{ fontWeight: 800, fontSize: "15px" }}>{service.name}</span>
<span style={{ color: "rgba(255,255,255,0.68)", fontSize: "13px", lineHeight: 1.35 }}>{service.description || "Sin descripción"}</span>
</span>
<span style={{ width: "22px", height: "22px", borderRadius: "50%", border: `2px solid ${selected ? "var(--wine-red)" : "rgba(255,255,255,0.35)"}`, background: selected ? "var(--wine-red)" : "transparent", flex: "0 0 auto" }} />
</button>
);
})}
</div>
</QuestionCard>
);
}
@@ -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 (
<>
<QuestionCard
isActive={currentStepIndex === 0}
title="Actualizar Horarios"
description="¿A quiénes quieres aplicar esta nueva rutina de horarios?"
onNext={() => {
if (scheduleScope === "specific" && !targetEmployeeId) {
alert("Por favor selecciona un colaborador.");
return;
}
handleNext();
}}
disableNext={!scheduleScope || (scheduleScope === "specific" && !targetEmployeeId)}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', width: '100%' }}>
<div
onClick={() => 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'
}}
>
<h4 style={{ margin: '0 0 5px 0' }}>Solo a </h4>
<p style={{ margin: 0, fontSize: '12px', opacity: 0.7 }}>Aplica esta rutina únicamente a tus horarios.</p>
</div>
<div
onClick={() => 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'
}}
>
<h4 style={{ margin: '0 0 5px 0' }}>A un colaborador en particular</h4>
<p style={{ margin: 0, fontSize: '12px', opacity: 0.7 }}>Aplica esta rutina a un integrante específico de tu equipo.</p>
</div>
<div
onClick={() => 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'
}}
>
<h4 style={{ margin: '0 0 5px 0' }}>A todos mis colaboradores</h4>
<p style={{ margin: 0, fontSize: '12px', opacity: 0.7 }}>Sobrescribe masivamente los horarios de todo tu equipo.</p>
</div>
{scheduleScope === "specific" && (
<div style={{ marginTop: '15px', width: '100%' }}>
<label style={{ color: 'white', display: 'block', marginBottom: '8px', fontSize: '14px' }}>Selecciona el colaborador:</label>
<select
value={targetEmployeeId || ""}
onChange={(e) => setTargetEmployeeId(e.target.value)}
style={{
width: '100%', padding: '12px', borderRadius: '8px',
background: 'rgba(255,255,255,0.1)', color: 'white', border: '1px solid rgba(255,255,255,0.2)',
outline: 'none', fontSize: '16px'
}}
>
<option value="" disabled style={{ color: 'black' }}>-- Seleccionar --</option>
{allEmployees.map((emp: any) => (
<option key={emp.id || emp._id || emp.employeeId} value={emp.id || emp._id || emp.employeeId} style={{ color: 'black' }}>
{emp.firstName} {emp.lastName}
</option>
))}
</select>
</div>
)}
<div style={{ background: "rgba(255, 193, 7, 0.14)", border: "1px solid rgba(255, 193, 7, 0.45)", borderRadius: "12px", padding: "12px", color: "#fff4cf", whiteSpace: "pre-line" }}>
{scheduleConflictMessage}
</div>
{pendingScheduleOverwrite && (
<div style={{ display: "flex", gap: "10px", justifyContent: "center" }}>
<button type="button" onClick={() => { setScheduleConflictMessage(""); setPendingScheduleOverwrite(false); }} style={{ padding: "10px 14px", borderRadius: "10px", border: "1px solid rgba(255,255,255,0.25)", background: "transparent", color: "white" }}>Cancelar / Editar</button>
<button type="button" onClick={() => handleUpdateScheduleSubmit("overwrite-conflicts")} style={{ padding: "10px 14px", borderRadius: "10px", border: "0", background: "var(--wine-red)", color: "white" }}>Sobrescribir conflictos</button>
</div>
</QuestionCard>
<QuestionCard
isActive={currentStepIndex === 1}
title="Días de Trabajo"
description="¿Qué días aplicará esta rutina?"
onNext={handleNext}
disableNext={!workingDaysMode}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', width: '100%' }}>
{[
{ 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 => (
<div
key={mode.id}
onClick={() => 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}
</div>
))}
</div>
</QuestionCard>
<QuestionCard
isActive={currentStepIndex === 2}
title="Horario Comercial"
description="¿A qué hora empieza y termina la jornada laboral?"
onNext={handleNext}
disableNext={!openTime || !closeTime}
>
<div style={{ display: 'flex', gap: '20px', width: '100%', justifyContent: 'center' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
<label style={{ color: 'white', fontSize: '12px', opacity: 0.7 }}>Apertura</label>
<input
type="time"
value={openTime}
onChange={(e) => 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'
}}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
<label style={{ color: 'white', fontSize: '12px', opacity: 0.7 }}>Cierre</label>
<input
type="time"
value={closeTime}
onChange={(e) => 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'
}}
/>
</div>
</div>
</QuestionCard>
<QuestionCard
isActive={currentStepIndex === 3}
title="Descansos"
description="¿Tienen un horario de corte al mediodía o descanso?"
onNext={() => {
if (hasBreak) {
handleNext();
} else {
handleUpdateScheduleSubmit();
}
}}
disableNext={hasBreak === null}
nextLabel={hasBreak ? "Siguiente" : (isCreating ? "Guardando..." : "Guardar Horarios")}
>
<div style={{ display: 'flex', gap: '15px', width: '100%', justifyContent: 'center' }}>
<div
onClick={() => 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'
}}
>
, hay descanso
</div>
<div
onClick={() => 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
</div>
</div>
</QuestionCard>
<QuestionCard
isActive={currentStepIndex === 4}
title="Horario de Descanso"
description="¿De qué hora a qué hora no estarán disponibles?"
onNext={handleUpdateScheduleSubmit}
disableNext={!breakStart || !breakEnd || isCreating}
nextLabel={isCreating ? "Guardando..." : "Guardar Horarios"}
>
<div style={{ display: 'flex', gap: '20px', width: '100%', justifyContent: 'center' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
<label style={{ color: 'white', fontSize: '12px', opacity: 0.7 }}>Inicio descanso</label>
<input
type="time"
value={breakStart}
onChange={(e) => 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'
}}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
<label style={{ color: 'white', fontSize: '12px', opacity: 0.7 }}>Fin descanso</label>
<input
type="time"
value={breakEnd}
onChange={(e) => 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'
}}
/>
</div>
</div>
</QuestionCard>
)}
</>
);
}
};
return (
<>
<QuestionCard
isActive={currentStepIndex === 0}
title="Actualizar Horarios"
description="¿A quiénes querés aplicar esta nueva rutina de horarios?"
onNext={handleNext}
disableNext={!scheduleScope || (scheduleScope === "specific" && selectedScheduleEmployeeIds.length === 0)}
>
<div style={{ display: "flex", flexDirection: "column", gap: "10px", width: "100%" }}>
<div onClick={() => setScheduleScope("me")} style={optionStyle(scheduleScope === "me")}>
<h4 style={{ margin: "0 0 5px 0" }}>Solo a </h4>
<p style={{ margin: 0, fontSize: "12px", opacity: 0.7 }}>Aplica esta rutina únicamente a tus horarios.</p>
</div>
<div onClick={() => setScheduleScope("specific")} style={optionStyle(scheduleScope === "specific")}>
<h4 style={{ margin: "0 0 5px 0" }}>A uno o más colaboradores</h4>
<p style={{ margin: 0, fontSize: "12px", opacity: 0.7 }}>Aplica esta rutina a uno o varios integrantes específicos de tu equipo.</p>
</div>
<div onClick={() => setScheduleScope("all")} style={optionStyle(scheduleScope === "all")}>
<h4 style={{ margin: "0 0 5px 0" }}>A todos mis colaboradores</h4>
<p style={{ margin: 0, fontSize: "12px", opacity: 0.7 }}>Actualiza masivamente los horarios de todo tu equipo.</p>
</div>
{scheduleScope === "specific" && (
<div style={{ marginTop: "15px", width: "100%" }}>
<label style={{ color: "white", display: "block", marginBottom: "8px", fontSize: "14px" }}>Seleccioná uno o más colaboradores:</label>
<CollaboratorPicker employees={allEmployees} multiple selectedIds={selectedScheduleEmployeeIds} onToggle={handleToggleScheduleEmployee} />
</div>
)}
</div>
</QuestionCard>
<QuestionCard isActive={currentStepIndex === 1} title="Días de Trabajo" description="¿Qué días aplicará esta rutina?" onNext={handleNext} disableNext={!workingDaysMode}>
<div style={{ display: "flex", flexDirection: "column", gap: "10px", width: "100%" }}>
{[
{ 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) => (
<div key={mode.id} onClick={() => setWorkingDaysMode(mode.id)} style={optionStyle(workingDaysMode === mode.id)}>
{mode.label}
</div>
))}
</div>
</QuestionCard>
<QuestionCard isActive={currentStepIndex === weekDayStep && isSpecificDay} title="Día específico" description="Elegí el día que querés configurar." onNext={handleNext} disableNext={specificWeekDay === null}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(130px, 1fr))", gap: "10px", width: "100%" }}>
{weekDays.map((day) => (
<div key={day.id} onClick={() => setSpecificWeekDay(day.id)} style={optionStyle(specificWeekDay === day.id)}>
{day.label}
</div>
))}
</div>
</QuestionCard>
<QuestionCard isActive={currentStepIndex === timeStep} title="Horario Comercial" description="¿A qué hora empieza y termina la jornada laboral?" onNext={handleNext} disableNext={!openTime || !closeTime}>
<div style={{ display: "flex", gap: "20px", width: "100%", justifyContent: "center" }}>
<div style={{ display: "flex", flexDirection: "column", gap: "5px" }}>
<label style={{ color: "white", fontSize: "12px", opacity: 0.7 }}>Apertura</label>
<input type="time" value={openTime} onChange={(e) => 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" }} />
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "5px" }}>
<label style={{ color: "white", fontSize: "12px", opacity: 0.7 }}>Cierre</label>
<input type="time" value={closeTime} onChange={(e) => 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" }} />
</div>
</div>
</QuestionCard>
<QuestionCard isActive={currentStepIndex === breakStep} title="Descansos" description="¿Tienen un horario de corte al mediodía o descanso?" onNext={continueAfterBreak} disableNext={hasBreak === null} nextLabel={hasBreak ? "Siguiente" : (isSpecificDay ? "Siguiente" : isCreating ? "Guardando..." : "Guardar Horarios")}>
<div style={{ display: "flex", flexDirection: "column", gap: "12px", width: "100%" }}>
<div style={{ display: "flex", gap: "15px", width: "100%", justifyContent: "center" }}>
<div onClick={() => setHasBreak(true)} style={{ ...optionStyle(hasBreak === true), flex: 1 }}>, hay descanso</div>
<div onClick={() => setHasBreak(false)} style={{ ...optionStyle(hasBreak === false), flex: 1 }}>No, horario corrido</div>
</div>
{renderConflictActions()}
</div>
</QuestionCard>
<QuestionCard isActive={currentStepIndex === breakTimeStep && hasBreak === true} title="Horario de Descanso" description="¿De qué hora a qué hora no estarán disponibles?" onNext={isSpecificDay ? handleNext : handleUpdateScheduleSubmit} disableNext={!breakStart || !breakEnd || isCreating} nextLabel={isSpecificDay ? "Siguiente" : isCreating ? "Guardando..." : "Guardar Horarios"}>
<div style={{ display: "flex", flexDirection: "column", gap: "12px", width: "100%" }}>
<div style={{ display: "flex", gap: "20px", width: "100%", justifyContent: "center" }}>
<div style={{ display: "flex", flexDirection: "column", gap: "5px" }}>
<label style={{ color: "white", fontSize: "12px", opacity: 0.7 }}>Inicio descanso</label>
<input type="time" value={breakStart} onChange={(e) => 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" }} />
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "5px" }}>
<label style={{ color: "white", fontSize: "12px", opacity: 0.7 }}>Fin descanso</label>
<input type="time" value={breakEnd} onChange={(e) => 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" }} />
</div>
</div>
{renderConflictActions()}
</div>
</QuestionCard>
<QuestionCard isActive={currentStepIndex === serviceScopeStep && isSpecificDay} title="Servicios del horario" description="¿Este horario aplica a todos los servicios o solo a algunos?" onNext={() => handleUpdateScheduleSubmit()} disableNext={isCreating || (scheduleServiceScope === "specific" && (scheduleServiceIds.length === 0 || hasNoCommonServices))} nextLabel={isCreating ? "Guardando..." : "Guardar Horarios"}>
<div style={{ display: "flex", flexDirection: "column", gap: "10px", width: "100%" }}>
{!canUseSpecificScheduleServices && (
<p style={{ color: "#ffd8a8", margin: 0, fontSize: "14px" }}>Seleccioná al menos un colaborador para configurar servicios específicos.</p>
)}
<div onClick={() => handleServiceScopeSelect("all")} style={optionStyle(scheduleServiceScope === "all")}>Todos los servicios</div>
<div onClick={() => canUseSpecificScheduleServices && handleServiceScopeSelect("specific")} style={{ ...optionStyle(scheduleServiceScope === "specific"), opacity: canUseSpecificScheduleServices ? 1 : 0.5, cursor: canUseSpecificScheduleServices ? "pointer" : "not-allowed" }}>Servicios específicos</div>
{scheduleServiceScope === "specific" && canUseSpecificScheduleServices && (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{hasNoCommonServices && (
<p style={{ color: "#ffd8a8", margin: 0, fontSize: "14px" }}>Los colaboradores seleccionados no tienen servicios activos en común. Usá todos los servicios o cambiá la selección.</p>
)}
{scheduleAvailableServices.map((service: any) => (
<label key={service.id || service._id} style={{ color: "white", display: "flex", gap: "10px", alignItems: "center" }}>
<input type="checkbox" checked={scheduleServiceIds.includes(service.id || service._id)} onChange={() => toggleService(service.id || service._id)} />
{service.name}
</label>
))}
</div>
)}
{renderConflictActions()}
</div>
</QuestionCard>
</>
);
}
@@ -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 }];
};
@@ -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;
}
}
@@ -1,13 +1,28 @@
import Link from "next/link";
import styles from "./ServicePrivate.module.css";
export default function ServicePrivate() {
return (
<div className="card error-card">
<h2 style={{ color: "var(--wine-red)" }}>
Este servicio aun no ha sido publicado!
</h2>
<p>
Este servicio no ha sido publicado. Por favor, intenta volver a
cargar la página o regresa más tarde.
</p>
<div className={styles.page}>
<section className={styles.card} aria-labelledby="service-private-title">
<div className={styles.illustrationPanel} aria-hidden="true">
<img src="/error.png" alt="" className={styles.illustration} />
</div>
<div className={styles.content}>
<span className={styles.badge}>Servicio no disponible</span>
<h1 id="service-private-title">Este servicio todavía no está publicado</h1>
<p>
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.
</p>
<div className={styles.actions}>
<Link href="/" className={styles.primaryAction}>Ir al inicio</Link>
<Link href="/landing" className={styles.secondaryAction}>Explorar servicios</Link>
</div>
</div>
</section>
</div>
);
}