Files
agenciapsilmno/blueprints/quick-create-overlay-blueprint.md
Leonardo f94a4ae97f padronizacao: foundation Fase 0+0.5 — blueprints + auditoria + clinical_notes
Pre-MVP: 3 blueprints canonicos (repository, composable, quick-create
overlay), AUDIT_BASELINE com 51 divergencias em 6 modulos, estrategia
PADRONIZACAO de 4 fases, DESIGN_BILLING_ORCHESTRATOR. Schema clinical
notes pronto pra Fase B (4 migrations + seed templates). AgendaEvent
Dialog.vue.bak deletado (lixo de refator anterior).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:19:45 -03:00

16 KiB
Raw Permalink Blame History

Quick-Create Overlay Blueprint

Status: Pattern universal. Promovido de agenda-only em 2026-05-20 após audit baseline (development/02-auditoria/AUDIT_BASELINE.md) identificar 3 candidates já em produção fora da agenda. Stack: Vue 3 + PrimeVue Dialog Canônicos:

  • src/features/agenda/components/ServiceQuickCreateDialog.vue (referência completa)
  • src/features/agenda/components/InsurancePlanQuickCreateDialog.vue
  • src/features/agenda/components/InsurancePlanServiceQuickCreateDialog.vue Legacy a refatorar (supabase direto, sem repository):
  • src/components/CadastroRapidoMedico.vue → migrar pra features/medicos/components/ (módulo 1 da Fase 1)
  • src/components/CadastroRapidoConvenio.vue → migrar pra features/insurance/components/
  • src/components/ComponentCadastroRapido.vue → migrar pra path apropriado conforme dono da entidade

1. Princípio

Problema: usuário está num fluxo (ex: agendar uma sessão) e precisa de uma entidade dependente que ainda não existe (serviço, convênio, plano). Navegar pra outra página significa perder o contexto do form em progresso.

Solução: mini-dialog por cima do dialog/fluxo atual, com campos mínimos pra criar a entidade, e ao salvar pré-seleciona ela no select que disparou o quick-create.

Regra absoluta: criar dependência faltante em qualquer fluxo deve abrir overlay POR CIMA, nunca navegar pra fora. Aplicável em todo o sistema desde a promoção do blueprint (2026-05-20). Origem do pattern: agenda (memória feedback_agenda_inline_quick_create, agora generalizada).


2. Quando aplicar (vs alternativas)

Situação Solução
Fluxo crítico travado por dependência faltante (form em progresso) Quick-create overlay
Cadastro completo, com todos os campos Página dedicada /entity/new ou Dialog full
Apenas selecionar item existente Select com busca; sem botão "+"
Onboarding ou setup wizard Não — fluxo é a página inteira, não um overlay

Anti-uso: quick-create NÃO é "shortcut pra criar do menu lateral". É fallback contextual quando o form atual depende de algo que falta. O parent precisa estar pronto pra receber o evento created e usar o ID.


3. Estrutura do componente <Entity>QuickCreateDialog.vue

<script setup>
import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
// CANÔNICO: importar da repository do feature dono da entidade.
// LEGACY: 3 componentes em src/components/ usam supabase direto — refatorar quando módulo dono for tocado na Fase 1.
import { createX } from '@/features/<feature>/services/<feature>Repository';

const props = defineProps({
    modelValue: { type: Boolean, default: false },
    ownerId: { type: String, default: '' },
    initialName: { type: String, default: '' }  // pré-preenche do search atual do select
});
const emit = defineEmits(['update:modelValue', 'created']);

const toast = useToast();
const tenantStore = useTenantStore();

const visible = ref(props.modelValue);
watch(() => props.modelValue, (v) => { visible.value = v; });
watch(visible, (v) => emit('update:modelValue', v));

const form = ref({ /* só campos MÍNIMOS obrigatórios + 1-2 opcionais úteis */ });
const saving = ref(false);

// Resetar form toda vez que abre
watch(() => props.modelValue, (v) => {
    if (v) form.value = { /* defaults + initialName */ };
});

const canSave = () => /* validação leve */;

