feat: implement comprehensive notification engine with policies, preferences, and automated dispatching services

This commit is contained in:
2026-07-21 18:53:11 -03:00
parent 40090cdec5
commit 10ca449f88
82 changed files with 10249 additions and 171 deletions
@@ -91,6 +91,23 @@ export const sendEmailNotification = async (
});
};
export const createImmediateNotificationJobs = async (
data: SendAppointmentNotificationParams
): Promise<ApiVoidResult> => {
return new Promise<ApiVoidResult>((resolve, reject) => {
schemaSendWapNotification
.validate(data, { abortEarly: true })
.then(() => {
resolve(
ApiRequest.post<ApiVoidResult>("appointments/create-immediate-notification-jobs", data)
);
})
.catch((error) => {
reject(new ApiError(500, error.message));
});
});
};
const schemaApplyDiscount = Yup.object().shape({
appointmentId: Yup.string().required("Debe seleccionar un turno."),
companyId: Yup.string().required("Organización no seleccionada."),
@@ -9,11 +9,10 @@ import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import {
applyAppointmentDiscount,
createImmediateNotificationJobs,
deleteAppointment,
deleteAppointmentDiscount,
findAppointmentsAdminByClient,
sendEmailNotification,
sendWapNotification,
updateAppointment,
} from "../Appointments.Service";
import {
@@ -26,8 +25,7 @@ import {
import { ChangeServiceParams } from "@models/Appointments.model";
import Textbox from "@core/app/components/Textbox/Textbox";
import { useMenuStore } from "@core/Store/Menu.Store";
import WhatsAppIcon from "@mui/icons-material/WhatsApp";
import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined";
import NotificationsActiveOutlinedIcon from "@mui/icons-material/NotificationsActiveOutlined";
import DeleteIcon from "@mui/icons-material/Delete";
import Switch from "@mui/material/Switch";
@@ -54,6 +52,8 @@ import filterObjects from "@components/TextObjectFilter/Filter.Service";
import DialogWindow from "@core/app/components/DialogWindow/DialogWindow";
import { DialogActions, DialogContent } from "@mui/material";
import Checkbox from "@mui/material/Checkbox";
import FormControlLabel from "@mui/material/FormControlLabel";
import { TextObjectFilterResult } from "@core/Models/TextObjectFilter.model";
import { FindDiscountsParams } from "@core/Models/Discounts.model";
import DiscountsIcon from "@mui/icons-material/LocalOfferOutlined";
@@ -312,71 +312,19 @@ export default function ClientAppointment() {
menu.clear();
menu.add({
text: "Enviar recordatorio por Whatsapp",
text: "Enviar notificación de alta",
onClick: () => {
if (!appointmentId) {
return;
}
eventHandler.setEventType(EVENT_TYPES.LOADING);
sendWapNotification({
appointmentId: appointmentId.toString(),
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
sessionUser: SessionUser.userId,
})
.then(() => {
alert.showSuccess("La notificatión fue enviada con éxito");
})
.catch((error) => {
alert.showError(error.format());
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
},
icon: <WhatsAppIcon />,
});
menu.add({
text: "Enviar recordatorio por Email",
onClick: () => {
if (!appointmentId) {
return;
}
eventHandler.setEventType(EVENT_TYPES.LOADING);
sendEmailNotification({
appointmentId: appointmentId.toString(),
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
sessionUser: SessionUser.userId,
})
.then(() => {
alert.showSuccess("La notificatión fue enviada con éxito");
})
.catch((error) => {
alert.showError(error.format());
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
},
icon: <EmailOutlinedIcon />,
});
menu.divider();
menu.add({
text: "Enviar alta de turno por Whatsapp",
onClick: () => {
if (!appointmentId) {
return;
}
eventHandler.setEventType(EVENT_TYPES.LOADING);
sendWapNotification({
createImmediateNotificationJobs({
appointmentId: appointmentId.toString(),
type: APPOINTMENT_NOTIFICATION_TYPE.CREATION,
sessionUser: SessionUser.userId,
})
.then(() => {
alert.showSuccess("La notificatión fue enviada con éxito");
alert.showSuccess("La notificación fue encolada con éxito");
})
.catch((error) => {
alert.showError(error.format());
@@ -385,23 +333,23 @@ export default function ClientAppointment() {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
},
icon: <WhatsAppIcon />,
icon: <NotificationsActiveOutlinedIcon />,
});
menu.add({
text: "Enviar alta de turno por Email",
text: "Enviar recordatorio",
onClick: () => {
if (!appointmentId) {
return;
}
eventHandler.setEventType(EVENT_TYPES.LOADING);
sendEmailNotification({
createImmediateNotificationJobs({
appointmentId: appointmentId.toString(),
type: APPOINTMENT_NOTIFICATION_TYPE.CREATION,
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
sessionUser: SessionUser.userId,
})
.then(() => {
alert.showSuccess("La notificatión fue enviada con éxito");
alert.showSuccess("La notificación fue encolada con éxito");
})
.catch((error) => {
alert.showError(error.format());
@@ -410,7 +358,7 @@ export default function ClientAppointment() {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
},
icon: <EmailOutlinedIcon />,
icon: <NotificationsActiveOutlinedIcon />,
});
if (showQuitDiscount) {
@@ -593,12 +541,25 @@ export default function ClientAppointment() {
return;
}
confirm.show(
let sendNotification = false;
confirm.showContent(
"Estas seguro que queres cancelar el turno?",
<FormControlLabel
control={
<Checkbox
onChange={(event) => {
sendNotification = event.target.checked;
}}
/>
}
label="Enviar notificación al cliente"
/>,
() => {
eventHandler.setEventType(EVENT_TYPES.LOADING);
deleteAppointment({
id: appointmentId?.toString(),
notification: sendNotification,
sessionUser: SessionUser.userId,
})
.then(() => {
@@ -0,0 +1,85 @@
.container {
margin-top: 32px;
display: flex;
flex-direction: column;
gap: 10px;
width: min(100%, 900px);
margin-left: auto;
margin-right: auto;
}
.channels {
display: flex;
flex-direction: column;
gap: 10px;
}
.card {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(360px, 1fr);
gap: 16px;
align-items: center;
padding: 16px;
border-radius: 10px;
border: 1px solid var(--border-color);
background-color: var(--bg-primary);
}
.channelTitle {
color: var(--text-primary);
font-weight: 700;
font-size: 16px;
}
.channelDescription {
color: var(--text-secondary);
font-size: 13px;
margin-top: 4px;
}
.options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.optionButton {
cursor: pointer;
width: 100%;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid var(--border-color);
background-color: var(--bg-primary);
color: var(--text-secondary);
font-weight: 500;
font-size: 14px;
transition: all 0.2s ease;
}
.optionButtonActive {
border-color: var(--primary-color);
background-color: rgba(25, 118, 210, 0.12);
color: var(--primary-color);
font-weight: 700;
}
@media (max-width: 760px) {
.card {
grid-template-columns: 1fr;
gap: 12px;
}
}
@media (max-width: 480px) {
.container {
margin-top: 24px;
}
.card {
padding: 14px;
}
.options {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,82 @@
"use client";
import { useParams } from "next/navigation";
import { useEffect } from "react";
import Saveable from "@components/Saveable/Saveable";
import { useNotificationPreferencesStore } from "@store/NotificationPreferences.Store";
import { useSessionStore } from "@store/Sesion.Store";
import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store";
import { useAlert } from "@store/Alert.Store";
import { getClientCompanyOverride, upsertClientCompanyOverride } from "@services/Notifications.Service";
import { NotificationChannel } from "@models/NotificationPreferences.model";
type UrlData = {
oid: string;
clientId: string;
};
export default function NotificationsLayout({ children }: { children: React.ReactNode }) {
const data = useParams<UrlData>();
const oid = data.oid;
const clientId = data.clientId;
const store = useNotificationPreferencesStore();
const SessionInfo = useSessionStore();
const eventHandler = useEventHandlerStore();
const alert = useAlert();
useEffect(() => {
if (!SessionInfo.userId || !oid || !clientId) {
return;
}
store.setCompanyId(oid);
store.setClientId(clientId);
store.setMode("client-override");
eventHandler.setEventType(EVENT_TYPES.LOADING);
getClientCompanyOverride(clientId, oid, SessionInfo.userId)
.then((override) => {
store.setClientOverride(override);
store.updateFormData({
preferredChannels: override.preferredChannels || [],
mutedChannels: override.mutedChannels || []
});
store.setDirty(false);
})
.catch((error) => {
alert.showError(error.format());
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
}, [SessionInfo.userId, oid, clientId]);
const handleSave = () => {
eventHandler.setEventType(EVENT_TYPES.LOADING);
upsertClientCompanyOverride(clientId, oid, SessionInfo.userId, {
preferredChannels: store.formData.preferredChannels as NotificationChannel[],
mutedChannels: store.formData.mutedChannels as NotificationChannel[]
})
.then((override) => {
store.setClientOverride(override);
store.setDirty(false);
eventHandler.setEventType(EVENT_TYPES.SLEEP);
})
.catch((error) => {
alert.showError(error.format());
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
};
useEffect(() => {
if (store.isDirty) {
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
}
}, [store.isDirty]);
return (
<Saveable onClick={handleSave}>
{children}
</Saveable>
);
}
@@ -0,0 +1,139 @@
"use client";
import { useParams } from "next/navigation";
import { useEffect } from "react";
import Location from "@components/Location/Location";
import { useLocationStore } from "@store/Location.Store";
import { useNotificationPreferencesStore } from "@store/NotificationPreferences.Store";
import { NotificationChannel } from "@models/NotificationPreferences.model";
import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined";
import ClientsIcon from "@mui/icons-material/AccountBoxOutlined";
import NotificationsIcon from "@mui/icons-material/NotificationsOutlined";
import MaterialAlert from "@mui/material/Alert";
import styles from "./NotificationsPage.module.css";
type UrlData = {
oid: string;
clientId: string;
};
const CHANNELS: { key: NotificationChannel; label: string }[] = [
{ key: "whatsapp", label: "WhatsApp" },
{ key: "email", label: "Email" },
{ key: "system", label: "Sistema" }
];
type ChannelPreference = "default" | "preferred" | "muted";
export default function NotificationsPage() {
const data = useParams<UrlData>();
const oid = data.oid;
const clientId = data.clientId;
const location = useLocationStore();
const store = useNotificationPreferencesStore();
useEffect(() => {
location.clear();
location.add({
url: "/admin/org",
title: "Organizaciones",
icon: null
});
location.add({
url: "/admin/org/profile/" + oid,
title: "Organización",
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />
});
location.add({
url: "/admin/org/" + oid + "/client/" + clientId + "/profile",
title: "Cliente",
icon: <ClientsIcon sx={{ fontSize: "32px", mr: "10px" }} />
});
location.add({
url: "",
title: "Notificaciones",
icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} />
});
}, [oid, clientId]);
const getChannelPreference = (channel: NotificationChannel): ChannelPreference => {
if (store.formData.mutedChannels.includes(channel)) {
return "muted";
}
if (store.formData.preferredChannels.includes(channel)) {
return "preferred";
}
return "default";
};
const selectPreference = (channel: NotificationChannel, preference: ChannelPreference) => {
store.updateFormData({
preferredChannels:
preference === "preferred"
? [...store.formData.preferredChannels.filter((c) => c !== channel), channel]
: store.formData.preferredChannels.filter((c) => c !== channel),
mutedChannels:
preference === "muted"
? [...store.formData.mutedChannels.filter((c) => c !== channel), channel]
: store.formData.mutedChannels.filter((c) => c !== channel)
});
};
return (
<>
<Location />
<div className={styles.container}>
<MaterialAlert severity="info" sx={{ marginBottom: "0" }}>
<b>Preferencias del cliente:</b> Configure cómo esta organización debe comunicarse con este cliente. Si deja un canal en estándar, se usará la política general de la organización.
</MaterialAlert>
<div className={styles.channels}>
{CHANNELS.map((channel) => {
const selectedPreference = getChannelPreference(channel.key);
return (
<div key={channel.key} className={styles.card}>
<div>
<div className={styles.channelTitle}>{channel.label}</div>
<div className={styles.channelDescription}>
{selectedPreference === "preferred" && "Priorizar este canal para el cliente."}
{selectedPreference === "muted" && "No enviar notificaciones por este canal."}
{selectedPreference === "default" && "Usar la configuración general de la organización."}
</div>
</div>
<div className={styles.options}>
{[
{ key: "default" as ChannelPreference, label: "Estándar" },
{ key: "preferred" as ChannelPreference, label: "Preferido" },
{ key: "muted" as ChannelPreference, label: "Silenciado" }
].map((option) => {
const isSelected = selectedPreference === option.key;
return (
<button
key={option.key}
type="button"
onClick={() => selectPreference(channel.key, option.key)}
className={`${styles.optionButton} ${isSelected ? styles.optionButtonActive : ""}`}
>
{option.label}
</button>
);
})}
</div>
</div>
);
})}
</div>
</div>
<div style={{ marginBottom: "40px" }}>&nbsp;</div>
</>
);
}
@@ -3,6 +3,7 @@ import ButtonOption from "@components/ButtonOption/ButtonOption";
import Location from "@components/Location/Location";
import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined";
import ClientsIcon from "@mui/icons-material/AccountBoxOutlined";
import NotificationsIcon from "@mui/icons-material/Notifications";
import { useLocationStore } from "@store/Location.Store";
import { useNavigation } from "@hooks/goto";
import { useParams } from "next/navigation";
@@ -120,6 +121,19 @@ export default function ClientProfile() {
)
}
/>
<ButtonOption
text="Notificaciones"
icon="/notifications-icon.svg"
onClick={() =>
goTo(
"/admin/org/" +
oid +
"/client/" +
clientId +
"/notifications"
)
}
/>
</div>
</>
);
@@ -148,6 +148,10 @@ export default function OrganizationEdit() {
TextObjectFilterResult[]
>([]);
const [templateWapAltaId, setTemplateWapAltaId] = useState<string>("");
const [templateWapCancellationFilterData, setTemplateWapCancellationFilterData] = useState<
TextObjectFilterResult[]
>([]);
const [templateWapCancellationId, setTemplateWapCancellationId] = useState<string>("");
const loadTemplatesCombos = () => {
if (!SessionInfo.userId) return;
@@ -168,6 +172,7 @@ export default function OrganizationEdit() {
};
filterObjects<FindTemplatesParams>("templates/filter-all", filter).then((res) => {
setTemplateWapAltaFilterData(res);
setTemplateWapCancellationFilterData(res);
setCombosLoaded(true);
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
@@ -222,6 +227,7 @@ export default function OrganizationEdit() {
requestAnimationFrame(() => {
setTemplateWapNotifId(response.templateWapNotifId);
setTemplateWapAltaId(response.templateWapAltaId);
setTemplateWapCancellationId(response.templateWapCancellationId);
});
})
.catch((error) => {
@@ -263,6 +269,7 @@ export default function OrganizationEdit() {
phoneNumber: phone,
templateWapNotifId: templateWapNotifId === "" ? undefined : templateWapNotifId,
templateWapAltaId: templateWapAltaId === "" ? undefined : templateWapAltaId,
templateWapCancellationId: templateWapCancellationId === "" ? undefined : templateWapCancellationId,
};
const schema = Yup.object().shape({
@@ -317,8 +324,10 @@ export default function OrganizationEdit() {
.positive("El Plazo Mínimo de Cancelación debe ser un número positivo."),
templateWapNotifId: Yup.string().optional().default(""),
templateWapAltaId: Yup.string().optional().default(""),
templateWapCancellationId: Yup.string().optional().default(""),
templateEmailNotifId: Yup.string().optional().default(""),
templateEmailAltaId: Yup.string().optional().default(""),
templateEmailCancellationId: Yup.string().optional().default(""),
});
schema
@@ -603,6 +612,17 @@ export default function OrganizationEdit() {
selectedId={templateWapAltaId}
/>
<TextObjectFilter
placeholder="Cancelación de Reservas"
content={templateWapAltaFilterContent}
data={templateWapCancellationFilterData}
onChange={(id) => {
setTemplateWapCancellationId(id);
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
}}
selectedId={templateWapCancellationId}
/>
<div style={{ marginBottom: "10px" }}>&nbsp;</div>
</>
);
@@ -0,0 +1,83 @@
"use client";
import { useParams } from "next/navigation";
import { useEffect } from "react";
import Saveable from "@components/Saveable/Saveable";
import { useNotificationPreferencesStore } from "@store/NotificationPreferences.Store";
import { useSessionStore } from "@store/Sesion.Store";
import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store";
import { useAlert } from "@store/Alert.Store";
import { getCompanyPolicy, upsertCompanyPolicy } from "@services/Notifications.Service";
import { NotificationChannel } from "@models/NotificationPreferences.model";
type UrlData = {
id: string;
};
export default function NotificationsLayout({ children }: { children: React.ReactNode }) {
const data = useParams<UrlData>();
const id = data.id;
const store = useNotificationPreferencesStore();
const SessionInfo = useSessionStore();
const eventHandler = useEventHandlerStore();
const alert = useAlert();
useEffect(() => {
if (!SessionInfo.userId || !id) {
return;
}
store.setCompanyId(id);
store.setMode("company-policy");
eventHandler.setEventType(EVENT_TYPES.LOADING);
getCompanyPolicy(id, SessionInfo.userId)
.then((policy) => {
store.setCompanyPolicy(policy);
store.updateFormData({
defaultChannels: policy.defaultChannels || [],
mutedChannels: policy.mutedChannels || [],
quietHours: policy.quietHours || null,
reminderRules: policy.reminderRules || []
});
store.setDirty(false);
})
.catch((error) => {
alert.showError(error.format());
})
.finally(() => {
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
}, [SessionInfo.userId, id]);
const handleSave = () => {
eventHandler.setEventType(EVENT_TYPES.LOADING);
upsertCompanyPolicy(id, SessionInfo.userId, {
defaultChannels: store.formData.defaultChannels as NotificationChannel[],
timezone: "America/Argentina/Buenos_Aires",
quietHours: store.formData.quietHours || undefined,
reminderRules: store.formData.reminderRules
})
.then((policy) => {
store.setCompanyPolicy(policy);
store.setDirty(false);
eventHandler.setEventType(EVENT_TYPES.SLEEP);
})
.catch((error) => {
alert.showError(error.format());
eventHandler.setEventType(EVENT_TYPES.SLEEP);
});
};
useEffect(() => {
if (store.isDirty) {
eventHandler.setEventType(EVENT_TYPES.NEED_SAVE);
}
}, [store.isDirty]);
return (
<Saveable onClick={handleSave}>
{children}
</Saveable>
);
}
@@ -0,0 +1,215 @@
"use client";
import { useParams } from "next/navigation";
import { useEffect } from "react";
import Location from "@components/Location/Location";
import TextTime from "@components/TextTime/TextTime";
import { useLocationStore } from "@store/Location.Store";
import { useNotificationPreferencesStore } from "@store/NotificationPreferences.Store";
import { useEventHandlerStore, EVENT_TYPES } from "@store/EventHandler.Store";
import { NotificationChannel } from "@models/NotificationPreferences.model";
import Switch from "@core/app/components/Switch/Switch";
import StorefrontOutlinedIcon from "@mui/icons-material/StorefrontOutlined";
import NotificationsIcon from "@mui/icons-material/NotificationsOutlined";
import MaterialAlert from "@mui/material/Alert";
import dayjs from "dayjs";
type UrlData = {
id: string;
};
const CHANNELS: { key: NotificationChannel; label: string }[] = [
{ key: "whatsapp", label: "WhatsApp" },
{ key: "email", label: "Email" },
{ key: "system", label: "Sistema" }
];
const REMINDER_PRESETS: { minutes: number; label: string }[] = [
{ minutes: 2, label: "2 minutos antes" },
{ minutes: 5, label: "5 minutos antes" },
{ minutes: 10, label: "10 minutos antes" }
];
export default function NotificationsPage() {
const data = useParams<UrlData>();
const id = data.id;
const location = useLocationStore();
const store = useNotificationPreferencesStore();
const eventHandler = useEventHandlerStore();
useEffect(() => {
location.clear();
location.add({
url: "/admin/org",
title: "Organizaciones",
icon: null
});
location.add({
url: "/admin/org/profile/" + id,
title: "Perfil",
icon: <StorefrontOutlinedIcon sx={{ mr: 0.5 }} fontSize="inherit" />
});
location.add({
url: "/admin/org/profile/" + id + "/notifications",
title: "Notificaciones",
icon: <NotificationsIcon sx={{ fontSize: "32px", mr: "10px" }} />
});
}, [id]);
const toggleChannel = (channel: NotificationChannel) => {
const current = store.formData.defaultChannels;
const updated = current.includes(channel)
? current.filter((c) => c !== channel)
: [...current, channel];
store.updateFormData({ defaultChannels: updated });
};
const updateQuietHours = (field: "from" | "to", value: string) => {
const current = store.formData.quietHours || { from: "00:00", to: "00:00" };
store.updateFormData({
quietHours: { ...current, [field]: value }
});
};
const quietHourValue = (value: string | undefined, fallback: string) => {
const [hour, minute] = (value || fallback).split(":").map(Number);
return dayjs(new Date()).hour(hour).minute(minute || 0).second(0).millisecond(0);
};
const toggleQuietHours = () => {
store.updateFormData({
quietHours: store.formData.quietHours ? null : { from: "22:00", to: "08:00" }
});
};
const toggleReminder = (minutes: number) => {
const exists = store.formData.reminderRules.some((r) => r.offset === minutes);
if (exists) {
const updated = store.formData.reminderRules.filter((r) => r.offset !== minutes);
store.updateFormData({ reminderRules: updated });
} else {
store.updateFormData({
reminderRules: [...store.formData.reminderRules, { offset: minutes, enabled: true }]
});
}
};
return (
<>
<Location />
<div style={{ marginTop: "20px" }}>
{/* Channel Toggles Section */}
<MaterialAlert severity="info" sx={{ marginBottom: "20px" }}>
<b>Canales de notificación:</b> Seleccione los canales por los cuales se enviarán las notificaciones automáticas.
</MaterialAlert>
<div style={{ display: "flex", flexDirection: "column", gap: "8px", marginBottom: "30px" }}>
{CHANNELS.map((channel) => (
<div
key={channel.key}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px 16px",
borderRadius: "4px",
border: "1px solid var(--border-color)",
backgroundColor: "var(--bg-primary)"
}}
>
<span style={{ color: "var(--text-primary)", fontWeight: "500" }}>{channel.label}</span>
<Switch
checked={store.formData.defaultChannels.includes(channel.key)}
onChange={() => toggleChannel(channel.key)}
/>
</div>
))}
</div>
{/* Quiet Hours Section */}
<MaterialAlert severity="info" sx={{ marginBottom: "20px" }}>
<b>Horario silencioso:</b> Las notificaciones no se enviarán durante este rango horario.
</MaterialAlert>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px 16px",
borderRadius: "8px",
border: "1px solid var(--border-color)",
backgroundColor: "var(--bg-primary)",
marginBottom: "16px"
}}
>
<span style={{ color: "var(--text-primary)", fontWeight: "500" }}>
Activar horario silencioso
</span>
<Switch
checked={Boolean(store.formData.quietHours)}
onChange={toggleQuietHours}
/>
</div>
{store.formData.quietHours && (
<div style={{ display: "flex", gap: "16px", alignItems: "flex-end", marginBottom: "30px" }}>
<div style={{ flex: 1 }}>
<TextTime
placeholder="Desde"
width="100%"
value={quietHourValue(store.formData.quietHours.from, "22:00")}
onChange={(value) => updateQuietHours("from", value.format("HH:mm"))}
/>
</div>
<div style={{ flex: 1 }}>
<TextTime
placeholder="Hasta"
width="100%"
value={quietHourValue(store.formData.quietHours.to, "08:00")}
onChange={(value) => updateQuietHours("to", value.format("HH:mm"))}
/>
</div>
</div>
)}
{/* Reminder Rules Section */}
<MaterialAlert severity="info" sx={{ marginBottom: "20px" }}>
<b>Reglas de recordatorio:</b> Active los momentos en los que desea enviar recordatorios antes del turno.
</MaterialAlert>
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px" }}>
{REMINDER_PRESETS.map((preset) => {
const isActive = store.formData.reminderRules.some((r) => r.offset === preset.minutes);
return (
<button
key={preset.minutes}
type="button"
onClick={() => toggleReminder(preset.minutes)}
style={{
cursor: "pointer",
padding: "10px 16px",
borderRadius: "999px",
border: `1px solid ${isActive ? "var(--primary-color)" : "var(--border-color)"}`,
backgroundColor: isActive ? "rgba(25, 118, 210, 0.12)" : "var(--bg-primary)",
color: isActive ? "var(--primary-color)" : "var(--text-secondary)",
fontWeight: isActive ? "600" : "500",
fontSize: "14px",
transition: "all 0.2s ease"
}}
>
{preset.label}
</button>
);
})}
</div>
</div>
<div style={{ marginBottom: "40px" }}>&nbsp;</div>
</>
);
}
@@ -20,6 +20,7 @@ import CashIcon from "@mui/icons-material/AccountBalanceOutlined";
import DiscountsIcon from "@mui/icons-material/LocalOfferOutlined";
import TemplatesIcon from "@mui/icons-material/SpeakerNotesOutlined";
import AssistanceIcon from "@mui/icons-material/HeadsetMicOutlined";
import NotificationsOutlinedIcon from "@mui/icons-material/NotificationsOutlined";
import ShareOutlinedIcon from "@mui/icons-material/ShareOutlined";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
@@ -88,6 +89,11 @@ export default function OrganizationProfile() {
text: "WhatsApp",
onClick: () => goTo("/admin/org/profile/" + id + "/wap"),
},
{
icon: <NotificationsOutlinedIcon />,
text: "Notificaciones",
onClick: () => goTo("/admin/org/profile/" + id + "/notifications"),
},
{
icon: <CategoryOutlinedIcon />,
text: "Servicios",
@@ -197,7 +197,7 @@ export default function TemplateCreate() {
eventHandler.setEventType(EVENT_TYPES.LOADING);
getDefaultTemplate({
companyId: id,
type: type,
type: typeRef.current,
sessionUser: SessionInfo.userId,
})
.then((res) => {
@@ -267,6 +267,7 @@ export default function TemplateCreate() {
data={[
{ id: TemplateTypes.WAP_REMINDER, text: "WhatsApp - Recordatorio" },
{ id: TemplateTypes.WAP_NEW_APPOINTMENT, text: "WhatsApp - Nueva reserva" },
{ id: TemplateTypes.WAP_CANCELLATION, text: "WhatsApp - Cancelación" },
]}
setSelectedValue={(value) => {
setType(value as TemplateTypes);
@@ -37,10 +37,14 @@ export default function TemplatesGrid() {
return "WhatsApp - Nueva reserva";
case TemplateTypes.WAP_REMINDER:
return "WhatsApp - Recordatorio";
case TemplateTypes.WAP_CANCELLATION:
return "WhatsApp - Cancelación";
case TemplateTypes.EMAIL_NEW_APPOINTMENT:
return "Email - Nueva reserva";
case TemplateTypes.EMAIL_REMINDER:
return "Email - Recordatorio";
case TemplateTypes.EMAIL_CANCELLATION:
return "Email - Cancelación";
}
};