feat: introduce service-based availability filtering for collaborator schedules and overrides

This commit is contained in:
2026-07-19 23:36:18 -03:00
parent f017f7e9aa
commit b01149a19e
10 changed files with 493 additions and 31 deletions
@@ -1,5 +1,205 @@
.scheduleItemRow {
color: var(--black);
border-bottom: solid 1px var(--white-dark);
height: 45px;
min-height: 64px;
display: grid;
grid-template-columns: 58px 58px minmax(0, 1fr) 88px;
align-items: center;
gap: 8px;
padding: 8px 0;
}
.timeCell {
display: flex;
align-items: center;
justify-content: center;
}
.servicesCell {
display: flex;
align-items: center;
min-width: 0;
gap: 6px;
}
.actionsCell {
display: flex;
align-items: center;
justify-content: flex-end;
}
.serviceTags {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
flex: 1;
padding: 4px 0;
}
.serviceTags :global(.MuiChip-root) {
max-width: 100%;
height: 26px;
}
.customizeButton {
color: var(--primary) !important;
background: color-mix(in srgb, var(--primary) 10%, white) !important;
}
.servicePanelBackdrop {
position: fixed;
inset: 0;
z-index: 1300;
display: flex;
align-items: flex-end;
justify-content: center;
padding: 16px;
background: rgba(0, 0, 0, 0.36);
}
.servicePanel {
width: min(100%, 440px);
max-height: min(84vh, 680px);
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: 28px;
padding: 10px 16px 16px;
background: #fff;
box-shadow: 0 -12px 36px rgba(0, 0, 0, 0.24);
}
.servicePanelHandle {
width: 48px;
height: 5px;
margin: 0 auto 14px;
border-radius: 999px;
background: var(--white-dark);
}
.servicePanelHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.servicePanelHeader h3,
.servicePanelEyebrow {
margin: 0;
}
.servicePanelEyebrow {
color: var(--primary);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.scopeCard,
.servicePickerItem,
.servicePanelDone {
width: 100%;
border: 0;
font: inherit;
cursor: pointer;
}
.scopeCard {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-bottom: 10px;
padding: 14px;
border: 1px solid var(--white-dark);
border-radius: 18px;
color: var(--black);
text-align: left;
background: #fff;
}
.scopeCard small {
display: block;
margin-top: 4px;
color: var(--gray);
line-height: 1.3;
}
.scopeCardActive {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 9%, white);
}
.scopeCardActive svg,
.servicePickerItemActive svg {
color: var(--primary);
}
.servicePickerList {
display: grid;
gap: 8px;
margin: 14px 0;
overflow-y: auto;
max-height: min(42vh, 320px);
padding-right: 2px;
}
.servicePickerItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 48px;
padding: 12px 14px;
border: 1px solid var(--white-dark);
border-radius: 16px;
color: var(--black);
text-align: left;
background: #fff;
}
.servicePickerItemActive {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 8%, white);
font-weight: 700;
}
.servicePanelDone {
min-height: 48px;
margin-top: 8px;
border-radius: 16px;
color: white;
font-weight: 700;
background: var(--primary);
}
@media (min-width: 720px) {
.servicePanelBackdrop {
align-items: center;
}
.servicePanel {
border-radius: 24px;
}
}
@media (max-width: 520px) {
.scheduleItemRow {
grid-template-columns: 52px 52px minmax(0, 1fr) 82px;
gap: 6px;
}
.servicesCell {
align-items: flex-start;
}
.serviceTags {
max-height: 72px;
overflow-y: auto;
}
}
@@ -1,42 +1,155 @@
import IconButton from "@mui/material/IconButton";
import DeleteIcon from "@mui/icons-material/Delete";
import { Grid2, ThemeProvider } from "@mui/material";
import TuneIcon from "@mui/icons-material/Tune";
import CloseIcon from "@mui/icons-material/Close";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked";
import { Box, Chip, ThemeProvider } from "@mui/material";
import style from "./ScheduleItemRow.module.css";
import turnosXpressTheme from "@core/app/theme/turnosXpress";
import Switch from "@mui/material/Switch";
import { useState } from "react";
export type ScheduleServiceOption = {
id: string;
name: string;
};
export interface ScheduleItemRowProps {
from: string;
to: string;
disabled: boolean;
serviceScope?: "all" | "specific";
serviceIds?: string[];
serviceOptions?: ScheduleServiceOption[];
onDelete: (from: string) => void;
onCheckChange: (from: string, data: boolean) => void;
onServiceScopeChange?: (from: string, serviceScope: "all" | "specific", serviceIds: string[]) => void;
}
export default function ScheduleItemRow(props: ScheduleItemRowProps): React.ReactElement {
const [isServicesPanelOpen, setIsServicesPanelOpen] = useState(false);
const selectedServiceIds = props.serviceIds || [];
const serviceScope = props.serviceScope || "all";
const serviceOptions = props.serviceOptions || [];
const selectedServices = serviceOptions.filter((service) => selectedServiceIds.includes(service.id));
const handleServiceScopeChange = (nextScope: "all" | "specific") => {
props.onServiceScopeChange?.(props.from, nextScope, nextScope === "all" ? [] : selectedServiceIds);
};
const handleServiceToggle = (serviceId: string) => {
const nextServiceIds = selectedServiceIds.includes(serviceId)
? selectedServiceIds.filter((selectedServiceId) => selectedServiceId !== serviceId)
: [...selectedServiceIds, serviceId];
props.onServiceScopeChange?.(
props.from,
"specific",
nextServiceIds
);
};
return (
<>
<Grid2 className={style.scheduleItemRow} container>
<Grid2
size={3}
sx={{ display: "flex", justifyContent: "center", alignItems: "center" }}
>
<div className={style.scheduleItemRow}>
<div className={style.timeCell}>
{props.from}
</Grid2>
<Grid2 size={1}></Grid2>
<Grid2
size={3}
sx={{ display: "flex", justifyContent: "center", alignItems: "center" }}
>
</div>
<div className={style.timeCell}>
{props.to}
</Grid2>
</div>
<Grid2 size={1}></Grid2>
<Grid2
size={4}
sx={{ display: "flex", justifyContent: "center", alignItems: "center" }}
>
<div>
<div className={style.servicesCell}>
<Box className={style.serviceTags}>
{serviceScope === "all" ? (
<Chip label="Todos los servicios" size="small" />
) : selectedServices.length > 0 ? (
selectedServices.map((service) => (
<Chip key={service.id} label={service.name} size="small" />
))
) : (
<Chip label="Sin servicios" size="small" variant="outlined" />
)}
</Box>
<IconButton
aria-label="Configurar servicios"
size="small"
className={style.customizeButton}
onClick={() => setIsServicesPanelOpen(true)}
>
<TuneIcon fontSize="small" />
</IconButton>
{isServicesPanelOpen && (
<div className={style.servicePanelBackdrop} onClick={() => setIsServicesPanelOpen(false)}>
<section className={style.servicePanel} onClick={(event) => event.stopPropagation()}>
<div className={style.servicePanelHandle} />
<div className={style.servicePanelHeader}>
<div>
<p className={style.servicePanelEyebrow}>Disponibilidad</p>
<h3>Servicios del horario</h3>
</div>
<IconButton aria-label="Cerrar" onClick={() => setIsServicesPanelOpen(false)}>
<CloseIcon />
</IconButton>
</div>
<button
type="button"
className={`${style.scopeCard} ${serviceScope === "all" ? style.scopeCardActive : ""}`}
onClick={() => handleServiceScopeChange("all")}
>
<span>
<strong>Todos los servicios</strong>
<small>Este horario queda disponible para cualquier servicio asignado.</small>
</span>
{serviceScope === "all" ? <CheckCircleIcon /> : <RadioButtonUncheckedIcon />}
</button>
<button
type="button"
className={`${style.scopeCard} ${serviceScope === "specific" ? style.scopeCardActive : ""}`}
onClick={() => handleServiceScopeChange("specific")}
>
<span>
<strong>Servicios específicos</strong>
<small>Elegí exactamente para qué servicios aplica este horario.</small>
</span>
{serviceScope === "specific" ? <CheckCircleIcon /> : <RadioButtonUncheckedIcon />}
</button>
{serviceScope === "specific" && (
<div className={style.servicePickerList}>
{serviceOptions.map((service) => {
const isSelected = selectedServiceIds.includes(service.id);
return (
<button
key={service.id}
type="button"
className={`${style.servicePickerItem} ${isSelected ? style.servicePickerItemActive : ""}`}
onClick={() => handleServiceToggle(service.id)}
>
<span>{service.name}</span>
{isSelected ? <CheckCircleIcon /> : <RadioButtonUncheckedIcon />}
</button>
);
})}
</div>
)}
<button
type="button"
className={style.servicePanelDone}
onClick={() => setIsServicesPanelOpen(false)}
>
Listo
</button>
</section>
</div>
)}
</div>
<div className={style.actionsCell}>
<ThemeProvider theme={turnosXpressTheme}>
<Switch
checked={!props.disabled}
@@ -49,11 +162,8 @@ export default function ScheduleItemRow(props: ScheduleItemRowProps): React.Reac
<IconButton aria-label="delete" color="error">
<DeleteIcon onClick={() => props.onDelete(props.from)} />
</IconButton>
</div>
</Grid2>
</Grid2 >
</div>
</div>
</>
);
}