async function onSave() {
    if (!canSave()) return;

    saving.value = true;
    try {
        // Sanitize (trim + maxlength slice + nullif vazio) ANTES de chamar repository
        const payload = {
            name: form.value.name.trim().slice(0, 120),
            // ...resto sanitizado
        };

        // Repository injeta owner_id (uid logado) + tenant_id (store) + faz uniqueness check
        // e throw em erro. Quick-create só decide o que mostrar ao usuário.
        const data = await createX(payload);

        toast.add({ severity: 'success', summary: '<Entity> criado', life: 2200 });
        emit('created', data);  // ← parent usa data.id pra pré-selecionar
        visible.value = false;
    } catch (e) {
        // Repository pode throw com message conhecido (ex: "Nome em uso") — mostra como warn ou error
        const isDup = /em uso|já existe|duplicate/i.test(e?.message || '');
        toast.add({
            severity: isDup ? 'warn' : 'error',
            summary: isDup ? 'Nome em uso' : 'Falha ao criar',
            detail: e?.message || 'Erro inesperado',
            life: 4000
        });
    } finally {
        saving.value = false;
    }
}
</script>

<!--
LEGACY-NOTE (2026-05-20): os 3 quick-creates em src/components/ (CadastroRapidoMedico,
CadastroRapidoConvenio, ComponentCadastroRapido) ainda usam supabase direto. Padrão acima
é o CANÔNICO pós-promoção. Refator vai acontecer no módulo correspondente da Fase 1.
-->

<template>
    <Dialog
        v-model:visible="visible"
        modal
        :draggable="false"
        :closable="!saving"
        header="Novo <entity>"
        class="w-[94vw] max-w-md"
    >
        <!-- Campos mínimos: 3-5 inputs, nada mais -->
        <div class="flex flex-col gap-3 pt-1"> ... </div>

        <template #footer>
            <Button label="Cancelar" text :disabled="saving" @click="visible = false" />
            <Button label="Salvar" :loading="saving" :disabled="!canSave()" @click="onSave" />
        </template>
    </Dialog>
</template>

4. Contrato canônico de props/emits

Props (sempre)

Prop Tipo Default Função
modelValue Boolean false Visibilidade do dialog. Two-way via v-model.
ownerId String '' Owner_id (terapeuta). Default: usuário logado.
initialName String '' Pré-preenche o campo nome com o search atual do select (UX win).

Props (opcionais por entidade)

  • parentId (String) — quando a entidade tem hierarquia (ex: plan_id em plan_service)
  • defaultDurationMin (Number) — quando faz sentido herdar valor do contexto
  • Outras herdadas do contexto, nunca mais que 3 props extras (senão vira form pesado, não quick-create)

Emits

Evento Payload Quando
update:modelValue Boolean v-model two-way
created Object (row inserida completa) Após insert bem-sucedido

Nunca emitir cancelled, closed, error — parent não precisa saber dessas distinções; update:modelValue=false cobre.


5. Integração no parent

Slot do botão + ao lado do select

<div class="flex gap-2 items-center">
    <Select v-model="selectedServiceId" :options="services" optionLabel="name" optionValue="id" class="flex-1" />
    <Button
        icon="pi pi-plus"
        v-tooltip.top="'Cadastrar novo serviço'"
        severity="secondary"
        size="small"
        @click="openServiceQuickCreate"
    />
</div>

Lock do dialog parent

Parent precisa travar seu próprio dismissableMask e closeOnEscape enquanto qualquer quick-create child está aberto, senão clicar fora fecha tudo:

<Dialog
    v-model:visible="parentVisible"
    :dismissableMask="!anyChildDialogOpen"
    :closeOnEscape="!anyChildDialogOpen"
    ...
>
const serviceQuickCreateOpen = ref(false);
const insuranceQuickCreateOpen = ref(false);
const anyChildDialogOpen = computed(() =>
    serviceQuickCreateOpen.value || insuranceQuickCreateOpen.value
);

Renderização dos quick-creates DENTRO do parent

<!-- DENTRO do template do parent dialog, antes do </Dialog> -->
<ServiceQuickCreateDialog
    v-model="serviceQuickCreateOpen"
    :owner-id="ownerId"
    :initial-name="serviceSearchText"
    @created="onServiceCreated"
/>

Handler on<Entity>Created

