feat: update collaborator invitation flow to handle existing users and pending invitations in StepEngine

This commit is contained in:
2026-07-20 14:46:14 -03:00
parent b01149a19e
commit a9afb13d15
4 changed files with 180 additions and 56 deletions
@@ -205,13 +205,13 @@ export default function ClientsGrid() {
<span className={pageStyle.bannedBadge}>Baneado</span>
)}
</div>
<p className={pageStyle.clientDetail}>
<div className={pageStyle.clientDetail}>
<div className={pageStyle.iconWrapper}>
<EmailOutlinedIcon sx={{ fontSize: 14 }} />
</div>
{client.email || "Sin email"}
</p>
<p className={pageStyle.clientPhone}>
</div>
<div className={pageStyle.clientPhone}>
<div className={pageStyle.iconWrapper}>
<PhoneOutlinedIcon sx={{ fontSize: 14 }} />
</div>
@@ -219,7 +219,7 @@ export default function ClientsGrid() {
? `${client.phoneCountryCode || ''} ${client.phoneAreaCode} - ${client.phoneNumber}`
: "Sin teléfono"
}
</p>
</div>
</div>
</div>
))}
@@ -120,6 +120,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
const [inviteRole, setInviteRole] = useState<"admin" | "colaborador">("colaborador");
const [isSearchingEmail, setIsSearchingEmail] = useState(false);
const [isUpgradeRequired, setIsUpgradeRequired] = useState(false);
const [inviteFlowNotice, setInviteFlowNotice] = useState("");
// State for Setup Collaborator Flow
const [collaboratorToSetup, setCollaboratorToSetup] = useState<any | null>(null);
@@ -128,6 +129,13 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
const [setupSelectedServices, setSetupSelectedServices] = useState<string[]>([]);
const [setupAvailableServices, setSetupAvailableServices] = useState<any[]>([]);
const getEmployeeUserId = (employee: any) => employee.userId?._id || employee.userId?.id || employee.userId;
const canManageEmployeeSchedules = (employee: any) => {
const isCurrentUser = String(getEmployeeUserId(employee)) === String(SessionInfo.userId);
return !employee.removed && (isCurrentUser || employee.guestOk === true);
};
const loadOrganizationContext = (org: any) => {
setCreatedCompanyId(org.id || org._id);
setOrgName(org.name || "");
@@ -219,11 +227,9 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
API.post<any[]>("employees/get-by-company", { companyId: org.id || org._id })
.then(empRes => {
if (empRes && empRes.length > 0) {
setAllEmployees(empRes);
const me = empRes.find((e: any) => {
const eUserId = e.userId?._id || e.userId?.id || e.userId;
return String(eUserId) === String(SessionInfo.userId);
});
const availableEmployees = empRes.filter(canManageEmployeeSchedules);
setAllEmployees(availableEmployees);
const me = availableEmployees.find((e: any) => String(getEmployeeUserId(e)) === String(SessionInfo.userId));
if (me) {
setEmployeeId(me.id || me._id || me.employeeId);
}
@@ -1256,7 +1262,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
for (let i = 0; i <= 6; i++) {
const isWorkingDay = workingDays.includes(i);
let schedules = [];
let schedules: Array<{ from: string; to: string; disabled: boolean }> = [];
if (isWorkingDay) {
if (hasBreak) {
schedules = [
@@ -1269,9 +1275,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
];
}
} else {
schedules = [
{ from: "00:00", to: "23:59", disabled: true }
];
schedules = [];
}
await API.post<any>("schedules/update", {
@@ -1368,14 +1372,14 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
onNext={() => {
if (doesProvideServices === null) return;
if (!doesProvideServices) {
// If they don't provide services, finish setup directly
window.location.href = "/landing/dashboard";
const query = createdCompanyId ? `&companyId=${createdCompanyId}` : "";
router.push(`/admin/assistant?flow=new-collaborator${query}`);
} else {
handleNext();
}
}}
disableNext={doesProvideServices === null}
nextLabel={doesProvideServices === false ? "Finalizar" : "Siguiente"}
nextLabel={doesProvideServices === false ? "Invitar colaborador" : "Siguiente"}
>
<div style={{ display: 'flex', gap: '15px', flexWrap: 'wrap', width: '100%', justifyContent: 'center' }}>
<div
@@ -1412,7 +1416,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
}}
>
<h4 style={{ margin: '0 0 5px 0', fontSize: '18px', fontWeight: doesProvideServices === false ? 'bold' : 'normal' }}>No, tengo un equipo</h4>
<p style={{ margin: 0, fontSize: '13px', color: 'rgba(255,255,255,0.7)' }}>Terminaremos aquí. Luego podrás invitar a tus colaboradores.</p>
<p style={{ margin: 0, fontSize: '13px', color: 'rgba(255,255,255,0.7)' }}>Te guiaremos para invitar al primer colaborador.</p>
</div>
</div>
</QuestionCard>
@@ -1724,7 +1728,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
for (let i = 0; i <= 6; i++) {
const isWorkingDay = workingDays.includes(i);
let schedules = [];
let schedules: Array<{ from: string; to: string; disabled: boolean }> = [];
if (isWorkingDay) {
if (hasBreak) {
schedules = [
@@ -1737,9 +1741,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
];
}
} else {
schedules = [
{ from: "00:00", to: "23:59", disabled: true }
];
schedules = [];
}
await API.post<any>("schedules/update", {
@@ -2016,7 +2018,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
return;
}
let schedules = [];
let schedules: Array<{ from: string; to: string; disabled: boolean }> = [];
if (hasBreak) {
schedules = [
{ from: openTime, to: breakStart, disabled: false },
@@ -2653,6 +2655,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
const handleCreateCollaborator = async () => {
if (!inviteCollaborator || !createdCompanyId) return;
setIsCreating(true);
setInviteFlowNotice("");
try {
await API.post<any>("employees/create", {
companyId: createdCompanyId,
@@ -2661,13 +2664,21 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
hostOk: true,
sessionUser: SessionInfo.userId
});
handleActionSuccess();
handleNext();
} catch (error: any) {
const errorMessage = error.desc || error.message || "Error al invitar al colaborador";
if (errorMessage.toLowerCase().includes("limite") || errorMessage.toLowerCase().includes("límite")) {
const normalizedErrorMessage = errorMessage.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
if (normalizedErrorMessage.includes("limite")) {
setIsUpgradeRequired(true);
} else if (normalizedErrorMessage.includes("invitacion") && normalizedErrorMessage.includes("pendiente")) {
setInviteFlowNotice("Ya se envió una invitación para que este usuario forme parte de la compañía y todavía no respondió.");
setCurrentStepIndex(5);
} else if (normalizedErrorMessage.includes("ya pertenece")) {
setInviteFlowNotice("Este usuario ya pertenece a la compañía.");
setCurrentStepIndex(6);
} else {
alert(errorMessage);
setInviteFlowNotice(errorMessage);
setCurrentStepIndex(5);
}
} finally {
setIsCreating(false);
@@ -2752,6 +2763,109 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
{null}
</QuestionCard>
<QuestionCard
isActive={currentStepIndex === 4}
title="Invitación Enviada"
description="¿Cómo quieres continuar?"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px', width: '100%' }}>
<div
onClick={() => {
setInviteEmail("");
setInviteCollaborator(null);
setInviteEmailError("");
setInviteRole("colaborador");
setIsUpgradeRequired(false);
setInviteFlowNotice("");
setCurrentStepIndex(1);
}}
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
>
<div style={{ fontWeight: 'bold' }}>Invitar otro colaborador</div>
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Agrega otra persona a tu equipo.</div>
</div>
<div
onClick={() => {
const query = createdCompanyId ? `&companyId=${createdCompanyId}` : "";
router.push(`/admin/assistant?flow=update-schedule${query}`);
}}
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
>
<div style={{ fontWeight: 'bold' }}>Configurar mis horarios</div>
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Define tus horarios personales de atención.</div>
</div>
<div
onClick={() => router.push("/admin/assistant")}
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
>
<div style={{ fontWeight: 'bold' }}>Terminar</div>
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Volver a la home del asistente.</div>
</div>
</div>
</QuestionCard>
<QuestionCard
isActive={currentStepIndex === 5}
title="Invitación Pendiente"
description={inviteFlowNotice || "No pudimos enviar la invitación en este momento."}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px', width: '100%' }}>
<div
onClick={() => {
setInviteEmail("");
setInviteCollaborator(null);
setInviteEmailError("");
setInviteRole("colaborador");
setIsUpgradeRequired(false);
setInviteFlowNotice("");
setCurrentStepIndex(1);
}}
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
>
<div style={{ fontWeight: 'bold' }}>Invitar otro colaborador</div>
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Busca otra persona para sumar al equipo.</div>
</div>
<div
onClick={() => router.push("/admin/assistant")}
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
>
<div style={{ fontWeight: 'bold' }}>Volver al asistente</div>
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Regresar a la home del asistente.</div>
</div>
</div>
</QuestionCard>
<QuestionCard
isActive={currentStepIndex === 6}
title="El usuario ya pertenece"
description={inviteFlowNotice || "Este usuario ya pertenece a la compañía."}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px', width: '100%' }}>
<div
onClick={() => {
setInviteEmail("");
setInviteCollaborator(null);
setInviteEmailError("");
setInviteRole("colaborador");
setIsUpgradeRequired(false);
setInviteFlowNotice("");
setCurrentStepIndex(1);
}}
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
>
<div style={{ fontWeight: 'bold' }}>Invitar otro colaborador</div>
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Busca otra persona para sumar al equipo.</div>
</div>
<div
onClick={() => router.push("/admin/assistant")}
style={{ padding: '15px', borderRadius: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', color: 'white', transition: 'all 0.2s' }}
>
<div style={{ fontWeight: 'bold' }}>Volver al asistente</div>
<div style={{ fontSize: '12px', opacity: 0.8, marginTop: '5px' }}>Regresar a la home del asistente.</div>
</div>
</div>
</QuestionCard>
<QuestionCard
isActive={currentStepIndex === 3 && isUpgradeRequired}
title="¡Haz crecer tu equipo!"
@@ -3202,6 +3316,24 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
return "Has configurado exitosamente tu negocio. Todo está listo para que empieces a recibir reservas y gestionar tu agenda como un profesional.";
};
const successButtonStyle: React.CSSProperties = {
padding: '16px 40px', fontSize: '18px', fontWeight: 'bold',
background: 'white', color: 'var(--wine-red)', border: 'none',
borderRadius: '30px', cursor: 'pointer',
boxShadow: '0 4px 15px rgba(0,0,0,0.3)',
transition: 'transform 0.2s, box-shadow 0.2s'
};
const handleSuccessButtonMouseOver = (e: React.MouseEvent<HTMLButtonElement>) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 6px 20px rgba(255,255,255,0.3)';
};
const handleSuccessButtonMouseOut = (e: React.MouseEvent<HTMLButtonElement>) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 15px rgba(0,0,0,0.3)';
};
return (
<div style={{
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
@@ -3232,7 +3364,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
{getSuccessMessage()}
</p>
<button
<button
onClick={() => {
if (action) {
router.push("/admin/assistant");
@@ -3240,21 +3372,9 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
window.location.href = "/landing/dashboard";
}
}}
style={{
padding: '16px 40px', fontSize: '18px', fontWeight: 'bold',
background: 'white', color: 'var(--wine-red)', border: 'none',
borderRadius: '30px', cursor: 'pointer',
boxShadow: '0 4px 15px rgba(0,0,0,0.3)',
transition: 'transform 0.2s, box-shadow 0.2s'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 6px 20px rgba(255,255,255,0.3)';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 15px rgba(0,0,0,0.3)';
}}
style={successButtonStyle}
onMouseOver={handleSuccessButtonMouseOver}
onMouseOut={handleSuccessButtonMouseOut}
>
{action ? "Volver al Menú Principal" : "Ir a mi Panel de Control"}
</button>
+14 -14
View File
@@ -341,7 +341,7 @@ function DashboardContent() {
selectedCompanyPlanSupportsRepeats &&
(isCompanyOwner || selectedOrganization?.isAdmin)
);
const showFreePlanStarterContent = Boolean(displayedPlan && displayedPlan.price === 0 && organizations.length === 0);
const showNoOrganizationStarterContent = Boolean(displayedPlan && organizations.length === 0);
const agendaItems = dashboardData?.personalAgenda ?? [];
const filteredAgendaItems = agendaItems.filter(item => !showOnlyMine || item.collaboratorId === SessionInfo.userId);
@@ -518,28 +518,28 @@ function DashboardContent() {
</div>
)}
{showFreePlanStarterContent && (
{showNoOrganizationStarterContent && (
<div className={style.starterCard}>
<div className={style.starterContent}>
<span className={style.starterEyebrow}>Tu plan gratuito ya está activo</span>
<h2>Ahora podés empezar a ofrecer turnos</h2>
<span className={style.starterEyebrow}>Tu plan {displayedPlan.name} ya está activo</span>
<h2>Creá tu primera organización para empezar a trabajar</h2>
<p>
Todavía no pertenecés a ninguna organización. Podés crear tu propio negocio con el asistente,
o esperar una invitación para sumarte al equipo de una organización existente.
Todavía no pertenecés a ninguna organización. Podés seguir el flujo guiado con el asistente
o hacerlo manualmente desde Mis Organizaciones.
</p>
</div>
<div className={style.starterActions}>
<button className={style.starterPrimaryButton} onClick={() => goTo("/admin/assistant?flow=onboarding-org&action=new-org")}>
Crear mi organización
Usar el asistente
</button>
<button className={style.starterSecondaryButton} onClick={() => goTo("/landing/my-appointments")}>
Ver mis turnos
<button className={style.starterSecondaryButton} onClick={() => goTo("/admin/org")}>
Crear manualmente
</button>
</div>
</div>
)}
{showFreePlanStarterContent && (
{showNoOrganizationStarterContent && (
<div className={style.starterGuideGrid}>
<section className={style.starterGuideCard}>
<span className={style.starterGuideNumber}>1</span>
@@ -568,10 +568,10 @@ function DashboardContent() {
</div>
)}
{showFreePlanStarterContent && (
{showNoOrganizationStarterContent && (
<div className={style.starterNextStepsPanel}>
<div>
<span className={style.starterEyebrow}>Qué podés hacer con tu plan básico</span>
<span className={style.starterEyebrow}>Qué podés hacer con tu plan</span>
<h3>Tu cuenta ya está lista para operar</h3>
<p>
Usá este primer paso para preparar la base de tu negocio. Cuando tengas tu organización creada,
@@ -588,7 +588,7 @@ function DashboardContent() {
)}
{/* Left Column (Sidebar) */}
{!showFreePlanStarterContent && <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{!showNoOrganizationStarterContent && <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{/* Agenda del Día */}
{dashboardData && dashboardData.role !== "NONE" && (
<div className={style.agendaCard}>
@@ -674,7 +674,7 @@ function DashboardContent() {
</div>}
{/* Right Column (Main Content) */}
{!showFreePlanStarterContent && <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{!showNoOrganizationStarterContent && <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{/* Rendimiento Financiero (Only for ADMIN) */}
{dashboardData && dashboardData.role === "ADMIN" && (
<FinancialWidget