210 lines
8.6 KiB
TypeScript
210 lines
8.6 KiB
TypeScript
"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>
|
|
);
|
|
} |