Files
scoreodonto.com/frontend/components/StackModal.tsx
T
VPS 4 Builder f084656712 feat(agenda): auditoria/autoria, faltas, a-reagendar, presença + RBAC por cargo
Agenda (multi-secretária):
- audit_log genérico + autoria (created/updated/canceled_by) + soft-delete + version (lock otimista)
- status novo vocabulário + índice único PARCIAL (libera slot ao cancelar/faltar)
- endpoints: falta, remarcar, restaurar, historico, pendencias (a reagendar), faltas, familia
- presença: heartbeat (DB última visita + Dragonfly ao vivo, janela 45s)
- frontend: StackModal, AgendaDetailModal, AgendaPresence; lista a-reagendar; pacienteId no fluxo de criação; chips de família + badge de faltas no modal

RBAC por cargo: funcoes.permissoes/nivel; gate de menu por cargo (array vazio herda role); hierarquia só-gerencia-abaixo no backend

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 16:10:58 +02:00

79 lines
3.3 KiB
TypeScript

import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
import { X, ChevronLeft, Maximize2, Minimize2 } from 'lucide-react';
// Modal em PILHA (estilo navegação iOS): páginas deslizam da direita, com botão voltar.
// Desktop abre pequeno + ícone maximizar (tela cheia). Mobile = tela cheia direto.
// Cada página é um componente que carrega o PRÓPRIO mínimo (carregamento progressivo).
export interface StackPage {
title: string;
render: () => ReactNode;
}
interface StackCtx {
push: (p: StackPage) => void;
pop: () => void;
close: () => void;
}
const Ctx = createContext<StackCtx | null>(null);
export const useStackModal = (): StackCtx => {
const c = useContext(Ctx);
if (!c) throw new Error('useStackModal deve ser usado dentro de <StackModal>');
return c;
};
export const StackModal: React.FC<{ isOpen: boolean; onClose: () => void; root: StackPage }> = ({ isOpen, onClose, root }) => {
const [stack, setStack] = useState<StackPage[]>([root]);
const [fullscreen, setFullscreen] = useState(false);
// Reabriu → reinicia a pilha. (Use também key={...} no pai p/ forçar remount por entidade.)
useEffect(() => { if (isOpen) setStack([root]); /* eslint-disable-line */ }, [isOpen]);
const push = useCallback((p: StackPage) => setStack(s => [...s, p]), []);
const pop = useCallback(() => setStack(s => (s.length > 1 ? s.slice(0, -1) : s)), []);
const close = useCallback(() => onClose(), [onClose]);
if (!isOpen) return null;
const current = stack[stack.length - 1];
const canBack = stack.length > 1;
return (
<div className="fixed inset-0 bg-black/60 z-[70] flex items-end sm:items-center justify-center backdrop-blur-sm sm:p-4">
<div
className={`bg-white shadow-2xl flex flex-col overflow-hidden transition-all duration-300 w-full h-dvh rounded-none ${
fullscreen ? 'sm:w-screen sm:h-dvh sm:rounded-none' : 'sm:w-[460px] sm:h-auto sm:max-h-[88vh] sm:rounded-3xl'
}`}
>
<div className="flex items-center gap-2 px-4 py-3 border-b border-gray-100 shrink-0">
{canBack ? (
<button onClick={pop} className="p-1.5 -ml-1 hover:bg-gray-100 rounded-lg text-gray-500 transition-colors">
<ChevronLeft size={20} />
</button>
) : (
<span className="w-2" />
)}
<h3 className="font-black text-sm uppercase text-gray-800 truncate flex-1">{current.title}</h3>
<button
onClick={() => setFullscreen(f => !f)}
className="hidden sm:inline-flex p-1.5 hover:bg-gray-100 rounded-lg text-gray-400 transition-colors"
title={fullscreen ? 'Restaurar' : 'Tela cheia'}
>
{fullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
</button>
<button onClick={close} className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-400 transition-colors">
<X size={18} />
</button>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar">
<Ctx.Provider value={{ push, pop, close }}>
<div key={stack.length} className="animate-in slide-in-from-right-4 fade-in duration-200 p-5">
{current.render()}
</div>
</Ctx.Provider>
</div>
</div>
</div>
);
};