feat: add notification configuration flow to StepEngine and integrate ClientPicker component
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import style from "./ClientPicker.module.css";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import PersonOffOutlined from "@mui/icons-material/PersonOffOutlined";
|
||||
import { OrganizationClientView } from "@models/Clients.model";
|
||||
|
||||
export interface ClientPickerProps {
|
||||
open: boolean;
|
||||
clients: OrganizationClientView[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onClose: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function formatPhone(c: OrganizationClientView): string {
|
||||
const parts = [c.phoneCountryCode, c.phoneAreaCode, c.phoneNumber]
|
||||
.filter(p => p != null && String(p).trim() !== "");
|
||||
if (parts.length === 0) return "";
|
||||
const joined = parts.map(p => String(p).trim()).join(" ");
|
||||
return joined.startsWith("+") ? joined : `+${joined}`;
|
||||
}
|
||||
|
||||
function getInitials(c: OrganizationClientView): string {
|
||||
const f = (c.firstName?.[0] || "").toUpperCase();
|
||||
const l = (c.lastName?.[0] || "").toUpperCase();
|
||||
return `${f}${l}` || "?";
|
||||
}
|
||||
|
||||
export default function ClientPicker({
|
||||
open,
|
||||
clients,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onClose,
|
||||
loading,
|
||||
}: ClientPickerProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
setQuery("");
|
||||
const t = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, 80);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
clearTimeout(t);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return clients;
|
||||
return clients.filter(c =>
|
||||
(c.firstName || "").toLowerCase().includes(q) ||
|
||||
(c.lastName || "").toLowerCase().includes(q) ||
|
||||
(c.fullName || "").toLowerCase().includes(q)
|
||||
);
|
||||
}, [clients, query]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const hasClients = clients.length > 0;
|
||||
const noResults = hasClients && filtered.length === 0 && query.trim() !== "";
|
||||
const searching = query.trim() !== "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={style.overlay}
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Seleccionar cliente"
|
||||
>
|
||||
<div
|
||||
className={style.sheet}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className={style.header}>
|
||||
<h2>Selecciona un cliente</h2>
|
||||
<button
|
||||
type="button"
|
||||
className={style.closeButton}
|
||||
onClick={onClose}
|
||||
aria-label="Cerrar"
|
||||
>
|
||||
<CloseIcon style={{ fontSize: 20 }} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className={style.searchWrap}>
|
||||
<SearchIcon
|
||||
style={{
|
||||
fontSize: 22,
|
||||
position: "absolute",
|
||||
left: "14px",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: "rgba(255,255,255,0.5)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={style.searchInput}
|
||||
placeholder="Buscar por nombre o apellido..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
aria-label="Buscar cliente"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{searching && (
|
||||
<button
|
||||
type="button"
|
||||
className={style.clearButton}
|
||||
onClick={() => setQuery("")}
|
||||
aria-label="Limpiar búsqueda"
|
||||
>
|
||||
<CloseIcon style={{ fontSize: 16 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={style.results}>
|
||||
{loading ? (
|
||||
<div className={style.centerState}>
|
||||
<div className={style.spinner} />
|
||||
<p className={style.emptyText}>Cargando clientes…</p>
|
||||
</div>
|
||||
) : !hasClients ? (
|
||||
<div className={style.centerState}>
|
||||
<PersonOffOutlined style={{ fontSize: 46, color: "rgba(255,255,255,0.35)" }} />
|
||||
<p className={style.emptyTitle}>Sin clientes todavía</p>
|
||||
<p className={style.emptyText}>
|
||||
No hay clientes para esta organización todavía. Cuando alguien reserve un turno, aparecerá acá.
|
||||
</p>
|
||||
</div>
|
||||
) : noResults ? (
|
||||
<div className={style.centerState}>
|
||||
<p className={style.emptyTitle}>No encontramos clientes con ese nombre.</p>
|
||||
<button
|
||||
type="button"
|
||||
className={style.clearCta}
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
Limpiar búsqueda
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(c => {
|
||||
const isSelected = c.id === selectedId;
|
||||
const sub = (c.email && c.email.trim() !== "") ? c.email : formatPhone(c);
|
||||
const avatar = c.avatar && c.avatar.trim() !== "";
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className={`${style.clientCard} ${isSelected ? style.clientCardSelected : ""}`}
|
||||
onClick={() => onSelect(c.id)}
|
||||
>
|
||||
<div className={style.avatarWrap}>
|
||||
<div className={style.avatarFallback}>{getInitials(c)}</div>
|
||||
{avatar && (
|
||||
<img
|
||||
src={c.avatar}
|
||||
alt={c.fullName}
|
||||
className={style.avatarImg}
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.opacity = "0";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={style.cardBody}>
|
||||
<div className={style.cardTitle}>
|
||||
<span className={style.fullName}>{c.fullName}</span>
|
||||
{c.banned && (
|
||||
<span className={style.bannedChip}>Suspendido</span>
|
||||
)}
|
||||
</div>
|
||||
{sub && <span className={style.subLine}>{sub}</span>}
|
||||
</div>
|
||||
|
||||
{isSelected && (
|
||||
<span className={style.checkWrap} aria-label="Seleccionado">
|
||||
<CheckIcon style={{ fontSize: 18 }} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user