A66 WIP: AgendaEventDialog quebrado em 5 composables + 265 specs + V2 esqueleto

Sub-sessao 1 entregue (composables):
- agendaEventHelpers (262L) — utilitarios puros (date, format, parse)
- useAgendaEventComposer (485L) — montagem do form + validacao
- useAgendaEventActions (387L) — save/delete/cancel/move actions
- useAgendaEventPickerBilling (378L) — pickers (terapeuta, servico,
  convenio) + calculo de billing
- useAgendaEventLifecycle (474L) — open/close/dirty state + autosave
- 5 specs em __tests__/ (75+76+28+43+43 = 265 testes), 495/495 passing

AgendaEventDialog: 3522 -> 2632 linhas (-25%) consumindo os composables.
Backup byte-identico em AgendaEventDialog.vue.bak pra rollback.

Sub-sessao 2 entregue (esqueleto, NAO TESTADO):
- AgendaEventDialogV2 (~1100L, 3 zonas: PACIENTE/QUANDO/O QUE)
- Preview em /preview/agenda-dialog-v2 com 5 cenarios
- Rota em routes.misc.js
- User testou e nao gostou do design — aguarda feedback especifico
  pra iteracao na sub-sessao 3 (migracao nos 9 consumers).

Dialogs auxiliares novos pro AgendaEventDialog:
- InsurancePlanQuickCreateDialog (criar convenio inline)
- ServiceQuickCreateDialog (criar tipo de sessao inline)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-06 09:13:22 -03:00
parent 957e912a7f
commit 6d9b36d592
17 changed files with 10963 additions and 1295 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,136 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/agenda/components/InsurancePlanQuickCreateDialog.vue
| Data: 2026-05-04
|
| Mini-dialog pra cadastrar convênio SEM sair do AgendaEventDialog.
| Mesmo pattern do ServiceQuickCreateDialog emite `created` com a row
| inserida, parent pré-seleciona.
|
| Campos mínimos (obrigatórios no schema):
| name, owner_id, tenant_id
| Opcionais:
| default_value (valor base sugerido), notes
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const props = defineProps({
modelValue: { type: Boolean, default: false },
ownerId: { type: String, default: '' },
initialName: { type: String, default: '' }
});
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({
name: '',
default_value: null,
notes: ''
});
const saving = ref(false);
watch(() => props.modelValue, (v) => {
if (v) {
form.value = {
name: props.initialName || '',
default_value: null,
notes: ''
};
}
});
const canSave = () => !!form.value.name?.trim();
async function onSave() {
if (!canSave()) return;
const ownerId = props.ownerId || (await supabase.auth.getUser()).data?.user?.id;
const tid = tenantStore.activeTenantId || tenantStore.tenantId;
if (!ownerId || !tid) {
toast.add({ severity: 'error', summary: 'Sem contexto', detail: 'Owner ou tenant ausentes.', life: 3500 });
return;
}
saving.value = true;
try {
const payload = {
owner_id: ownerId,
tenant_id: tid,
name: form.value.name.trim().slice(0, 120),
default_value: form.value.default_value != null ? Number(form.value.default_value) : null,
notes: form.value.notes?.trim().slice(0, 500) || null,
active: true
};
const { data, error } = await supabase.from('insurance_plans').insert(payload).select().single();
if (error) throw error;
toast.add({ severity: 'success', summary: 'Convênio criado', life: 2200 });
emit('created', data);
visible.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha ao criar convênio', detail: e?.message || 'Erro inesperado', life: 4000 });
} finally {
saving.value = false;
}
}
</script>
<template>
<Dialog
v-model:visible="visible"
modal
:draggable="false"
:closable="!saving"
header="Novo convênio"
class="w-[94vw] max-w-md"
pt:mask:class="backdrop-blur-sm"
>
<div class="flex flex-col gap-3 pt-1">
<FloatLabel variant="on">
<InputText id="ins-name" v-model="form.name" class="w-full" autofocus maxlength="120" />
<label for="ins-name">Nome do convênio *</label>
</FloatLabel>
<FloatLabel variant="on">
<InputNumber
id="ins-value"
v-model="form.default_value"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
:max="999999"
class="w-full"
/>
<label for="ins-value">Valor base sugerido (opcional)</label>
</FloatLabel>
<FloatLabel variant="on">
<Textarea id="ins-notes" v-model="form.notes" class="w-full" rows="2" autoResize maxlength="500" />
<label for="ins-notes">Observações (opcional)</label>
</FloatLabel>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="saving" @click="visible = false" />
<Button
label="Criar convênio"
icon="pi pi-check"
:loading="saving"
:disabled="!canSave()"
class="rounded-full"
@click="onSave"
/>
</template>
</Dialog>
</template>
@@ -0,0 +1,154 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/agenda/components/ServiceQuickCreateDialog.vue
| Data: 2026-05-04
|
| Mini-dialog pra cadastrar um serviço SEM sair do AgendaEventDialog.
| Pattern: usuário agendando, percebe que falta o serviço no catálogo,
| clica "+", preenche nome/duração/valor, salva o emit `created` retorna
| o id pra que o parent pré-selecione no select de serviços.
|
| Campos mínimos (obrigatórios no schema):
| name, price, owner_id, tenant_id
| Opcionais úteis:
| duration_min, description
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const props = defineProps({
modelValue: { type: Boolean, default: false },
ownerId: { type: String, default: '' },
initialName: { type: String, default: '' }
});
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({
name: '',
price: null,
duration_min: 50,
description: ''
});
const saving = ref(false);
watch(() => props.modelValue, (v) => {
if (v) {
form.value = {
name: props.initialName || '',
price: null,
duration_min: 50,
description: ''
};
}
});
const canSave = () => !!form.value.name?.trim() && form.value.price != null && Number(form.value.price) >= 0;
async function onSave() {
if (!canSave()) return;
const ownerId = props.ownerId || (await supabase.auth.getUser()).data?.user?.id;
const tid = tenantStore.activeTenantId || tenantStore.tenantId;
if (!ownerId || !tid) {
toast.add({ severity: 'error', summary: 'Sem contexto', detail: 'Owner ou tenant ausentes.', life: 3500 });
return;
}
saving.value = true;
try {
const payload = {
owner_id: ownerId,
tenant_id: tid,
name: form.value.name.trim().slice(0, 120),
price: Number(form.value.price),
duration_min: form.value.duration_min ? Number(form.value.duration_min) : null,
description: form.value.description?.trim().slice(0, 500) || null,
active: true
};
const { data, error } = await supabase.from('services').insert(payload).select().single();
if (error) throw error;
toast.add({ severity: 'success', summary: 'Serviço criado', life: 2200 });
emit('created', data);
visible.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha ao criar serviço', detail: e?.message || 'Erro inesperado', life: 4000 });
} finally {
saving.value = false;
}
}
</script>
<template>
<Dialog
v-model:visible="visible"
modal
:draggable="false"
:closable="!saving"
header="Novo serviço"
class="w-[94vw] max-w-md"
pt:mask:class="backdrop-blur-sm"
>
<div class="flex flex-col gap-3 pt-1">
<FloatLabel variant="on">
<InputText id="svc-name" v-model="form.name" class="w-full" autofocus maxlength="120" />
<label for="svc-name">Nome do serviço *</label>
</FloatLabel>
<div class="grid grid-cols-2 gap-3">
<FloatLabel variant="on">
<InputNumber
id="svc-price"
v-model="form.price"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
:max="999999"
class="w-full"
/>
<label for="svc-price">Valor *</label>
</FloatLabel>
<FloatLabel variant="on">
<InputNumber
id="svc-duration"
v-model="form.duration_min"
:min="0"
:max="600"
:step="5"
suffix=" min"
class="w-full"
/>
<label for="svc-duration">Duração</label>
</FloatLabel>
</div>
<FloatLabel variant="on">
<Textarea id="svc-desc" v-model="form.description" class="w-full" rows="2" autoResize maxlength="500" />
<label for="svc-desc">Descrição (opcional)</label>
</FloatLabel>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="saving" @click="visible = false" />
<Button
label="Criar serviço"
icon="pi pi-check"
:loading="saving"
:disabled="!canSave()"
class="rounded-full"
@click="onSave"
/>
</template>
</Dialog>
</template>