MelissaPaciente: dialog inline nova sessao + createSession mutation

Espelha o padrao do "Lancamento" mas pra agenda — botao "Agendar" agora
navega pra aba Agenda e abre dialog de nova sessao.

NOVO em src/features/patients/composables/usePatientSessions.js
- createSession(patientId, payload) — INSERT agenda_eventos com
  status='agendado', resolve owner_id (auth.getUser) e tenant_id (lazy
  import tenantStore). Auto-reload via _lastPatientId.
  Validacao: inicio_em + fim_em obrigatorios.
  Retorna {ok, data?, error?}.

NOVO em MelissaPaciente.vue
- Refs novaSessaoOpen + novaSessaoForm (tipo/data/hora/duracao_min/
  modalidade/titulo_custom/observacoes)
- 3 catalogos:
  - SESSAO_TIPOS: Sessao/Primeira/Retorno/Avaliacao/Devolutiva
  - SESSAO_DURACOES: 30/40/45/50/55/60/90/120 min
  - SESSAO_MODALIDADES: Presencial/Online
- goAgendar() agora alem de navegar pra aba Agenda, tambem inicializa
  o form (default amanha 09:00, sessao 50min presencial) e abre o dialog.
- salvarSessao() handler com validacao (data + hora) e construcao de
  inicio_em/fim_em a partir de data + hora + duracao_min. Local time
  -> ISO via Date constructor.
- <Dialog> 460px com form: Tipo + grid 2-col (data + hora) + grid 2-col
  (duracao + modalidade) + titulo opcional + observacoes Textarea.
- CSS .mpa-novo-lanc__opt pra "(opcional)" em cinza.

Validacoes:
- Data e hora obrigatorios (warn toast)
- Date constructor invalido -> warn toast

