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
+5 -1
View File
@@ -167,7 +167,11 @@ class EmployeeManager implements IEmployeesManager {
} }
if (employeeCheck) { if (employeeCheck) {
throw new Error("El usuario ya pertenece a la compañia"); if (!employeeCheck.guestOk) {
throw new Error("La invitación para este usuario ya está pendiente");
}
throw new Error("El usuario ya pertenece a la compañía");
} }
const canAdd = await MetricsList.canAddEmployee(companyCheck.ownerId); const canAdd = await MetricsList.canAddEmployee(companyCheck.ownerId);
@@ -205,13 +205,13 @@ export default function ClientsGrid() {
<span className={pageStyle.bannedBadge}>Baneado</span> <span className={pageStyle.bannedBadge}>Baneado</span>
)} )}
</div> </div>
<p className={pageStyle.clientDetail}> <div className={pageStyle.clientDetail}>
<div className={pageStyle.iconWrapper}> <div className={pageStyle.iconWrapper}>
<EmailOutlinedIcon sx={{ fontSize: 14 }} /> <EmailOutlinedIcon sx={{ fontSize: 14 }} />
</div> </div>
{client.email || "Sin email"} {client.email || "Sin email"}
</p> </div>
<p className={pageStyle.clientPhone}> <div className={pageStyle.clientPhone}>
<div className={pageStyle.iconWrapper}> <div className={pageStyle.iconWrapper}>
<PhoneOutlinedIcon sx={{ fontSize: 14 }} /> <PhoneOutlinedIcon sx={{ fontSize: 14 }} />
</div> </div>
@@ -219,7 +219,7 @@ export default function ClientsGrid() {
? `${client.phoneCountryCode || ''} ${client.phoneAreaCode} - ${client.phoneNumber}` ? `${client.phoneCountryCode || ''} ${client.phoneAreaCode} - ${client.phoneNumber}`
: "Sin teléfono" : "Sin teléfono"
} }
</p> </div>
</div> </div>
</div> </div>
))} ))}
@@ -120,6 +120,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
const [inviteRole, setInviteRole] = useState<"admin" | "colaborador">("colaborador"); const [inviteRole, setInviteRole] = useState<"admin" | "colaborador">("colaborador");
const [isSearchingEmail, setIsSearchingEmail] = useState(false); const [isSearchingEmail, setIsSearchingEmail] = useState(false);
const [isUpgradeRequired, setIsUpgradeRequired] = useState(false); const [isUpgradeRequired, setIsUpgradeRequired] = useState(false);
const [inviteFlowNotice, setInviteFlowNotice] = useState("");
// State for Setup Collaborator Flow // State for Setup Collaborator Flow
const [collaboratorToSetup, setCollaboratorToSetup] = useState<any | null>(null); 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 [setupSelectedServices, setSetupSelectedServices] = useState<string[]>([]);
const [setupAvailableServices, setSetupAvailableServices] = useState<any[]>([]); 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) => { const loadOrganizationContext = (org: any) => {
setCreatedCompanyId(org.id || org._id); setCreatedCompanyId(org.id || org._id);
setOrgName(org.name || ""); 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 }) API.post<any[]>("employees/get-by-company", { companyId: org.id || org._id })
.then(empRes => { .then(empRes => {
if (empRes && empRes.length > 0) { if (empRes && empRes.length > 0) {
setAllEmployees(empRes); const availableEmployees = empRes.filter(canManageEmployeeSchedules);
const me = empRes.find((e: any) => { setAllEmployees(availableEmployees);
const eUserId = e.userId?._id || e.userId?.id || e.userId; const me = availableEmployees.find((e: any) => String(getEmployeeUserId(e)) === String(SessionInfo.userId));
return String(eUserId) === String(SessionInfo.userId);
});
if (me) { if (me) {
setEmployeeId(me.id || me._id || me.employeeId); 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++) { for (let i = 0; i <= 6; i++) {
const isWorkingDay = workingDays.includes(i); const isWorkingDay = workingDays.includes(i);
let schedules = []; let schedules: Array<{ from: string; to: string; disabled: boolean }> = [];
if (isWorkingDay) { if (isWorkingDay) {
if (hasBreak) { if (hasBreak) {
schedules = [ schedules = [
@@ -1269,9 +1275,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
]; ];
} }
} else { } else {
schedules = [ schedules = [];
{ from: "00:00", to: "23:59", disabled: true }
];
} }
await API.post<any>("schedules/update", { await API.post<any>("schedules/update", {
@@ -1368,14 +1372,14 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
onNext={() => { onNext={() => {
if (doesProvideServices === null) return; if (doesProvideServices === null) return;
if (!doesProvideServices) { if (!doesProvideServices) {
// If they don't provide services, finish setup directly const query = createdCompanyId ? `&companyId=${createdCompanyId}` : "";
window.location.href = "/landing/dashboard"; router.push(`/admin/assistant?flow=new-collaborator${query}`);
} else { } else {
handleNext(); handleNext();
} }
}} }}
disableNext={doesProvideServices === null} 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 style={{ display: 'flex', gap: '15px', flexWrap: 'wrap', width: '100%', justifyContent: 'center' }}>
<div <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> <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>
</div> </div>
</QuestionCard> </QuestionCard>
@@ -1724,7 +1728,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
for (let i = 0; i <= 6; i++) { for (let i = 0; i <= 6; i++) {
const isWorkingDay = workingDays.includes(i); const isWorkingDay = workingDays.includes(i);
let schedules = []; let schedules: Array<{ from: string; to: string; disabled: boolean }> = [];
if (isWorkingDay) { if (isWorkingDay) {
if (hasBreak) { if (hasBreak) {
schedules = [ schedules = [
@@ -1737,9 +1741,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
]; ];
} }
} else { } else {
schedules = [ schedules = [];
{ from: "00:00", to: "23:59", disabled: true }
];
} }
await API.post<any>("schedules/update", { await API.post<any>("schedules/update", {
@@ -2016,7 +2018,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
return; return;
} }
let schedules = []; let schedules: Array<{ from: string; to: string; disabled: boolean }> = [];
if (hasBreak) { if (hasBreak) {
schedules = [ schedules = [
{ from: openTime, to: breakStart, disabled: false }, { from: openTime, to: breakStart, disabled: false },
@@ -2653,6 +2655,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
const handleCreateCollaborator = async () => { const handleCreateCollaborator = async () => {
if (!inviteCollaborator || !createdCompanyId) return; if (!inviteCollaborator || !createdCompanyId) return;
setIsCreating(true); setIsCreating(true);
setInviteFlowNotice("");
try { try {
await API.post<any>("employees/create", { await API.post<any>("employees/create", {
companyId: createdCompanyId, companyId: createdCompanyId,
@@ -2661,13 +2664,21 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
hostOk: true, hostOk: true,
sessionUser: SessionInfo.userId sessionUser: SessionInfo.userId
}); });
handleActionSuccess(); handleNext();
} catch (error: any) { } catch (error: any) {
const errorMessage = error.desc || error.message || "Error al invitar al colaborador"; 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); 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 { } else {
alert(errorMessage); setInviteFlowNotice(errorMessage);
setCurrentStepIndex(5);
} }
} finally { } finally {
setIsCreating(false); setIsCreating(false);
@@ -2752,6 +2763,109 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
{null} {null}
</QuestionCard> </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 <QuestionCard
isActive={currentStepIndex === 3 && isUpgradeRequired} isActive={currentStepIndex === 3 && isUpgradeRequired}
title="¡Haz crecer tu equipo!" 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."; 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 ( return (
<div style={{ <div style={{
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
@@ -3232,7 +3364,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
{getSuccessMessage()} {getSuccessMessage()}
</p> </p>
<button <button
onClick={() => { onClick={() => {
if (action) { if (action) {
router.push("/admin/assistant"); router.push("/admin/assistant");
@@ -3240,21 +3372,9 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
window.location.href = "/landing/dashboard"; window.location.href = "/landing/dashboard";
} }
}} }}
style={{ style={successButtonStyle}
padding: '16px 40px', fontSize: '18px', fontWeight: 'bold', onMouseOver={handleSuccessButtonMouseOver}
background: 'white', color: 'var(--wine-red)', border: 'none', onMouseOut={handleSuccessButtonMouseOut}
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)';
}}
> >
{action ? "Volver al Menú Principal" : "Ir a mi Panel de Control"} {action ? "Volver al Menú Principal" : "Ir a mi Panel de Control"}
</button> </button>
+14 -14
View File
@@ -341,7 +341,7 @@ function DashboardContent() {
selectedCompanyPlanSupportsRepeats && selectedCompanyPlanSupportsRepeats &&
(isCompanyOwner || selectedOrganization?.isAdmin) (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 agendaItems = dashboardData?.personalAgenda ?? [];
const filteredAgendaItems = agendaItems.filter(item => !showOnlyMine || item.collaboratorId === SessionInfo.userId); const filteredAgendaItems = agendaItems.filter(item => !showOnlyMine || item.collaboratorId === SessionInfo.userId);
@@ -518,28 +518,28 @@ function DashboardContent() {
</div> </div>
)} )}
{showFreePlanStarterContent && ( {showNoOrganizationStarterContent && (
<div className={style.starterCard}> <div className={style.starterCard}>
<div className={style.starterContent}> <div className={style.starterContent}>
<span className={style.starterEyebrow}>Tu plan gratuito ya está activo</span> <span className={style.starterEyebrow}>Tu plan {displayedPlan.name} ya está activo</span>
<h2>Ahora podés empezar a ofrecer turnos</h2> <h2>Creá tu primera organización para empezar a trabajar</h2>
<p> <p>
Todavía no pertenecés a ninguna organización. Podés crear tu propio negocio con el asistente, Todavía no pertenecés a ninguna organización. Podés seguir el flujo guiado con el asistente
o esperar una invitación para sumarte al equipo de una organización existente. o hacerlo manualmente desde Mis Organizaciones.
</p> </p>
</div> </div>
<div className={style.starterActions}> <div className={style.starterActions}>
<button className={style.starterPrimaryButton} onClick={() => goTo("/admin/assistant?flow=onboarding-org&action=new-org")}> <button className={style.starterPrimaryButton} onClick={() => goTo("/admin/assistant?flow=onboarding-org&action=new-org")}>
Crear mi organización Usar el asistente
</button> </button>
<button className={style.starterSecondaryButton} onClick={() => goTo("/landing/my-appointments")}> <button className={style.starterSecondaryButton} onClick={() => goTo("/admin/org")}>
Ver mis turnos Crear manualmente
</button> </button>
</div> </div>
</div> </div>
)} )}
{showFreePlanStarterContent && ( {showNoOrganizationStarterContent && (
<div className={style.starterGuideGrid}> <div className={style.starterGuideGrid}>
<section className={style.starterGuideCard}> <section className={style.starterGuideCard}>
<span className={style.starterGuideNumber}>1</span> <span className={style.starterGuideNumber}>1</span>
@@ -568,10 +568,10 @@ function DashboardContent() {
</div> </div>
)} )}
{showFreePlanStarterContent && ( {showNoOrganizationStarterContent && (
<div className={style.starterNextStepsPanel}> <div className={style.starterNextStepsPanel}>
<div> <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> <h3>Tu cuenta ya está lista para operar</h3>
<p> <p>
Usá este primer paso para preparar la base de tu negocio. Cuando tengas tu organización creada, 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) */} {/* 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 */} {/* Agenda del Día */}
{dashboardData && dashboardData.role !== "NONE" && ( {dashboardData && dashboardData.role !== "NONE" && (
<div className={style.agendaCard}> <div className={style.agendaCard}>
@@ -674,7 +674,7 @@ function DashboardContent() {
</div>} </div>}
{/* Right Column (Main Content) */} {/* 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) */} {/* Rendimiento Financiero (Only for ADMIN) */}
{dashboardData && dashboardData.role === "ADMIN" && ( {dashboardData && dashboardData.role === "ADMIN" && (
<FinancialWidget <FinancialWidget