Compare commits
6 Commits
646cec5833
...
39cf0178e6
| Author | SHA1 | Date | |
|---|---|---|---|
| 39cf0178e6 | |||
| 279b4f78e8 | |||
| 988a4e5892 | |||
| 8f4e6679eb | |||
| 8e3c09d1b1 | |||
| 8b0e633aac |
@@ -90,7 +90,13 @@ const props = defineProps({
|
|||||||
initialData: { type: Object, default: () => ({}) },
|
initialData: { type: Object, default: () => ({}) },
|
||||||
|
|
||||||
closeOnCreated: { type: Boolean, default: true },
|
closeOnCreated: { type: Boolean, default: true },
|
||||||
resetOnOpen: { type: Boolean, default: true }
|
resetOnOpen: { type: Boolean, default: true },
|
||||||
|
|
||||||
|
// Quando true (uso "in-flow", ex: dentro do AgendaEventDialog),
|
||||||
|
// esconde o botao "Salvar e ver pacientes" — navegar pra lista
|
||||||
|
// abandonaria o fluxo onde o cadastro foi aberto. Default false
|
||||||
|
// mantem o botao visivel pra usos standalone (sidebar, menu).
|
||||||
|
hideViewListButton: { type: Boolean, default: false }
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'created']);
|
const emit = defineEmits(['update:modelValue', 'created']);
|
||||||
@@ -346,9 +352,10 @@ async function submit(mode = 'only') {
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<Button label="Cancelar" severity="secondary" text :disabled="saving" @click="close" />
|
<Button label="Cancelar" severity="secondary" text :disabled="saving" @click="close" />
|
||||||
<!-- Na rota de pacientes: só "Salvar" -->
|
<!-- Na rota de pacientes OU em fluxo (hideViewListButton): só "Salvar" / "Salvar e fechar" -->
|
||||||
<Button v-if="isOnPatientsPage" label="Salvar" :loading="saving" :disabled="saving" @click="submit('only')" />
|
<Button v-if="isOnPatientsPage" label="Salvar" :loading="saving" :disabled="saving" @click="submit('only')" />
|
||||||
<!-- Fora da rota de pacientes: "Salvar e fechar" + "Salvar e ver pacientes" -->
|
<Button v-else-if="hideViewListButton" label="Salvar e fechar" :loading="saving" :disabled="saving" @click="submit('only')" />
|
||||||
|
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver pacientes" -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Button label="Salvar e fechar" severity="secondary" outlined :loading="saving" :disabled="saving" @click="submit('only')" />
|
<Button label="Salvar e fechar" severity="secondary" outlined :loading="saving" :disabled="saving" @click="submit('only')" />
|
||||||
<Button label="Salvar e ver pacientes" :loading="saving" :disabled="saving" @click="submit('view')" />
|
<Button label="Salvar e ver pacientes" :loading="saving" :disabled="saving" @click="submit('view')" />
|
||||||
|
|||||||
@@ -33,7 +33,13 @@ import PatientsCadastroPage from '@/features/patients/cadastro/PatientsCadastroP
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: Boolean, default: false },
|
modelValue: { type: Boolean, default: false },
|
||||||
patientId: { type: String, default: null }
|
patientId: { type: String, default: null },
|
||||||
|
|
||||||
|
// Quando true (uso "in-flow", ex: aberto de dentro do AgendaEventDialog),
|
||||||
|
// esconde o botao "Salvar e ver pacientes" — navegar pra lista
|
||||||
|
// abandonaria o fluxo onde o cadastro foi aberto. Default false
|
||||||
|
// mantem o botao visivel pra usos standalone (Pacientes, Melissa).
|
||||||
|
hideViewListButton: { type: Boolean, default: false }
|
||||||
});
|
});
|
||||||
const emit = defineEmits(['update:modelValue', 'created']);
|
const emit = defineEmits(['update:modelValue', 'created']);
|
||||||
|
|
||||||
@@ -188,9 +194,10 @@ async function onCreated(data) {
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<Button label="Cancelar" severity="secondary" text :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="isOpen = false" />
|
<Button label="Cancelar" severity="secondary" text :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="isOpen = false" />
|
||||||
<!-- Na rota de pacientes: só "Salvar" -->
|
<!-- Na rota de pacientes OU em fluxo (hideViewListButton): só um botao -->
|
||||||
<Button v-if="isOnPatientsPage" label="Salvar" :loading="!!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
|
<Button v-if="isOnPatientsPage" label="Salvar" :loading="!!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
|
||||||
<!-- Fora da rota de pacientes: "Salvar e fechar" + "Salvar e ver pacientes" -->
|
<Button v-else-if="hideViewListButton" label="Salvar e fechar" :loading="!!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
|
||||||
|
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver pacientes" -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Button label="Salvar e fechar" severity="secondary" outlined :loading="pendingMode === 'only' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
|
<Button label="Salvar e fechar" severity="secondary" outlined :loading="pendingMode === 'only' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
|
||||||
<Button label="Salvar e ver pacientes" :loading="pendingMode === 'view' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('view')" />
|
<Button label="Salvar e ver pacientes" :loading="pendingMode === 'view' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('view')" />
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import interactionPlugin from '@fullcalendar/interaction';
|
|||||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||||
|
|
||||||
import ProgressSpinner from 'primevue/progressspinner';
|
import ProgressSpinner from 'primevue/progressspinner';
|
||||||
|
import { FC_TOUCH_DEFAULTS } from '@/features/agenda/utils/fcDefaults';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
// UI
|
// UI
|
||||||
@@ -71,6 +72,7 @@ const calendarOptions = computed(() => {
|
|||||||
const maxTime = isWorkHours ? props.slotMaxTime : '23:59:59';
|
const maxTime = isWorkHours ? props.slotMaxTime : '23:59:59';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...FC_TOUCH_DEFAULTS,
|
||||||
plugins: [timeGridPlugin, interactionPlugin, dayGridPlugin],
|
plugins: [timeGridPlugin, interactionPlugin, dayGridPlugin],
|
||||||
initialView: initialView.value,
|
initialView: initialView.value,
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import dayGridPlugin from '@fullcalendar/daygrid';
|
|||||||
import listPlugin from '@fullcalendar/list';
|
import listPlugin from '@fullcalendar/list';
|
||||||
import interactionPlugin from '@fullcalendar/interaction';
|
import interactionPlugin from '@fullcalendar/interaction';
|
||||||
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
||||||
|
import { FC_TOUCH_DEFAULTS } from '@/features/agenda/utils/fcDefaults';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
view: { type: String, default: 'day' }, // 'day' | 'week' | 'month'
|
view: { type: String, default: 'day' }, // 'day' | 'week' | 'month'
|
||||||
@@ -258,6 +259,7 @@ function emitDebug(col) {
|
|||||||
|
|
||||||
function buildFcOptions(ownerId) {
|
function buildFcOptions(ownerId) {
|
||||||
const base = {
|
const base = {
|
||||||
|
...FC_TOUCH_DEFAULTS,
|
||||||
plugins: [timeGridPlugin, dayGridPlugin, listPlugin, interactionPlugin],
|
plugins: [timeGridPlugin, dayGridPlugin, listPlugin, interactionPlugin],
|
||||||
locale: ptBrLocale,
|
locale: ptBrLocale,
|
||||||
timeZone: props.timezone,
|
timeZone: props.timezone,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -270,16 +270,22 @@ function onPatientCreatedRapido(p) {
|
|||||||
cadRapidoOpen.value = false;
|
cadRapidoOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mostra TODOS os pacientes (inclusive Inativo/Arquivado) — UX intencional:
|
||||||
|
// nao-Ativos vem com badge + disabled no template, ordenados pro final.
|
||||||
|
// selectPaciente bloqueia o clique se status !== 'Ativo'.
|
||||||
|
const _aev2StatusRank = { Ativo: 0, Inativo: 1, Arquivado: 2 };
|
||||||
const filteredPatients = computed(() => {
|
const filteredPatients = computed(() => {
|
||||||
const q = String(pacienteSearch.value || '').trim().toLowerCase();
|
const q = String(pacienteSearch.value || '').trim().toLowerCase();
|
||||||
const list = (patients.value || []).filter((p) => p.status === 'Ativo');
|
const list = patients.value || [];
|
||||||
if (!q) return list;
|
const matched = !q
|
||||||
return list.filter((p) => {
|
? [...list]
|
||||||
|
: list.filter((p) => {
|
||||||
const nome = String(p.nome || '').toLowerCase();
|
const nome = String(p.nome || '').toLowerCase();
|
||||||
const email = String(p.email || '').toLowerCase();
|
const email = String(p.email || '').toLowerCase();
|
||||||
const tel = String(p.telefone || '').toLowerCase();
|
const tel = String(p.telefone || '').toLowerCase();
|
||||||
return nome.includes(q) || email.includes(q) || tel.includes(q);
|
return nome.includes(q) || email.includes(q) || tel.includes(q);
|
||||||
});
|
});
|
||||||
|
return matched.sort((a, b) => (_aev2StatusRank[a.status] ?? 3) - (_aev2StatusRank[b.status] ?? 3));
|
||||||
});
|
});
|
||||||
|
|
||||||
function goToAgendamentosRecebidos() {
|
function goToAgendamentosRecebidos() {
|
||||||
@@ -924,6 +930,9 @@ const heroDateText = computed(() => {
|
|||||||
:key="p.id"
|
:key="p.id"
|
||||||
type="button"
|
type="button"
|
||||||
class="aev2-picker-item"
|
class="aev2-picker-item"
|
||||||
|
:class="{ 'aev2-picker-item--blocked': p.status && p.status !== 'Ativo' }"
|
||||||
|
:disabled="p.status && p.status !== 'Ativo'"
|
||||||
|
:title="p.status === 'Arquivado' ? 'Paciente arquivado — não é possível agendar' : p.status === 'Inativo' ? 'Paciente inativo — não é possível agendar' : ''"
|
||||||
@click="selectPaciente(p)"
|
@click="selectPaciente(p)"
|
||||||
>
|
>
|
||||||
<Avatar v-if="p.avatar_url" :image="p.avatar_url" shape="circle" size="normal" />
|
<Avatar v-if="p.avatar_url" :image="p.avatar_url" shape="circle" size="normal" />
|
||||||
@@ -932,6 +941,8 @@ const heroDateText = computed(() => {
|
|||||||
<div class="font-semibold truncate">{{ p.nome }}</div>
|
<div class="font-semibold truncate">{{ p.nome }}</div>
|
||||||
<div class="text-xs opacity-60 truncate">{{ p.email || p.telefone }}</div>
|
<div class="text-xs opacity-60 truncate">{{ p.email || p.telefone }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Tag v-if="p.status === 'Arquivado'" value="Arquivado" severity="danger" class="shrink-0" />
|
||||||
|
<Tag v-else-if="p.status === 'Inativo'" value="Inativo" severity="warn" class="shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -946,6 +957,7 @@ const heroDateText = computed(() => {
|
|||||||
email-field="email_principal"
|
email-field="email_principal"
|
||||||
phone-field="telefone"
|
phone-field="telefone"
|
||||||
:extra-payload="{ status: 'Ativo' }"
|
:extra-payload="{ status: 'Ativo' }"
|
||||||
|
hide-view-list-button
|
||||||
@created="onPatientCreatedRapido"
|
@created="onPatientCreatedRapido"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1477,4 +1489,9 @@ const heroDateText = computed(() => {
|
|||||||
transition: background .15s;
|
transition: background .15s;
|
||||||
}
|
}
|
||||||
.aev2-picker-item:hover { background: var(--aev2-pill-bg); }
|
.aev2-picker-item:hover { background: var(--aev2-pill-bg); }
|
||||||
|
.aev2-picker-item--blocked {
|
||||||
|
opacity: .6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.aev2-picker-item--blocked:hover { background: transparent; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -67,10 +67,23 @@ async function onSave() {
|
|||||||
}
|
}
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
|
const name = form.value.name.trim().slice(0, 120);
|
||||||
|
|
||||||
|
// Nome unico por owner (case-insensitive) — espelha a validacao
|
||||||
|
// do useServices.save() pra impedir duplicata tambem quando o
|
||||||
|
// cadastro vem do quick-create dentro do AgendaEventDialog.
|
||||||
|
const { data: dups, error: dupErr } = await supabase.from('services').select('id').eq('owner_id', ownerId).ilike('name', name).limit(1);
|
||||||
|
if (dupErr) throw dupErr;
|
||||||
|
if (dups && dups.length > 0) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Nome em uso', detail: 'Já existe um serviço com este nome.', life: 3500 });
|
||||||
|
saving.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
owner_id: ownerId,
|
owner_id: ownerId,
|
||||||
tenant_id: tid,
|
tenant_id: tid,
|
||||||
name: form.value.name.trim().slice(0, 120),
|
name,
|
||||||
price: Number(form.value.price),
|
price: Number(form.value.price),
|
||||||
duration_min: form.value.duration_min ? Number(form.value.duration_min) : null,
|
duration_min: form.value.duration_min ? Number(form.value.duration_min) : null,
|
||||||
description: form.value.description?.trim().slice(0, 500) || null,
|
description: form.value.description?.trim().slice(0, 500) || null,
|
||||||
|
|||||||
@@ -333,6 +333,33 @@ describe('selectPaciente / clearPaciente', () => {
|
|||||||
expect(composer.form.value.paciente_id).toBe(null);
|
expect(composer.form.value.paciente_id).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('selectPaciente copia status pro form quando Ativo', () => {
|
||||||
|
const composer = makeComposer();
|
||||||
|
const { selectPaciente } = setup({ composer });
|
||||||
|
selectPaciente({ id: 'p-1', nome: 'Ana', status: 'Ativo' });
|
||||||
|
expect(composer.form.value.paciente_id).toBe('p-1');
|
||||||
|
expect(composer.form.value.paciente_status).toBe('Ativo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selectPaciente bloqueia paciente Arquivado (defesa em camadas)', () => {
|
||||||
|
const composer = makeComposer();
|
||||||
|
const { selectPaciente, pacientePickerOpen } = setup({ composer });
|
||||||
|
pacientePickerOpen.value = true;
|
||||||
|
selectPaciente({ id: 'p-arq', nome: 'Ana', status: 'Arquivado' });
|
||||||
|
// Form nao deve ter sido tocado
|
||||||
|
expect(composer.form.value.paciente_id).toBe(null);
|
||||||
|
expect(composer.form.value.paciente_status).toBeFalsy();
|
||||||
|
// Picker permanece aberto pro user escolher outro
|
||||||
|
expect(pacientePickerOpen.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selectPaciente bloqueia paciente Inativo', () => {
|
||||||
|
const composer = makeComposer();
|
||||||
|
const { selectPaciente } = setup({ composer });
|
||||||
|
selectPaciente({ id: 'p-ina', nome: 'Bruno', status: 'Inativo' });
|
||||||
|
expect(composer.form.value.paciente_id).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
it('clearPaciente limpa form + samePatientConflict', () => {
|
it('clearPaciente limpa form + samePatientConflict', () => {
|
||||||
const composer = makeComposer({
|
const composer = makeComposer({
|
||||||
formExtra: { paciente_id: 'p-1', paciente_nome: 'Ana', paciente_avatar: 'url' }
|
formExtra: { paciente_id: 'p-1', paciente_nome: 'Ana', paciente_avatar: 'url' }
|
||||||
|
|||||||
@@ -87,10 +87,46 @@ export function useAgendaEventActions({
|
|||||||
acceptSeverity: isCancelar ? 'danger' : 'warn',
|
acceptSeverity: isCancelar ? 'danger' : 'warn',
|
||||||
accept: async () => {
|
accept: async () => {
|
||||||
try {
|
try {
|
||||||
|
// Se o evento é ocorrência VIRTUAL de recorrência
|
||||||
|
// (id "rec::..." sem row real em agenda_eventos),
|
||||||
|
// delega pro parent — useMelissaAgenda.onUpdateSeriesEvent
|
||||||
|
// e AgendaTerapeutaPage.onUpdateSeriesEvent materializam
|
||||||
|
// a linha antes de aplicar status. Sem essa delegação,
|
||||||
|
// UPDATE direto em id virtual quebra com PostgreSQL
|
||||||
|
// "invalid input syntax for type uuid".
|
||||||
|
const formId = composer.form.value.id;
|
||||||
|
const isVirtual =
|
||||||
|
!!composer.form.value.is_occurrence ||
|
||||||
|
(typeof formId === 'string' && formId.startsWith('rec::'));
|
||||||
|
|
||||||
|
if (isVirtual) {
|
||||||
|
emit('updateSeriesEvent', {
|
||||||
|
id: null, // sem row real
|
||||||
|
status: newVal,
|
||||||
|
recurrence_date:
|
||||||
|
composer.form.value.recurrence_date ||
|
||||||
|
composer.form.value.original_date ||
|
||||||
|
String(composer.form.value.inicio_em || '').slice(0, 10),
|
||||||
|
inicio_em: composer.form.value.inicio_em,
|
||||||
|
fim_em: composer.form.value.fim_em,
|
||||||
|
is_virtual: true,
|
||||||
|
// Form completo do dialog — handler usa pra resolver
|
||||||
|
// recurrence_id/patient_id sem depender de dialogEventRow.
|
||||||
|
row: { ...composer.form.value }
|
||||||
|
});
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Status atualizado',
|
||||||
|
detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`,
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('agenda_eventos')
|
.from('agenda_eventos')
|
||||||
.update({ status: newVal })
|
.update({ status: newVal })
|
||||||
.eq('id', composer.form.value.id)
|
.eq('id', formId)
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|||||||
@@ -286,9 +286,18 @@ export function useAgendaEventPickerBilling({
|
|||||||
|
|
||||||
function selectPaciente(p) {
|
function selectPaciente(p) {
|
||||||
if (!p?.id) return;
|
if (!p?.id) return;
|
||||||
|
// Bloqueia clique em paciente arquivado/inativo — defesa em camadas:
|
||||||
|
// o template do picker ja marca esses items como disabled, mas se
|
||||||
|
// alguem chamar selectPaciente programaticamente (cache stale, etc),
|
||||||
|
// a regra precisa valer.
|
||||||
|
if (p.status && p.status !== 'Ativo') return;
|
||||||
composer.form.value.paciente_id = p.id;
|
composer.form.value.paciente_id = p.id;
|
||||||
composer.form.value.paciente_nome = p.nome || '';
|
composer.form.value.paciente_nome = p.nome || '';
|
||||||
composer.form.value.paciente_avatar = p.avatar_url || '';
|
composer.form.value.paciente_avatar = p.avatar_url || '';
|
||||||
|
// Sem isso, form.paciente_status fica '' e canSave nao consegue
|
||||||
|
// aplicar getPatientAgendaPermissions — qualquer falha do filtro
|
||||||
|
// acima vira sessao criavel com paciente fora do escopo.
|
||||||
|
composer.form.value.paciente_status = p.status || '';
|
||||||
pacientePickerOpen.value = false;
|
pacientePickerOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +305,7 @@ export function useAgendaEventPickerBilling({
|
|||||||
composer.form.value.paciente_id = null;
|
composer.form.value.paciente_id = null;
|
||||||
composer.form.value.paciente_nome = '';
|
composer.form.value.paciente_nome = '';
|
||||||
composer.form.value.paciente_avatar = '';
|
composer.form.value.paciente_avatar = '';
|
||||||
|
composer.form.value.paciente_status = '';
|
||||||
actions.samePatientConflict.value = null;
|
actions.samePatientConflict.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,21 @@ export function useServices() {
|
|||||||
async function save(payload) {
|
async function save(payload) {
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
try {
|
||||||
|
const name = payload.name?.trim();
|
||||||
|
if (!name) throw new Error('Nome do serviço é obrigatório.');
|
||||||
|
if (!payload.owner_id) throw new Error('Owner ausente.');
|
||||||
|
|
||||||
|
// Nome unico por owner (case-insensitive). No update,
|
||||||
|
// ignora o proprio id pra nao conflitar consigo mesmo
|
||||||
|
// quando o usuario salva sem mudar o nome.
|
||||||
|
let dupQuery = supabase.from('services').select('id').eq('owner_id', payload.owner_id).ilike('name', name).limit(1);
|
||||||
|
if (payload.id) dupQuery = dupQuery.neq('id', payload.id);
|
||||||
|
const { data: dups, error: dupErr } = await dupQuery;
|
||||||
|
if (dupErr) throw dupErr;
|
||||||
|
if (dups && dups.length > 0) {
|
||||||
|
throw new Error('Já existe um serviço com este nome.');
|
||||||
|
}
|
||||||
|
|
||||||
if (payload.id) {
|
if (payload.id) {
|
||||||
const { id, owner_id, tenant_id, ...fields } = payload;
|
const { id, owner_id, tenant_id, ...fields } = payload;
|
||||||
const { error: err } = await supabase.from('services').update(fields).eq('id', id).eq('owner_id', owner_id);
|
const { error: err } = await supabase.from('services').update(fields).eq('id', id).eq('owner_id', owner_id);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import dayGridPlugin from '@fullcalendar/daygrid';
|
|||||||
import listPlugin from '@fullcalendar/list';
|
import listPlugin from '@fullcalendar/list';
|
||||||
import interactionPlugin from '@fullcalendar/interaction';
|
import interactionPlugin from '@fullcalendar/interaction';
|
||||||
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
||||||
|
import { FC_TOUCH_DEFAULTS } from '@/features/agenda/utils/fcDefaults';
|
||||||
|
|
||||||
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue';
|
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue';
|
||||||
import BloqueioDialog from '@/features/agenda/components/BloqueioDialog.vue';
|
import BloqueioDialog from '@/features/agenda/components/BloqueioDialog.vue';
|
||||||
@@ -672,6 +673,7 @@ const _initSlotMax = slotMaxTime.value;
|
|||||||
const fcOptions = computed(() => ({
|
const fcOptions = computed(() => ({
|
||||||
plugins: [timeGridPlugin, dayGridPlugin, listPlugin, interactionPlugin],
|
plugins: [timeGridPlugin, dayGridPlugin, listPlugin, interactionPlugin],
|
||||||
locale: ptBrLocale,
|
locale: ptBrLocale,
|
||||||
|
...FC_TOUCH_DEFAULTS,
|
||||||
timeZone: timezone.value,
|
timeZone: timezone.value,
|
||||||
|
|
||||||
headerToolbar: false,
|
headerToolbar: false,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
* FullCalendar shared defaults.
|
||||||
|
*
|
||||||
|
* Centraliza opções que devem se aplicar a TODAS as instâncias do
|
||||||
|
* FullCalendar do sistema (MelissaAgenda, AgendaTerapeutaPage,
|
||||||
|
* AgendaClinicMosaic, e futuras). Se aparecer outra agenda, basta:
|
||||||
|
*
|
||||||
|
* import { FC_TOUCH_DEFAULTS } from '@/features/agenda/utils/fcDefaults';
|
||||||
|
* const fcOptions = { ...FC_TOUCH_DEFAULTS, ...resto };
|
||||||
|
*
|
||||||
|
* Sem isso, tablet/touch fica sem paridade com mouse — ver detalhe abaixo.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defaults pra paridade touch ↔ mouse no FullCalendar.
|
||||||
|
*
|
||||||
|
* Por padrão o FC exige long-press de 1000ms no touch antes de disparar
|
||||||
|
* `select` ou `eventDrop` — no mouse, qualquer clique/drag funciona na hora.
|
||||||
|
* Zerar os dois delays faz tap se comportar igual clique do mouse.
|
||||||
|
*
|
||||||
|
* - selectLongPressDelay: 0 → tap em slot vazio dispara `select` na hora
|
||||||
|
* (abre o AgendaEventDialog igual ao desktop)
|
||||||
|
* - eventLongPressDelay: 0 → tap-and-drag em evento existente já move/resize
|
||||||
|
* sem precisar segurar 1s
|
||||||
|
*
|
||||||
|
* O FC continua diferenciando tap curto (select de 1 slot) de tap+drag
|
||||||
|
* (select de range) automaticamente — não há perda de funcionalidade.
|
||||||
|
*/
|
||||||
|
export const FC_TOUCH_DEFAULTS = Object.freeze({
|
||||||
|
selectLongPressDelay: 0,
|
||||||
|
eventLongPressDelay: 0
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { supabase } from '@/lib/supabase/client';
|
import { supabase } from '@/lib/supabase/client';
|
||||||
|
import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
|
||||||
|
|
||||||
export function usePatientSessions() {
|
export function usePatientSessions() {
|
||||||
const sessions = ref([]);
|
const sessions = ref([]);
|
||||||
@@ -19,6 +20,9 @@ export function usePatientSessions() {
|
|||||||
const busy = ref(false); // mutations em curso (updateStatus etc)
|
const busy = ref(false); // mutations em curso (updateStatus etc)
|
||||||
let _lastPatientId = null;
|
let _lastPatientId = null;
|
||||||
|
|
||||||
|
// Instancia local — refs internos (rules/exceptions) ficam isolados deste consumidor.
|
||||||
|
const { loadAndExpand } = useRecurrence();
|
||||||
|
|
||||||
async function load(patientId) {
|
async function load(patientId) {
|
||||||
_lastPatientId = patientId || null;
|
_lastPatientId = patientId || null;
|
||||||
if (!patientId) {
|
if (!patientId) {
|
||||||
@@ -29,14 +33,49 @@ export function usePatientSessions() {
|
|||||||
error.value = '';
|
error.value = '';
|
||||||
sessions.value = [];
|
sessions.value = [];
|
||||||
try {
|
try {
|
||||||
|
// 1. Linhas reais — `recurrence_id`/`recurrence_date` inclusos pra
|
||||||
|
// mergeWithStoredSessions deduplicar virtuais de sessões já materializadas.
|
||||||
const { data, error: err } = await supabase
|
const { data, error: err } = await supabase
|
||||||
.from('agenda_eventos')
|
.from('agenda_eventos')
|
||||||
.select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, observacoes')
|
.select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, observacoes, patient_id, recurrence_id, recurrence_date')
|
||||||
.eq('patient_id', patientId)
|
.eq('patient_id', patientId)
|
||||||
.order('inicio_em', { ascending: false })
|
.order('inicio_em', { ascending: false })
|
||||||
.limit(100);
|
.limit(100);
|
||||||
if (err) throw err;
|
if (err) throw err;
|
||||||
sessions.value = data || [];
|
const realRows = data || [];
|
||||||
|
|
||||||
|
// 2. Expande recorrências do owner + filtra só as deste paciente.
|
||||||
|
// Range default: 6 meses atrás → 12 meses à frente (cobre histórico
|
||||||
|
// recente + ~1 ano de séries semanais/quinzenais futuras). Sem expansão,
|
||||||
|
// sessão 1 aparece (materializada) mas as N-1 virtuais ficam invisíveis.
|
||||||
|
let virtualOccs = [];
|
||||||
|
try {
|
||||||
|
const { data: userData } = await supabase.auth.getUser();
|
||||||
|
const ownerId = userData?.user?.id || null;
|
||||||
|
if (ownerId) {
|
||||||
|
let tenantId = null;
|
||||||
|
try {
|
||||||
|
const { useTenantStore } = await import('@/stores/tenantStore');
|
||||||
|
tenantId = useTenantStore().activeTenantId || null;
|
||||||
|
} catch { /* sem tenant store — segue */ }
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const rangeStart = new Date(now.getFullYear(), now.getMonth() - 6, 1);
|
||||||
|
const rangeEnd = new Date(now.getFullYear() + 1, now.getMonth(), 1);
|
||||||
|
|
||||||
|
const expanded = await loadAndExpand(ownerId, rangeStart, rangeEnd, realRows, tenantId);
|
||||||
|
virtualOccs = expanded.filter((r) => r.is_occurrence && r.patient_id === patientId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback silencioso — UI segue funcional só com sessões reais.
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[usePatientSessions] recurrence expand falhou:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Merge desc por inicio_em (mantém contrato do composable original).
|
||||||
|
const merged = [...realRows, ...virtualOccs];
|
||||||
|
merged.sort((a, b) => String(b.inicio_em).localeCompare(String(a.inicio_em)));
|
||||||
|
sessions.value = merged;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e?.message || 'Falha ao carregar sessões.';
|
error.value = e?.message || 'Falha ao carregar sessões.';
|
||||||
sessions.value = [];
|
sessions.value = [];
|
||||||
@@ -145,17 +184,96 @@ export function usePatientSessions() {
|
|||||||
/**
|
/**
|
||||||
* 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.
|
||||||
|
*
|
||||||
|
* Aceita string (UUID legado) OU a row inteira da sessão. Quando vier a row
|
||||||
|
* e ela for ocorrência virtual (is_occurrence=true, id `rec::ruleId::date`),
|
||||||
|
* MATERIALIZA primeiro: cria/encontra a linha real em agenda_eventos com
|
||||||
|
* recurrence_id+recurrence_date apontando pra regra, depois aplica o status.
|
||||||
|
* Sem isso o UPDATE falha com "invalid input syntax for type uuid" porque
|
||||||
|
* o id virtual nunca existiu no banco. Espelha o pattern de
|
||||||
|
* useMelissaAgenda.onUpdateSeriesEvent (L808-850).
|
||||||
|
*
|
||||||
* Retorna {ok: true} ou {ok: false, error: msg}.
|
* Retorna {ok: true} ou {ok: false, error: msg}.
|
||||||
*/
|
*/
|
||||||
async function updateStatus(sessionId, novoStatus) {
|
async function updateStatus(sessionOrId, novoStatus) {
|
||||||
if (!sessionId || busy.value) return { ok: false, error: 'busy' };
|
if (!sessionOrId || busy.value) return { ok: false, error: 'busy' };
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
try {
|
try {
|
||||||
|
// Caminho A — string UUID legado ou row real (id é UUID real).
|
||||||
|
const isObject = typeof sessionOrId === 'object' && sessionOrId !== null;
|
||||||
|
const isVirtual = isObject && !!sessionOrId.is_occurrence;
|
||||||
|
|
||||||
|
if (!isVirtual) {
|
||||||
|
const realId = isObject ? sessionOrId.id : sessionOrId;
|
||||||
|
if (!realId || typeof realId !== 'string' || realId.startsWith('rec::')) {
|
||||||
|
return { ok: false, error: 'ID inválido pra atualizar status (virtual sem row).' };
|
||||||
|
}
|
||||||
const { error: err } = await supabase
|
const { error: err } = await supabase
|
||||||
.from('agenda_eventos')
|
.from('agenda_eventos')
|
||||||
.update({ status: novoStatus })
|
.update({ status: novoStatus })
|
||||||
.eq('id', sessionId);
|
.eq('id', realId);
|
||||||
if (err) throw err;
|
if (err) throw err;
|
||||||
|
if (_lastPatientId) await load(_lastPatientId);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caminho B — ocorrência virtual: materializar antes de atualizar.
|
||||||
|
const row = sessionOrId;
|
||||||
|
const rid = row.recurrence_id;
|
||||||
|
const rDate = row.recurrence_date || row.original_date || String(row.inicio_em || '').slice(0, 10);
|
||||||
|
|
||||||
|
if (!rid || !rDate) {
|
||||||
|
return { ok: false, error: 'Ocorrência sem recurrence_id/date — não dá pra materializar.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Já existe row materializada (mesmo recurrence_id+date)? Usa ela.
|
||||||
|
const { data: existing, error: exErr } = await supabase
|
||||||
|
.from('agenda_eventos')
|
||||||
|
.select('id')
|
||||||
|
.eq('recurrence_id', rid)
|
||||||
|
.eq('recurrence_date', rDate)
|
||||||
|
.maybeSingle();
|
||||||
|
if (exErr) throw exErr;
|
||||||
|
|
||||||
|
if (existing?.id) {
|
||||||
|
const { error: upErr } = await supabase
|
||||||
|
.from('agenda_eventos')
|
||||||
|
.update({ status: novoStatus })
|
||||||
|
.eq('id', existing.id);
|
||||||
|
if (upErr) throw upErr;
|
||||||
|
} else {
|
||||||
|
// Materializa NOVA row a partir da virtual. Owner/tenant via auth+store.
|
||||||
|
const { data: userData } = await supabase.auth.getUser();
|
||||||
|
const ownerId = userData?.user?.id || null;
|
||||||
|
let tenantId = null;
|
||||||
|
try {
|
||||||
|
const { useTenantStore } = await import('@/stores/tenantStore');
|
||||||
|
tenantId = useTenantStore().activeTenantId || null;
|
||||||
|
} catch { /* sem store — segue */ }
|
||||||
|
|
||||||
|
const newRow = {
|
||||||
|
owner_id: ownerId,
|
||||||
|
tenant_id: tenantId,
|
||||||
|
recurrence_id: rid,
|
||||||
|
recurrence_date: rDate,
|
||||||
|
patient_id: row.patient_id || row.paciente_id || _lastPatientId,
|
||||||
|
tipo: row.tipo || 'sessao',
|
||||||
|
status: novoStatus,
|
||||||
|
inicio_em: row.inicio_em,
|
||||||
|
fim_em: row.fim_em,
|
||||||
|
modalidade: row.modalidade || 'presencial',
|
||||||
|
titulo: row.titulo || null,
|
||||||
|
titulo_custom: row.titulo_custom || null,
|
||||||
|
observacoes: row.observacoes || null,
|
||||||
|
determined_commitment_id: row.determined_commitment_id || null,
|
||||||
|
price: row.price ?? null
|
||||||
|
};
|
||||||
|
const { error: insErr } = await supabase
|
||||||
|
.from('agenda_eventos')
|
||||||
|
.insert([newRow]);
|
||||||
|
if (insErr) throw insErr;
|
||||||
|
}
|
||||||
|
|
||||||
if (_lastPatientId) await load(_lastPatientId);
|
if (_lastPatientId) await load(_lastPatientId);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import dayGridPlugin from '@fullcalendar/daygrid';
|
|||||||
import listPlugin from '@fullcalendar/list';
|
import listPlugin from '@fullcalendar/list';
|
||||||
import interactionPlugin from '@fullcalendar/interaction';
|
import interactionPlugin from '@fullcalendar/interaction';
|
||||||
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
||||||
|
import { FC_TOUCH_DEFAULTS } from '@/features/agenda/utils/fcDefaults';
|
||||||
import { useMelissaEventosRange, useMelissaTodasSessoesPaciente } from './composables/useMelissaEventos';
|
import { useMelissaEventosRange, useMelissaTodasSessoesPaciente } from './composables/useMelissaEventos';
|
||||||
import { MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
|
import { MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
|
||||||
import { useMelissaPacientesAside } from './composables/useMelissaPacientesAside';
|
import { useMelissaPacientesAside } from './composables/useMelissaPacientesAside';
|
||||||
@@ -244,7 +245,11 @@ const VIEW_MAP = {
|
|||||||
dia: 'timeGridDay',
|
dia: 'timeGridDay',
|
||||||
semana: 'timeGridWeek',
|
semana: 'timeGridWeek',
|
||||||
mes: 'dayGridMonth',
|
mes: 'dayGridMonth',
|
||||||
lista: 'listWeek'
|
// listAll é view custom (configurada em fcOptions.views) — cobre 2 anos
|
||||||
|
// (1 ano antes + 1 ano depois de hoje). Substituiu listWeek que mostrava
|
||||||
|
// só 7 dias e escondia recorrências semanais/quinzenais futuras. Cap do
|
||||||
|
// loadAndExpand é 730d (MAX_RANGE_DAYS), 2 anos cai exatamente no limite.
|
||||||
|
lista: 'listAll'
|
||||||
};
|
};
|
||||||
|
|
||||||
const fcRef = ref(null);
|
const fcRef = ref(null);
|
||||||
@@ -384,6 +389,11 @@ const fcEvents = computed(() => {
|
|||||||
const evPid = ev.patient_id || ev.paciente_id;
|
const evPid = ev.patient_id || ev.paciente_id;
|
||||||
if (onlySess && !evPid) continue;
|
if (onlySess && !evPid) continue;
|
||||||
if (pacienteId && evPid !== pacienteId) continue;
|
if (pacienteId && evPid !== pacienteId) continue;
|
||||||
|
// Eventos cujo paciente foi arquivado/desativado ganham classe
|
||||||
|
// visual ("inativo") — borda tracejada + opacidade reduzida no CSS
|
||||||
|
// abaixo. Mantem a cor do commitment pra nao perder contexto.
|
||||||
|
const pStatus = ev.paciente_status;
|
||||||
|
const isInactivePatient = pStatus === 'Arquivado' || pStatus === 'Inativo';
|
||||||
out.push({
|
out.push({
|
||||||
id: ev.id,
|
id: ev.id,
|
||||||
title: ev.label,
|
title: ev.label,
|
||||||
@@ -392,6 +402,7 @@ const fcEvents = computed(() => {
|
|||||||
backgroundColor: `${ev.color}26`, // ~15% opacity
|
backgroundColor: `${ev.color}26`, // ~15% opacity
|
||||||
borderColor: ev.color,
|
borderColor: ev.color,
|
||||||
textColor: 'white',
|
textColor: 'white',
|
||||||
|
classNames: isInactivePatient ? ['ma-evt--inactive-patient'] : undefined,
|
||||||
extendedProps: ev
|
extendedProps: ev
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -403,6 +414,19 @@ const fcEvents = computed(() => {
|
|||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hint pra trocar pra view 'lista' quando há ocorrências de recorrência
|
||||||
|
// visíveis em view dia/semana/mês. Cada view dessas cobre janela curta
|
||||||
|
// (1d / 7d / ~35d) — séries semanais com 4+ ocorrências sempre extrapolam.
|
||||||
|
// Lista cobre 2 anos centrada no usuário, então mostra passado/presente/futuro.
|
||||||
|
// Não mostra no modo "Ver todas" (já tá em modo lista paralela) nem na própria
|
||||||
|
// view lista. Some quando nenhum virtual aparece (sem recorrência ativa visível).
|
||||||
|
const showRecurrenceHint = computed(() => {
|
||||||
|
if (calendarView.value === 'lista') return false;
|
||||||
|
if (verTodasSessoes.value) return false;
|
||||||
|
if (!eventosSemana.value?.length) return false;
|
||||||
|
return eventosSemana.value.some((ev) => ev.is_occurrence);
|
||||||
|
});
|
||||||
|
|
||||||
// ── slotMinTime / slotMaxTime baseado em timeMode ─────────────
|
// ── slotMinTime / slotMaxTime baseado em timeMode ─────────────
|
||||||
// 24: 00–24h. 12: 06–18h. my: range das workRules (snap em 30min).
|
// 24: 00–24h. 12: 06–18h. my: range das workRules (snap em 30min).
|
||||||
function _hhmmToMin(t) {
|
function _hhmmToMin(t) {
|
||||||
@@ -492,11 +516,22 @@ function slotLabelContent(arg) {
|
|||||||
|
|
||||||
const fcOptions = computed(() => ({
|
const fcOptions = computed(() => ({
|
||||||
plugins: [timeGridPlugin, dayGridPlugin, listPlugin, interactionPlugin],
|
plugins: [timeGridPlugin, dayGridPlugin, listPlugin, interactionPlugin],
|
||||||
|
...FC_TOUCH_DEFAULTS,
|
||||||
locale: ptBrLocale,
|
locale: ptBrLocale,
|
||||||
headerToolbar: false, // toolbar é nossa (custom glass)
|
headerToolbar: false, // toolbar é nossa (custom glass)
|
||||||
initialView: VIEW_MAP[calendarView.value] || 'timeGridWeek',
|
initialView: VIEW_MAP[calendarView.value] || 'timeGridWeek',
|
||||||
initialDate: refDate.value,
|
initialDate: refDate.value,
|
||||||
nowIndicator: true,
|
nowIndicator: true,
|
||||||
|
// View custom "listAll": list view cobrindo 2 anos. Usada quando user clica
|
||||||
|
// o toggle "Lista" — exibe passadas + presentes + futuras numa varredura só.
|
||||||
|
// setView('lista') faz gotoDate(hoje - 1 ano) pra centrar.
|
||||||
|
views: {
|
||||||
|
listAll: {
|
||||||
|
type: 'list',
|
||||||
|
duration: { years: 2 },
|
||||||
|
buttonText: 'Lista'
|
||||||
|
}
|
||||||
|
},
|
||||||
// Drag/resize/select habilitam apenas com M (composable disponível) —
|
// Drag/resize/select habilitam apenas com M (composable disponível) —
|
||||||
// standalone (sem M) fica readonly por compat (preview puro).
|
// standalone (sem M) fica readonly por compat (preview puro).
|
||||||
editable: !!M,
|
editable: !!M,
|
||||||
@@ -696,6 +731,14 @@ function goToday() { fcApi()?.today(); }
|
|||||||
function setView(v) {
|
function setView(v) {
|
||||||
calendarView.value = v;
|
calendarView.value = v;
|
||||||
fcApi()?.changeView(VIEW_MAP[v]);
|
fcApi()?.changeView(VIEW_MAP[v]);
|
||||||
|
// Lista cobre 2 anos — abrimos centrado: pula pra (hoje - 1 ano) pra
|
||||||
|
// mostrar passado + presente + futuro de uma vez. Outras views mantém
|
||||||
|
// o refDate atual (datesSet sincroniza viewStart/End normalmente).
|
||||||
|
if (v === 'lista') {
|
||||||
|
const umAnoAtras = new Date();
|
||||||
|
umAnoAtras.setFullYear(umAnoAtras.getFullYear() - 1);
|
||||||
|
fcApi()?.gotoDate(umAnoAtras);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Menu de Bloqueio (toolbar) ─────────────────────────────────
|
// ── Menu de Bloqueio (toolbar) ─────────────────────────────────
|
||||||
@@ -1574,16 +1617,21 @@ defineExpose({
|
|||||||
@bloqueio="onActionsBloqueio"
|
@bloqueio="onActionsBloqueio"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="ma-cal__view ma-cal__view--xl-only flex bg-[var(--m-bg-soft)] border border-[var(--m-border)] rounded-[10px] p-0.5 flex-shrink-0 max-[1279px]:hidden">
|
<div class="ma-cal__view ma-cal__view--xl-only flex-shrink-0 max-[1279px]:hidden">
|
||||||
<button
|
<SelectButton
|
||||||
v-for="opt in [{v:'dia',l:'Dia'},{v:'semana',l:'Semana'},{v:'mes',l:'Mês'},{v:'lista',l:'Lista'}]"
|
:model-value="calendarView"
|
||||||
:key="opt.v"
|
:options="[
|
||||||
class="ma-cal__view-btn bg-transparent border-0 text-[var(--m-text-muted)] px-3 py-[5px] rounded-lg text-[0.75rem] font-medium [font-family:inherit] cursor-pointer transition-all duration-[140ms] hover:text-[var(--m-text)] focus-visible:outline-2 focus-visible:outline-[var(--m-accent)] focus-visible:outline-offset-2"
|
{ v: 'dia', l: 'Dia' },
|
||||||
:class="{ 'is-active': calendarView === opt.v }"
|
{ v: 'semana', l: 'Semana' },
|
||||||
@click="setView(opt.v)"
|
{ v: 'mes', l: 'Mês' },
|
||||||
>
|
{ v: 'lista', l: 'Lista' }
|
||||||
{{ opt.l }}
|
]"
|
||||||
</button>
|
option-value="v"
|
||||||
|
option-label="l"
|
||||||
|
:allow-empty="false"
|
||||||
|
size="small"
|
||||||
|
@update:model-value="(v) => v && setView(v)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1662,6 +1710,32 @@ defineExpose({
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Aviso quando há ocorrências de recorrência visíveis E o user
|
||||||
|
não está na view 'lista'. Cobre o caso "sessão recorrente
|
||||||
|
com count=4 — aparece só na semana atual; outras 3 ficam
|
||||||
|
fora do range visível". Botão troca pra view lista, que
|
||||||
|
cobre 2 anos (passado + futuro) numa varredura só. -->
|
||||||
|
<Transition name="ma-patient-banner">
|
||||||
|
<div
|
||||||
|
v-if="showRecurrenceHint"
|
||||||
|
class="ma-cal__recurrence-hint flex items-center gap-2.5 px-3.5 py-1.5 border-b border-[var(--m-border)] bg-[color-mix(in_srgb,var(--m-accent)_6%,var(--m-bg-medium))] text-[var(--m-text)] text-[0.78rem] flex-shrink-0"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<i class="pi pi-refresh text-[var(--m-accent)] text-[0.74rem] flex-shrink-0" />
|
||||||
|
<span class="min-w-0 flex-1 leading-[1.3] text-[var(--m-text-muted)]">
|
||||||
|
Há sessões recorrentes visíveis — pode haver mais fora deste período.
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-1.5 px-2.5 py-[5px] rounded-lg border border-[var(--m-border)] bg-[var(--m-bg-soft)] text-[var(--m-text)] text-[0.74rem] font-medium [font-family:inherit] cursor-pointer transition-[background-color,border-color,color] duration-[140ms] flex-shrink-0 hover:bg-[var(--m-bg-soft-hover)] hover:border-[var(--m-border-strong)] focus-visible:outline-2 focus-visible:outline-[var(--m-accent)] focus-visible:outline-offset-2"
|
||||||
|
v-tooltip.bottom="'Lista cobre 2 anos centrada em hoje'"
|
||||||
|
@click="setView('lista')"
|
||||||
|
>
|
||||||
|
<i class="pi pi-list" />
|
||||||
|
<span>Ver na lista</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<!-- FullCalendar — view selecionada via :key pra re-mount limpo -->
|
<!-- FullCalendar — view selecionada via :key pra re-mount limpo -->
|
||||||
<div class="ma-cal__fc">
|
<div class="ma-cal__fc">
|
||||||
<!-- Overlay leve enquanto eventos carregam. Não bloqueia interação,
|
<!-- Overlay leve enquanto eventos carregam. Não bloqueia interação,
|
||||||
@@ -2015,18 +2089,14 @@ defineExpose({
|
|||||||
/* ═══ COL 2: Calendar central ════════════════════════════════ */
|
/* ═══ COL 2: Calendar central ════════════════════════════════ */
|
||||||
/* .ma-cal*, .ma-cal__toolbar, .ma-cal__nav*, .ma-cal__btn (versão ghost
|
/* .ma-cal*, .ma-cal__toolbar, .ma-cal__nav*, .ma-cal__btn (versão ghost
|
||||||
da toolbar), .ma-cal__icon, .ma-cal__period, .ma-cal__right, .ma-cal__view*
|
da toolbar), .ma-cal__icon, .ma-cal__period, .ma-cal__right, .ma-cal__view*
|
||||||
migraram pra Tailwind no template. Aqui ficam SO os state modifiers:
|
migraram pra Tailwind no template. Aqui fica SO o state modifier
|
||||||
.is-today-active (botao Hoje desabilitado) e .is-active do view-btn. */
|
.is-today-active (botao Hoje desabilitado). O view selector agora e
|
||||||
|
PrimeVue SelectButton — visual herdado do tema do projeto. */
|
||||||
.ma-cal__btn.is-today-active {
|
.ma-cal__btn.is-today-active {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.ma-cal__btn.is-today-active:hover { background: transparent; }
|
.ma-cal__btn.is-today-active:hover { background: transparent; }
|
||||||
.ma-cal__view-btn.is-active {
|
|
||||||
background: var(--m-bg-soft-hover);
|
|
||||||
color: var(--m-text);
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* .ma-cal__loading migrou pra Tailwind utilities no template. */
|
/* .ma-cal__loading migrou pra Tailwind utilities no template. */
|
||||||
.ma-loading-fade-enter-active,
|
.ma-loading-fade-enter-active,
|
||||||
@@ -2171,6 +2241,16 @@ defineExpose({
|
|||||||
.ma-cal__fc :deep(.fc-list-day-cushion) {
|
.ma-cal__fc :deep(.fc-list-day-cushion) {
|
||||||
background: var(--m-bg-soft);
|
background: var(--m-bg-soft);
|
||||||
}
|
}
|
||||||
|
/* Sticky day header em listAll precisa de z-index + bg opaco — sem isso,
|
||||||
|
conforme o user dá scroll, .fc-list-event passa POR CIMA do header
|
||||||
|
(stacking context da row de evento vence o cushion sem z-index). */
|
||||||
|
.ma-cal__fc :deep(.fc-list-day) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.ma-cal__fc :deep(.fc-list-day > *) {
|
||||||
|
background: var(--m-bg-medium);
|
||||||
|
}
|
||||||
.ma-cal__fc :deep(.fc-list-event:hover td) {
|
.ma-cal__fc :deep(.fc-list-event:hover td) {
|
||||||
background: var(--m-bg-soft);
|
background: var(--m-bg-soft);
|
||||||
}
|
}
|
||||||
@@ -2197,6 +2277,20 @@ defineExpose({
|
|||||||
.ma-cal__fc :deep(.fc-event-main) {
|
.ma-cal__fc :deep(.fc-event-main) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Eventos de paciente Inativo/Arquivado — borda tracejada + opacidade reduzida.
|
||||||
|
Mantem a cor do commitment (contexto preservado) e funciona em todas as
|
||||||
|
views (day/week/month/list) porque ataca .fc-event direto. */
|
||||||
|
.ma-cal__fc :deep(.fc-event.ma-evt--inactive-patient) {
|
||||||
|
opacity: 0.58;
|
||||||
|
border-left-style: dashed;
|
||||||
|
}
|
||||||
|
.ma-cal__fc :deep(.fc-list-event.ma-evt--inactive-patient) {
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
.ma-cal__fc :deep(.fc-list-event.ma-evt--inactive-patient td) {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
.ma-cal__fc :deep(.mc-fc-event) {
|
.ma-cal__fc :deep(.mc-fc-event) {
|
||||||
padding: 4px 6px;
|
padding: 4px 6px;
|
||||||
color: var(--m-text);
|
color: var(--m-text);
|
||||||
@@ -2393,7 +2487,7 @@ html:not(.app-dark) .ma-cal__fc :deep(.mc-fc-event__badge--modal.is-presencial)
|
|||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Lista (listWeek) */
|
/* Lista (listAll — view custom 2 anos centrada em hoje) */
|
||||||
.ma-cal__fc :deep(.fc-list-day-text),
|
.ma-cal__fc :deep(.fc-list-day-text),
|
||||||
.ma-cal__fc :deep(.fc-list-day-side-text) {
|
.ma-cal__fc :deep(.fc-list-day-side-text) {
|
||||||
color: var(--m-text);
|
color: var(--m-text);
|
||||||
|
|||||||
@@ -652,16 +652,42 @@ function fecharEvento() {
|
|||||||
// ── Actions do MelissaEventoPanel ──────────────────────────────
|
// ── Actions do MelissaEventoPanel ──────────────────────────────
|
||||||
// updateStatus: muda status no DB e refetcha agenda. Pattern espelha
|
// updateStatus: muda status no DB e refetcha agenda. Pattern espelha
|
||||||
// AgendaTerapeutaPage (sem optimistic update por simplicidade no MVP).
|
// AgendaTerapeutaPage (sem optimistic update por simplicidade no MVP).
|
||||||
|
//
|
||||||
|
// Quando `ev` é ocorrência VIRTUAL de recorrência (id `rec::...` sem row real),
|
||||||
|
// delega pro M.onUpdateSeriesEvent que materializa antes do UPDATE — sem isso
|
||||||
|
// PostgreSQL recusa o UPDATE com "invalid input syntax for type uuid".
|
||||||
async function updateEventoStatus(novoStatus, msgSucesso) {
|
async function updateEventoStatus(novoStatus, msgSucesso) {
|
||||||
const ev = eventoSelecionado.value;
|
const ev = eventoSelecionado.value;
|
||||||
if (!ev?.id || eventoBusy.value) return;
|
if (!ev?.id || eventoBusy.value) return;
|
||||||
eventoBusy.value = true;
|
eventoBusy.value = true;
|
||||||
try {
|
try {
|
||||||
|
const isVirtual =
|
||||||
|
!!ev.is_occurrence ||
|
||||||
|
(typeof ev.id === 'string' && ev.id.startsWith('rec::'));
|
||||||
|
|
||||||
|
if (isVirtual) {
|
||||||
|
await M.onUpdateSeriesEvent({
|
||||||
|
id: null,
|
||||||
|
status: novoStatus,
|
||||||
|
recurrence_date:
|
||||||
|
ev.recurrence_date ||
|
||||||
|
ev.original_date ||
|
||||||
|
String(ev.inicio_em || '').slice(0, 10),
|
||||||
|
inicio_em: ev.inicio_em,
|
||||||
|
fim_em: ev.fim_em,
|
||||||
|
is_virtual: true,
|
||||||
|
// Passa o ev completo — sem isso o handler depende de
|
||||||
|
// dialogEventRow.value (que está vazio quando o user clica
|
||||||
|
// direto no evento do FC sem abrir o dialog antes).
|
||||||
|
row: ev
|
||||||
|
});
|
||||||
|
} else {
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('agenda_eventos')
|
.from('agenda_eventos')
|
||||||
.update({ status: novoStatus })
|
.update({ status: novoStatus })
|
||||||
.eq('id', ev.id);
|
.eq('id', ev.id);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
}
|
||||||
toast.add({ severity: 'success', summary: msgSucesso, life: 2200 });
|
toast.add({ severity: 'success', summary: msgSucesso, life: 2200 });
|
||||||
// Refetch:
|
// Refetch:
|
||||||
// - M.refetch() alimenta a Agenda (FullCalendar + ocorrências virtuais)
|
// - M.refetch() alimenta a Agenda (FullCalendar + ocorrências virtuais)
|
||||||
|
|||||||
@@ -321,8 +321,11 @@ async function revertRecordPaid(record) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handler de mutacao de status (Realizada / Falta / Cancelar)
|
// Handler de mutacao de status (Realizada / Falta / Cancelar)
|
||||||
|
// Passa a row inteira pro composable porque pode ser ocorrência virtual de
|
||||||
|
// recorrência (id `rec::ruleId::date`) — nesse caso o composable materializa
|
||||||
|
// uma linha real antes de aplicar o status (UPDATE em id virtual quebra).
|
||||||
async function updateSessionStatus(ev, novoStatus, msg) {
|
async function updateSessionStatus(ev, novoStatus, msg) {
|
||||||
const result = await sessionsHook.updateStatus(ev.id, novoStatus);
|
const result = await sessionsHook.updateStatus(ev, novoStatus);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
toast.add({ severity: 'success', summary: msg, life: 2200 });
|
toast.add({ severity: 'success', summary: msg, life: 2200 });
|
||||||
} else {
|
} else {
|
||||||
@@ -539,6 +542,21 @@ async function onSessaoDialogSave(payload) {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Mudança de status numa ocorrência (cancelado/remarcado/etc) — delega pro
|
||||||
|
// handler do composable que SABE materializar ocorrência virtual antes de
|
||||||
|
// aplicar o status. Sem isso o UPDATE em id virtual quebra ("invalid input
|
||||||
|
// syntax for type uuid"). Espelha o wire-up de MelissaLayout/AgendaTerapeuta.
|
||||||
|
async function onSessaoDialogUpdateSeries(payload) {
|
||||||
|
if (typeof melissaAgenda?.onUpdateSeriesEvent === 'function') {
|
||||||
|
await melissaAgenda.onUpdateSeriesEvent(payload);
|
||||||
|
}
|
||||||
|
if (props.patientId) {
|
||||||
|
await Promise.all([
|
||||||
|
sessionsHook.load(props.patientId),
|
||||||
|
recorrenciasHook.load(props.patientId)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
async function onSessaoDialogDelete(payload) {
|
async function onSessaoDialogDelete(payload) {
|
||||||
if (typeof melissaAgenda?.onDialogDelete === 'function') {
|
if (typeof melissaAgenda?.onDialogDelete === 'function') {
|
||||||
await melissaAgenda.onDialogDelete(payload);
|
await melissaAgenda.onDialogDelete(payload);
|
||||||
@@ -2296,7 +2314,7 @@ onBeforeUnmount(() => {
|
|||||||
:lock-patient="true"
|
:lock-patient="true"
|
||||||
@save="onSessaoDialogSave"
|
@save="onSessaoDialogSave"
|
||||||
@delete="onSessaoDialogDelete"
|
@delete="onSessaoDialogDelete"
|
||||||
@updateSeriesEvent="onSessaoDialogSave"
|
@updateSeriesEvent="onSessaoDialogUpdateSeries"
|
||||||
@editSeriesOccurrence="onSessaoDialogSave"
|
@editSeriesOccurrence="onSessaoDialogSave"
|
||||||
>
|
>
|
||||||
<template #headerLeft>
|
<template #headerLeft>
|
||||||
|
|||||||
@@ -806,7 +806,11 @@ function _buildHandlers(deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── onUpdateSeriesEvent — mudança de status numa ocorrência ──
|
// ── onUpdateSeriesEvent — mudança de status numa ocorrência ──
|
||||||
async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim_em, is_virtual }) {
|
//
|
||||||
|
// `row` opcional: row completa quando o chamador NÃO abriu o dialog antes
|
||||||
|
// (MelissaEventoPanel clica direto no evento → não há dialogEventRow ainda).
|
||||||
|
// Sem isso, recurrence_id/patient_id caem pra null e criavam row órfã.
|
||||||
|
async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow }) {
|
||||||
try {
|
try {
|
||||||
if (id) {
|
if (id) {
|
||||||
await update(id, { status });
|
await update(id, { status });
|
||||||
@@ -814,20 +818,29 @@ function _buildHandlers(deps) {
|
|||||||
}
|
}
|
||||||
if (!is_virtual || !inicio_em) return;
|
if (!is_virtual || !inicio_em) return;
|
||||||
|
|
||||||
const rid = dialogEventRow.value?.recurrence_id ?? dialogEventRow.value?.serie_id ?? null;
|
// Prioridade: row passado pelo chamador > dialogEventRow > vazio.
|
||||||
|
// dialogEventRow só está populado se o user abriu o dialog antes.
|
||||||
|
const row = callerRow || dialogEventRow.value || {};
|
||||||
|
const rid = row.recurrence_id ?? row.serie_id ?? dialogEventRow.value?.recurrence_id ?? dialogEventRow.value?.serie_id ?? null;
|
||||||
const rDate = recurrence_date || inicio_em?.slice(0, 10);
|
const rDate = recurrence_date || inicio_em?.slice(0, 10);
|
||||||
|
|
||||||
const { data: existing } = await supabase
|
if (!rid) {
|
||||||
|
toast.add({ severity: 'warn', summary: 'Erro', detail: 'Não foi possível identificar a regra de recorrência desta ocorrência.', life: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// .is() pra null seria inválido aqui — rid já validado acima.
|
||||||
|
const { data: existing, error: exErr } = await supabase
|
||||||
.from('agenda_eventos')
|
.from('agenda_eventos')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('recurrence_id', rid)
|
.eq('recurrence_id', rid)
|
||||||
.eq('recurrence_date', rDate)
|
.eq('recurrence_date', rDate)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
if (exErr) throw exErr;
|
||||||
|
|
||||||
if (existing?.id) {
|
if (existing?.id) {
|
||||||
await update(existing.id, { status });
|
await update(existing.id, { status });
|
||||||
} else {
|
} else {
|
||||||
const row = dialogEventRow.value || {};
|
|
||||||
await create({
|
await create({
|
||||||
owner_id: ownerId.value,
|
owner_id: ownerId.value,
|
||||||
tenant_id: clinicTenantId.value,
|
tenant_id: clinicTenantId.value,
|
||||||
@@ -841,6 +854,7 @@ function _buildHandlers(deps) {
|
|||||||
titulo: row.titulo || 'Sessão',
|
titulo: row.titulo || 'Sessão',
|
||||||
patient_id: row.patient_id || row.paciente_id || null,
|
patient_id: row.patient_id || row.paciente_id || null,
|
||||||
determined_commitment_id: row.determined_commitment_id || null,
|
determined_commitment_id: row.determined_commitment_id || null,
|
||||||
|
modalidade: row.modalidade || 'presencial',
|
||||||
price: row.price ?? null
|
price: row.price ?? null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,13 +20,16 @@
|
|||||||
*
|
*
|
||||||
* Sem auth/tenant: retorna [] silencioso (UI segue funcional).
|
* Sem auth/tenant: retorna [] silencioso (UI segue funcional).
|
||||||
*
|
*
|
||||||
* NÃO inclui ocorrências virtuais de recorrência (loadAndExpand) pra simplificar
|
* Inclui ocorrências virtuais de recorrência (via useRecurrence.loadAndExpand)
|
||||||
* o preview. Adicionar quando promover Melissa pra produção.
|
* mescladas com linhas reais — assim widgets ("Hoje", mini-cal, "Ver todas")
|
||||||
|
* mostram sessões futuras de séries semanais/quinzenais mesmo antes da
|
||||||
|
* materialização. Falha do expand não bloqueia: fallback silencioso pra reais.
|
||||||
*/
|
*/
|
||||||
import { ref, watch, onMounted, computed } from 'vue';
|
import { ref, watch, onMounted, computed } from 'vue';
|
||||||
import { supabase } from '@/lib/supabase/client';
|
import { supabase } from '@/lib/supabase/client';
|
||||||
import { useTenantStore } from '@/stores/tenantStore';
|
import { useTenantStore } from '@/stores/tenantStore';
|
||||||
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore';
|
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore';
|
||||||
|
import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
|
||||||
|
|
||||||
// ── Cores por tipo/status (consistente com o resto do Melissa) ──
|
// ── Cores por tipo/status (consistente com o resto do Melissa) ──
|
||||||
function pickColor(tipo, status) {
|
function pickColor(tipo, status) {
|
||||||
@@ -48,29 +51,43 @@ function isoToDecimalHour(iso) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeEvent(r) {
|
function normalizeEvent(r) {
|
||||||
const pacNome = r.patients?.nome_completo || '';
|
// Linhas reais (joined query) trazem `patients.nome_completo`; ocorrências
|
||||||
|
// virtuais (loadAndExpand) trazem `paciente_nome`/`patient_name`. Aceita os 3
|
||||||
|
// pra que a mesma normalização sirva pros dois tipos sem perder o rótulo.
|
||||||
|
const pacNome = r.patients?.nome_completo || r.paciente_nome || r.patient_name || '';
|
||||||
|
const pacStatus = r.patients?.status || r.paciente_status || '';
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
tipo: r.tipo || 'sessao',
|
tipo: r.tipo || 'sessao',
|
||||||
status: r.status || '',
|
status: r.status || '',
|
||||||
titulo: r.titulo || '',
|
titulo: r.titulo || r.titulo_custom || '',
|
||||||
patient_id: r.patient_id || null,
|
patient_id: r.patient_id || null,
|
||||||
pacienteNome: pacNome,
|
pacienteNome: pacNome,
|
||||||
|
// Status do paciente — usado pelo MelissaAgenda pra marcar visualmente
|
||||||
|
// eventos de paciente Inativo/Arquivado (borda tracejada + opacidade).
|
||||||
|
paciente_status: pacStatus,
|
||||||
modalidade: r.modalidade || '',
|
modalidade: r.modalidade || '',
|
||||||
descricao: r.observacoes || '',
|
descricao: r.observacoes || '',
|
||||||
color: pickColor(r.tipo, r.status),
|
color: pickColor(r.tipo, r.status),
|
||||||
label: pacNome || r.titulo || '—',
|
label: pacNome || r.titulo || r.titulo_custom || '—',
|
||||||
inicio_em: r.inicio_em,
|
inicio_em: r.inicio_em,
|
||||||
fim_em: r.fim_em,
|
fim_em: r.fim_em,
|
||||||
startH: isoToDecimalHour(r.inicio_em),
|
startH: isoToDecimalHour(r.inicio_em),
|
||||||
endH: isoToDecimalHour(r.fim_em),
|
endH: isoToDecimalHour(r.fim_em),
|
||||||
dateKey: String(r.inicio_em || '').slice(0, 10),
|
dateKey: String(r.inicio_em || '').slice(0, 10),
|
||||||
price: r.price != null ? Number(r.price) : 0,
|
price: r.price != null ? Number(r.price) : 0,
|
||||||
billed: !!r.billed
|
billed: !!r.billed,
|
||||||
|
// Flag pra consumidores diferenciarem materializada vs virtual de recorrência
|
||||||
|
// (UI pode optar por badge, click handler pode materializar antes de editar).
|
||||||
|
is_occurrence: !!r.is_occurrence,
|
||||||
|
recurrence_id: r.recurrence_id ?? null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helper interno: garante uid + tenant + faz a query ──
|
// ── Helper interno: garante uid + tenant + faz a query ──
|
||||||
|
// Carrega linhas reais de agenda_eventos no range + expande recorrências
|
||||||
|
// virtuais (loadAndExpand). Falha da expansão não bloqueia o fetch: cai pra
|
||||||
|
// só-reais com aviso no console — UI continua funcional.
|
||||||
async function _fetchRange(start, end) {
|
async function _fetchRange(start, end) {
|
||||||
const tenantStore = useTenantStore();
|
const tenantStore = useTenantStore();
|
||||||
const { data: userData } = await supabase.auth.getUser();
|
const { data: userData } = await supabase.auth.getUser();
|
||||||
@@ -82,9 +99,11 @@ async function _fetchRange(start, end) {
|
|||||||
|
|
||||||
if (!userId || !tid) return [];
|
if (!userId || !tid) return [];
|
||||||
|
|
||||||
|
// Recurrence_id/date inclusos no select pra mergeWithStoredSessions dedupar
|
||||||
|
// ocorrências já materializadas (sessões reais ganham precedência sobre virtuais).
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('agenda_eventos')
|
.from('agenda_eventos')
|
||||||
.select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, price, billed, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
|
.select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, price, billed, recurrence_id, recurrence_date, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
|
||||||
.eq('owner_id', userId)
|
.eq('owner_id', userId)
|
||||||
.is('mirror_of_event_id', null)
|
.is('mirror_of_event_id', null)
|
||||||
.gte('inicio_em', start.toISOString())
|
.gte('inicio_em', start.toISOString())
|
||||||
@@ -92,7 +111,20 @@ async function _fetchRange(start, end) {
|
|||||||
.order('inicio_em', { ascending: true });
|
.order('inicio_em', { ascending: true });
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return (data || []).map(normalizeEvent);
|
const realRows = data || [];
|
||||||
|
|
||||||
|
// Expansão de recorrências — falha silenciosa não derruba a UI.
|
||||||
|
let virtualOccs = [];
|
||||||
|
try {
|
||||||
|
const { loadAndExpand } = useRecurrence();
|
||||||
|
const expanded = await loadAndExpand(userId, start, end, realRows, tid);
|
||||||
|
virtualOccs = expanded.filter((r) => r.is_occurrence);
|
||||||
|
} catch (e) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[useMelissaEventos] recurrence expand falhou:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...realRows, ...virtualOccs].map(normalizeEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Range helpers ──────────────────────────────────────────────
|
// ── Range helpers ──────────────────────────────────────────────
|
||||||
@@ -293,14 +325,33 @@ export function useMelissaTodasSessoesPaciente() {
|
|||||||
|
|
||||||
const { data, error: err } = await supabase
|
const { data, error: err } = await supabase
|
||||||
.from('agenda_eventos')
|
.from('agenda_eventos')
|
||||||
.select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, price, billed, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
|
.select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, price, billed, recurrence_id, recurrence_date, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
|
||||||
.eq('owner_id', userId)
|
.eq('owner_id', userId)
|
||||||
.eq('patient_id', patientId)
|
.eq('patient_id', patientId)
|
||||||
.is('mirror_of_event_id', null)
|
.is('mirror_of_event_id', null)
|
||||||
.order('inicio_em', { ascending: false });
|
.order('inicio_em', { ascending: false });
|
||||||
|
|
||||||
if (err) throw err;
|
if (err) throw err;
|
||||||
eventos.value = (data || []).map(normalizeEvent);
|
const realRows = data || [];
|
||||||
|
|
||||||
|
// Expansão de recorrências do paciente — range padrão -6mo → +12mo
|
||||||
|
// (cobre histórico recente + ~1 ano de série semanal/quinzenal futura).
|
||||||
|
let virtualOccs = [];
|
||||||
|
try {
|
||||||
|
const { loadAndExpand } = useRecurrence();
|
||||||
|
const now = new Date();
|
||||||
|
const rangeStart = new Date(now.getFullYear(), now.getMonth() - 6, 1);
|
||||||
|
const rangeEnd = new Date(now.getFullYear() + 1, now.getMonth(), 1);
|
||||||
|
const expanded = await loadAndExpand(userId, rangeStart, rangeEnd, realRows, tid);
|
||||||
|
virtualOccs = expanded.filter((r) => r.is_occurrence && r.patient_id === patientId);
|
||||||
|
} catch (rec) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[useMelissaTodasSessoesPaciente] recurrence expand falhou:', rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = [...realRows, ...virtualOccs];
|
||||||
|
merged.sort((a, b) => String(b.inicio_em).localeCompare(String(a.inicio_em)));
|
||||||
|
eventos.value = merged.map(normalizeEvent);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e?.message || 'Erro ao carregar sessões';
|
error.value = e?.message || 'Erro ao carregar sessões';
|
||||||
eventos.value = [];
|
eventos.value = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user