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)
|
||||
);
|
||||
|
||||
/**
|
||||
* 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
|
||||
* ao final pra refletir o novo estado nos computeds derivados.
|
||||
@@ -114,6 +172,7 @@ export function usePatientSessions() {
|
||||
busy,
|
||||
load,
|
||||
updateStatus,
|
||||
createSession,
|
||||
proximaSessao,
|
||||
ultimaSessao,
|
||||
totalSessoes,
|
||||
|
||||
@@ -416,12 +416,100 @@ function addFinancial() {
|
||||
novoLancOpen.value = true;
|
||||
}
|
||||
|
||||
// Atalho: navega pra aba Agenda (mesma logica do "Lancamento" so que sem
|
||||
// dialog — a aba Agenda ja mostra todas as sessoes com acoes de marcar
|
||||
// realizada/falta/cancelar inline).
|
||||
// Atalho: navega pra aba Agenda + abre dialog de nova sessao.
|
||||
const novaSessaoOpen = ref(false);
|
||||
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() {
|
||||
activeTab.value = 'agenda';
|
||||
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() {
|
||||
const f = novoLancForm.value;
|
||||
@@ -2030,6 +2118,101 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
<style scoped>
|
||||
@@ -3053,6 +3236,11 @@ onBeforeUnmount(() => {
|
||||
font-weight: 700;
|
||||
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 ═══════ */
|
||||
.mpa-conv-cta {
|
||||
|
||||
Reference in New Issue
Block a user