diff --git a/src/features/patients/composables/usePatientRecurrences.js b/src/features/patients/composables/usePatientRecurrences.js new file mode 100644 index 0000000..082a258 --- /dev/null +++ b/src/features/patients/composables/usePatientRecurrences.js @@ -0,0 +1,103 @@ +/* +|-------------------------------------------------------------------------- +| Agência PSI +|-------------------------------------------------------------------------- +| Arquivo: src/features/patients/composables/usePatientRecurrences.js +| +| Carrega regras de recorrencia (recurrence_rules) filtradas por paciente. +| Usado pela Tab Agenda do MelissaPaciente pra mostrar "este paciente tem +| sessao toda segunda 14h" e dar acoes inline (cancelar/reativar). +| +| Mutations espelham o pattern de MelissaRecorrencias.vue. +|-------------------------------------------------------------------------- +*/ +import { ref, computed } from 'vue'; +import { supabase } from '@/lib/supabase/client'; + +export function usePatientRecurrences() { + const rules = ref([]); + const loading = ref(false); + const error = ref(''); + const busy = ref(false); + let _lastPatientId = null; + + async function load(patientId) { + _lastPatientId = patientId || null; + if (!patientId) { + rules.value = []; + return; + } + loading.value = true; + error.value = ''; + rules.value = []; + try { + const { data, error: err } = await supabase + .from('recurrence_rules') + .select('*') + .eq('patient_id', patientId) + .order('start_date', { ascending: false }); + if (err) throw err; + rules.value = data || []; + } catch (e) { + error.value = e?.message || 'Falha ao carregar recorrencias.'; + rules.value = []; + } finally { + loading.value = false; + } + } + + async function cancel(ruleId) { + if (!ruleId || busy.value) return { ok: false, error: 'busy' }; + busy.value = true; + try { + const { error: err } = await supabase + .from('recurrence_rules') + .update({ status: 'cancelado', updated_at: new Date().toISOString() }) + .eq('id', ruleId); + if (err) throw err; + if (_lastPatientId) await load(_lastPatientId); + return { ok: true }; + } catch (e) { + return { ok: false, error: e?.message || 'Erro ao cancelar' }; + } finally { + busy.value = false; + } + } + + async function reactivate(ruleId) { + if (!ruleId || busy.value) return { ok: false, error: 'busy' }; + busy.value = true; + try { + const { error: err } = await supabase + .from('recurrence_rules') + .update({ status: 'ativo', updated_at: new Date().toISOString() }) + .eq('id', ruleId); + if (err) throw err; + if (_lastPatientId) await load(_lastPatientId); + return { ok: true }; + } catch (e) { + return { ok: false, error: e?.message || 'Erro ao reativar' }; + } finally { + busy.value = false; + } + } + + const ativas = computed(() => rules.value.filter((r) => r.status === 'ativo')); + const canceladas = computed(() => rules.value.filter((r) => r.status === 'cancelado')); + const totalAtivas = computed(() => ativas.value.length); + const totalCanceladas = computed(() => canceladas.value.length); + + return { + rules, + loading, + error, + busy, + load, + cancel, + reactivate, + ativas, + canceladas, + totalAtivas, + totalCanceladas + }; +} diff --git a/src/features/patients/utils/patientFormatters.js b/src/features/patients/utils/patientFormatters.js index c82ce8e..806e5ca 100644 --- a/src/features/patients/utils/patientFormatters.js +++ b/src/features/patients/utils/patientFormatters.js @@ -179,6 +179,58 @@ export const DOC_TYPE_LABEL = { outro: 'Outro' }; +/** + * Map de dia da semana (0=Domingo) -> label pt-br. + */ +export const WEEKDAY_LABEL = ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado']; +export const WEEKDAY_LABEL_SHORT = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb']; + +/** + * Label legivel da regra de recorrencia. + * Ex: "Toda segunda às 14:00", "A cada 2 semanas, terça às 09:00", + * "Quartas e sextas às 16:00", "Mensal no dia 15". + */ +export function fmtRecurrenceLabel(rule) { + if (!rule) return '—'; + const time = String(rule.start_time || '').slice(0, 5); + const interval = Number(rule.interval) || 1; + + if (rule.type === 'weekly' || (rule.type === 'biweekly' && interval === 1)) { + const dow = (rule.weekdays || [])[0]; + if (dow == null) return time ? `Semanal às ${time}` : 'Semanal'; + const dayLbl = WEEKDAY_LABEL[dow] || '?'; + if (rule.type === 'biweekly') { + return time ? `Quinzenal · ${dayLbl} às ${time}` : `Quinzenal · ${dayLbl}`; + } + return time ? `Toda ${dayLbl.toLowerCase()} às ${time}` : `Toda ${dayLbl.toLowerCase()}`; + } + if (rule.type === 'custom_weekdays') { + const dows = (rule.weekdays || []).map((d) => WEEKDAY_LABEL_SHORT[d]).filter(Boolean); + const dayList = dows.length ? dows.join(', ') : '?'; + return time ? `${dayList} às ${time}` : dayList; + } + if (rule.type === 'monthly') { + return time ? `Mensal às ${time}` : 'Mensal'; + } + if (rule.type === 'yearly') { + return time ? `Anual às ${time}` : 'Anual'; + } + return rule.type || 'Recorrência'; +} + +/** + * Label pro fim da regra: "Sem data de fim", "Até DD/MM/YYYY", "N sessões no total". + */ +export function fmtRecurrenceFim(rule) { + if (!rule) return ''; + if (rule.end_date) return `Até ${fmtDateBR(rule.end_date)}`; + if (rule.max_occurrences) { + const n = Number(rule.max_occurrences); + return `${n} ${n === 1 ? 'sessão' : 'sessões'} no total`; + } + return 'Sem data de fim'; +} + /** * Channel label pra conversa: whatsapp -> WhatsApp, sms -> SMS, email -> E-mail. */ diff --git a/src/layout/melissa/MelissaPaciente.vue b/src/layout/melissa/MelissaPaciente.vue index c9aac84..b29e637 100644 --- a/src/layout/melissa/MelissaPaciente.vue +++ b/src/layout/melissa/MelissaPaciente.vue @@ -31,6 +31,7 @@ import { usePatientSessions } from '@/features/patients/composables/usePatientSe import { usePatientFinancial } from '@/features/patients/composables/usePatientFinancial'; import { usePatientMessages } from '@/features/patients/composables/usePatientMessages'; import { usePatientDocuments } from '@/features/patients/composables/usePatientDocuments'; +import { usePatientRecurrences } from '@/features/patients/composables/usePatientRecurrences'; import { useRecurrence } from '@/features/agenda/composables/useRecurrence'; import { pickField, @@ -41,6 +42,8 @@ import { fmtCurrency, fmtHourShort, fmtDayShort, + fmtRecurrenceLabel, + fmtRecurrenceFim, fmtCPF, fmtRG, fmtGender, @@ -72,6 +75,7 @@ const sessionsHook = usePatientSessions(); const financialHook = usePatientFinancial(); const messagesHook = usePatientMessages(); const documentsHook = usePatientDocuments(); +const recorrenciasHook = usePatientRecurrences(); const recurrenceHook = useRecurrence(); // ── Breakpoints + drawer ─────────────────────────────────── @@ -217,6 +221,38 @@ const groupNames = computed(() => detail.groups.value.map((g) => g?.name).filter const groupLabel = computed(() => groupNames.value.length ? groupNames.value.join(', ') : '—'); const groupCountLabel = computed(() => groupNames.value.length <= 1 ? 'Grupo' : 'Grupos'); +// ── Tab Agenda: bloco recorrencias do paciente ───────────── +const recorrenciasShowCanc = ref(false); +const recorrenciasVisiveis = computed(() => + recorrenciasShowCanc.value ? recorrenciasHook.rules.value : recorrenciasHook.ativas.value +); +async function onCancelRecurrence(rule) { + const result = await recorrenciasHook.cancel(rule.id); + if (result.ok) { + toast.add({ severity: 'success', summary: 'Recorrência cancelada', life: 2200 }); + } else { + toast.add({ + severity: 'error', + summary: 'Falha ao cancelar', + detail: result.error || 'Erro inesperado', + life: 4000 + }); + } +} +async function onReactivateRecurrence(rule) { + const result = await recorrenciasHook.reactivate(rule.id); + if (result.ok) { + toast.add({ severity: 'success', summary: 'Recorrência reativada', life: 2200 }); + } else { + toast.add({ + severity: 'error', + summary: 'Falha ao reativar', + detail: result.error || 'Erro inesperado', + life: 4000 + }); + } +} + // ── Tab Agenda: filtros + agrupamento por mes ────────────── const agendaSessoesFiltradas = computed(() => { const list = sessionsHook.sessions.value; @@ -538,8 +574,12 @@ async function salvarSessao() { life: 3000 }); novaSessaoOpen.value = false; - // Recarrega sessoes do paciente (caso start_date seja hoje). - await sessionsHook.load(props.patientId); + // Recarrega sessoes (caso start_date seja hoje) + recorrencias + // (a regra recem-criada precisa aparecer no bloco da Tab Agenda). + await Promise.all([ + sessionsHook.load(props.patientId), + recorrenciasHook.load(props.patientId) + ]); } catch (e) { toast.add({ severity: 'error', @@ -618,7 +658,8 @@ async function loadAll(id) { sessionsHook.load(id), financialHook.load(id), messagesHook.load(id), - documentsHook.load(id) + documentsHook.load(id), + recorrenciasHook.load(id) ]); } @@ -1654,8 +1695,111 @@ onBeforeUnmount(() => {
Sem futura
+ +
+ 05 +
+
+ Recorrências +
+
{{ recorrenciasHook.totalAtivas.value }}
+
+ + +
+
+ +
+
+
Recorrências
+
+ {{ recorrenciasVisiveis.length }} + +
+
+
    +
  • +
    + +
    +
    +
    + {{ fmtRecurrenceLabel(rule) }} + + +
    +
    + {{ rule.duration_min }}min + + + {{ rule.modalidade === 'online' ? 'Online' : 'Presencial' }} + + {{ fmtRecurrenceFim(rule) }} + + desde {{ fmtDateBR(rule.start_date) }} + +
    +

    + {{ rule.observacoes }} +

    +
    +
    + + +
    +
  • +
+
+