feat: implement collaborator reactivation logic, add access denied handling, and improve metric filtering

This commit is contained in:
2026-07-19 18:56:39 -03:00
parent af6c87acb6
commit 74d6ee6706
27 changed files with 3148 additions and 37 deletions
@@ -52,6 +52,7 @@ export default function ClientDashboardLayout({
const { goTo } = useNavigation();
const [client, setClient] = useState<OrganizationClientView | null>(null);
const [isCheckingAccess, setIsCheckingAccess] = useState(true);
const [clientName, setClientName] = useState<string>("");
const [, setClientEmail] = useState<string>("");
const [clientAvatar, setClientAvatar] = useState<string>("");
@@ -106,6 +107,7 @@ export default function ClientDashboardLayout({
}
eventHandler.setEventType(EVENT_TYPES.LOADING);
setIsCheckingAccess(true);
findClientById({
id: clientId,
@@ -119,9 +121,15 @@ export default function ClientDashboardLayout({
setClientAvatar(client.avatar);
})
.catch((error) => {
if (error?.desc?.includes("No tienes permisos") || error?.desc?.includes("no tiene permisos")) {
setClient(null);
goTo("/admin/access-denied");
return;
}
alert.showError(error.format());
})
.finally(() => {
setIsCheckingAccess(false);
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
};
@@ -131,7 +139,8 @@ export default function ClientDashboardLayout({
<Content>
<Suspense fallback={<SuspenseLoading />}>
<Secure>
<SectionContainer style={{ top: "70px" }}>
{isCheckingAccess && <SuspenseLoading />}
{client && <SectionContainer style={{ top: "70px" }}>
<div className={style.clientProfileHeader}>
<Avatar
name="userProfileAvatar"
@@ -195,7 +204,7 @@ export default function ClientDashboardLayout({
</div>
</div>
<Menu />
</SectionContainer>
</SectionContainer>}
</Secure>
</Suspense>
</Content>
@@ -193,8 +193,8 @@ export default function OrganizationProfile() {
const filter = useFilterStore();
const alert = useAlert();
const SessionInfo = useSessionStore();
const appointmentsView = useAppointmentsViewStore();
const { goTo } = useNavigation();
const appointmentsView = useAppointmentsViewStore();
const [windowWidth] = useWindowSize();
@@ -317,6 +317,7 @@ export default function OrganizationProfile() {
employeeId: appointmentsView.collaboratorId
? appointmentsView.collaboratorId
: collaboratorId,
sessionUser: SessionInfo.userId,
};
const filteredFilter = Object.fromEntries(
@@ -338,6 +339,10 @@ export default function OrganizationProfile() {
]);
})
.catch((error) => {
if (error?.desc?.includes("No tienes permisos") || error?.desc?.includes("no tiene permisos")) {
goTo("/admin/access-denied");
return;
}
alert.showError(error.format());
})
.finally(() => {
@@ -14,6 +14,7 @@ import {
CollaboratorSchedulesView,
UpdateScheduleParams,
UpdateEmployeeParams,
RemoveCollaboratorParams,
} from "@models/Collaborators.model";
import { CollaboratorView, FindCollaboratorByEmailParams } from "@models/User.model";
import { ApiVoidResult } from "@models/Api.VoidResult.type";
@@ -128,3 +129,7 @@ export const updateSchedulesOverrides = async (data: UpdateSchedulesOverridesPar
export const deleteSchedulesOverrides = async (data: DeleteSchedulesOverridesParams): Promise<ApiVoidResult> => {
return ApiRequest.post<ApiVoidResult>("schedules-overrides/delete", data);
};
export const removeCollaborator = async (data: RemoveCollaboratorParams): Promise<ApiVoidResult> => {
return ApiRequest.post<ApiVoidResult>("employees/remove-collaborator", data);
};
@@ -0,0 +1,223 @@
/* ── Removal card (collapsible) ─────────────────────────────── */
.removalCard {
margin-top: 32px;
padding: 0;
border-radius: 16px;
background: linear-gradient(
135deg,
rgba(255, 42, 127, 0.06) 0%,
rgba(255, 74, 42, 0.04) 100%
);
border: 1px solid rgba(255, 74, 42, 0.18);
position: relative;
overflow: hidden;
}
.removalCard::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(
90deg,
var(--wine-red) 0%,
var(--red) 100%
);
border-radius: 16px 16px 0 0;
z-index: 1;
}
/* ── Toggle button (always visible header) ──────────────────── */
.removalToggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 20px 24px;
border: none;
background: transparent;
cursor: pointer;
font-family: "Kanit", serif;
text-align: left;
transition: background 0.18s ease;
}
.removalToggle:hover {
background: rgba(255, 42, 127, 0.03);
}
.removalToggleLeft {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.removalToggleRight {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
margin-left: 16px;
}
.removalToggleText {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
/* ── Icon circle ────────────────────────────────────────────── */
.removalIconCircle {
width: 44px;
height: 44px;
border-radius: 12px;
background: linear-gradient(
135deg,
rgba(255, 42, 127, 0.12) 0%,
rgba(255, 74, 42, 0.10) 100%
);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--wine-red);
}
/* ── Typography ─────────────────────────────────────────────── */
.removalTitle {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--black);
letter-spacing: -0.01em;
line-height: 1.3;
}
.removalSubtitle {
font-size: 13px;
color: var(--black-light);
opacity: 0.7;
line-height: 1.3;
}
.removalExpandLabel {
font-size: 13px;
font-weight: 500;
color: var(--wine-red);
white-space: nowrap;
}
.removalChevron {
font-size: 22px;
color: var(--wine-red);
transition: transform 0.22s ease;
}
/* ── Expanded body ──────────────────────────────────────────── */
.removalBody {
padding: 0 24px 24px 24px;
animation: removalSlideIn 0.22s ease;
}
@keyframes removalSlideIn {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.removalDivider {
height: 1px;
background: rgba(255, 74, 42, 0.12);
margin-bottom: 20px;
}
/* ── Description & list (expanded content) ──────────────────── */
.removalDescription {
margin: 0 0 16px 0;
font-size: 15px;
color: var(--black-light);
line-height: 1.65;
}
.removalList {
margin: 0 0 22px 0;
padding: 0 0 0 22px;
font-size: 14.5px;
color: var(--black-light);
line-height: 1.75;
}
.removalList li {
padding: 3px 0;
}
.removalList li::marker {
color: var(--wine-red);
font-size: 10px;
}
/* ── Action row ─────────────────────────────────────────────── */
.removalActions {
display: flex;
justify-content: flex-end;
}
.removeButton {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 11px 28px;
border: none;
border-radius: 100px;
font-family: "Kanit", serif;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.01em;
cursor: pointer;
transition:
background 0.22s ease,
box-shadow 0.22s ease,
transform 0.15s ease;
background: linear-gradient(
135deg,
rgba(255, 42, 127, 0.10) 0%,
rgba(255, 74, 42, 0.08) 100%
);
color: var(--red);
border: 1.5px solid rgba(255, 74, 42, 0.30);
box-shadow: 0 1px 3px rgba(255, 42, 127, 0.06);
}
.removeButton:hover {
background: var(--red);
color: var(--white);
border-color: var(--red);
box-shadow: 0 4px 14px rgba(255, 74, 42, 0.30);
transform: translateY(-1px);
}
.removeButton:active {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(255, 74, 42, 0.20);
}
.removeButtonIcon {
font-size: 18px;
transition: color 0.22s ease;
}
@@ -1,18 +1,40 @@
"use client";
import { useParams } from "next/navigation";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import Location from "@components/Location/Location";
import { useLocationStore } from "@store/Location.Store";
import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store";
import { useSessionStore } from "@store/Sesion.Store";
import { useAlert } from "@store/Alert.Store";
import { useConfirmStore } from "@store/Confirm.Store";
import AccountCircleOutlinedIcon from "@mui/icons-material/AccountCircleOutlined";
import LocalPoliceOutlinedIcon from "@mui/icons-material/LocalPoliceOutlined";
import MaterialAlert from "@mui/material/Alert";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import MuiButton from "@mui/material/Button";
import Radio from "@mui/material/Radio";
import RadioGroup from "@mui/material/RadioGroup";
import FormControlLabel from "@mui/material/FormControlLabel";
import FormControl from "@mui/material/FormControl";
import turnosXpressTheme from "@core/app/theme/turnosXpress";
import { ThemeProvider } from "@emotion/react";
import { PopoverPicker } from "@core/app/components/ColorPicker/ColorPicker";
import { findCollaboratorsById, updateCollaborator } from "../../Collaborators.Service";
import {
findCollaboratorsById,
updateCollaborator,
loadCollaboratorsByCompanyId,
removeCollaborator,
} from "../../Collaborators.Service";
import { ApiError } from "@models/Server.Error.model";
import { UpdateEmployeeParams } from "@models/Collaborators.model";
import { UpdateEmployeeParams, CompanyEmployeesView, EmployeeRoles } from "@models/Collaborators.model";
import { useNavigation } from "@hooks/goto";
import style from "./edit.module.css";
import PersonOffOutlinedIcon from "@mui/icons-material/PersonOffOutlined";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
type UrlData = {
id: string;
@@ -27,9 +49,19 @@ export default function OrganizationEdit() {
const eventHandler = useEventHandlerStore();
const SessionInfo = useSessionStore();
const alert = useAlert();
const confirm = useConfirmStore();
const { goTo } = useNavigation();
const [collaboratorName, setCollaboratorName] = useState<string>("");
const [collaboratorUserId, setCollaboratorUserId] = useState<string>("");
const [collaboratorColor, setCollaboratorColor] = useState<string>("");
const [collaborators, setCollaborators] = useState<CompanyEmployeesView[]>([]);
const [isOwner, setIsOwner] = useState<boolean>(false);
const [replacementDialogOpen, setReplacementDialogOpen] = useState<boolean>(false);
const [selectedReplacement, setSelectedReplacement] = useState<string>("");
const [removalCollapsed, setRemovalCollapsed] = useState<boolean>(true);
const toggleRemoval = useCallback(() => setRemovalCollapsed((prev) => !prev), []);
useEffect(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
@@ -58,6 +90,7 @@ export default function OrganizationEdit() {
eventHandler.setEventType(EVENT_TYPES.LOADING);
loadCollaborator();
loadCollaborators();
}, [collaboratorName]);
useEffect(() => {
@@ -83,6 +116,21 @@ export default function OrganizationEdit() {
}
}, [eventHandler.eventType]);
const loadCollaborators = () => {
loadCollaboratorsByCompanyId({ companyId: id })
.then((list) => {
setCollaborators(list);
const current = list.find((c) => c.id === cid);
setIsOwner(
current !== undefined &&
current.roles.includes(EmployeeRoles.OWNER)
);
})
.catch(() => {
// Non-critical — removal button still works, server validates
});
};
const loadCollaborator = () => {
eventHandler.setEventType(EVENT_TYPES.LOADING);
findCollaboratorsById({ id: cid })
@@ -92,6 +140,7 @@ export default function OrganizationEdit() {
}
setCollaboratorName(collaborator.fullName);
setCollaboratorUserId(collaborator.userId);
setCollaboratorColor(collaborator.calendarColor);
})
.catch((error) => {
@@ -102,6 +151,65 @@ export default function OrganizationEdit() {
});
};
const handleRemoveClick = () => {
if (isOwner) {
alert.showError("No se puede eliminar al propietario de la organización");
return;
}
if (String(SessionInfo.userId) === collaboratorUserId) {
alert.showError("No puedes eliminarte a ti mismo de la organización");
return;
}
const candidates = collaborators.filter(
(c) => c.id !== cid
);
if (candidates.length > 0) {
setReplacementDialogOpen(true);
setSelectedReplacement("");
} else {
confirm.show(
"¿Estás seguro de que deseas remover este colaborador de la organización?",
handleConfirmRemove,
() => {}
);
}
};
const handleConfirmReplace = () => {
setReplacementDialogOpen(false);
confirm.show(
"¿Estás seguro de que deseas remover este colaborador de la organización? Los turnos futuros serán transferidos al colaborador seleccionado.",
handleConfirmRemove,
() => {}
);
};
const handleConfirmRemove = () => {
eventHandler.setEventType(EVENT_TYPES.LOADING);
removeCollaborator({
employeeId: cid,
companyId: id,
sessionUser: SessionInfo.userId,
...(selectedReplacement
? { replacementEmployeeId: selectedReplacement }
: {}),
})
.then(() => {
alert.showSuccess("Colaborador removido correctamente");
goTo("/admin/org/profile/" + id + "/collaborators");
})
.catch((error: ApiError) => {
alert.showError(error.format());
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
};
return (
<>
<Location />
@@ -119,7 +227,124 @@ export default function OrganizationEdit() {
}}
/>
<div style={{ marginBottom: "10px" }}>&nbsp;</div>
{!isOwner && (
<div className={style.removalCard}>
<button
type="button"
className={style.removalToggle}
onClick={toggleRemoval}
aria-expanded={!removalCollapsed}
aria-controls="removal-details"
>
<div className={style.removalToggleLeft}>
<div className={style.removalIconCircle}>
<PersonOffOutlinedIcon fontSize="medium" />
</div>
<div className={style.removalToggleText}>
<h3 className={style.removalTitle}>
Remover colaborador
</h3>
{!removalCollapsed && (
<span className={style.removalSubtitle}>
Acción irreversible revisá los efectos antes de continuar
</span>
)}
</div>
</div>
<div className={style.removalToggleRight}>
{removalCollapsed ? (
<>
<span className={style.removalExpandLabel}>
Mostrar opciones de remoción
</span>
<ExpandMoreIcon className={style.removalChevron} />
</>
) : (
<ExpandLessIcon className={style.removalChevron} />
)}
</div>
</button>
{!removalCollapsed && (
<div id="removal-details" className={style.removalBody}>
<div className={style.removalDivider} />
<p className={style.removalDescription}>
Al remover este colaborador de la organización:
</p>
<ul className={style.removalList}>
<li>Dejará de pertenecer a la organización.</li>
<li>
Los turnos futuros y repeticiones activas serán
transferidos a otro colaborador.
</li>
<li>El historial de turnos permanecerá preservado.</li>
<li>
Ya no recibirá nuevas reservas ni turnos asignados.
</li>
</ul>
<div className={style.removalActions}>
<button
className={style.removeButton}
type="button"
onClick={handleRemoveClick}
>
<PersonOffOutlinedIcon className={style.removeButtonIcon} />
Remover colaborador
</button>
</div>
</div>
)}
</div>
)}
<ThemeProvider theme={turnosXpressTheme}>
<Dialog
open={replacementDialogOpen}
onClose={() => setReplacementDialogOpen(false)}
>
<DialogTitle>Seleccionar reemplazo</DialogTitle>
<DialogContent>
<p style={{ marginBottom: "16px", color: "#666" }}>
Este colaborador tiene turnos futuros asignados.
Seleccione un colaborador de reemplazo para transferir los turnos.
</p>
<FormControl component="fieldset">
<RadioGroup
value={selectedReplacement}
onChange={(e) => setSelectedReplacement(e.target.value)}
>
{collaborators
.filter((c) => c.id !== cid)
.map((collaborator) => (
<FormControlLabel
key={collaborator.id}
value={collaborator.id}
control={<Radio />}
label={`${collaborator.fullName} (${collaborator.email})`}
/>
))}
</RadioGroup>
</FormControl>
</DialogContent>
<DialogActions>
<MuiButton onClick={() => setReplacementDialogOpen(false)}>
Cancelar
</MuiButton>
<MuiButton
onClick={handleConfirmReplace}
disabled={!selectedReplacement}
color="success"
variant="contained"
autoFocus
>
Aceptar
</MuiButton>
</DialogActions>
</Dialog>
</ThemeProvider>
</>
);
}
@@ -11,6 +11,7 @@ import Menu from "@components/Menu/Menu";
import { useOrganizationHeaderStore } from "@core/Store/OrganizationHeader.Store";
import Content from "@core/app/components/Content/Content";
import SuspenseLoading from "@core/app/components/SuspendeLoading/SuspenseLoading";
import { useNavigation } from "@core/app/hooks/goto";
type UrlData = {
id: string;
@@ -18,24 +19,33 @@ type UrlData = {
export default function OrgLayout({ children }: { children: React.ReactNode }) {
const [organization, setOrganization] = useState<MyOranizationsView | undefined>();
const [isCheckingAccess, setIsCheckingAccess] = useState(true);
const data = useParams<UrlData>();
const organizationId = data.id;
const SessionInfo = useSessionStore();
const organizationHeader = useOrganizationHeaderStore();
const { goTo } = useNavigation();
useEffect(() => {
if (!SessionInfo.userId) {
return;
}
loadOrganizationsById({ id: organizationId, sessionUser: SessionInfo.userId }).then(
(organization) => {
setIsCheckingAccess(true);
loadOrganizationsById({ id: organizationId, sessionUser: SessionInfo.userId })
.then((organization) => {
setOrganization(organization);
organizationHeader.setHeaderColor(organization.headerColor);
organizationHeader.setHeaderFontColor(organization.headerFontColor);
organizationHeader.setHeaderFontShadowColor(organization.headerFontShadowColor);
}
);
})
.catch(() => {
setOrganization(undefined);
goTo("/admin/access-denied");
})
.finally(() => {
setIsCheckingAccess(false);
});
}, [SessionInfo.userId]);
return (
@@ -43,9 +53,14 @@ export default function OrgLayout({ children }: { children: React.ReactNode }) {
<Content>
<Suspense fallback={<SuspenseLoading />}>
<Secure>
{isCheckingAccess && <SuspenseLoading />}
{organization && <OrganizationHeader data={organization} />}
<OrganizationContent>{children}</OrganizationContent>
<Menu />
{organization && (
<>
<OrganizationContent>{children}</OrganizationContent>
<Menu />
</>
)}
</Secure>
</Suspense>
</Content>
@@ -0,0 +1,123 @@
.page {
align-items: center;
background: radial-gradient(circle at top, rgba(255, 47, 125, 0.24), transparent 30%), linear-gradient(160deg, #3b0076 0%, #4b0097 48%, #8b2cff 100%);
display: flex;
justify-content: center;
min-height: calc(100vh - 56px);
padding: 48px 20px;
}
.card {
background: rgba(255, 255, 255, 0.96);
border: 1px solid rgba(255, 255, 255, 0.72);
border-radius: 28px;
box-shadow: 0 28px 70px rgba(20, 0, 50, 0.28);
max-width: 720px;
overflow: hidden;
padding: 0;
width: 100%;
}
.accent {
background: linear-gradient(90deg, #ff2f7d, #ff6b35, #7c3aed);
height: 7px;
}
.content {
align-items: center;
display: flex;
flex-direction: column;
gap: 18px;
padding: 44px 42px 38px;
text-align: center;
}
.iconWrap {
align-items: center;
background: linear-gradient(145deg, rgba(255, 47, 125, 0.12), rgba(124, 58, 237, 0.12));
border: 1px solid rgba(255, 47, 125, 0.2);
border-radius: 24px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
color: #ff2f7d;
display: flex;
height: 76px;
justify-content: center;
width: 76px;
}
.eyebrow {
background: rgba(255, 47, 125, 0.1);
border-radius: 999px;
color: #b91c5c;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
padding: 7px 13px;
text-transform: uppercase;
}
.title {
color: #2b174f;
font-size: clamp(28px, 4vw, 38px);
line-height: 1.05;
margin: 0;
}
.description {
color: #4b5563;
font-size: 16px;
line-height: 1.65;
margin: 0;
max-width: 560px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
margin-top: 8px;
}
.primaryAction,
.secondaryAction {
align-items: center;
border-radius: 999px;
display: inline-flex;
font-weight: 900;
justify-content: center;
min-height: 46px;
padding: 0 20px;
text-decoration: none;
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
}
.primaryAction {
background: linear-gradient(135deg, #ff2f7d, #7c3aed);
box-shadow: 0 14px 28px rgba(124, 58, 237, 0.28);
color: #ffffff;
}
.secondaryAction {
background: #ffffff;
border: 1px solid rgba(124, 58, 237, 0.18);
color: #4b148c;
}
.primaryAction:hover,
.secondaryAction:hover {
box-shadow: 0 18px 34px rgba(43, 23, 79, 0.18);
transform: translateY(-2px);
}
@media (max-width: 640px) {
.content {
padding: 34px 24px 30px;
}
.actions,
.primaryAction,
.secondaryAction {
width: 100%;
}
}
@@ -0,0 +1,38 @@
"use client";
import Link from "next/link";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import HomeOutlinedIcon from "@mui/icons-material/HomeOutlined";
import GroupsOutlinedIcon from "@mui/icons-material/GroupsOutlined";
import style from "./access-denied.module.css";
export default function AdminAccessDeniedPage() {
return (
<div className={style.page}>
<div className={style.card}>
<div className={style.accent} />
<div className={style.content}>
<div className={style.iconWrap}>
<ShieldOutlinedIcon sx={{ fontSize: 42 }} />
</div>
<span className={style.eyebrow}>Acceso restringido</span>
<h1 className={style.title}>No tenés permisos para acceder</h1>
<p className={style.description}>
Esta sección pertenece a una organización en la que ya no tenés permisos activos.
Si creés que se trata de un error, comunicate con un administrador de la organización.
</p>
<div className={style.actions}>
<Link className={style.primaryAction} href="/admin/org">
<GroupsOutlinedIcon sx={{ fontSize: 19, mr: 0.75 }} />
Ir a Mis organizaciones
</Link>
<Link className={style.secondaryAction} href="/landing/dashboard">
<HomeOutlinedIcon sx={{ fontSize: 19, mr: 0.75 }} />
Volver al dashboard
</Link>
</div>
</div>
</div>
</div>
);
}