Pular para o conteúdo
Edição no lugar

Componentes

Edição no lugar

Editar uma lista não abre modal e não navega: a linha vira a ficha, no mesmo lugar, e o resto da tela recua. É o vocabulário que faz Insumos, Custos, Serviços, Horários, Conhecimento e Contratos parecerem a mesma tela — e uma tela nova herda tudo isso só de usar as peças.
src/components/ui/crud-block.tsxGaveta · EditSurface§ foco em camadas

O bloco inteiro, funcionando

Adicione, edite e exclua à vontade: são as peças reais, com estado local. Repare no que acontece com o resto da página quando um campo recebe foco.
Insumos
O que se gasta por procedimento. Cada insumo entra no custo da ficha.
Luva de procedimentoem 5 fichasR$ 0,24/un
Agulha 30Gem 5 fichasR$ 0,31/un

Custo somado por atendimentoR$ 0,55

Três gestos, três comportamentos: o botão do cabeçalho ABRE a gaveta de criar (e vira “Fechar”); “Editar” faz a linha e a ficha cruzarem no mesmo eixo; “Excluir” arma a tarja com a consequência escrita.

As peças

Sete exports, cada um com um trabalho. O estado (adding, editing, useActionState) mora no bloco que consome — aqui é só a casca.
PeçaO que é
CrudCardO card do bloco: título, descrição e o botão-toggle de adicionar (que some no vazio — ali o CTA do empty state assume).
CrudEmptyStateÍcone circular + título + texto + CTA. O CTA some enquanto o form de adicionar está aberto.
GavetaO vão animado. Duas gavetas irmãs cruzam: a da linha fecha enquanto a da ficha abre.
EditSurfaceO tapete de edição: bg-ink-50 rounded-md p-4, com data-dim. É ele que liga o foco em camadas.
CrudRowShellA linha: miolo livre + Editar + Excluir-com-tarja + alerta de erro do delete.
CrudFormActionsSalvar (primário) + Cancelar (ghost). Os dois dormem enquanto pending.
CrudTotalRowO rodapé de total, com o valor em tabular à direita.
PropTipoPadrãoO que faz
CrudCard readOnlytrueVista só-leitura (o catálogo de serviços pra quem não é admin): cabeçalho simples, sem toggle e sem data-dim-keep — nada ali edita, então não há véu pra furar.
CrudCard canAddbooleantrueEsconde o toggle de quem não pode editar (viewer).
CrudCard splitbooleanfalseCabeçalho em card próprio, separado do card da lista: o que explica o conceito e o que opera a lista param de disputar o mesmo retângulo.
CrudCard headerExtraReactNodeConteúdo extra do cabeçalho (o orçamento da base de conhecimento) — fura o véu junto com o título.
Gaveta abertabooleanRecebe a prop em vez de ser montada pelo pai: a SAÍDA precisa de alguém segurando o conteúdo enquanto o vão fecha.
CrudRowShell deleteSentencestringO que some, em uma frase — cada bloco sabe a consequência do seu.

A anatomia de um bloco

A ordem é sempre a mesma, e é ela que faz seis telas diferentes se lerem igual.
o esqueleto
<CrudCard title="Insumos" description="…" addLabel="Insumo"
          adding={adding} onToggleAdding={…} empty={itens.length === 0}>

  {/* 1. gaveta de CRIAR — o form tem Cancelar PRÓPRIO */}
  <Gaveta aberta={adding}>
    <EditSurface><Field … /><CrudFormActions … /></EditSurface>
  </Gaveta>

  {/* 2. vazio OU lista */}
  {vazio ? <CrudEmptyState … /> : (
    <div className="divide-y divide-border">
      {itens.map((i) => (
        <div key={i.id}>
          <Gaveta aberta={editando !== i.id}><CrudRowShell …>{miolo}</CrudRowShell></Gaveta>
          <Gaveta aberta={editando === i.id}>
            <EditSurface><Field … /><CrudFormActions … /></EditSurface>
          </Gaveta>
        </div>
      ))}
      {/* 3. total */}
      <CrudTotalRow label="…">R$ …</CrudTotalRow>
    </div>
  )}
</CrudCard>

Armadilha já pagaO form de CRIAR precisa de Cancelar próprio

{doc && onClose} deixava a clínica com ZERO documentos sem caminho de saída: o “Fechar” do cabeçalho só existe com canEdit && !empty, e o CTA do CrudEmptyState some quando adding é verdadeiro. E o “Fechar” cru engolia o texto calado — o guarda de rascunho global só intercepta <a href> e submit marcado.

NotaQuem pergunta “descartar?” e quem não pergunta

O Cancelar/Esc pergunta por dentro (há rascunho?); o onClose é o caminho de sucesso e NUNCA pergunta — senão salvar com sucesso abre um confirm.

O tapete e o foco em camadas

EditSurface é o que liga a assinatura do produto. Usar a peça basta: nenhuma tela reimplementa o efeito.
CasoO que usarPor quê
Linha que vira fichaEditSurface (traz data-dim)O tapete só existe enquanto a edição existe.
Formulário SEMPRE aberto (capacidade, impostos, regras de agendamento, grade de horários)data-dim + data-dim-solto, sem EditSurfaceTapete permanente mudaria o desenho da tela em REPOUSO. No solto, a elevação entra por ::before sangrando 12px: aparece e some sem empurrar um pixel.
Tapete de LEITURA (o conteúdo do documento aberto na lista)bg-ink-50 sem data-dimMarcá-lo faria a tela inteira recuar sem ninguém preenchendo nada.
Cabeçalho do card durante a ediçãodata-dim-keepFura a película e continua legível.

O vocabulário completo — data-dim, data-dim-solto, data-field, data-dim-veil, data-dim-keep, data-dim-far — e as armadilhas de cada um estão em Foco em camadas.

Salvar

O botão de salvar de um bloco pode não estar dentro do bloco.

O slot da barra de tela

Se a tela tem PageBar, o Salvar de cada bloco sobe pra barra sozinho: o bloco publica um slot e a barra desenha o botão, com o estado (“Salvando…”, “Salvo ✓”) ao lado. Sem barra na tela, ele volta a desenhar o botão dentro do card — tela que ainda não migrou continua com jeito de salvar.

// O bloco não sabe onde o botão vai aparecer:
const { naBarra, ref } = useSaveSlot({ label: "Salvar", dirty, flash, pending, ativo });

// Painel de aba OCULTA não empurra o Salvar pra barra:
<SaveSlotGate open={abaAtiva}>…</SaveSlotGate>

Armadilha já pagaSubmit programático lê o DOM, não o React

setX(novo); commit(); no mesmo tick envia o valor anterior: o requestSubmit() monta o FormData a partir do DOM, que ainda não recebeu o render novo — a tela mostra o novo, o banco fica com o velho, e o “Salvo ✓” acende igual. Imune: controle NATIVO nomeado. Quebra: valor que só o React escreve (<input type="hidden" value={estado}>) e o Checkbox do Radix. Cura: flushSync antes do commit.

Armadilha já pagaCallback inline nas deps + componente que sobrevive

useCloseOnSuccess re-disparava a cada render do pai e, com a Gaveta segurando o form montado por 240ms, salvar um insumo e clicar no seguinte engolia o clique. A cura mora no hook (callback em ref + um disparo por identidade de estado) e vale pros sete blocos de uma vez.