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>
16 KiB
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.vuesrc/features/agenda/components/InsurancePlanServiceQuickCreateDialog.vueLegacy a refatorar (supabase direto, sem repository):src/components/CadastroRapidoMedico.vue→ migrar prafeatures/medicos/components/(módulo 1 da Fase 1)src/components/CadastroRapidoConvenio.vue→ migrar prafeatures/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_idemplan_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(todossupabasedireto, emsrc/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 encapsulaanyChildDialogOpen(DRY entre parent dialogs com 2+ children)<BaseQuickCreateDialog>wrapper component com slots#fields,#footer-extrae 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 Dialogcommodal,:draggable="false",:closable="!saving"- Form reset quando abre (
watch modelValue) - Sanitização:
trim() + slice(maxlength) + nullifANTES de chamar repository - Insert via repository (não supabase direto) — repository injeta
owner_id+tenant_ide faz uniqueness check - Toast feedback em success/warn/error (warn quando erro for "nome em uso", error caso contrário)
- Emit
createdcom row completo (não só id) - Parent:
anyChildDialogOpencomputed lock - Parent:
dismissableMaskecloseOnEscapebindados ao lock - Parent: handler
on<Entity>Createdinsere row na lista local e pré-seleciona - Parent:
initialNamecapturado do search atual do select - Botão "+":
size="small"severity="secondary"v-tooltip autofocusno 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:padronizacaotag: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