/* ============================================================
AGENDA — Componentes base (exportados em window)
============================================================ */
const { useState, useEffect, useRef, useCallback } = React;
/* ---------- Ícones (line icons) ---------- */
const ICONS = {
dashboard: 'M3 3h7v7H3zM14 3h7v4h-7zM14 11h7v10h-7zM3 14h7v7H3z',
calendar: 'M8 2v4M16 2v4M3 9h18M3 6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
users: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75',
user: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8',
userX: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8M17 8l5 5M22 8l-5 5',
tag: 'M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82zM7 7h.01',
team: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75',
wallet: 'M21 12V7H5a2 2 0 0 1 0-4h14v4M3 5v14a2 2 0 0 0 2 2h16v-5M18 12a2 2 0 0 0 0 4h4v-4z',
check2: 'M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11',
history: 'M3 3v5h5M3.05 13A9 9 0 1 0 6 5.3L3 8M12 7v5l4 2',
bell: 'M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 0 1-3.46 0',
settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z',
plus: 'M12 5v14M5 12h14',
search: 'M21 21l-4.35-4.35M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z',
sun: 'M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10zM12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4',
moon: 'M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z',
chevDown: 'M6 9l6 6 6-6',
chevRight: 'M9 18l6-6-6-6',
chevLeft: 'M15 18l-6-6 6-6',
x: 'M18 6L6 18M6 6l12 12',
check: 'M20 6L9 17l-5-5',
clock: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 6v6l4 2',
phone: 'M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.9.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z',
mail: 'M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM22 6l-10 7L2 6',
dots: 'M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2zM12 6a1 1 0 1 0 0-2 1 1 0 0 0 0 2zM12 20a1 1 0 1 0 0-2 1 1 0 0 0 0 2z',
edit: 'M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z',
trash: 'M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2',
filter: 'M22 3H2l8 9.46V19l4 2v-8.54z',
trendUp: 'M23 6l-9.5 9.5-5-5L1 18M17 6h6v6',
trendDown: 'M23 18l-9.5-9.5-5 5L1 6M17 18h6v-6',
card: 'M1 4h22v16H1zM1 10h22',
building: 'M3 21h18M5 21V7l8-4v18M19 21V11l-6-3M9 9h.01M9 12h.01M9 15h.01M9 18h.01',
health: 'M22 12h-4l-3 9L9 3l-3 9H2',
scissors: 'M6 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM20 4L8.12 15.88M14.47 14.48L20 20M8.12 8.12L12 12',
briefcase: 'M20 7h-16a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2zM16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16',
whatsapp: 'M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8z',
pin: 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0zM12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
star: 'M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01z',
logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9',
grid: 'M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z',
list: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01',
arrowRight: 'M5 12h14M12 5l7 7-7 7',
zap: 'M13 2L3 14h9l-1 8 10-12h-9z',
download: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3',
print: 'M6 9V2h12v7M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2M6 14h12v8H6zM18 12h.01',
copy: 'M20 9H11a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-9a2 2 0 0 0-2-2zM5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1',
info: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 16v-4M12 8h.01',
doc: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M16 13H8M16 17H8M10 9H8',
refresh: 'M23 4v6h-6M20.49 15a9 9 0 1 1-2.12-9.36L23 10',
};
function Icon({ name, size = 18, stroke = 2, fill = false, style }) {
const d = ICONS[name] || ICONS.info;
return (
{d.split('M').filter(Boolean).map((seg, i) => )}
);
}
/* Marca vetorial embutida: continua visível mesmo em uma publicação sem os PNGs. */
function BrandMark({ className = '', title = 'DEL AGENDA' }) {
return (
);
}
/* ---------- Avatar ---------- */
const AV_COLORS = {
blue: 'var(--ev-blue)', teal: 'var(--ev-teal)', violet: 'var(--ev-violet)',
cyan: 'var(--ev-cyan)', amber: 'var(--ev-amber)', rose: 'var(--ev-rose)',
green: 'var(--ev-green)', gray: 'var(--ev-gray)',
};
function ProfilePhotoViewer({ src, alt, onClose }) {
const [closing, setClosing] = useState(false);
const closingRef = useRef(false);
const closeTimerRef = useRef(null);
const onCloseRef = useRef(onClose);
useEffect(() => { onCloseRef.current = onClose; }, [onClose]);
const requestClose = useCallback(() => {
if (closingRef.current) return;
closingRef.current = true;
setClosing(true);
closeTimerRef.current = window.setTimeout(() => onCloseRef.current(), 190);
}, []);
useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const onKeyDown = (event) => {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopImmediatePropagation();
requestClose();
};
window.addEventListener('keydown', onKeyDown, true);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener('keydown', onKeyDown, true);
if (closeTimerRef.current) window.clearTimeout(closeTimerRef.current);
};
}, [requestClose]);
if (!src) return null;
return ReactDOM.createPortal(
{
event.stopPropagation();
if (event.target === event.currentTarget) requestClose();
}}
onMouseDown={(event) => event.stopPropagation()}>
,
document.body
);
}
function Avatar({ initials, color = 'blue', size = 36, ring = false, src = '', alt = '' }) {
const [photoFailed, setPhotoFailed] = useState(false);
const [viewer, setViewer] = useState(null);
useEffect(() => setPhotoFailed(false), [src]);
const avatarColor = AV_COLORS[color] || color;
const showPhoto = !!src && !photoFailed;
const photoBorder = Math.max(2, Math.min(5, Math.round(size * 0.085)));
const openViewer = (event) => {
if (!showPhoto) return;
event.preventDefault();
event.stopPropagation();
setViewer({ src, alt: alt || 'Foto do perfil' });
};
return (
<>
{
if (showPhoto && (event.key === 'Enter' || event.key === ' ')) openViewer(event);
}}>
{showPhoto
?
setPhotoFailed(true)} />
: initials}
{viewer && setViewer(null)} />}
>
);
}
/* ---------- Badge ---------- */
function Badge({ children, cls = 'plain', dot = false }) {
return {dot && }{children} ;
}
/* ---------- Button ---------- */
function Button({ children, variant = 'default', size, icon, iconRight, block, onClick, disabled, type }) {
const cls = ['btn', variant !== 'default' ? variant : '', size || '', block ? 'block' : ''].filter(Boolean).join(' ');
return (
{icon && }
{children}
{iconRight && }
);
}
/* ---------- Modal ---------- */
function Modal({ title, sub, children, footer, onClose, wide }) {
useEffect(() => {
const h = (e) => e.key === 'Escape' && onClose();
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}, [onClose]);
// Portal para document.body: imune a ancestrais com transform/filter
// (que quebrariam o position:fixed do overlay).
return ReactDOM.createPortal(
e.target === e.currentTarget && onClose()}>
{children}
{footer &&
{footer}
}
,
document.body
);
}
/* ---------- Drawer ---------- */
const Drawer = React.forwardRef(function Drawer({ children, onClose, head, footer, className }, ref) {
const [closing, setClosing] = useState(false);
const closingRef = useRef(false);
const closeTimerRef = useRef(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const requestClose = useCallback((afterClose) => {
if (closingRef.current) return;
closingRef.current = true;
setClosing(true);
const reducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
closeTimerRef.current = window.setTimeout(() => {
const callback = typeof afterClose === 'function' ? afterClose : null;
onCloseRef.current();
if (callback) callback();
}, reducedMotion ? 0 : 280);
}, []);
React.useImperativeHandle(ref, () => ({ close: requestClose }), [requestClose]);
useEffect(() => {
const h = (e) => {
if (e.key !== 'Escape' || closingRef.current) return;
// Um modal de confirmação aberto sobre o drawer deve receber o Esc primeiro.
if (document.querySelector('body > .overlay')) return;
requestClose();
};
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}, [requestClose]);
useEffect(() => () => {
if (closeTimerRef.current) window.clearTimeout(closeTimerRef.current);
}, []);
const renderedHead = typeof head === 'function' ? head(requestClose) : head;
// Portal para document.body: garante que o drawer ocupe a viewport inteira
// mesmo quando aberto a partir de uma tela com animação/transform.
return ReactDOM.createPortal(
{renderedHead}
{children}
{footer &&
{footer}
}
,
document.body
);
});
/* ---------- Field ---------- */
function Field({ label, optional, children, full }) {
return (
{label && {label}{optional && (opcional) } }
{children}
);
}
function Input(props) { return ; }
function Textarea(props) { return ; }
function Select({ children, ...props }) { return {children} ; }
function ColorPicker({ value, onChange, usedColors = [], currentColor = '', label = 'Cor da agenda', optional = true }) {
const DB = window.DB || {};
const normalize = DB.normalizeColor || ((c) => String(c || '').trim().toLowerCase() || 'gray');
const swatches = DB.personColorSwatches || [];
const selected = normalize(value);
const current = currentColor ? normalize(currentColor) : '';
const usedBy = new Map();
(usedColors || []).forEach((entry) => {
const color = normalize(entry && typeof entry === 'object' ? entry.color : entry);
const owner = entry && typeof entry === 'object' ? String(entry.name || '').trim() : '';
if (color && color !== 'gray' && color !== current && !usedBy.has(color)) {
usedBy.set(color, owner);
}
});
const takenBy = selected !== 'gray' ? usedBy.get(selected) : '';
const taken = selected !== 'gray' && usedBy.has(selected);
const customValue = /^#[0-9a-f]{6}$/i.test(selected) ? selected : '#d1d5db';
return (
{swatches.map((item) => {
const color = normalize(item.value);
const owner = usedBy.get(color) || '';
const disabled = usedBy.has(color);
const active = selected === color;
return (
onChange && onChange(item.value)}
style={{
width: 32, height: 32, borderRadius: 8, border: active ? '2px solid var(--text)' : '1px solid var(--border-strong)',
background: DB.colorValue ? DB.colorValue(item.value) : item.value,
boxShadow: active ? '0 0 0 3px var(--primary-soft)' : 'none',
}}
/>
);
})}
onChange && onChange(e.target.value.toLowerCase())}
style={{ width: 18, height: 18, border: 0, padding: 0, background: 'transparent' }} />
Personalizada
{taken
? `Esta cor já está sendo usada${takenBy ? ` por ${takenBy}` : ' por outra pessoa'}. Escolha outra.`
: (usedBy.size > 0 ? 'Cores marcadas já estão em uso. Sem seleção, será usado cinza claro.' : 'Se nenhuma cor for escolhida, será usado cinza claro.')}
);
}
/* ---------- Input com máscara (telefone, CPF, CEP) ----------
`mask` recebe o texto e devolve já formatado. O onChange recebe um
evento com o valor mascarado — compatível com os handlers `set('campo')`. */
function MaskInput({ mask, value, onChange, ...props }) {
const apply = mask ? (v) => window.DB.mask[mask](v) : (v) => v;
return (
onChange && onChange({ target: { value: apply(e.target.value) } })}
{...props} />
);
}
/* ---------- Input monetário (R$) ----------
Mantém o valor como número; exibe formatado. onChange recebe o número. */
function MoneyInput({ value, onChange, ...props }) {
return (
onChange && onChange(window.DB.moneyFromDigits(e.target.value))}
{...props} />
);
}
/* ---------- Toggle ---------- */
function Switch({ on, onClick, disabled = false, ariaLabel }) {
return ;
}
/* ---------- Segmented ---------- */
function Seg({ options, value, onChange }) {
return (
{options.map(o => (
onChange(o.value)}>{o.label}
))}
);
}
/* ---------- Checkbox ---------- */
function Checkbox({ on, onClick }) {
return ;
}
/* ---------- Dropdown menu ---------- */
function Menu({ trigger, children, align = 'right', width }) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState(null);
const ref = useRef(null);
const menuRef = useRef(null);
useEffect(() => {
if (!open) return;
const h = (e) => {
if (ref.current && !ref.current.contains(e.target) && (!menuRef.current || !menuRef.current.contains(e.target))) setOpen(false);
};
const close = () => setOpen(false);
document.addEventListener('mousedown', h);
window.addEventListener('scroll', close, true);
window.addEventListener('resize', close);
return () => {
document.removeEventListener('mousedown', h);
window.removeEventListener('scroll', close, true);
window.removeEventListener('resize', close);
};
}, [open]);
const toggle = () => {
if (!open && ref.current) {
const r = ref.current.getBoundingClientRect();
const up = window.innerHeight - r.bottom < 280 && r.top > 280; // perto do rodapé, abre para cima
const requestedWidth = Number(width) || 190;
const menuWidth = Math.min(requestedWidth, Math.max(0, window.innerWidth - 24));
const desiredLeft = align === 'left' ? r.left : r.right - menuWidth;
const maxLeft = Math.max(12, window.innerWidth - menuWidth - 12);
setPos({
top: up ? 'auto' : r.bottom + 6,
bottom: up ? window.innerHeight - r.top + 6 : 'auto',
left: Math.max(12, Math.min(desiredLeft, maxLeft)),
right: 'auto',
width: menuWidth,
});
}
setOpen(o => !o);
};
// Portal + position:fixed: o menu nunca é cortado por containers
// com overflow (cards de tabela, drawers).
return (
{trigger}
{open && pos && ReactDOM.createPortal(
setOpen(false)}>{children}
,
document.body
)}
);
}
/* ---------- EmptyState ---------- */
function Empty({ icon = 'search', title, desc, action }) {
return (
{title}
{desc &&
{desc}
}
{action}
);
}
/* ---------- Toast ---------- */
function useToasts() {
const [toasts, setToasts] = useState([]);
const push = useCallback((msg, icon = 'check') => {
const id = Math.random();
setToasts(t => [...t, { id, msg, icon }]);
setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 4000);
}, []);
const close = useCallback((id) => {
setToasts(t => t.filter(x => x.id !== id));
}, []);
const node = (
{toasts.map(t => (
{t.msg}
close(t.id)}>
))}
);
return [push, node];
}
/* helpers */
function timeToMin(t) {
const [h, m] = String(t || '').split(':').map(Number);
return Number.isFinite(h) && Number.isFinite(m) ? h * 60 + m : 0;
}
function minToTime(m) {
const v = Math.max(0, Math.min(1439, Number(m) || 0));
return `${String(Math.floor(v / 60)).padStart(2, '0')}:${String(v % 60).padStart(2, '0')}`;
}
function addMinutes(start, minutes) { return minToTime(timeToMin(start) + minutes); }
function endTime(start, dur) { return addMinutes(start, dur); }
function apptEnd(a) { return a && a.end ? a.end : endTime(a.start, a.dur || 30); }
function apptDur(a) {
const dur = timeToMin(apptEnd(a)) - timeToMin(a.start);
return dur > 0 ? dur : (a && a.dur) || 30;
}
Object.assign(window, {
Icon, BrandMark, Avatar, Badge, Button, Modal, Drawer, Field, Input, Textarea, Select, ColorPicker,
MaskInput, MoneyInput,
Switch, Seg, Checkbox, Menu, Empty, useToasts, AV_COLORS,
timeToMin, minToTime, addMinutes, endTime, apptEnd, apptDur,
});