agenda: Fase 5 (status change/edit cobrada) + indicadores visuais + UX convenio
DB
- drop agenda_excecoes (substituida por financial_exceptions + lock-edit
baseado em financial_records)
- financial_records.payment_link (Asaas + link compartilhavel)
- financial_exceptions.consume_on_miss (rotular nao-show consome ou nao)
- billing_contracts.charging_style (upfront/saldo/per_session)
Payment refactor
- paymentSettlement -> paymentMethod (string) + markPaidNow (bool).
Handler aplica payment_method sempre; status='paid'+paid_at apenas
quando markPaidNow=true && method != 'link'. Asaas (link) sempre
liquida via webhook, nunca nasce paid.
- create_financial_record_for_session com pos-RPC patch pra payment_method
e (opcional) status='paid' quando user marca "ja recebi".
Indicadores visuais (3 canais distintos por estado)
- Paid: barra esquerda emerald-500 4px na agenda (MelissaAgenda),
pi-check-circle no popover/Resumo.
- Pending: badge \$ amber canto direito (mantido); linha amber no popover/
Resumo "A receber R\$ X (cobranca pendente)".
- Neutro: sem badge nem barra (compromisso pessoal, bloqueio, ou
ocorrencia virtual de pacote upfront/saldo).
- Bulk-load de paymentState em _reloadRange etapa 4 (1 query unica em
financial_records mapeada por agenda_evento_id).
- AgendaEventDialog Resumo lateral ganha linha entre pi-clock e
pi-map-marker via novo sessionPaymentRecord (sem guard de
occurrenceMode, contrario ao occFinancialRecord que continua so pra
Rail/Clinica). 5 estados: paid+paid_at, overdue+venceu, pending+vence,
sem cobranca c/ valor, sem cobranca s/ valor.
UX de convenio
- InsurancePlanServiceQuickCreateDialog novo: cadastra procedimento
POR CIMA do AgendaEventDialog sem sair da agenda. Auto-seleciona
novo procedimento so quando nada estava selecionado antes.
- Caixa cinza "Cadastrar procedimento" sempre visivel quando convenio
selecionado, com copy variavel (0 procedimentos: chamada urgente;
1+: "se quiser adicionar mais").
- "+ Novo convenio" toolbar em ConfiguracoesConveniosPage (botao
estava faltando, empty state mandava clicar em botao inexistente).
- Hint contextual abaixo do card Sessao/Honorarios: convenio = "N da
guia eh opcional", gratuito = "sem cobranca", particular = sem hint.
Label "N da Guia" tambem ganhou "(opcional)" no service-picker dialog.
Bug fixes
- pickDbFields whitelist faltando 'modalidade' (useMelissaAgenda.js:74)
— sessoes avulsas eram salvas como presencial independente da
escolha visual. Adicionado.
- goToConveniosConfig removida — fazia router.push("/therapist/
configuracoes/convenios") mas /configuracoes/* eh rota raiz, nao
filha. Substituida pelo quick-create inline (#1).
- bloqueioCobrindo + dialogBlockOverlap passados via deps em
_buildHandlers (refs do useMelissaAgenda nao sao acessiveis no
escopo de _buildHandlers).
Fase 5 (status change + edit cobrada)
- AgendaStatusChangeConfirmDialog: confirm dialog quando user muda
status pra realizada/faltou/cancelado, com opcoes de markPaid ou
gerar cobranca conforme o caso.
- useAgendaBloqueios novo composable: extrai logica de bloqueios
cinza (background events) do MelissaAgenda.
Doc viva
- src/docs/agenda-compromisso-financeiro-cenarios.html: 13 cenarios
de teste manual. C1-C4 ja validados. Cada teste validado vira parte
da doc final pra area de ajuda (pos-Fase 9).
Wiki/handoff
- agenda-compromisso-fluxo e agenda-billing-pesquisa-mercado (decisoes
arquiteturais sobre billing).
- HANDOFF.md atualizado.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import MelissaConfigList from './MelissaConfigList.vue';
|
||||
// InputText/Select/Textarea/InputMask/Skeleton/Tag/Button: auto via PrimeVueResolver
|
||||
|
||||
@@ -30,6 +31,55 @@ const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const router = useRouter();
|
||||
const tenantStore = useTenantStore();
|
||||
const { layoutConfig, setVariant } = useLayout();
|
||||
|
||||
// Troca de layout variant (classic/rail/melissa). Confirma + persiste +
|
||||
// hard reload — sair do shell Melissa requer reload pq AppLayout não tem
|
||||
// branch pra essa rota; quem renderiza Melissa é a rota /melissa separada.
|
||||
const variantSwitchOpen = ref(false);
|
||||
async function switchToVariant(v) {
|
||||
if (!['classic', 'rail', 'melissa'].includes(v)) return;
|
||||
if (layoutConfig.variant === v) return;
|
||||
if (variantSwitchOpen.value) return;
|
||||
variantSwitchOpen.value = true;
|
||||
const labels = { classic: 'Clássico', rail: 'Rail', melissa: 'Melissa' };
|
||||
confirm.require({
|
||||
header: `Trocar para o layout ${labels[v]}`,
|
||||
message: 'A página será recarregada para aplicar o novo layout. Confirma?',
|
||||
icon: 'pi pi-th-large',
|
||||
acceptLabel: 'Trocar e recarregar',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => {
|
||||
try {
|
||||
setVariant(v);
|
||||
if (userId.value) {
|
||||
const { error } = await supabase
|
||||
.from('user_settings')
|
||||
.upsert(
|
||||
{
|
||||
user_id: userId.value,
|
||||
layout_variant: v,
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{ onConflict: 'user_id' }
|
||||
);
|
||||
if (error) {
|
||||
const msg = String(error.message || '');
|
||||
const tolerant = /does not exist/i.test(msg) || /permission denied/i.test(msg) || /violates row-level security/i.test(msg);
|
||||
if (!tolerant) throw error;
|
||||
}
|
||||
}
|
||||
toast.add({ severity: 'info', summary: `Aplicando ${labels[v]}`, detail: 'Recarregando…', life: 1500 });
|
||||
window.location.assign('/');
|
||||
} catch (e) {
|
||||
variantSwitchOpen.value = false;
|
||||
toast.add({ severity: 'error', summary: 'Erro ao trocar layout', detail: e?.message || 'Tente novamente.', life: 4000 });
|
||||
}
|
||||
},
|
||||
reject: () => { variantSwitchOpen.value = false; },
|
||||
onHide: () => { variantSwitchOpen.value = false; }
|
||||
});
|
||||
}
|
||||
|
||||
const AVATAR_BUCKET = 'avatars';
|
||||
|
||||
@@ -933,6 +983,92 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div><!-- /.mpr-w__body -->
|
||||
</div>
|
||||
|
||||
<!-- ── Layout (variante de navegação) ── -->
|
||||
<div id="mpr-sec-layout" class="mpr-w">
|
||||
<div class="mpr-w__head">
|
||||
<div class="mpr-w__icon"><i class="pi pi-th-large" /></div>
|
||||
<div class="mpr-w__title">
|
||||
<div class="mpr-w__title-text">Layout</div>
|
||||
<div class="mpr-w__sub">Estilo de navegação principal — troca exige reload</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mpr-w__body">
|
||||
<div class="mpr-lv-grid">
|
||||
<button
|
||||
class="mpr-lv-card"
|
||||
:class="{ 'mpr-lv-card--current': layoutConfig.variant === 'classic' }"
|
||||
:disabled="layoutConfig.variant === 'classic'"
|
||||
@click="switchToVariant('classic')"
|
||||
>
|
||||
<div class="mpr-lv-preview mpr-lv-preview--classic">
|
||||
<div class="mpr-lv-sidebar" />
|
||||
<div class="mpr-lv-main">
|
||||
<div class="mpr-lv-bar" />
|
||||
<div class="mpr-lv-line" />
|
||||
<div class="mpr-lv-line mpr-lv-line--sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mpr-lv-foot">
|
||||
<div class="mpr-lv-radio">
|
||||
<div v-if="layoutConfig.variant === 'classic'" class="mpr-lv-dot" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mpr-lv-name">Clássico</div>
|
||||
<div class="mpr-lv-sub">Sidebar lateral com menu completo</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="mpr-lv-card"
|
||||
:class="{ 'mpr-lv-card--current': layoutConfig.variant === 'rail' }"
|
||||
:disabled="layoutConfig.variant === 'rail'"
|
||||
@click="switchToVariant('rail')"
|
||||
>
|
||||
<div class="mpr-lv-preview mpr-lv-preview--rail">
|
||||
<div class="mpr-lv-rail" />
|
||||
<div class="mpr-lv-panel" />
|
||||
<div class="mpr-lv-main">
|
||||
<div class="mpr-lv-bar" />
|
||||
<div class="mpr-lv-line" />
|
||||
<div class="mpr-lv-line mpr-lv-line--sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mpr-lv-foot">
|
||||
<div class="mpr-lv-radio">
|
||||
<div v-if="layoutConfig.variant === 'rail'" class="mpr-lv-dot" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mpr-lv-name">Rail</div>
|
||||
<div class="mpr-lv-sub">Mini rail + painel expansível, full-width</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="mpr-lv-card"
|
||||
:class="{ 'mpr-lv-card--current': layoutConfig.variant === 'melissa' }"
|
||||
:disabled="layoutConfig.variant === 'melissa'"
|
||||
@click="switchToVariant('melissa')"
|
||||
>
|
||||
<div class="mpr-lv-preview mpr-lv-preview--melissa">
|
||||
<div class="mpr-lv-melissa-bg" />
|
||||
<div class="mpr-lv-melissa-dock" />
|
||||
</div>
|
||||
<div class="mpr-lv-foot">
|
||||
<div class="mpr-lv-radio">
|
||||
<div v-if="layoutConfig.variant === 'melissa'" class="mpr-lv-dot" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mpr-lv-name">Melissa</div>
|
||||
<div class="mpr-lv-sub">Lockscreen-style com dock central (atual)</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div><!-- /.mpr-w__body -->
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1676,4 +1812,135 @@ onBeforeUnmount(() => {
|
||||
.mpr-custom { flex-direction: column; gap: 8px; }
|
||||
.mpr-custom .mpr-btn--icon { align-self: flex-end; }
|
||||
}
|
||||
|
||||
/* ═══════ Layout variant cards ═══════ */
|
||||
.mpr-lv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.mpr-lv-card {
|
||||
background: var(--m-surface-2, var(--surface-card));
|
||||
border: 1px solid var(--m-border, var(--surface-border));
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, background .15s, transform .12s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.mpr-lv-card:hover:not(:disabled) {
|
||||
border-color: var(--p-primary-500, #7c3aed);
|
||||
background: var(--m-surface-hover, var(--surface-hover));
|
||||
}
|
||||
.mpr-lv-card:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.mpr-lv-card--current {
|
||||
border-color: var(--p-primary-500, #7c3aed);
|
||||
box-shadow: 0 0 0 1px var(--p-primary-500, #7c3aed) inset;
|
||||
}
|
||||
.mpr-lv-preview {
|
||||
height: 92px;
|
||||
border-radius: 6px;
|
||||
background: var(--m-surface-3, var(--surface-100));
|
||||
border: 1px solid var(--m-border, var(--surface-border));
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
.mpr-lv-preview--classic .mpr-lv-sidebar {
|
||||
width: 30%;
|
||||
background: var(--p-primary-500, #7c3aed);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.mpr-lv-preview--rail .mpr-lv-rail {
|
||||
width: 12%;
|
||||
background: var(--p-primary-500, #7c3aed);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.mpr-lv-preview--rail .mpr-lv-panel {
|
||||
width: 22%;
|
||||
background: var(--p-primary-500, #7c3aed);
|
||||
opacity: 0.35;
|
||||
}
|
||||
.mpr-lv-main {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.mpr-lv-bar {
|
||||
height: 8px;
|
||||
background: var(--m-border-strong, var(--surface-300));
|
||||
border-radius: 2px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.mpr-lv-line {
|
||||
height: 5px;
|
||||
background: var(--m-border-strong, var(--surface-300));
|
||||
border-radius: 2px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.mpr-lv-line--sm { width: 65%; }
|
||||
.mpr-lv-preview--melissa {
|
||||
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 100%);
|
||||
}
|
||||
.mpr-lv-melissa-bg {
|
||||
position: absolute;
|
||||
inset: 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.mpr-lv-melissa-dock {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 60%;
|
||||
height: 14px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
.mpr-lv-foot {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
}
|
||||
.mpr-lv-radio {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 9999px;
|
||||
border: 1.5px solid var(--m-border-strong, var(--surface-400));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.mpr-lv-card--current .mpr-lv-radio {
|
||||
border-color: var(--p-primary-500, #7c3aed);
|
||||
}
|
||||
.mpr-lv-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 9999px;
|
||||
background: var(--p-primary-500, #7c3aed);
|
||||
}
|
||||
.mpr-lv-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.mpr-lv-sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.3;
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user