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:
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user