feat: implement comprehensive notification engine with policies, preferences, and automated dispatching services
This commit is contained in:
+17
@@ -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."),
|
||||
|
||||
+27
-66
@@ -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(() => {
|
||||
|
||||
+85
@@ -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;
|
||||
}
|
||||
}
|
||||
+82
@@ -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>
|
||||
);
|
||||
}
|
||||
+139
@@ -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" }}> </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" }}> </div>
|
||||
</>
|
||||
);
|
||||
|
||||
+83
@@ -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>
|
||||
);
|
||||
}
|
||||
+215
@@ -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" }}> </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",
|
||||
|
||||
+2
-1
@@ -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";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user