f94a4ae97f
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>
432 lines
16 KiB
Markdown
432 lines
16 KiB
Markdown
# 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`
|
||
|
||
```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
|
||
|
||
```vue
|
||
<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:
|
||
|
||
```vue
|
||
<Dialog
|
||
v-model:visible="parentVisible"
|
||
:dismissableMask="!anyChildDialogOpen"
|
||
:closeOnEscape="!anyChildDialogOpen"
|
||
...
|
||
>
|
||
```
|
||
|
||
```js
|
||
const serviceQuickCreateOpen = ref(false);
|
||
const insuranceQuickCreateOpen = ref(false);
|
||
const anyChildDialogOpen = computed(() =>
|
||
serviceQuickCreateOpen.value || insuranceQuickCreateOpen.value
|
||
);
|
||
```
|
||
|
||
### Renderização dos quick-creates DENTRO do parent
|
||
|
||
```vue
|
||
<!-- 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`
|
||
|
||
```js
|
||
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`
|
||
|
||
```js
|
||
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
|
||
|
||
```vue
|
||
<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
|
||
|
||
```vue
|
||
<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 "+"
|
||
|
||
```js
|
||
// ❌ — 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
|
||
|
||
```js
|
||
// ❌ — 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`
|
||
|
||
```js
|
||
// ❌
|
||
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`
|
||
|
||
```js
|
||
// ❌ — 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:
|
||
|
||
```js
|
||
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`
|