function onServiceCreated(row) {
    // 1. Inserir na lista local (sem re-fetch)
    services.value = [row, ...services.value];
    // 2. Pré-selecionar no select
    selectedServiceId.value = row.id;
    // 3. (Opcional) Focar o próximo campo
    nextTick(() => priceInputRef.value?.focus());
}

Handler openXQuickCreate

function openServiceQuickCreate() {
    serviceSearchText.value = currentSearchInSelect.value;  // capture pra initialName
    serviceQuickCreateOpen.value = true;
}

6. Convenções de UX

Campos mínimos absolutos

Quick-create não é cadastro completo. Inclui só:

  • 1 campo obrigatório principal (nome)
  • 1-2 campos obrigatórios secundários (preço, duração)
  • 1 campo opcional (descrição)

Resto (categorias, tags, configurações avançadas) edita depois em /entity/:id.

Maxlength visível

<InputText v-model="form.name" maxlength="120" />

Slice no save: .trim().slice(0, 120) — defesa em profundidade.

Botão "+" sempre size="small" severity="secondary"

Discrição visual — não compete com CTA do dialog parent.

Toast em vez de inline error

Mini-dialog não tem espaço pra banner de erro. Toast no canto superior direito (padrão PrimeVue) basta.

autofocus no primeiro input

<InputText autofocus v-model="form.name" />

Usuário já está em modo "digitar" — pular o clique no input.

:loading="saving" no botão Salvar

Spinner + disabled simultâneo. PrimeVue já dá o efeito visual.


7. Anti-patterns (NÃO fazer)

Navegar pra rota nova no botão "+"

// ❌ — destrói o form em progresso
function openServiceQuickCreate() {
    router.push('/saas/services/new');
}

Abre o overlay.

Quick-create que pede 10 campos

Se a entidade exige cadastro complexo (campos condicionais, validações cruzadas, upload de arquivo), não cabe num quick-create. Use página dedicada e aceite que o usuário perde contexto. Ou crie um wizard.

Sem dups check antes do insert

// ❌ — usuário clica 2x, cria duplicata silenciosa
await supabase.from('services').insert(payload).select().single();

ilike por name antes; aborta com warn toast.

Não emitir o objeto completo no created

// ❌
emit('created', { id: data.id });  // parent precisa de mais que id

// ❌ pior ainda
emit('created');  // parent não sabe o que foi criado

emit('created', data) — row completa do banco.

Não capturar initialName do search atual

Quando usuário digita "Sessão 50min" no select e clica "+", o initialName= deve já vir preenchido. Senão usuário re-digita.

Parent sem anyChildDialogOpen no lock

Sem o lock, clicar fora do quick-create child fecha o parent inteiro. Bug clássico.

Re-fetch da lista após created

// ❌ — round-trip desnecessário; o evento já trouxe o row
async function onServiceCreated() {
    await loadServices();
}

Inserir o row recebido direto na lista local; só re-fetch se houver lógica de ordenação complexa.

Múltiplos quick-creates abertos ao mesmo tempo

Permitir abrir um quick-create de plano de saúde enquanto outro de serviço está aberto = stack visual confuso. Force fechar o atual antes de abrir o próximo, OU mantenha o lock no anyChildDialogOpen que cobre.


8. Sanitização (memória feedback_sanitizacao)

Toda entrada de quick-create:

const name = form.value.name?.trim().slice(0, 120) || null;
const description = form.value.description?.trim().slice(0, 500) || null;
const price = form.value.price != null ? Number(form.value.price) : null;

Padrão: trim()slice(maxlength)nullif vazio → cast tipo.

Pro upload (não comum em quick-create, mas se houver): mime allowlist + size check antes de submitter.


9. Promotion History & Path Convention

Histórico

  • 2026-05-04 — Pattern nasceu em features/agenda/ com 3 quick-creates (Service, InsurancePlan, InsurancePlanService). Documentado como agenda-only com promotion criteria explícito.
  • 2026-05-20 — Audit baseline identificou 3 candidates já em produção fora da agenda: CadastroRapidoMedico.vue, CadastroRapidoConvenio.vue, ComponentCadastroRapido.vue (todos supabase direto, em src/components/). Promotion criteria atingida 3×. Blueprint promovido pra universal.

Path convention pós-promoção

