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:
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 tá 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>
|
||||
Reference in New Issue
Block a user