carousel, agenda arquivados, agenda cor, agenda arquivados, grupos pacientes, pacientes arquivados - desativados, sessoes verificadas, ajuste notificações, Prontuario, Agenda Animation, Menu Profile, bagdes Profile, Offline
This commit is contained in:
@@ -70,7 +70,7 @@ const calendarOptions = computed(() => {
|
||||
// Header desativado (você controla no Toolbar)
|
||||
headerToolbar: false,
|
||||
|
||||
// Visão “produto”: blocos com linhas suaves
|
||||
// Visão "produto": blocos com linhas suaves
|
||||
nowIndicator: true,
|
||||
allDaySlot: false,
|
||||
expandRows: true,
|
||||
@@ -93,7 +93,7 @@ const calendarOptions = computed(() => {
|
||||
hour12: false
|
||||
},
|
||||
|
||||
// Horário “verdadeiro” de funcionamento (se você usar)
|
||||
// Horário "verdadeiro" de funcionamento (se você usar)
|
||||
businessHours: props.businessHours,
|
||||
|
||||
// Dados
|
||||
@@ -183,7 +183,7 @@ onMounted(() => {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* Deixa o calendário “respirar” dentro de cards/layouts */
|
||||
/* Deixa o calendário "respirar" dentro de cards/layouts */
|
||||
:deep(.fc){
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
@@ -281,12 +281,13 @@ function buildFcOptions (ownerId) {
|
||||
eventResize: (info) => emit('eventResize', info),
|
||||
|
||||
eventContent: (arg) => {
|
||||
const ext = arg.event.extendedProps || {}
|
||||
const avatarUrl = ext.paciente_avatar || ''
|
||||
const nome = ext.paciente_nome || ''
|
||||
const obs = ext.observacoes || ''
|
||||
const title = arg.event.title || ''
|
||||
const timeText = arg.timeText || ''
|
||||
const ext = arg.event.extendedProps || {}
|
||||
const avatarUrl = ext.paciente_avatar || ''
|
||||
const nome = ext.paciente_nome || ''
|
||||
const obs = ext.observacoes || ''
|
||||
const title = arg.event.title || ''
|
||||
const timeText = arg.timeText || ''
|
||||
const pacienteStatus = ext.paciente_status || ''
|
||||
|
||||
const esc = (s) => String(s ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
@@ -305,8 +306,11 @@ function buildFcOptions (ownerId) {
|
||||
? `<div class="ev-avatar ev-avatar-initials">${esc(initials(nome))}</div>`
|
||||
: ''
|
||||
|
||||
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : ''
|
||||
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : ''
|
||||
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : ''
|
||||
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : ''
|
||||
const statusBadge = (pacienteStatus === 'Inativo' || pacienteStatus === 'Arquivado')
|
||||
? `<span style="display:inline-block;background:#f97316;color:#fff;font-size:9px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:1px 5px;border-radius:3px;line-height:1.4;margin-top:2px;">${pacienteStatus === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'}</span>`
|
||||
: ''
|
||||
|
||||
return {
|
||||
html: `<div class="ev-custom">
|
||||
@@ -314,10 +318,25 @@ function buildFcOptions (ownerId) {
|
||||
<div class="ev-body">
|
||||
${timeHtml}
|
||||
<div class="ev-title">${esc(title)}</div>
|
||||
${statusBadge}
|
||||
${obsHtml}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
},
|
||||
|
||||
eventClassNames: (arg) => {
|
||||
const classes = []
|
||||
if (arg?.event?.backgroundColor) classes.push('evt-has-color')
|
||||
return classes
|
||||
},
|
||||
|
||||
eventDidMount: (info) => {
|
||||
const bgColor = info.event.extendedProps?.commitment_bg_color
|
||||
if (bgColor) {
|
||||
info.el.style.setProperty('background-color', bgColor, 'important')
|
||||
info.el.style.setProperty('border-color', bgColor, 'important')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,21 @@
|
||||
Este dia é folga na sua jornada. Você ainda pode salvar se necessário.
|
||||
</Message>
|
||||
|
||||
<!-- ── Restrições de status do paciente ───────────── -->
|
||||
<Message v-if="isArchivedPastEdit" severity="warn" class="mb-3" :closable="false">
|
||||
<i class="pi pi-lock mr-1" />
|
||||
<b>Paciente arquivado.</b> O histórico de sessões é somente leitura.
|
||||
</Message>
|
||||
<Message v-if="isEdit && form.paciente_status === 'Inativo' && isSessionFuture" severity="warn" class="mb-3" :closable="false">
|
||||
<i class="pi pi-ban mr-1" />
|
||||
<b>Paciente inativo.</b> Remarcação de sessões está bloqueada.
|
||||
</Message>
|
||||
<Message v-if="!isEdit && isSessionEvent && form.paciente_id && !agendaPerms.canCreateSession" severity="error" class="mb-3" :closable="false">
|
||||
<i class="pi pi-ban mr-1" />
|
||||
<b>{{ form.paciente_status === 'Arquivado' ? 'Paciente arquivado.' : 'Paciente inativo.' }}</b>
|
||||
Novos agendamentos estão bloqueados.
|
||||
</Message>
|
||||
|
||||
<!-- ── Alerta: solicitação pendente neste horário ─── -->
|
||||
<Message v-if="solicitacaoPendente && isSessionEvent && !isEdit" severity="info" class="mb-3" :closable="false">
|
||||
<div class="flex items-center justify-between gap-3 w-full flex-wrap">
|
||||
@@ -182,7 +197,13 @@
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-bold text-base truncate">{{ form.paciente_nome }}</div>
|
||||
<div class="text-xs text-color-secondary">Paciente vinculado</div>
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class="text-xs text-color-secondary">Paciente vinculado</span>
|
||||
<span
|
||||
v-if="form.paciente_status === 'Inativo' || form.paciente_status === 'Arquivado'"
|
||||
style="display:inline-block;background:#f97316;color:#fff;font-size:9px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:1px 6px;border-radius:3px;line-height:1.5;"
|
||||
>{{ form.paciente_status === 'Arquivado' ? 'arquivado' : 'desativado' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1 shrink-0">
|
||||
<Button v-if="!patientLocked" icon="pi pi-pencil" severity="secondary" outlined size="small" class="rounded-full h-8 w-8" v-tooltip.top="'Trocar'" @click="openPacientePicker" />
|
||||
@@ -265,10 +286,12 @@
|
||||
<div class="field-card__body">
|
||||
<SelectButton
|
||||
v-model="form.status"
|
||||
:options="statusOptions"
|
||||
:options="statusOptionsFiltered"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
optionDisabled="disabled"
|
||||
:allowEmpty="false"
|
||||
:disabled="isArchivedPastEdit"
|
||||
class="w-full status-select-btn"
|
||||
/>
|
||||
</div>
|
||||
@@ -684,6 +707,7 @@
|
||||
variant="filled"
|
||||
rows="3"
|
||||
autoResize
|
||||
:disabled="isArchivedPastEdit"
|
||||
/>
|
||||
<label for="aed-observacoes-side">Observação</label>
|
||||
</FloatLabel>
|
||||
@@ -692,6 +716,11 @@
|
||||
<!-- Opção de recorrência para sessão SEM série (criação ou avulsa) -->
|
||||
<template v-if="!hasSerie">
|
||||
<div class="side-card__title mb-2">Frequência</div>
|
||||
<Message v-if="isSessionEvent && form.paciente_id && !agendaPerms.canCreateRecurrence" severity="warn" class="mb-3" :closable="false">
|
||||
<i class="pi pi-ban mr-1" />
|
||||
<b>{{ form.paciente_status === 'Arquivado' ? 'Paciente arquivado.' : 'Paciente inativo.' }}</b>
|
||||
Criação de recorrências está bloqueada.
|
||||
</Message>
|
||||
|
||||
<!-- Data de início (= form.dia) com botão Hoje -->
|
||||
<div class="rec-startdate-row mb-3">
|
||||
@@ -708,8 +737,12 @@
|
||||
v-for="f in freqOpcoes"
|
||||
:key="f.value"
|
||||
class="freq-chip"
|
||||
:class="{ 'freq-chip--active': recorrenciaType === f.value }"
|
||||
@click="recorrenciaType = f.value"
|
||||
:class="{
|
||||
'freq-chip--active': recorrenciaType === f.value,
|
||||
'opacity-40 cursor-not-allowed': f.value !== 'avulsa' && !agendaPerms.canCreateRecurrence
|
||||
}"
|
||||
:disabled="f.value !== 'avulsa' && !agendaPerms.canCreateRecurrence"
|
||||
@click="(!agendaPerms.canCreateRecurrence && f.value !== 'avulsa') ? null : (recorrenciaType = f.value)"
|
||||
>{{ f.label }}</button>
|
||||
</div>
|
||||
|
||||
@@ -1101,6 +1134,7 @@ import { useServices } from '@/features/agenda/composables/useServices'
|
||||
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices'
|
||||
import { usePatientDiscounts } from '@/features/agenda/composables/usePatientDiscounts'
|
||||
import { useInsurancePlans } from '@/features/agenda/composables/useInsurancePlans'
|
||||
import { getPatientAgendaPermissions } from '@/composables/usePatientLifecycle'
|
||||
|
||||
function patientInitials (nome) {
|
||||
const parts = String(nome || '').trim().split(/\s+/).filter(Boolean)
|
||||
@@ -1663,7 +1697,8 @@ const patients = ref([])
|
||||
|
||||
const filteredPatients = computed(() => {
|
||||
const q = String(pacienteSearch.value || '').trim().toLowerCase()
|
||||
const list = patients.value || []
|
||||
// Somente pacientes Ativos podem ser selecionados para novos agendamentos
|
||||
const list = (patients.value || []).filter(p => p.status === 'Ativo')
|
||||
if (!q) return list
|
||||
return list.filter(p => {
|
||||
const nome = String(p.nome || '').toLowerCase()
|
||||
@@ -2137,6 +2172,39 @@ const pillDeleteMenuItems = computed(() => {
|
||||
})
|
||||
|
||||
function isPast (iso) { return iso ? new Date(iso) < new Date() : false }
|
||||
|
||||
// ── Permissões de agenda por status do paciente ───────────────────────────
|
||||
const agendaPerms = computed(() => getPatientAgendaPermissions(form.value.paciente_status || ''))
|
||||
|
||||
// Sessão atual é futura? (para edição: usa inicio_em do evento original)
|
||||
const isSessionFuture = computed(() => {
|
||||
if (!isEdit.value) return true
|
||||
const iso = props.eventRow?.inicio_em
|
||||
return iso ? new Date(iso) > new Date() : true
|
||||
})
|
||||
|
||||
// Arquivado editando sessão passada → somente leitura
|
||||
const isArchivedPastEdit = computed(() =>
|
||||
isEdit.value &&
|
||||
form.value.paciente_status === 'Arquivado' &&
|
||||
!isSessionFuture.value
|
||||
)
|
||||
|
||||
// Inativo editando sessão futura → remarcar bloqueado
|
||||
const isInativoFutureEdit = computed(() =>
|
||||
isEdit.value &&
|
||||
form.value.paciente_status === 'Inativo' &&
|
||||
isSessionFuture.value
|
||||
)
|
||||
|
||||
// StatusOptions com remarcar desabilitado para Inativo
|
||||
const statusOptionsFiltered = computed(() => [
|
||||
{ label: 'Agendado', value: 'agendado' },
|
||||
{ label: 'Realizado', value: 'realizado' },
|
||||
{ label: 'Faltou', value: 'faltou' },
|
||||
{ label: 'Cancelado', value: 'cancelado' },
|
||||
{ label: 'Remarcar', value: 'remarcar', disabled: isInativoFutureEdit.value },
|
||||
])
|
||||
function fmtWeekdayShort (iso) { return new Date(iso).toLocaleDateString('pt-BR', { weekday: 'short' }).replace('.', '').slice(0, 3) }
|
||||
function fmtDayNum (iso) { return new Date(iso).getDate() }
|
||||
function fmtMonthShort (iso) { return new Date(iso).toLocaleDateString('pt-BR', { month: 'short' }).replace('.', '') }
|
||||
@@ -2411,6 +2479,18 @@ const canSave = computed(() => {
|
||||
if (!form.value.commitment_id) return false
|
||||
if (requiresPatient.value && !form.value.paciente_id) return false
|
||||
if (isSessionEvent.value && billingType.value === 'particular' && commitmentItems.value.length === 0) return false
|
||||
|
||||
// ── Restrições por status do paciente ────────────────────
|
||||
if (isSessionEvent.value && form.value.paciente_status) {
|
||||
const perms = agendaPerms.value
|
||||
// Criar sessão avulsa ou com recorrência: bloqueado para Inativo/Arquivado
|
||||
if (!isEdit.value && !perms.canCreateSession) return false
|
||||
// Criar recorrência: bloqueado para Inativo/Arquivado
|
||||
if (!isEdit.value && recorrenciaType.value !== 'avulsa' && !perms.canCreateRecurrence) return false
|
||||
// Arquivado tentando salvar sessão passada: bloqueado
|
||||
if (isArchivedPastEdit.value) return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -2615,9 +2695,10 @@ function resetForm () {
|
||||
id: r?.id || null,
|
||||
owner_id: r?.owner_id || props.ownerId || '',
|
||||
terapeuta_id: r?.terapeuta_id ?? null,
|
||||
paciente_id: r?.paciente_id ?? null,
|
||||
paciente_nome: r?.paciente_nome ?? r?.patient_name ?? '',
|
||||
paciente_id: r?.paciente_id ?? null,
|
||||
paciente_nome: r?.paciente_nome ?? r?.patient_name ?? '',
|
||||
paciente_avatar: r?.paciente_avatar ?? '',
|
||||
paciente_status: r?.paciente_status ?? '',
|
||||
commitment_id: r?.determined_commitment_id ?? null,
|
||||
titulo_custom: r?.titulo_custom || '',
|
||||
status: r?.status || 'agendado',
|
||||
|
||||
@@ -93,7 +93,7 @@ const emit = defineEmits(['refresh', 'collapse'])
|
||||
padding-right: .25rem;
|
||||
}
|
||||
|
||||
/* Melhor sensação de “sessões” */
|
||||
/* Melhor sensação de "sessões" */
|
||||
.slot-top, .slot-bottom{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -113,7 +113,7 @@ const attentionSeverity = computed(() => {
|
||||
<!-- Ações rápidas -->
|
||||
<div class="flex align-items-center justify-content-between gap-2 flex-wrap">
|
||||
<div class="text-xs" style="color: var(--text-color-secondary);">
|
||||
Ações rápidas (criação sempre abre modal — nada nasce “direto”).
|
||||
Ações rápidas (criação sempre abre modal — nada nasce "direto").
|
||||
</div>
|
||||
|
||||
<div class="flex align-items-center gap-2">
|
||||
|
||||
@@ -35,7 +35,7 @@ const BASE_SELECT = `
|
||||
mirror_of_event_id, price,
|
||||
insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id,
|
||||
patients!agenda_eventos_patient_id_fkey (
|
||||
id, nome_completo, avatar_url
|
||||
id, nome_completo, avatar_url, status
|
||||
),
|
||||
determined_commitments!agenda_eventos_determined_commitment_fk (
|
||||
id, bg_color, text_color
|
||||
@@ -183,5 +183,6 @@ function flattenRow (r) {
|
||||
delete out.patients
|
||||
out.paciente_nome = patient?.nome_completo || out.paciente_nome || ''
|
||||
out.paciente_avatar = patient?.avatar_url || out.paciente_avatar || ''
|
||||
out.paciente_status = patient?.status || out.paciente_status || ''
|
||||
return out
|
||||
}
|
||||
@@ -164,6 +164,10 @@
|
||||
@click="gotoResult(r)"
|
||||
>
|
||||
<div class="font-medium truncate">{{ r.titulo || 'Sem título' }}</div>
|
||||
<span
|
||||
v-if="r.patients?.status === 'Inativo' || r.patients?.status === 'Arquivado'"
|
||||
class="inline-block text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide mt-0.5"
|
||||
>{{ r.patients?.status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado' }}</span>
|
||||
|
||||
<div class="mt-1 flex items-center justify-between gap-2 text-xs opacity-70">
|
||||
<span class="truncate">{{ fmtDateTime(r.inicio_em) }}</span>
|
||||
@@ -304,6 +308,10 @@
|
||||
<div class="font-semibold text-sm truncate">
|
||||
{{ r.paciente_nome || r.patient_name || r.titulo || 'Sem título' }}
|
||||
</div>
|
||||
<span
|
||||
v-if="r.paciente_status === 'Inativo' || r.paciente_status === 'Arquivado'"
|
||||
class="inline-block text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide mt-0.5"
|
||||
>{{ r.paciente_status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado' }}</span>
|
||||
|
||||
<!-- Linha 3: título (se paciente diferente de título) -->
|
||||
<div
|
||||
@@ -623,7 +631,18 @@ const timeModeOptions = [
|
||||
{ label: 'Meu Horário', value: 'my' }
|
||||
]
|
||||
|
||||
const mosaicMode = computed(() => timeMode.value === '24' ? 'full_24h' : 'work_hours')
|
||||
const mosaicMode = ref('work_hours')
|
||||
|
||||
const mosaicModeOptions = [
|
||||
{ label: 'Horas de Trabalho', value: 'work_hours' },
|
||||
{ label: 'Grade Completa', value: 'full_24h' }
|
||||
]
|
||||
|
||||
// Sincroniza mosaicMode com timeMode: '24h' força grade completa
|
||||
watch(timeMode, (v) => {
|
||||
if (v === '24') mosaicMode.value = 'full_24h'
|
||||
else if (mosaicMode.value === 'full_24h') mosaicMode.value = 'work_hours'
|
||||
})
|
||||
|
||||
function settingsFallbackStart () {
|
||||
const s = settings.value
|
||||
@@ -936,15 +955,26 @@ const baseRows = computed(() => {
|
||||
})
|
||||
|
||||
if (!onlySessions.value) return refined
|
||||
return refined.filter(r => {
|
||||
const tipo = normalizeEventoTipo(r.tipo, EVENTO_TIPO.SESSAO)
|
||||
return tipo === EVENTO_TIPO.SESSAO || r.masked === true
|
||||
})
|
||||
// Filtrar por patient_id — filtro por tipo não funciona pois o enum do banco
|
||||
// usa 'sessao' para todos os compromissos não-bloqueio (Análise, Leitura, etc.)
|
||||
return refined.filter(r => !!(r.patient_id || r.masked))
|
||||
})
|
||||
|
||||
const allEvents = computed(() => {
|
||||
// Mapa id → cores para injetar em ocorrências virtuais (que não têm o join determined_commitments)
|
||||
const colorMap = new Map(
|
||||
(commitmentOptionsNormalized.value || [])
|
||||
.filter(c => c.id)
|
||||
.map(c => [c.id, { bg_color: c.bg_color || null, text_color: c.text_color || null }])
|
||||
)
|
||||
function withCommitmentColors (r) {
|
||||
if (r.determined_commitments || !r.determined_commitment_id) return r
|
||||
const colors = colorMap.get(r.determined_commitment_id)
|
||||
return colors ? { ...r, determined_commitments: colors } : r
|
||||
}
|
||||
|
||||
// eventos reais (sem ocorrências virtuais para evitar duplicatas)
|
||||
const realRows = (baseRows.value || []).filter(r => !r.is_occurrence)
|
||||
const realRows = (baseRows.value || []).filter(r => !r.is_occurrence).map(withCommitmentColors)
|
||||
const base = mapAgendaEventosToCalendarEvents(realRows)
|
||||
|
||||
// ocorrências virtuais das séries
|
||||
@@ -953,8 +983,8 @@ const allEvents = computed(() => {
|
||||
const tipo = normalizeEventoTipo(r.tipo, EVENTO_TIPO.SESSAO)
|
||||
const dc = r.determined_commitment_id
|
||||
if (tipo === EVENTO_TIPO.SESSAO && dc && !isSessionCommitmentId(dc)) return maskPrivateRow(r)
|
||||
if (onlySessions.value && tipo !== EVENTO_TIPO.SESSAO) return null
|
||||
return r
|
||||
if (onlySessions.value && !(r.patient_id || r.masked)) return null
|
||||
return withCommitmentColors(r)
|
||||
}).filter(Boolean)
|
||||
const occEvents = mapAgendaEventosToCalendarEvents(occRows)
|
||||
|
||||
@@ -2059,7 +2089,7 @@ async function _reloadRange () {
|
||||
// Expande recorrências para cada terapeuta no range
|
||||
const allMerged = []
|
||||
for (const ownId of ownerIds.value) {
|
||||
const merged = await loadAndExpand(ownId, start, end, rows.value.filter(r => r.owner_id === ownId))
|
||||
const merged = await loadAndExpand(ownId, start, end, rows.value.filter(r => r.owner_id === ownId), tenantId.value)
|
||||
allMerged.push(...merged.filter(r => r.is_occurrence))
|
||||
}
|
||||
_occurrenceRows.value = allMerged
|
||||
@@ -2536,7 +2566,7 @@ function goRecorrencias () { router.push({ name: 'admin-agenda-recorrencias' })
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: greenyellow;
|
||||
background: var(--primary-color, #6366f1);
|
||||
}
|
||||
|
||||
/* Badge numérico no header */
|
||||
|
||||
@@ -91,10 +91,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Aviso: fora da jornada -->
|
||||
<div v-if="hasEventsOutsideWorkHours" class="mx-3 md:mx-4 mb-3 rounded-[6px] p-3" style="background:color-mix(in srgb,var(--yellow-400,#facc15) 10%,var(--surface-card));border:1px solid color-mix(in srgb,var(--yellow-400,#facc15) 35%,transparent);">
|
||||
<div ref="foraJornadaBannerRef" v-if="hasEventsOutsideWorkHours" class="mx-3 md:mx-4 mb-3 rounded-[6px] p-3" style="background:color-mix(in srgb,var(--yellow-400,#facc15) 10%,var(--surface-card));border:1px solid color-mix(in srgb,var(--yellow-400,#facc15) 35%,transparent);">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="pi pi-exclamation-triangle shrink-0" style="color:var(--yellow-600,#ca8a04);" />
|
||||
<div class="font-semibold text-sm flex-1">Compromissos fora da jornada</div>
|
||||
<div class="font-semibold text-sm">Compromissos fora da jornada</div>
|
||||
<Button label="Ver 24h" size="small" severity="secondary" outlined class="rounded-full shrink-0" @click="timeMode = '24'" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,12 +185,21 @@
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="block text-[1rem] font-semibold truncate">{{ ev.paciente_nome || ev.patient_name || ev.titulo || '—' }}</span>
|
||||
<div class="flex items-center gap-1 mt-0.5">
|
||||
<div class="flex items-center gap-1 mt-0.5 flex-wrap">
|
||||
<span class="text-[0.65rem] bg-[var(--surface-border)] text-[var(--text-color-secondary)] px-1.5 py-px rounded font-semibold">{{ ev.modalidade || 'Presencial' }}</span>
|
||||
<span v-if="ev.recurrence_id" class="text-[1rem] text-[var(--primary-color,#6366f1)]">↻</span>
|
||||
<span v-if="ev.paciente_status === 'Inativo' || ev.paciente_status === 'Arquivado'" class="text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide">{{ ev.paciente_status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<i class="text-xs text-[var(--text-color-secondary)] flex-shrink-0" :class="statusIcon(ev.status)" />
|
||||
<button
|
||||
v-if="ev.patient_id || ev.paciente_id"
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center border-none bg-transparent text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--primary-color,#6366f1)] transition-colors duration-100 cursor-pointer flex-shrink-0"
|
||||
title="Opções"
|
||||
@click.stop="openTodayEvMenu($event, ev)"
|
||||
>
|
||||
<i class="pi pi-ellipsis-v text-[0.7rem]" />
|
||||
</button>
|
||||
<i v-else class="text-xs text-[var(--text-color-secondary)] flex-shrink-0" :class="statusIcon(ev.status)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,6 +208,25 @@
|
||||
<Button label="Novo Compromisso" icon="pi pi-plus" class="w-full rounded-full" @click="onCreateFromButton(); agPanelOpen = false" />
|
||||
</div>
|
||||
|
||||
<!-- Card: Pacientes Desativados/Arquivados com sessões pendentes -->
|
||||
<div
|
||||
v-if="desativadoPatients.length"
|
||||
class="border border-orange-500/40 rounded-md bg-orange-500/5 p-3 cursor-pointer hover:bg-orange-500/10 transition-colors duration-150"
|
||||
@click="desativadoDialogOpen = true"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<i class="pi pi-exclamation-triangle text-orange-500 text-sm" />
|
||||
<span class="text-[0.72rem] font-bold text-orange-600 uppercase tracking-wide flex-1">Atenção: sessões pendentes</span>
|
||||
<span class="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded-full bg-orange-500 text-white text-[0.62rem] font-bold">
|
||||
{{ desativadoPatients.reduce((s, p) => s + p.sessions.length, 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[0.7rem] text-orange-700/80 leading-snug m-0">
|
||||
{{ desativadoPatients.length }} paciente{{ desativadoPatients.length > 1 ? 's' : '' }}
|
||||
desativado{{ desativadoPatients.length > 1 ? 's' : '' }} ou arquivado{{ desativadoPatients.length > 1 ? 's' : '' }}
|
||||
com sessões agendadas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Pacientes -->
|
||||
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
|
||||
@@ -406,12 +434,21 @@
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="block text-[1rem] font-semibold truncate">{{ ev.paciente_nome || ev.patient_name || ev.titulo || '—' }}</span>
|
||||
<div class="flex items-center gap-1 mt-0.5">
|
||||
<div class="flex items-center gap-1 mt-0.5 flex-wrap">
|
||||
<span class="text-[0.65rem] bg-[var(--surface-border)] text-[var(--text-color-secondary)] px-1.5 py-px rounded font-semibold">{{ ev.modalidade || 'Presencial' }}</span>
|
||||
<span v-if="ev.recurrence_id" class="text-[1rem] text-[var(--primary-color,#6366f1)]" title="Recorrente">↻</span>
|
||||
<span v-if="ev.paciente_status === 'Inativo' || ev.paciente_status === 'Arquivado'" class="text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide">{{ ev.paciente_status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<i class="text-xs text-[var(--text-color-secondary)] flex-shrink-0" :class="statusIcon(ev.status)" />
|
||||
<button
|
||||
v-if="ev.patient_id || ev.paciente_id"
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center border-none bg-transparent text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--primary-color,#6366f1)] transition-colors duration-100 cursor-pointer flex-shrink-0"
|
||||
title="Opções"
|
||||
@click.stop="openTodayEvMenu($event, ev)"
|
||||
>
|
||||
<i class="pi pi-ellipsis-v text-[0.7rem]" />
|
||||
</button>
|
||||
<i v-else class="text-xs text-[var(--text-color-secondary)] flex-shrink-0" :class="statusIcon(ev.status)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -420,6 +457,25 @@
|
||||
<Button label="Novo Compromisso" icon="pi pi-plus" class="w-full rounded-full" @click="onCreateFromButton" />
|
||||
</div>
|
||||
|
||||
<!-- Card: Pacientes Desativados/Arquivados com sessões pendentes -->
|
||||
<div
|
||||
v-if="desativadoPatients.length"
|
||||
class="border border-orange-500/40 rounded-md bg-orange-500/5 p-3 cursor-pointer hover:bg-orange-500/10 transition-colors duration-150"
|
||||
@click="desativadoDialogOpen = true"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<i class="pi pi-exclamation-triangle text-orange-500 text-sm" />
|
||||
<span class="text-[0.72rem] font-bold text-orange-600 uppercase tracking-wide flex-1">Atenção: sessões pendentes</span>
|
||||
<span class="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded-full bg-orange-500 text-white text-[0.62rem] font-bold">
|
||||
{{ desativadoPatients.reduce((s, p) => s + p.sessions.length, 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[0.7rem] text-orange-700/80 leading-snug m-0">
|
||||
{{ desativadoPatients.length }} paciente{{ desativadoPatients.length > 1 ? 's' : '' }}
|
||||
desativado{{ desativadoPatients.length > 1 ? 's' : '' }} ou arquivado{{ desativadoPatients.length > 1 ? 's' : '' }}
|
||||
com sessões agendadas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Pacientes -->
|
||||
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
|
||||
@@ -549,7 +605,7 @@
|
||||
|
||||
<!-- Sem resultados -->
|
||||
<div v-else-if="searchResults.length === 0" class="text-color-secondary text-sm">
|
||||
Nenhum resultado para “<b>{{ searchTrim }}</b>"
|
||||
Nenhum resultado para "<b>{{ searchTrim }}</b>"
|
||||
<span class="text-xs"> ({{ searchScope === 'month' ? 'mês inteiro' : 'período atual' }})</span>.
|
||||
</div>
|
||||
|
||||
@@ -579,6 +635,7 @@
|
||||
<div class="font-semibold text-sm truncate">
|
||||
{{ r.paciente_nome || r.patient_name || r.titulo || 'Sem título' }}
|
||||
</div>
|
||||
<span v-if="r.paciente_status === 'Inativo' || r.paciente_status === 'Arquivado'" class="inline-block text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide mt-0.5">{{ r.paciente_status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado' }}</span>
|
||||
|
||||
<!-- Linha 3: título (se paciente diferente de título) -->
|
||||
<div
|
||||
@@ -737,6 +794,140 @@
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
Dialog: Sessões Pendentes de Pacientes Desativados/Arquivados
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
<Dialog
|
||||
v-model:visible="desativadoDialogOpen"
|
||||
modal
|
||||
:draggable="false"
|
||||
:style="{ width: '1100px', maxWidth: '97vw', height: '85vh' }"
|
||||
:pt="{ content: { style: 'padding:0; display:flex; flex-direction:column; height:100%; overflow:hidden;' }, header: { style: 'padding: 1rem 1.25rem 0.75rem' } }"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<i class="pi pi-exclamation-triangle text-orange-500" />
|
||||
<span class="font-semibold text-base">Sessões agendadas — pacientes desativados</span>
|
||||
<span class="ml-1 inline-flex items-center justify-center min-w-[22px] h-[22px] px-1 rounded-full bg-orange-500 text-white text-xs font-bold">
|
||||
{{ desativadoPatients.reduce((s, p) => s + p.sessions.length, 0) }}
|
||||
</span>
|
||||
<!-- Tabs pacientes -->
|
||||
<div class="flex gap-1 ml-auto flex-wrap">
|
||||
<button
|
||||
v-for="p in desativadoPatients"
|
||||
:key="p.id"
|
||||
class="px-3 py-1 rounded-full text-xs font-semibold border transition-all duration-150"
|
||||
:class="desativadoSelected?.id === p.id
|
||||
? 'bg-orange-500 text-white border-orange-500'
|
||||
: 'bg-transparent text-orange-600 border-orange-500/40 hover:bg-orange-500/10'"
|
||||
@click="desativadoSelected = p"
|
||||
>
|
||||
{{ (p.nome_completo || '—').split(' ')[0] }}
|
||||
<span class="ml-1 opacity-70">({{ p.sessions.length }})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Body: split panel -->
|
||||
<div v-if="desativadoSelected" class="flex flex-col lg:flex-row flex-1 min-h-0 overflow-hidden">
|
||||
|
||||
<!-- Sidebar esquerda: lista de sessões -->
|
||||
<div class="w-full lg:w-[340px] lg:flex-shrink-0 flex flex-col border-b lg:border-b-0 lg:border-r border-[var(--surface-border)]">
|
||||
|
||||
<!-- Patient info -->
|
||||
<div class="px-4 py-3 border-b border-[var(--surface-border)] bg-orange-500/5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm flex-shrink-0"
|
||||
style="background: #f97316;">
|
||||
{{ (desativadoSelected.nome_completo || '?').charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-sm truncate">{{ desativadoSelected.nome_completo }}</div>
|
||||
<span style="display:inline-block;background:#f97316;color:#fff;font-size:9px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:1px 6px;border-radius:3px;line-height:1.5;">
|
||||
{{ desativadoSelected.status === 'Arquivado' ? 'arquivado' : 'desativado' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ml-auto text-right flex-shrink-0">
|
||||
<div class="text-lg font-bold text-orange-500">{{ desativadoSelected.sessions.length }}</div>
|
||||
<div class="text-[0.65rem] text-[var(--text-color-secondary)]">sessão(ões)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sessions list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="p-3 flex flex-col gap-2">
|
||||
<div
|
||||
v-for="s in desativadoSelected.sessions"
|
||||
:key="s.id"
|
||||
class="rounded-lg border p-2.5 cursor-pointer transition-all duration-150 group"
|
||||
:class="desativadoFocused?.id === s.id
|
||||
? 'border-orange-500 bg-orange-500/8 shadow-sm'
|
||||
: 'border-[var(--surface-border)] bg-[var(--surface-card)] hover:border-orange-500/40 hover:bg-orange-500/5'"
|
||||
@click="focusDesativadoSession(s)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-bold text-[var(--text-color)]">
|
||||
{{ fmtDesativadoDate(s.inicio_em) }}
|
||||
</div>
|
||||
<div class="text-[0.7rem] text-[var(--text-color-secondary)]">
|
||||
{{ fmtDesativadoTime(s.inicio_em) }} · {{ fmtDesativadoDur(s.inicio_em, s.fim_em) }}
|
||||
</div>
|
||||
<div v-if="s.titulo" class="text-[0.7rem] text-[var(--text-color-secondary)] truncate mt-0.5">{{ s.titulo }}</div>
|
||||
</div>
|
||||
<Tag :value="s.modalidade || 'Presencial'" severity="secondary" class="text-[0.6rem] shrink-0" />
|
||||
</div>
|
||||
<div class="flex gap-1.5 mt-2">
|
||||
<Button
|
||||
label="Ver na agenda"
|
||||
icon="pi pi-external-link"
|
||||
size="small"
|
||||
severity="warn"
|
||||
outlined
|
||||
class="flex-1 text-[0.65rem]"
|
||||
@click.stop="openSessionInMainCalendar(s)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Painel direito: mini FullCalendar -->
|
||||
<div class="flex-1 min-w-0 min-h-[300px] lg:min-h-0 overflow-hidden flex flex-col">
|
||||
<div class="px-4 py-2 border-b border-[var(--surface-border)] flex items-center gap-2">
|
||||
<i class="pi pi-calendar text-[var(--text-color-secondary)] text-xs" />
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">Clique em uma sessão para abrir na agenda principal e cancelar ou remarcar</span>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-2">
|
||||
<FullCalendar
|
||||
v-if="desativadoDialogOpen && desativadoSelected"
|
||||
ref="desativadoFcRef"
|
||||
:options="desativadoFcOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Fechar" severity="secondary" outlined @click="desativadoDialogOpen = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Menu contexto: Sessões Hoje -->
|
||||
<Menu ref="todayEvMenuRef" :model="todayEvMenuItems" :popup="true" />
|
||||
|
||||
<!-- Dialog: Prontuário -->
|
||||
<PatientProntuario
|
||||
:key="selectedPatient?.id || 'none'"
|
||||
v-model="prontuarioOpen"
|
||||
:patient="selectedPatient"
|
||||
@close="closeProntuario"
|
||||
/>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -753,11 +944,13 @@ import Calendar from 'primevue/calendar'
|
||||
import FullCalendar from '@fullcalendar/vue3'
|
||||
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||
import listPlugin from '@fullcalendar/list'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
import ptBrLocale from '@fullcalendar/core/locales/pt-br'
|
||||
|
||||
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue'
|
||||
import BloqueioDialog from '@/features/agenda/components/BloqueioDialog.vue'
|
||||
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
|
||||
import ProximosFeriadosCard from '@/features/agenda/components/ProximosFeriadosCard.vue'
|
||||
|
||||
import { useSupportDebugStore } from '@/support/supportDebugStore'
|
||||
@@ -810,7 +1003,7 @@ const {
|
||||
const commitmentOptionsNormalized = computed(() => {
|
||||
const list = Array.isArray(determinedCommitments.value) ? determinedCommitments.value : []
|
||||
|
||||
// prioridade pra “Sessão" primeiro (native_key = session)
|
||||
// prioridade pra "Sessão" primeiro (native_key = session)
|
||||
const priority = new Map([
|
||||
['session', 0],
|
||||
['class', 1],
|
||||
@@ -876,13 +1069,16 @@ onMounted(async () => {
|
||||
if (tid) loadFeriados(tid)
|
||||
})
|
||||
|
||||
// Carrega desativados assim que ownerId estiver disponível
|
||||
watch(ownerId, (id) => { if (id) loadDesativados() }, { immediate: true })
|
||||
|
||||
// Range atual
|
||||
const currentRange = ref({ start: null, end: null })
|
||||
|
||||
// -----------------------------
|
||||
// Topbar state
|
||||
// -----------------------------
|
||||
const onlySessions = ref(true)
|
||||
const onlySessions = ref(false)
|
||||
const calendarView = ref('day') // day | week | month
|
||||
const timeMode = ref('my') // 24 | 12 | my
|
||||
const search = ref('')
|
||||
@@ -954,6 +1150,52 @@ const headerMenuRef = ref(null)
|
||||
const agPanelOpen = ref(false)
|
||||
const blockMenuRef = ref(null)
|
||||
|
||||
// ── Prontuário ────────────────────────────────────────────────
|
||||
const prontuarioOpen = ref(false)
|
||||
const selectedPatient = ref(null)
|
||||
|
||||
function openProntuario (patientId, patientNome) {
|
||||
if (!patientId) return
|
||||
selectedPatient.value = { id: patientId, nome_completo: patientNome || '' }
|
||||
prontuarioOpen.value = true
|
||||
}
|
||||
function closeProntuario () { prontuarioOpen.value = false; selectedPatient.value = null }
|
||||
|
||||
// ── Menu de contexto: Sessões Hoje ────────────────────────────
|
||||
const todayEvMenuRef = ref(null)
|
||||
const _todayEvAtivo = ref(null)
|
||||
|
||||
const todayEvMenuItems = computed(() => [
|
||||
{
|
||||
label: 'Opções',
|
||||
items: [
|
||||
{
|
||||
label: 'Ver prontuário',
|
||||
icon: 'pi pi-file-edit',
|
||||
disabled: !(_todayEvAtivo.value?.patient_id || _todayEvAtivo.value?.paciente_id),
|
||||
command: () => {
|
||||
const id = _todayEvAtivo.value?.patient_id || _todayEvAtivo.value?.paciente_id
|
||||
const nome = _todayEvAtivo.value?.paciente_nome || _todayEvAtivo.value?.patient_name || ''
|
||||
openProntuario(id, nome)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Abrir na agenda',
|
||||
icon: 'pi pi-calendar',
|
||||
command: () => {
|
||||
if (_todayEvAtivo.value) onEventRowClick(_todayEvAtivo.value)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
function openTodayEvMenu (event, ev) {
|
||||
event.stopPropagation()
|
||||
_todayEvAtivo.value = ev
|
||||
todayEvMenuRef.value?.toggle(event)
|
||||
}
|
||||
|
||||
// Bloqueio dialog
|
||||
const bloqueioDialogOpen = ref(false)
|
||||
const bloqueioMode = ref('horario')
|
||||
@@ -1098,8 +1340,10 @@ const allRows = computed(() => [
|
||||
|
||||
const calendarRows = computed(() => {
|
||||
return allRows.value.filter(r => {
|
||||
const tipo = normalizeEventoTipo(r.tipo, EVENTO_TIPO.SESSAO)
|
||||
if (onlySessions.value && tipo !== EVENTO_TIPO.SESSAO) return false
|
||||
// "Apenas Sessões" = eventos vinculados a paciente.
|
||||
// Filtrar por tipo não funciona pois o banco usa 'sessao' para todos os compromissos
|
||||
// que não são bloqueio — compromissos pessoais (Análise, Leitura, etc.) têm o mesmo tipo.
|
||||
if (onlySessions.value && !(r.patient_id || r.paciente_id)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
@@ -1172,7 +1416,8 @@ const searchResults = computed(() => {
|
||||
? monthSearchRows.value
|
||||
: (calendarRows.value || []).map(r => ({
|
||||
...r,
|
||||
paciente_nome: r.paciente_nome || r.patient_name || r.nome_paciente || ''
|
||||
paciente_nome: r.paciente_nome || r.patient_name || r.nome_paciente || '',
|
||||
paciente_status: r.paciente_status || r.extendedProps?.paciente_status || ''
|
||||
}))
|
||||
return source.filter(r => _matchRow(r, q))
|
||||
})
|
||||
@@ -1191,7 +1436,7 @@ async function loadMonthSearchRows () {
|
||||
// mergeWithStoredSessions deduplicar sessões materializadas de séries.
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id, owner_id, tipo, status, titulo, inicio_em, fim_em, observacoes, modalidade, determined_commitment_id, insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id, recurrence_id, recurrence_date, patients!agenda_eventos_patient_id_fkey(nome_completo)')
|
||||
.select('id, owner_id, tipo, status, titulo, inicio_em, fim_em, observacoes, modalidade, determined_commitment_id, insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id, recurrence_id, recurrence_date, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
|
||||
.eq('owner_id', uid)
|
||||
.is('mirror_of_event_id', null)
|
||||
.gte('inicio_em', startISO)
|
||||
@@ -1199,7 +1444,7 @@ async function loadMonthSearchRows () {
|
||||
.order('inicio_em', { ascending: true })
|
||||
if (error) throw error
|
||||
|
||||
const realRows = (data || []).map(r => ({ ...r, paciente_nome: r.patients?.nome_completo || '' }))
|
||||
const realRows = (data || []).map(r => ({ ...r, paciente_nome: r.patients?.nome_completo || '', paciente_status: r.patients?.status || '' }))
|
||||
|
||||
// 2. Ocorrências virtuais de recorrência (não existem em agenda_eventos).
|
||||
// loadAndExpand retorna merged = reais + virtuais; filtramos só is_occurrence
|
||||
@@ -1230,10 +1475,27 @@ watch(currentDate, (newD, oldD) => {
|
||||
})
|
||||
|
||||
const calendarEvents = computed(() => {
|
||||
// Mapa id → {bg_color, text_color} dos commitments já carregados na página.
|
||||
// Usado para injetar cores nas ocorrências virtuais, que não têm o join
|
||||
// determined_commitments (useRecurrence faz select('*') sem join).
|
||||
const colorMap = new Map(
|
||||
(commitmentOptionsNormalized.value || [])
|
||||
.filter(c => c.id)
|
||||
.map(c => [c.id, { bg_color: c.bg_color || null, text_color: c.text_color || null }])
|
||||
)
|
||||
|
||||
// Injeta determined_commitments em qualquer row que ainda não tenha — resolve
|
||||
// tanto ocorrências virtuais quanto eventos reais com join nulo.
|
||||
function withCommitmentColors (r) {
|
||||
if (r.determined_commitments || !r.determined_commitment_id) return r
|
||||
const colors = colorMap.get(r.determined_commitment_id)
|
||||
return colors ? { ...r, determined_commitments: colors } : r
|
||||
}
|
||||
|
||||
// separa reais e virtuais para aplicar mapAgendaEventosToCalendarEvents
|
||||
// em cada grupo — as virtuais precisam do mesmo tratamento de cores
|
||||
const realRows = calendarRows.value.filter(r => !r.is_occurrence)
|
||||
const occRows = calendarRows.value.filter(r => r.is_occurrence)
|
||||
const realRows = calendarRows.value.filter(r => !r.is_occurrence).map(withCommitmentColors)
|
||||
const occRows = calendarRows.value.filter(r => r.is_occurrence).map(withCommitmentColors)
|
||||
|
||||
const base = mapAgendaEventosToCalendarEvents(realRows)
|
||||
const occEvents = mapAgendaEventosToCalendarEvents(occRows)
|
||||
@@ -1387,11 +1649,12 @@ const fcOptions = computed(() => ({
|
||||
|
||||
eventContent: (arg) => {
|
||||
const ext = arg.event.extendedProps || {}
|
||||
const avatarUrl = ext.paciente_avatar || ''
|
||||
const nome = ext.paciente_nome || ''
|
||||
const obs = ext.observacoes || ''
|
||||
const title = arg.event.title || ''
|
||||
const timeText = arg.timeText || ''
|
||||
const avatarUrl = ext.paciente_avatar || ''
|
||||
const nome = ext.paciente_nome || ''
|
||||
const obs = ext.observacoes || ''
|
||||
const title = arg.event.title || ''
|
||||
const timeText = arg.timeText || ''
|
||||
const pacienteStatus = ext.paciente_status || ''
|
||||
|
||||
const esc = (s) => String(s ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
@@ -1410,8 +1673,11 @@ const fcOptions = computed(() => ({
|
||||
? `<div class="ev-avatar ev-avatar-initials">${esc(initials(nome))}</div>`
|
||||
: ''
|
||||
|
||||
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : ''
|
||||
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : ''
|
||||
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : ''
|
||||
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : ''
|
||||
const inativoBadge = (pacienteStatus === 'Inativo' || pacienteStatus === 'Arquivado')
|
||||
? `<span style="display:inline-block;background:#f97316;color:#fff;font-size:9px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:1px 5px;border-radius:3px;line-height:1.4;margin-top:2px;">${pacienteStatus === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'}</span>`
|
||||
: ''
|
||||
|
||||
return {
|
||||
html: `<div class="ev-custom">
|
||||
@@ -1419,6 +1685,7 @@ const fcOptions = computed(() => ({
|
||||
<div class="ev-body">
|
||||
${timeHtml}
|
||||
<div class="ev-title">${esc(title)}</div>
|
||||
${inativoBadge}
|
||||
${obsHtml}
|
||||
</div>
|
||||
</div>`
|
||||
@@ -1442,12 +1709,47 @@ const fcOptions = computed(() => ({
|
||||
const classes = []
|
||||
if (tipo === EVENTO_TIPO.SESSAO) classes.push('evt-session')
|
||||
if (tipo === EVENTO_TIPO.BLOQUEIO) classes.push('evt-block')
|
||||
// Quando o evento já tem cor do commitment, marca para que o CSS
|
||||
// não sobrescreva com a cor primária padrão via !important
|
||||
if (arg?.event?.backgroundColor) classes.push('evt-has-color')
|
||||
if (qn && hit) classes.push('evt-hit')
|
||||
if (qn && !hit) classes.push('evt-dim')
|
||||
return classes
|
||||
},
|
||||
|
||||
eventDidMount: (info) => {
|
||||
const bgColor = info.event.extendedProps?.commitment_bg_color
|
||||
if (bgColor) {
|
||||
info.el.style.setProperty('background-color', bgColor, 'important')
|
||||
info.el.style.setProperty('border-color', bgColor, 'important')
|
||||
}
|
||||
// Marca o elemento com o id do evento para scroll+pulse posterior
|
||||
info.el.dataset.eventId = info.event.id
|
||||
}
|
||||
}))
|
||||
|
||||
// ── Scroll + pulse no evento do FullCalendar ─────────────────
|
||||
const foraJornadaBannerRef = ref(null)
|
||||
|
||||
async function scrollToAndPulseEvent (eventId) {
|
||||
await nextTick()
|
||||
// Aguarda o FC renderizar após possível mudança de view/data
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
|
||||
// Pulsa o banner "fora da jornada" se estiver visível
|
||||
const banner = foraJornadaBannerRef.value
|
||||
if (banner) {
|
||||
banner.classList.add('notif-card--highlight')
|
||||
setTimeout(() => banner.classList.remove('notif-card--highlight'), 2000)
|
||||
}
|
||||
|
||||
const el = document.querySelector(`[data-event-id="${eventId}"]`)
|
||||
if (!el) return
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
el.classList.add('notif-card--highlight')
|
||||
setTimeout(() => el.classList.remove('notif-card--highlight'), 2000)
|
||||
}
|
||||
|
||||
|
||||
// ── Resumo do dia (coluna direita) ────────────────────────────────────────────
|
||||
const todayEvents = computed(() => {
|
||||
@@ -1508,6 +1810,7 @@ function onEventRowClick (ev) {
|
||||
if (ev.inicio_em) {
|
||||
getApi()?.gotoDate?.(new Date(ev.inicio_em))
|
||||
calendarView.value = 'day'
|
||||
scrollToAndPulseEvent(ev.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1569,6 +1872,119 @@ function getApi () {
|
||||
return fcRef.value?.getApi?.() || null
|
||||
}
|
||||
|
||||
// ── Pacientes Desativados com sessões pendentes ────────────
|
||||
const desativadoPatients = ref([])
|
||||
const desativadoDialogOpen = ref(false)
|
||||
const desativadoSelected = ref(null)
|
||||
const desativadoFocused = ref(null)
|
||||
const desativadoFcRef = ref(null)
|
||||
|
||||
async function loadDesativados () {
|
||||
if (!ownerId.value) return
|
||||
try {
|
||||
const { data: pats, error: pErr } = await supabase
|
||||
.from('patients')
|
||||
.select('id, nome_completo, status')
|
||||
.eq('owner_id', ownerId.value)
|
||||
.in('status', ['Inativo', 'Arquivado'])
|
||||
|
||||
if (pErr) { console.warn('[loadDesativados] patients error:', pErr); desativadoPatients.value = []; return }
|
||||
if (!pats?.length) { desativadoPatients.value = []; return }
|
||||
|
||||
const patIds = pats.map(p => p.id)
|
||||
const sessQ = supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id, patient_id, inicio_em, fim_em, status, titulo, modalidade, determined_commitment_id')
|
||||
.in('patient_id', patIds)
|
||||
.order('inicio_em', { ascending: true })
|
||||
if (ownerId.value) sessQ.eq('owner_id', ownerId.value)
|
||||
if (clinicTenantId.value) sessQ.eq('tenant_id', clinicTenantId.value)
|
||||
const { data: sessions, error: sErr } = await sessQ
|
||||
|
||||
if (sErr) { console.warn('[loadDesativados] sessions error:', sErr) }
|
||||
|
||||
const byPat = new Map()
|
||||
for (const s of (sessions || [])) {
|
||||
if (!byPat.has(s.patient_id)) byPat.set(s.patient_id, [])
|
||||
byPat.get(s.patient_id).push(s)
|
||||
}
|
||||
|
||||
desativadoPatients.value = pats
|
||||
.filter(p => byPat.has(p.id))
|
||||
.map(p => ({ ...p, sessions: byPat.get(p.id) }))
|
||||
|
||||
if (desativadoPatients.value.length && !desativadoSelected.value) {
|
||||
desativadoSelected.value = desativadoPatients.value[0]
|
||||
}
|
||||
} catch (e) { console.warn('[loadDesativados] erro:', e) }
|
||||
}
|
||||
|
||||
const desativadoFcOptions = computed(() => {
|
||||
const patient = desativadoSelected.value
|
||||
if (!patient) return {}
|
||||
const events = (patient.sessions || []).map(s => ({
|
||||
id: s.id,
|
||||
title: s.titulo || 'Sessão',
|
||||
start: s.inicio_em,
|
||||
end: s.fim_em,
|
||||
backgroundColor: desativadoFocused.value?.id === s.id ? '#ea580c' : '#f97316',
|
||||
borderColor: desativadoFocused.value?.id === s.id ? '#ea580c' : '#f97316',
|
||||
textColor: '#fff',
|
||||
extendedProps: { session: s }
|
||||
}))
|
||||
const firstDate = patient.sessions[0]?.inicio_em
|
||||
return {
|
||||
plugins: [listPlugin, dayGridPlugin, interactionPlugin],
|
||||
locale: ptBrLocale,
|
||||
initialView: 'listMonth',
|
||||
initialDate: firstDate || new Date().toISOString(),
|
||||
events,
|
||||
headerToolbar: { left: 'prev,next today', center: 'title', right: 'listMonth,dayGridMonth' },
|
||||
height: '100%',
|
||||
noEventsText: 'Nenhuma sessão encontrada.',
|
||||
eventClick: (info) => {
|
||||
const s = info.event.extendedProps.session
|
||||
openSessionInMainCalendar(s)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(desativadoSelected, () => {
|
||||
desativadoFocused.value = null
|
||||
})
|
||||
|
||||
function focusDesativadoSession (session) {
|
||||
desativadoFocused.value = session
|
||||
const api = desativadoFcRef.value?.getApi?.()
|
||||
if (api) api.gotoDate(new Date(session.inicio_em))
|
||||
}
|
||||
|
||||
function openSessionInMainCalendar (session) {
|
||||
desativadoDialogOpen.value = false
|
||||
const date = new Date(session.inicio_em)
|
||||
currentDate.value = date
|
||||
getApi()?.gotoDate?.(date)
|
||||
// Muda para visão de dia para facilitar encontrar a sessão
|
||||
const api = getApi()
|
||||
if (api) api.changeView('timeGridDay', date)
|
||||
}
|
||||
|
||||
// Helpers de formato para o painel de desativados
|
||||
function fmtDesativadoDate (iso) {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleDateString('pt-BR', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
function fmtDesativadoTime (iso) {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
function fmtDesativadoDur (start, end) {
|
||||
if (!start || !end) return ''
|
||||
const min = Math.round((new Date(end) - new Date(start)) / 60000)
|
||||
return `${min}min`
|
||||
}
|
||||
|
||||
watch(calendarView, async () => {
|
||||
await nextTick()
|
||||
getApi()?.changeView?.(fcViewName.value)
|
||||
@@ -1947,6 +2363,7 @@ function onEventClick (info) {
|
||||
paciente_id: ep.paciente_id ?? null,
|
||||
paciente_nome: ep.paciente_nome ?? null,
|
||||
paciente_avatar: ep.paciente_avatar ?? null,
|
||||
paciente_status: ep.paciente_status ?? null,
|
||||
tipo: normalizeEventoTipo(ep.tipo, EVENTO_TIPO.SESSAO),
|
||||
status: ep.status,
|
||||
titulo: ev.title,
|
||||
@@ -2847,11 +3264,16 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
/* ── Cores dos eventos (global — aplicadas pelo FullCalendar) ── */
|
||||
.fc-event.evt-session {
|
||||
/* Cor primária padrão só quando o evento não tem cor personalizada do commitment */
|
||||
.fc-event.evt-session:not(.evt-has-color) {
|
||||
background-color: var(--p-primary-500, #6366f1) !important;
|
||||
border-color: var(--p-primary-600, #4f46e5) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
/* Cor de texto branca garantida para eventos com cor personalizada */
|
||||
.fc-event.evt-session.evt-has-color {
|
||||
color: #fff !important;
|
||||
}
|
||||
.fc-event.evt-block {
|
||||
background-color: #ef4444 !important;
|
||||
border-color: #dc2626 !important;
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function listClinicEvents ({ tenantId, ownerIds, startISO, endISO }
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('*, patients!agenda_eventos_patient_id_fkey(id, nome_completo, avatar_url), determined_commitments!agenda_eventos_determined_commitment_fk(id, bg_color, text_color)')
|
||||
.select('*, patients!agenda_eventos_patient_id_fkey(id, nome_completo, avatar_url, status), determined_commitments!agenda_eventos_determined_commitment_fk(id, bg_color, text_color)')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('owner_id', safeOwnerIds)
|
||||
.gte('inicio_em', startISO)
|
||||
|
||||
@@ -89,6 +89,7 @@ function _mapRow (r) {
|
||||
paciente_id: r.patient_id ?? null, // alias para compatibilidade com dialog/form
|
||||
paciente_nome: nomeP,
|
||||
paciente_avatar: r.patients?.avatar_url ?? r.paciente_avatar ?? null,
|
||||
paciente_status: r.patients?.status ?? r.paciente_status ?? null,
|
||||
|
||||
// campos
|
||||
observacoes: r.observacoes ?? null,
|
||||
|
||||
@@ -157,6 +157,18 @@
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Inativos</div>
|
||||
</div>
|
||||
|
||||
<!-- Arquivados -->
|
||||
<div
|
||||
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border min-w-[72px] flex-1 cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
|
||||
:class="filters.status === 'Arquivado'
|
||||
? 'border-slate-500 bg-slate-500/5 shadow-[0_0_0_3px_rgba(100,116,139,0.15)]'
|
||||
: 'border-slate-500/30 bg-slate-500/5 hover:border-slate-500/50'"
|
||||
@click="setStatus('Arquivado')"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none text-slate-500">{{ kpis.archived }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Arquivados</div>
|
||||
</div>
|
||||
|
||||
<!-- Último atendimento — não clicável -->
|
||||
<div
|
||||
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] min-w-[72px] flex-1"
|
||||
@@ -436,7 +448,7 @@
|
||||
|
||||
<Column field="status" header="Status" v-if="isColVisible('status')" :key="'col-status'" sortable style="width: 9rem;">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status" :severity="data.status === 'Ativo' ? 'success' : 'danger'" />
|
||||
<Tag :value="data.status" :severity="statusSeverity(data.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
@@ -481,13 +493,17 @@
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column :key="'col-acoes'" header="Ações" style="width: 20rem;" frozen alignFrozen="right">
|
||||
<Column :key="'col-acoes'" header="Ações" style="width: 22rem;" frozen alignFrozen="right">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button label="Sessões" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(data)" />
|
||||
<Button v-if="historySet.has(data.id)" :label="`Sessões × ${sessionCountMap.get(data.id) || 0}`" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(data)" />
|
||||
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(data)" />
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="goEdit(data)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Excluir'" @click="confirmDeleteOne(data)" />
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="goEdit(data)" />
|
||||
<PatientActionMenu
|
||||
:patient="data"
|
||||
:hasHistory="historySet.has(data.id)"
|
||||
@updated="fetchAll"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
@@ -542,7 +558,7 @@
|
||||
</div>
|
||||
<div class="text-base text-color-secondary">{{ fmtPhoneBR(pat.telefone) }} · {{ pat.email_principal || '—' }}</div>
|
||||
</div>
|
||||
<Tag :value="pat.status" :severity="pat.status === 'Ativo' ? 'success' : 'danger'" />
|
||||
<Tag :value="pat.status" :severity="statusSeverity(pat.status)" />
|
||||
</div>
|
||||
|
||||
<!-- Grupos + Tags -->
|
||||
@@ -553,10 +569,14 @@
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="mt-3 flex gap-2 justify-end flex-wrap">
|
||||
<Button label="Sessões" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(pat)" />
|
||||
<Button v-if="historySet.has(pat.id)" :label="`Sessões × ${sessionCountMap.get(pat.id) || 0}`" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(pat)" />
|
||||
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(pat)" />
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" @click="goEdit(pat)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined size="small" @click="confirmDeleteOne(pat)" />
|
||||
<PatientActionMenu
|
||||
:patient="pat"
|
||||
:hasHistory="historySet.has(pat.id)"
|
||||
@updated="fetchAll"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -586,14 +606,85 @@
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value="grupos">
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-color-secondary">Atalho para a página de Grupos.</div>
|
||||
<Button label="Abrir Grupos" icon="pi pi-external-link" outlined @click="goGroups" />
|
||||
<!-- Cabeçalho da view de grupos -->
|
||||
<div class="flex items-center justify-between gap-3 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-sitemap text-[var(--primary-color,#6366f1)]" />
|
||||
<span class="font-semibold text-[var(--text-color)]">Pacientes distribuídos por grupo</span>
|
||||
<span
|
||||
v-if="groupedPatientsView.length"
|
||||
class="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[0.65rem] font-bold"
|
||||
>{{ groupedPatientsView.length }}</span>
|
||||
</div>
|
||||
<Button label="Gerenciar grupos" icon="pi pi-external-link" severity="secondary" outlined size="small" @click="goGroups" />
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex justify-center py-10">
|
||||
<ProgressSpinner />
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="groupedPatientsView.length === 0" class="flex flex-col items-center justify-center gap-3 py-12 text-[var(--text-color-secondary)]">
|
||||
<div class="w-14 h-14 rounded-xl bg-indigo-500/10 flex items-center justify-center">
|
||||
<i class="pi pi-sitemap text-2xl text-indigo-500" />
|
||||
</div>
|
||||
<div class="font-semibold text-[var(--text-color)]">Nenhuma associação encontrada</div>
|
||||
<div class="text-sm opacity-70 text-center max-w-xs">Associe pacientes a grupos no cadastro ou na listagem para visualizá-los aqui.</div>
|
||||
<Button label="Gerenciar grupos" icon="pi pi-sitemap" outlined size="small" class="mt-1" @click="goGroups" />
|
||||
</div>
|
||||
|
||||
<!-- Grid de grupos -->
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
<div
|
||||
v-for="grp in groupedPatientsView"
|
||||
:key="grp.id"
|
||||
class="rounded-xl border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden flex flex-col shadow-sm hover:shadow-md transition-shadow duration-200"
|
||||
>
|
||||
<!-- Barra de cor do grupo -->
|
||||
<div class="h-1.5 w-full" :style="grpColorStyle(grp.color)" />
|
||||
|
||||
<!-- Header do grupo -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-[var(--surface-border,#f1f5f9)]">
|
||||
<div
|
||||
class="w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-sm flex-shrink-0 shadow-sm"
|
||||
:style="grpColorStyle(grp.color)"
|
||||
>
|
||||
{{ (grp.name || '?')[0].toUpperCase() }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-[var(--text-color)] truncate text-sm">{{ grp.name }}</div>
|
||||
<div class="text-[0.72rem] text-[var(--text-color-secondary)] opacity-70">
|
||||
{{ grp.patients.length }} paciente{{ grp.patients.length !== 1 ? 's' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center justify-center min-w-[26px] h-6 px-1.5 rounded-full text-white text-xs font-bold flex-shrink-0"
|
||||
:style="grpColorStyle(grp.color)"
|
||||
>{{ grp.patients.length }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Chips de pacientes -->
|
||||
<div class="p-3 flex flex-wrap gap-1.5 flex-1">
|
||||
<button
|
||||
v-for="p in grp.patients.slice(0, 12)"
|
||||
:key="p.id"
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] text-xs text-[var(--text-color)] hover:bg-[var(--primary-color,#6366f1)] hover:text-white hover:border-transparent cursor-pointer transition-all duration-150 font-medium group"
|
||||
v-tooltip.top="p.nome_completo"
|
||||
@click="goEdit(p)"
|
||||
>
|
||||
<span class="w-5 h-5 rounded-full bg-indigo-500/15 text-indigo-600 group-hover:bg-white/20 group-hover:text-white flex items-center justify-center text-[9px] font-bold flex-shrink-0 transition-colors">
|
||||
{{ (p.nome_completo || '?').charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
<span class="max-w-[120px] truncate">{{ (p.nome_completo || '—').split(' ').slice(0, 2).join(' ') }}</span>
|
||||
</button>
|
||||
<span
|
||||
v-if="grp.patients.length > 12"
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-full border border-dashed border-[var(--surface-border)] text-xs text-[var(--text-color-secondary)] font-medium"
|
||||
>+{{ grp.patients.length - 12 }} mais</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
@@ -696,6 +787,7 @@ import ProgressSpinner from 'primevue/progressspinner'
|
||||
|
||||
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
|
||||
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
|
||||
import PatientActionMenu from '@/components/patients/PatientActionMenu.vue'
|
||||
|
||||
// ── Descontos por paciente ────────────────────────────────────────
|
||||
const discountMap = ref({})
|
||||
@@ -827,20 +919,24 @@ function setAllColumns () { selectedColumns.value = columnCatalogAll.map(c =
|
||||
|
||||
const sort = reactive({ field: 'created_at', order: -1 })
|
||||
|
||||
const kpis = reactive({ total: 0, active: 0, inactive: 0, latestLastAttended: '' })
|
||||
const kpis = reactive({ total: 0, active: 0, inactive: 0, archived: 0, latestLastAttended: '' })
|
||||
|
||||
const filters = reactive({
|
||||
status: 'Todos', search: '',
|
||||
status: 'Ativo', search: '',
|
||||
groupId: null, tagId: null,
|
||||
createdFrom: null, createdTo: null
|
||||
})
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Todos', value: 'Todos' },
|
||||
{ label: 'Ativo', value: 'Ativo' },
|
||||
{ label: 'Inativo', value: 'Inativo' }
|
||||
{ label: 'Ativos', value: 'Ativo' },
|
||||
{ label: 'Inativos', value: 'Inativo' },
|
||||
{ label: 'Arquivados', value: 'Arquivado' },
|
||||
{ label: 'Todos', value: 'Todos' }
|
||||
]
|
||||
|
||||
const historySet = ref(new Set())
|
||||
const sessionCountMap = ref(new Map())
|
||||
|
||||
const groupOptions = computed(() => (groups.value || []).map(g => ({ label: g.name, value: g.id })))
|
||||
const tagOptions = computed(() => (tags.value || []).map(t => ({ label: t.name, value: t.id })))
|
||||
|
||||
@@ -964,7 +1060,7 @@ function onFilterChangedDebounced () {
|
||||
function onFilterChanged () { updateKpis() }
|
||||
function setStatus (s) { filters.status = s; onFilterChanged() }
|
||||
function clearAllFilters () {
|
||||
filters.status = 'Todos'; filters.search = ''; filters.groupId = null
|
||||
filters.status = 'Ativo'; filters.search = ''; filters.groupId = null
|
||||
filters.tagId = null; filters.createdFrom = null; filters.createdTo = null
|
||||
onFilterChanged()
|
||||
}
|
||||
@@ -987,6 +1083,15 @@ function normalizeStatus (s) {
|
||||
return v.charAt(0).toUpperCase() + v.slice(1)
|
||||
}
|
||||
|
||||
function statusSeverity (s) {
|
||||
if (s === 'Ativo') return 'success'
|
||||
if (s === 'Inativo') return 'warn'
|
||||
if (s === 'Arquivado') return 'secondary'
|
||||
if (s === 'Alta') return 'info'
|
||||
if (s === 'Encaminhado') return 'contrast'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
function initials (name) {
|
||||
const parts = String(name || '').trim().split(/\s+/).filter(Boolean)
|
||||
if (!parts.length) return '—'
|
||||
@@ -1196,44 +1301,68 @@ async function hydrateAssociationsSupabase () {
|
||||
groups: groupsByPatient.get(p.id) || [],
|
||||
tags: tagsByPatient.get(p.id) || []
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────
|
||||
function confirmDeleteOne (row) {
|
||||
const nome = row?.nome_completo || 'este paciente'
|
||||
confirm.require({
|
||||
header: 'Excluir paciente',
|
||||
message: `Tem certeza que deseja excluir "${nome}"?`,
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Excluir', rejectLabel: 'Cancelar', acceptClass: 'p-button-danger',
|
||||
accept: () => removePatient(row)
|
||||
})
|
||||
}
|
||||
// Calcula historySet — uma única query para todos os ids
|
||||
const { data: evtCounts } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('patient_id')
|
||||
.in('patient_id', ids)
|
||||
.not('patient_id', 'is', null)
|
||||
.limit(1000)
|
||||
|
||||
async function removePatient (row) {
|
||||
try {
|
||||
await supabase.from('patient_group_patient').delete().eq('patient_id', row.id)
|
||||
await supabase.from('patient_patient_tag').delete().eq('patient_id', row.id)
|
||||
const { error } = await supabase.from('patients').delete().eq('id', row.id).eq('owner_id', uid.value)
|
||||
if (error) throw error
|
||||
patients.value = (patients.value || []).filter(p => p.id !== row.id)
|
||||
updateKpis()
|
||||
toast.add({ severity: 'success', summary: 'Ok', detail: 'Paciente excluído.', life: 2500 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não consegui excluir.', life: 3500 })
|
||||
const tempSet = new Set()
|
||||
const countMap = new Map()
|
||||
for (const r of (evtCounts || [])) {
|
||||
if (r.patient_id) {
|
||||
tempSet.add(r.patient_id)
|
||||
countMap.set(r.patient_id, (countMap.get(r.patient_id) || 0) + 1)
|
||||
}
|
||||
}
|
||||
historySet.value = tempSet
|
||||
sessionCountMap.value = countMap
|
||||
}
|
||||
|
||||
// Delete movido para PatientActionMenu + usePatientLifecycle
|
||||
|
||||
// ── KPIs ──────────────────────────────────────────────────
|
||||
function updateKpis () {
|
||||
const all = patients.value || []
|
||||
kpis.total = all.length
|
||||
kpis.active = all.filter(p => p.status === 'Ativo').length
|
||||
kpis.inactive = all.filter(p => p.status === 'Inativo').length
|
||||
kpis.archived = all.filter(p => p.status === 'Arquivado').length
|
||||
const dates = all.map(p => (p.last_attended_at || '').slice(0, 10)).filter(Boolean).sort()
|
||||
kpis.latestLastAttended = dates.length ? dates[dates.length - 1] : ''
|
||||
}
|
||||
|
||||
// ── Grupos view ───────────────────────────────────────────
|
||||
const groupedPatientsView = computed(() => {
|
||||
const all = patients.value || []
|
||||
const grpMap = new Map()
|
||||
for (const g of (groups.value || [])) {
|
||||
grpMap.set(g.id, { id: g.id, name: g.name || g.nome, color: g.color || g.cor, patients: [], isSystem: !!g.is_system })
|
||||
}
|
||||
const ungrouped = { id: '__none__', name: 'Sem grupo', color: null, patients: [], isSystem: false }
|
||||
for (const p of all) {
|
||||
const gs = p.groups || []
|
||||
if (!gs.length) {
|
||||
ungrouped.patients.push(p)
|
||||
} else {
|
||||
for (const g of gs) {
|
||||
if (grpMap.has(g.id)) grpMap.get(g.id).patients.push(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = [...grpMap.values()].filter(g => g.patients.length > 0).sort((a, b) => b.patients.length - a.patients.length)
|
||||
if (ungrouped.patients.length > 0) result.push(ungrouped)
|
||||
return result
|
||||
})
|
||||
|
||||
function grpColorStyle (color) {
|
||||
if (!color) return { background: 'var(--surface-border)' }
|
||||
return { background: color.startsWith('#') ? color : `#${color}` }
|
||||
}
|
||||
|
||||
const HIGHLIGHT_MS = 24 * 60 * 60 * 1000
|
||||
function isRecent (row) {
|
||||
if (!row?.created_at) return false
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user