Pra criar sessoes mais complexas (recorrencia, multi-paciente, conflitos
de agenda), o user vai pra MelissaAgenda direto que tem o
AgendaEventDialog completo. Aqui no prontuario eh o caminho rapido.

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-08 11:20:00 -03:00
parent 21c71f75d6
commit a8ab13b201
2 changed files with 250 additions and 3 deletions
@@ -84,6 +84,64 @@ export function usePatientSessions() {
.slice(0, 6) .slice(0, 6)
); );
/**
* Cria uma nova sessao na agenda do paciente.
*
* payload: {
* inicio_em: ISO timestamp,
* fim_em: ISO timestamp,
* tipo: 'sessao' | 'primeira' | 'retorno' | etc,
* modalidade: 'presencial' | 'online',
* titulo?: string,
* titulo_custom?: string,
* observacoes?: string
* }
* Retorna {ok, data?, error?}.
*/
async function createSession(patientId, payload = {}) {
if (!patientId || busy.value) return { ok: false, error: 'busy' };
if (!payload?.inicio_em || !payload?.fim_em) {
return { ok: false, error: 'Inicio/fim obrigatorios' };
}
busy.value = true;
try {
const { data: userData } = await supabase.auth.getUser();
const ownerId = userData?.user?.id;
let tenantId = null;
try {
const { useTenantStore } = await import('@/stores/tenantStore');
tenantId = useTenantStore().activeTenantId || null;
} catch { /* sem tenant store — segue */ }
const row = {
patient_id: patientId,
owner_id: ownerId,
tenant_id: tenantId,
inicio_em: payload.inicio_em,
fim_em: payload.fim_em,
status: 'agendado',
modalidade: payload.modalidade || 'presencial',
tipo: payload.tipo || 'sessao',
titulo: String(payload.titulo || '').trim() || null,
titulo_custom: String(payload.titulo_custom || '').trim() || null,
observacoes: String(payload.observacoes || '').trim() || null
};
const { data, error: err } = await supabase
.from('agenda_eventos')
.insert([row])
.select()
.single();
if (err) throw err;
if (_lastPatientId) await load(_lastPatientId);
return { ok: true, data };
} catch (e) {
return { ok: false, error: e?.message || 'Erro ao agendar sessao' };
} finally {
busy.value = false;
}
}
/** /**
* Atualiza o status de uma sessao (mutation). Recarrega a lista do paciente * Atualiza o status de uma sessao (mutation). Recarrega a lista do paciente
* ao final pra refletir o novo estado nos computeds derivados. * ao final pra refletir o novo estado nos computeds derivados.
@@ -114,6 +172,7 @@ export function usePatientSessions() {
busy, busy,
load, load,
updateStatus, updateStatus,
createSession,
proximaSessao, proximaSessao,
ultimaSessao, ultimaSessao,
totalSessoes, totalSessoes,
+191 -3
View File
@@ -416,12 +416,100 @@ function addFinancial() {
novoLancOpen.value = true; novoLancOpen.value = true;
} }
// Atalho: navega pra aba Agenda (mesma logica do "Lancamento" so que sem // Atalho: navega pra aba Agenda + abre dialog de nova sessao.
// dialog — a aba Agenda ja mostra todas as sessoes com acoes de marcar const novaSessaoOpen = ref(false);
// realizada/falta/cancelar inline). const novaSessaoForm = ref({
tipo: 'sessao',
data: '',
hora: '',
duracao_min: 50,
modalidade: 'presencial',
titulo_custom: '',
observacoes: ''
});
const SESSAO_TIPOS = [
{ label: 'Sessão', value: 'sessao' },
{ label: 'Primeira consulta', value: 'primeira' },
{ label: 'Retorno', value: 'retorno' },
{ label: 'Avaliação', value: 'avaliacao' },
{ label: 'Devolutiva', value: 'devolutiva' }
];
const SESSAO_DURACOES = [
{ label: '30 min', value: 30 },
{ label: '40 min', value: 40 },
{ label: '45 min', value: 45 },
{ label: '50 min', value: 50 },
{ label: '55 min', value: 55 },
{ label: '1 hora', value: 60 },
{ label: '1h 30', value: 90 },
{ label: '2 horas', value: 120 }
];
const SESSAO_MODALIDADES = [
{ label: 'Presencial', value: 'presencial' },
{ label: 'Online (vídeo)', value: 'online' }
];
function goAgendar() { function goAgendar() {
activeTab.value = 'agenda'; activeTab.value = 'agenda';
if (isMobile.value) drawerOpen.value = false; if (isMobile.value) drawerOpen.value = false;
if (!props.patientId) {
toast.add({ severity: 'warn', summary: 'Paciente sem ID', life: 2500 });
return;
}
// Default: amanha as 09:00, sessao 50min presencial.
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
novaSessaoForm.value = {
tipo: 'sessao',
data: tomorrow.toISOString().slice(0, 10),
hora: '09:00',
duracao_min: 50,
modalidade: 'presencial',
titulo_custom: '',
observacoes: ''
};
novaSessaoOpen.value = true;
}
async function salvarSessao() {
const f = novaSessaoForm.value;
if (!f.data) {
toast.add({ severity: 'warn', summary: 'Data obrigatória', life: 2500 });
return;
}
if (!f.hora) {
toast.add({ severity: 'warn', summary: 'Hora obrigatória', life: 2500 });
return;
}
// Monta inicio_em + fim_em a partir de data + hora + duracao_min.
// Local time -> ISO. Sem timezone explicit, usa o do browser (consistente
// com o pattern do AgendaEventDialog e MelissaAgenda).
const [y, m, d] = f.data.split('-').map(Number);
const [hh, mm] = f.hora.split(':').map(Number);
const inicio = new Date(y, (m || 1) - 1, d || 1, hh || 0, mm || 0, 0);
if (Number.isNaN(inicio.getTime())) {
toast.add({ severity: 'warn', summary: 'Data/hora inválida', life: 2500 });
return;
}
const fim = new Date(inicio.getTime() + (Number(f.duracao_min) || 50) * 60 * 1000);
const result = await sessionsHook.createSession(props.patientId, {
inicio_em: inicio.toISOString(),
fim_em: fim.toISOString(),
tipo: f.tipo,
modalidade: f.modalidade,
titulo_custom: f.titulo_custom,
observacoes: f.observacoes
});
if (result.ok) {
toast.add({ severity: 'success', summary: 'Sessão agendada', life: 2200 });
novaSessaoOpen.value = false;
} else {
toast.add({
severity: 'error',
summary: 'Falha ao agendar',
detail: result.error || 'Erro inesperado',
life: 4000
});
}
} }
async function salvarLancamento() { async function salvarLancamento() {
const f = novoLancForm.value; const f = novoLancForm.value;
@@ -2030,6 +2118,101 @@ onBeforeUnmount(() => {
</button> </button>
</template> </template>
</Dialog> </Dialog>
<!-- Dialog: nova sessao na agenda. Mesma logica do Lancamento form
simples inline com tipo/data/hora/duracao/modalidade/titulo/obs. -->
<Dialog
v-model:visible="novaSessaoOpen"
modal
dismissable-mask
:style="{ width: '460px', maxWidth: '92vw' }"
header="Nova sessão"
>
<div class="mpa-novo-lanc">
<div class="mpa-novo-lanc__field">
<label class="mpa-novo-lanc__label">Tipo</label>
<Select
v-model="novaSessaoForm.tipo"
:options="SESSAO_TIPOS"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div class="mpa-novo-lanc__row">
<div class="mpa-novo-lanc__field">
<label class="mpa-novo-lanc__label">Data *</label>
<InputText
v-model="novaSessaoForm.data"
type="date"
class="w-full"
/>
</div>
<div class="mpa-novo-lanc__field">
<label class="mpa-novo-lanc__label">Hora *</label>
<InputText
v-model="novaSessaoForm.hora"
type="time"
class="w-full"
/>
</div>
</div>
<div class="mpa-novo-lanc__row">
<div class="mpa-novo-lanc__field">
<label class="mpa-novo-lanc__label">Duração</label>
<Select
v-model="novaSessaoForm.duracao_min"
:options="SESSAO_DURACOES"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div class="mpa-novo-lanc__field">
<label class="mpa-novo-lanc__label">Modalidade</label>
<Select
v-model="novaSessaoForm.modalidade"
:options="SESSAO_MODALIDADES"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
</div>
<div class="mpa-novo-lanc__field">
<label class="mpa-novo-lanc__label">Título <span class="mpa-novo-lanc__opt">(opcional)</span></label>
<InputText
v-model="novaSessaoForm.titulo_custom"
placeholder="Ex: Sessão de avaliação inicial"
class="w-full"
/>
</div>
<div class="mpa-novo-lanc__field">
<label class="mpa-novo-lanc__label">Observações <span class="mpa-novo-lanc__opt">(opcional)</span></label>
<Textarea
v-model="novaSessaoForm.observacoes"
rows="2"
auto-resize
placeholder="Anotações que aparecem no card da agenda"
class="w-full"
/>
</div>
</div>
<template #footer>
<button class="mpa-quick-btn" @click="novaSessaoOpen = false">
<i class="pi pi-times" />
<span>Cancelar</span>
</button>
<button
class="mpa-quick-btn mpa-quick-btn--cta"
:disabled="sessionsHook.busy.value"
@click="salvarSessao"
>
<i :class="sessionsHook.busy.value ? 'pi pi-spin pi-spinner' : 'pi pi-check'" :style="{ color: '#10b981' }" />
<span>Agendar sessão</span>
</button>
</template>
</Dialog>
</template> </template>
<style scoped> <style scoped>
@@ -3053,6 +3236,11 @@ onBeforeUnmount(() => {
font-weight: 700; font-weight: 700;
color: var(--m-text); color: var(--m-text);
} }
.mpa-novo-lanc__opt {
font-weight: 400;
color: var(--m-text-muted);
opacity: 0.75;
}
/* ═══════ Tab Conversas (Fase 7) — CTA pra drawer ═══════ */ /* ═══════ Tab Conversas (Fase 7) — CTA pra drawer ═══════ */
.mpa-conv-cta { .mpa-conv-cta {