Caso Path Exemplo
Entidade pertence a 1 feature claro src/features/<feature>/components/<Entity>QuickCreateDialog.vue features/medicos/components/MedicoQuickCreateDialog.vue
Entidade é cross-feature (raro) src/components/quick-create/<Entity>QuickCreateDialog.vue (nenhum hoje)

Anti-pattern: quick-create morando em src/components/ raiz sem subpasta — perde discoverability e mistura com componentes utilitários.

Plano de migração dos 3 legacy

Cada refator acontece quando o módulo dono for tocado na Fase 1:

Componente atual Path destino Quando Fix obrigatório
src/components/CadastroRapidoMedico.vue src/features/medicos/components/MedicoQuickCreateDialog.vue Módulo 1 (Home/Components) — pode criar features/medicos/ se ainda não existe Migrar pra repository; usar _tenantGuards
src/components/CadastroRapidoConvenio.vue src/features/insurance/components/InsurancePlanQuickCreateDialog.vue (consolidar com o existente na agenda?) Módulo 1 Idem; verificar se duplica features/agenda/components/InsurancePlanQuickCreateDialog.vue
src/components/ComponentCadastroRapido.vue depende do que cria Módulo 1 Idem

Boilerplate DRY (futuro, não-prioritário)

Quando houver 5+ quick-creates seguindo o pattern, considerar:

  • useQuickCreateLock() composable que encapsula anyChildDialogOpen (DRY entre parent dialogs com 2+ children)
  • <BaseQuickCreateDialog> wrapper component com slots #fields, #footer-extra e props padrão

Não fazer agora — 6 instâncias ainda é pouco pra inflar abstração. Pattern atual (cada quick-create standalone) é fácil de entender e copiar.


10. Checklist de auditoria

Aplica-se a todo quick-create do sistema pós-promoção (2026-05-20):

  • Path correto (feature folder se entidade pertence a 1 feature; src/components/quick-create/ se cross-feature)
  • Nome do arquivo: <Entity>QuickCreateDialog.vue (PascalCase)
  • Props canônicas: modelValue, ownerId, initialName
  • Emits canônicos: update:modelValue, created
  • Dialog com modal, :draggable="false", :closable="!saving"
  • Form reset quando abre (watch modelValue)
  • Sanitização: trim() + slice(maxlength) + nullif ANTES de chamar repository
  • Insert via repository (não supabase direto) — repository injeta owner_id+tenant_id e faz uniqueness check
  • Toast feedback em success/warn/error (warn quando erro for "nome em uso", error caso contrário)
  • Emit created com row completo (não só id)
  • Parent: anyChildDialogOpen computed lock
  • Parent: dismissableMask e closeOnEscape bindados ao lock
  • Parent: handler on<Entity>Created insere row na lista local e pré-seleciona
  • Parent: initialName capturado do search atual do select
  • Botão "+": size="small" severity="secondary" v-tooltip
  • autofocus no primeiro input
  • :loading="saving" + :disabled="!canSave()" no Salvar
  • Máximo 3-5 inputs no form (senão não é quick-create — vira página dedicada)

Divergências viram items em dev_auditoria_items com:

  • categoria: padronizacao
  • tag: padronizacao:<modulo> (módulo dono da entidade)
  • severidade: alta se usa supabase direto em vez de repository, ou viola lock (vaza dismiss); média se viola contrato (emits/props); baixa se cosmético

11. Referências

  • Canônicos: src/features/agenda/components/ServiceQuickCreateDialog.vue, InsurancePlanQuickCreateDialog.vue, InsurancePlanServiceQuickCreateDialog.vue
  • Parent integrador: src/features/agenda/components/AgendaEventDialog.vue (linhas ~3081-3107, ~3170, ~3274, ~3307)
  • Legacy a refatorar: src/components/CadastroRapidoMedico.vue, CadastroRapidoConvenio.vue, ComponentCadastroRapido.vue
  • Dialog base: blueprints/dialog-blueprint.md
  • Repository pareado: blueprints/repository-blueprint.md
  • Audit baseline: development/02-auditoria/AUDIT_BASELINE.md (3 candidates descobertos em 2026-05-20)
  • Memória: feedback_agenda_inline_quick_create.md (superseded — pattern agora universal), feedback_sanitizacao.md
  • Estratégia: development/02-auditoria/PADRONIZACAO.md