Melissa polish + Prontuario Visao Geral + agenda historico
Sprints B (05-03) e C (05-04) acumulados: - NotificationDrawer/Item redesign (visual mais limpo, ações inline) - Dock pins compose (useMelissaDockPins) + cache store global (melissaCacheStore) - MelissaAgenda: timeline FullCalendar parity + cards resumo, histórico card com useMelissaAgendaHistorico, MelissaEventoPanel ajustado - useFeriados: cache opt-in pra evitar fetch redundante de feriados - PatientProntuario: aba Visão Geral nova; PatientConversationsTab polish - AgendaClinicMosaic / AgendaTerapeutaPage / useAgendaSettings: ajustes de paridade com Melissa - DocumentsListPage: pequenos ajustes - DB migration 20260504000001: fix do trigger pra status 'excluido' nas cancel_notifications Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
-- ==========================================================================
|
||||||
|
-- Agencia PSI — Fix: cancel_notifications_on_session_cancel referencia 'excluido'
|
||||||
|
-- ==========================================================================
|
||||||
|
-- A funcao trigger comparava NEW.status IN ('cancelado', 'excluido'), mas o
|
||||||
|
-- enum status_evento_agenda nunca teve o valor 'excluido'. Postgres precisa
|
||||||
|
-- fazer cast do literal pro tipo do enum, e o cast falha com:
|
||||||
|
--
|
||||||
|
-- invalid input value for enum status_evento_agenda: "excluido"
|
||||||
|
--
|
||||||
|
-- Isso quebrava QUALQUER UPDATE que mudasse status pra um valor != atual,
|
||||||
|
-- pois o IF tinha que avaliar a expressao com 'excluido'.
|
||||||
|
--
|
||||||
|
-- O front-end nunca usou 'excluido' (statusOptions em AgendaEventDialog.vue
|
||||||
|
-- so tem agendado/realizado/faltou/cancelado/remarcado). Delete e hard delete
|
||||||
|
-- via DELETE — nao tem soft-delete em agenda_eventos. Logo, 'excluido' eh
|
||||||
|
-- codigo morto e pode ser removido.
|
||||||
|
--
|
||||||
|
-- Refs:
|
||||||
|
-- - src/features/agenda/components/AgendaEventDialog.vue:1071 (statusOptions)
|
||||||
|
-- - schema/03_functions/_all.sql:1056 (funcao original)
|
||||||
|
-- ==========================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.cancel_notifications_on_session_cancel() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.status = 'cancelado' AND OLD.status <> 'cancelado' THEN
|
||||||
|
PERFORM public.cancel_patient_pending_notifications(
|
||||||
|
NEW.patient_id, NULL, NEW.id
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
-->
|
-->
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import { useToast } from 'primevue/usetoast';
|
import { useToast } from 'primevue/usetoast';
|
||||||
import { isToday, isYesterday, differenceInDays } from 'date-fns';
|
import { isToday, isYesterday, differenceInDays } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +29,7 @@ import NotificationItem from './NotificationItem.vue';
|
|||||||
|
|
||||||
const store = useNotificationStore();
|
const store = useNotificationStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const filter = ref('unread'); // 'unread' | 'all'
|
const filter = ref('unread'); // 'unread' | 'all'
|
||||||
@@ -101,7 +102,10 @@ function handleArchive(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goToHistory() {
|
function goToHistory() {
|
||||||
router.push('/therapist/notificacoes');
|
// Se o user está em /melissa/*, navega pra equivalente Melissa
|
||||||
|
// (preserva o overlay) — senão cai na rota do role.
|
||||||
|
const target = route.path?.startsWith('/melissa') ? '/melissa/notificacoes' : '/therapist/notificacoes';
|
||||||
|
router.push(target);
|
||||||
store.drawerOpen = false;
|
store.drawerOpen = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
-->
|
-->
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { ptBR } from 'date-fns/locale';
|
import { ptBR } from 'date-fns/locale';
|
||||||
import { useNotificationStore } from '@/stores/notificationStore';
|
import { useNotificationStore } from '@/stores/notificationStore';
|
||||||
@@ -30,6 +30,7 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['read', 'archive']);
|
const emit = defineEmits(['read', 'archive']);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
const store = useNotificationStore();
|
const store = useNotificationStore();
|
||||||
const conversationDrawer = useConversationDrawerStore();
|
const conversationDrawer = useConversationDrawerStore();
|
||||||
const tenantStore = useTenantStore();
|
const tenantStore = useTenantStore();
|
||||||
@@ -52,6 +53,32 @@ const DEEPLINK_ALIASES = {
|
|||||||
'/conversas': { therapist: '/therapist/conversas', clinic_admin: '/admin/conversas' }
|
'/conversas': { therapist: '/therapist/conversas', clinic_admin: '/admin/conversas' }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mapeamento de slug "última parte da rota" → seção Melissa equivalente.
|
||||||
|
// Usado quando o user está no layout Melissa (/melissa/*) pra evitar que
|
||||||
|
// uma notificação o jogue pro layout rail (rota do role) e perca o overlay.
|
||||||
|
// Slugs cobertos: páginas dedicadas + embeds (financeiro, documentos, etc).
|
||||||
|
const MELISSA_SLUG_MAP = {
|
||||||
|
agenda: 'agenda',
|
||||||
|
conversas: 'conversas',
|
||||||
|
patients: 'pacientes',
|
||||||
|
pacientes: 'pacientes',
|
||||||
|
medicos: 'medicos',
|
||||||
|
recorrencias: 'recorrencias',
|
||||||
|
compromissos: 'compromissos',
|
||||||
|
grupos: 'grupos',
|
||||||
|
tags: 'tags',
|
||||||
|
'cadastros-recebidos': 'cadastros-recebidos',
|
||||||
|
financeiro: 'financeiro',
|
||||||
|
'financeiro-lancamentos': 'financeiro-lancamentos',
|
||||||
|
documentos: 'documentos',
|
||||||
|
'documentos-templates': 'documentos-templates',
|
||||||
|
'agendamentos-recebidos': 'agendamentos-recebidos',
|
||||||
|
'online-scheduling': 'online-scheduling',
|
||||||
|
relatorios: 'relatorios',
|
||||||
|
notificacoes: 'notificacoes',
|
||||||
|
'link-externo': 'link-externo'
|
||||||
|
};
|
||||||
|
|
||||||
function resolveDeeplink(link) {
|
function resolveDeeplink(link) {
|
||||||
if (!link || typeof link !== 'string') return link;
|
if (!link || typeof link !== 'string') return link;
|
||||||
const alias = DEEPLINK_ALIASES[link];
|
const alias = DEEPLINK_ALIASES[link];
|
||||||
@@ -60,6 +87,22 @@ function resolveDeeplink(link) {
|
|||||||
return alias[role] || alias.therapist;
|
return alias[role] || alias.therapist;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Se o user está em /melissa/*, traduz o destino pra equivalente Melissa
|
||||||
|
// quando possível. Pra rotas sem mapping conhecido, retorna o path original
|
||||||
|
// (vai navegar pro layout rail — caso raro, geralmente é uma rota muito
|
||||||
|
// específica que não tem versão Melissa ainda).
|
||||||
|
function applyMelissaContext(path) {
|
||||||
|
if (!path || typeof path !== 'string') return path;
|
||||||
|
if (path.startsWith('/melissa')) return path; // já é Melissa
|
||||||
|
if (!route.path?.startsWith('/melissa')) return path; // user não está em Melissa
|
||||||
|
// Strip prefixos de role e pega o último segmento significativo
|
||||||
|
const cleaned = path.replace(/^\/(therapist|admin|clinic|crm)/, '');
|
||||||
|
const parts = cleaned.split('/').filter(Boolean);
|
||||||
|
if (!parts.length) return path;
|
||||||
|
const slug = MELISSA_SLUG_MAP[parts[parts.length - 1]] || MELISSA_SLUG_MAP[parts[0]];
|
||||||
|
return slug ? `/melissa/${slug}` : path;
|
||||||
|
}
|
||||||
|
|
||||||
const meta = computed(() => typeMap[props.item.type] || DEFAULT_TYPE);
|
const meta = computed(() => typeMap[props.item.type] || DEFAULT_TYPE);
|
||||||
const isUnread = computed(() => !props.item.read_at);
|
const isUnread = computed(() => !props.item.read_at);
|
||||||
|
|
||||||
@@ -128,8 +171,9 @@ async function handleRowClick() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: segue deeplink resolvido por alias
|
// Fallback: segue deeplink resolvido por alias e (se user está em
|
||||||
const deeplink = resolveDeeplink(payload.deeplink);
|
// Melissa) traduzido pra equivalente do layout Melissa.
|
||||||
|
const deeplink = applyMelissaContext(resolveDeeplink(payload.deeplink));
|
||||||
if (deeplink) {
|
if (deeplink) {
|
||||||
router.push(deeplink);
|
router.push(deeplink);
|
||||||
store.drawerOpen = false;
|
store.drawerOpen = false;
|
||||||
@@ -150,7 +194,7 @@ async function handleOpenConversation(e) {
|
|||||||
function handleOpenDeeplink(e) {
|
function handleOpenDeeplink(e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const payload = props.item.payload || {};
|
const payload = props.item.payload || {};
|
||||||
const deeplink = resolveDeeplink(payload.deeplink);
|
const deeplink = applyMelissaContext(resolveDeeplink(payload.deeplink));
|
||||||
if (deeplink) {
|
if (deeplink) {
|
||||||
router.push(deeplink);
|
router.push(deeplink);
|
||||||
store.drawerOpen = false;
|
store.drawerOpen = false;
|
||||||
|
|||||||
@@ -18,8 +18,15 @@
|
|||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { supabase } from '@/lib/supabase/client';
|
import { supabase } from '@/lib/supabase/client';
|
||||||
import { getFeriadosNacionais } from '@/utils/feriadosBR';
|
import { getFeriadosNacionais } from '@/utils/feriadosBR';
|
||||||
|
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore';
|
||||||
|
|
||||||
|
// opts.cache (opt-in): habilita stale-while-revalidate via melissaCacheStore.
|
||||||
|
// Default false pra preservar comportamento em páginas SaaS/admin que
|
||||||
|
// editam feriados (esperam invalidação imediata após criar/remover).
|
||||||
|
export function useFeriados(opts = {}) {
|
||||||
|
const useCache = !!opts.cache;
|
||||||
|
const cache = useCache ? useMelissaCacheStore() : null;
|
||||||
|
|
||||||
export function useFeriados() {
|
|
||||||
const ano = ref(new Date().getFullYear());
|
const ano = ref(new Date().getFullYear());
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const municipais = ref([]); // linhas da tabela `feriados`
|
const municipais = ref([]); // linhas da tabela `feriados`
|
||||||
@@ -51,14 +58,53 @@ export function useFeriados() {
|
|||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function _doFetch(tenantId, cacheKey) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('feriados')
|
||||||
|
.select('*')
|
||||||
|
.or(`tenant_id.eq.${tenantId},tenant_id.is.null`)
|
||||||
|
.gte('data', `${ano.value}-01-01`)
|
||||||
|
.lte('data', `${ano.value}-12-31`)
|
||||||
|
.order('data');
|
||||||
|
if (error) throw error;
|
||||||
|
const rows = data || [];
|
||||||
|
if (cache && cacheKey) cache.set('feriados', rows, cacheKey);
|
||||||
|
municipais.value = rows;
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Load municipais do Supabase ───────────────────────────
|
// ── Load municipais do Supabase ───────────────────────────
|
||||||
async function load(tenantId, year) {
|
async function load(tenantId, year) {
|
||||||
if (year) ano.value = year;
|
if (year) ano.value = year;
|
||||||
if (!tenantId) return;
|
if (!tenantId) return;
|
||||||
|
|
||||||
|
if (cache) {
|
||||||
|
const cacheKey = `${tenantId}:${ano.value}`;
|
||||||
|
const cached = cache.get('feriados', cacheKey, MELISSA_CACHE_TTL.feriados);
|
||||||
|
if (cached) {
|
||||||
|
municipais.value = cached;
|
||||||
|
_doFetch(tenantId, cacheKey).catch((e) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[useFeriados] revalidate', e);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try { await _doFetch(tenantId, cacheKey); }
|
||||||
|
finally { loading.value = false; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comportamento legado (sem cache) — páginas de admin que editam.
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
// Busca feriados do tenant + feriados globais (tenant_id null, cadastrados pelo SAAS)
|
const { data, error } = await supabase
|
||||||
const { data, error } = await supabase.from('feriados').select('*').or(`tenant_id.eq.${tenantId},tenant_id.is.null`).gte('data', `${ano.value}-01-01`).lte('data', `${ano.value}-12-31`).order('data');
|
.from('feriados')
|
||||||
|
.select('*')
|
||||||
|
.or(`tenant_id.eq.${tenantId},tenant_id.is.null`)
|
||||||
|
.gte('data', `${ano.value}-01-01`)
|
||||||
|
.lte('data', `${ano.value}-12-31`)
|
||||||
|
.order('data');
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
municipais.value = data || [];
|
municipais.value = data || [];
|
||||||
} finally {
|
} finally {
|
||||||
@@ -71,6 +117,7 @@ export function useFeriados() {
|
|||||||
const { data, error } = await supabase.from('feriados').insert(payload).select().single();
|
const { data, error } = await supabase.from('feriados').insert(payload).select().single();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
municipais.value = [...municipais.value, data].sort((a, b) => a.data.localeCompare(b.data));
|
municipais.value = [...municipais.value, data].sort((a, b) => a.data.localeCompare(b.data));
|
||||||
|
if (cache) cache.invalidate('feriados');
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +126,7 @@ export function useFeriados() {
|
|||||||
const { error } = await supabase.from('feriados').delete().eq('id', id);
|
const { error } = await supabase.from('feriados').delete().eq('id', id);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
municipais.value = municipais.value.filter((f) => f.id !== id);
|
municipais.value = municipais.value.filter((f) => f.id !== id);
|
||||||
|
if (cache) cache.invalidate('feriados');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Verificar duplicata ───────────────────────────────────
|
// ── Verificar duplicata ───────────────────────────────────
|
||||||
|
|||||||
@@ -309,7 +309,6 @@ function buildFcOptions(ownerId) {
|
|||||||
const nome = ext.paciente_nome || '';
|
const nome = ext.paciente_nome || '';
|
||||||
const obs = ext.observacoes || '';
|
const obs = ext.observacoes || '';
|
||||||
const title = arg.event.title || '';
|
const title = arg.event.title || '';
|
||||||
const timeText = arg.timeText || '';
|
|
||||||
const pacienteStatus = ext.paciente_status || '';
|
const pacienteStatus = ext.paciente_status || '';
|
||||||
|
|
||||||
const esc = (s) =>
|
const esc = (s) =>
|
||||||
@@ -326,21 +325,28 @@ function buildFcOptions(ownerId) {
|
|||||||
return (p[0][0] + p[p.length - 1][0]).toUpperCase();
|
return (p[0][0] + p[p.length - 1][0]).toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fmtHour = (d) => {
|
||||||
|
if (!d) return '';
|
||||||
|
const h = d.getHours();
|
||||||
|
const m = d.getMinutes();
|
||||||
|
return m === 0 ? `${h}h` : `${h}:${String(m).padStart(2, '0')}h`;
|
||||||
|
};
|
||||||
|
const range = arg.event.start && arg.event.end ? `${fmtHour(arg.event.start)}-${fmtHour(arg.event.end)}` : arg.timeText || '';
|
||||||
|
|
||||||
const avatarHtml = avatarUrl ? `<img src="${esc(avatarUrl)}" class="ev-avatar ev-avatar-img" />` : nome ? `<div class="ev-avatar ev-avatar-initials">${esc(initials(nome))}</div>` : '';
|
const avatarHtml = avatarUrl ? `<img src="${esc(avatarUrl)}" class="ev-avatar ev-avatar-img" />` : nome ? `<div class="ev-avatar ev-avatar-initials">${esc(initials(nome))}</div>` : '';
|
||||||
|
|
||||||
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : '';
|
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : '';
|
||||||
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : '';
|
const titleLine = `<div class="ev-title"><span class="ev-name">${esc(title)}</span>${range ? ` <span class="ev-hour">(${esc(range)})</span>` : ''}</div>`;
|
||||||
const statusBadge =
|
const statusBadge =
|
||||||
pacienteStatus === 'Inativo' || pacienteStatus === 'Arquivado'
|
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>`
|
? `<span class="ev-badge">${pacienteStatus === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'}</span>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `<div class="ev-custom">
|
html: `<div class="ev-custom">
|
||||||
${avatarHtml}
|
${avatarHtml}
|
||||||
<div class="ev-body">
|
<div class="ev-body">
|
||||||
${timeHtml}
|
${titleLine}
|
||||||
<div class="ev-title">${esc(title)}</div>
|
|
||||||
${statusBadge}
|
${statusBadge}
|
||||||
${obsHtml}
|
${obsHtml}
|
||||||
</div>
|
</div>
|
||||||
@@ -475,20 +481,34 @@ function buildFcOptions(ownerId) {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.ev-time {
|
.ev-title {
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
opacity: 0.8;
|
line-height: 1.3;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.ev-title {
|
.ev-name {
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
}
|
||||||
overflow: hidden;
|
.ev-hour {
|
||||||
text-overflow: ellipsis;
|
font-weight: 400;
|
||||||
line-height: 1.3;
|
font-size: 10px;
|
||||||
|
opacity: 0.75;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
.ev-badge {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
.ev-obs {
|
.ev-obs {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
|||||||
@@ -16,20 +16,53 @@
|
|||||||
*/
|
*/
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { getMyAgendaSettings, getMyWorkSchedule } from '../services/agendaRepository';
|
import { getMyAgendaSettings, getMyWorkSchedule } from '../services/agendaRepository';
|
||||||
|
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore';
|
||||||
|
|
||||||
|
// opts.cache (opt-in): habilita stale-while-revalidate via melissaCacheStore.
|
||||||
|
// Default false pra preservar comportamento de páginas de configuração que
|
||||||
|
// editam settings/workRules (esperam ver mudança imediata após salvar).
|
||||||
|
export function useAgendaSettings(opts = {}) {
|
||||||
|
const useCache = !!opts.cache;
|
||||||
|
const cache = useCache ? useMelissaCacheStore() : null;
|
||||||
|
|
||||||
export function useAgendaSettings() {
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const settings = ref(null);
|
const settings = ref(null);
|
||||||
const workRules = ref([]); // [{ dia_semana, hora_inicio, hora_fim }]
|
const workRules = ref([]); // [{ dia_semana, hora_inicio, hora_fim }]
|
||||||
|
|
||||||
async function load() {
|
async function _doFetch() {
|
||||||
loading.value = true;
|
|
||||||
error.value = '';
|
|
||||||
try {
|
|
||||||
const [cfg, rules] = await Promise.all([getMyAgendaSettings(), getMyWorkSchedule()]);
|
const [cfg, rules] = await Promise.all([getMyAgendaSettings(), getMyWorkSchedule()]);
|
||||||
settings.value = cfg;
|
settings.value = cfg;
|
||||||
workRules.value = rules;
|
workRules.value = rules;
|
||||||
|
if (cache) {
|
||||||
|
// Cache key inclui owner_id da config — invalida automaticamente
|
||||||
|
// se o usuário trocar (multi-tenant ou alternar entre staff).
|
||||||
|
const key = cfg?.owner_id || 'anon';
|
||||||
|
cache.set('agendaSettings', { settings: cfg, workRules: rules }, key);
|
||||||
|
}
|
||||||
|
return { settings: cfg, workRules: rules };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (cache) {
|
||||||
|
// Sem owner_id ainda, key vira 'anon' — pega qualquer cache
|
||||||
|
// do mesmo escopo (que normalmente é o user logado).
|
||||||
|
const cached = cache.get('agendaSettings', undefined, MELISSA_CACHE_TTL.agendaSettings);
|
||||||
|
if (cached) {
|
||||||
|
settings.value = cached.settings;
|
||||||
|
workRules.value = cached.workRules;
|
||||||
|
_doFetch().catch((e) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[useAgendaSettings] revalidate', e);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
await _doFetch();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e?.message || 'Falha ao carregar configurações da agenda.';
|
error.value = e?.message || 'Falha ao carregar configurações da agenda.';
|
||||||
settings.value = null;
|
settings.value = null;
|
||||||
|
|||||||
@@ -742,7 +742,6 @@ const fcOptions = computed(() => ({
|
|||||||
const nome = ext.paciente_nome || '';
|
const nome = ext.paciente_nome || '';
|
||||||
const obs = ext.observacoes || '';
|
const obs = ext.observacoes || '';
|
||||||
const title = arg.event.title || '';
|
const title = arg.event.title || '';
|
||||||
const timeText = arg.timeText || '';
|
|
||||||
const pacienteStatus = ext.paciente_status || '';
|
const pacienteStatus = ext.paciente_status || '';
|
||||||
|
|
||||||
const esc = (s) =>
|
const esc = (s) =>
|
||||||
@@ -759,21 +758,28 @@ const fcOptions = computed(() => ({
|
|||||||
return (p[0][0] + p[p.length - 1][0]).toUpperCase();
|
return (p[0][0] + p[p.length - 1][0]).toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fmtHour = (d) => {
|
||||||
|
if (!d) return '';
|
||||||
|
const h = d.getHours();
|
||||||
|
const m = d.getMinutes();
|
||||||
|
return m === 0 ? `${h}h` : `${h}:${String(m).padStart(2, '0')}h`;
|
||||||
|
};
|
||||||
|
const range = arg.event.start && arg.event.end ? `${fmtHour(arg.event.start)}-${fmtHour(arg.event.end)}` : arg.timeText || '';
|
||||||
|
|
||||||
const avatarHtml = avatarUrl ? `<img src="${esc(avatarUrl)}" class="ev-avatar ev-avatar-img" />` : nome ? `<div class="ev-avatar ev-avatar-initials">${esc(initials(nome))}</div>` : '';
|
const avatarHtml = avatarUrl ? `<img src="${esc(avatarUrl)}" class="ev-avatar ev-avatar-img" />` : nome ? `<div class="ev-avatar ev-avatar-initials">${esc(initials(nome))}</div>` : '';
|
||||||
|
|
||||||
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : '';
|
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : '';
|
||||||
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : '';
|
const titleLine = `<div class="ev-title"><span class="ev-name">${esc(title)}</span>${range ? ` <span class="ev-hour">(${esc(range)})</span>` : ''}</div>`;
|
||||||
const inativoBadge =
|
const inativoBadge =
|
||||||
pacienteStatus === 'Inativo' || pacienteStatus === 'Arquivado'
|
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>`
|
? `<span class="ev-badge">${pacienteStatus === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'}</span>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `<div class="ev-custom">
|
html: `<div class="ev-custom">
|
||||||
${avatarHtml}
|
${avatarHtml}
|
||||||
<div class="ev-body">
|
<div class="ev-body">
|
||||||
${timeHtml}
|
${titleLine}
|
||||||
<div class="ev-title">${esc(title)}</div>
|
|
||||||
${inativoBadge}
|
${inativoBadge}
|
||||||
${obsHtml}
|
${obsHtml}
|
||||||
</div>
|
</div>
|
||||||
@@ -3424,20 +3430,34 @@ onBeforeUnmount(() => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.ev-time {
|
.ev-title {
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
opacity: 0.8;
|
line-height: 1.3;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.ev-title {
|
.ev-name {
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
}
|
||||||
overflow: hidden;
|
.ev-hour {
|
||||||
text-overflow: ellipsis;
|
font-weight: 400;
|
||||||
line-height: 1.3;
|
font-size: 10px;
|
||||||
|
opacity: 0.75;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
.ev-badge {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
.ev-obs {
|
.ev-obs {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
|||||||
@@ -180,13 +180,10 @@ watch(filters, () => fetchDocuments(), { deep: true })
|
|||||||
EMBEDDED MODE — dentro do prontuário (sem hero, layout compacto)
|
EMBEDDED MODE — dentro do prontuário (sem hero, layout compacto)
|
||||||
══════════════════════════════════════════════════════════════ -->
|
══════════════════════════════════════════════════════════════ -->
|
||||||
<div v-if="embedded">
|
<div v-if="embedded">
|
||||||
<!-- Header compacto -->
|
<!-- Header compacto: ações alinhadas à direita, sem label -->
|
||||||
<div class="flex items-center justify-between gap-2 mb-4">
|
<div class="flex items-center justify-end gap-2 mb-4">
|
||||||
<span class="text-sm font-semibold text-[var(--text-color-secondary)] uppercase tracking-wider">Documentos</span>
|
<Button label="Upload" icon="pi pi-upload" size="small" outlined class="rounded-full" @click="uploadDlg = true" />
|
||||||
<div class="flex gap-1.5">
|
<Button label="Template" icon="pi pi-file-pdf" size="small" class="rounded-full" @click="generateDlg = true" />
|
||||||
<Button icon="pi pi-file-pdf" text rounded size="small" v-tooltip.top="'Gerar documento'" @click="generateDlg = true" />
|
|
||||||
<Button icon="pi pi-upload" text rounded size="small" v-tooltip.top="'Upload'" @click="uploadDlg = true" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
@@ -195,11 +192,11 @@ watch(filters, () => fetchDocuments(), { deep: true })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty -->
|
<!-- Empty -->
|
||||||
<div v-else-if="!documents.length" class="py-10 px-6 text-center">
|
<div v-else-if="!documents.length" class="empty-rich">
|
||||||
<i class="pi pi-inbox text-2xl opacity-20 mb-2 block" />
|
<div class="empty-rich__icon"><i class="pi pi-folder-open" /></div>
|
||||||
<div class="font-semibold text-sm">Nenhum documento ainda</div>
|
<div class="empty-rich__title">Nenhum documento ainda</div>
|
||||||
<div class="text-xs opacity-60 mt-1">Faça upload do primeiro documento deste paciente</div>
|
<div class="empty-rich__sub">Faça upload do primeiro laudo, receita, exame ou termo assinado deste paciente.</div>
|
||||||
<Button v-if="resolvedPatientId" label="Enviar documento" icon="pi pi-upload" severity="secondary" outlined size="small" class="rounded-full mt-3" @click="uploadDlg = true" />
|
<Button v-if="resolvedPatientId" label="Enviar documento" icon="pi pi-upload" class="empty-rich__cta rounded-full" @click="uploadDlg = true" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Lista -->
|
<!-- Lista -->
|
||||||
@@ -331,15 +328,17 @@ watch(filters, () => fetchDocuments(), { deep: true })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty -->
|
<!-- Empty -->
|
||||||
<div v-else-if="!documents.length" class="py-10 px-6 text-center">
|
<div v-else-if="!documents.length" class="empty-rich m-4">
|
||||||
<i class="pi pi-inbox text-2xl opacity-20 mb-2 block" />
|
<div class="empty-rich__icon">
|
||||||
<div class="font-semibold text-sm">
|
<i :class="hasActiveFilter ? 'pi pi-filter-slash' : 'pi pi-folder-open'" />
|
||||||
|
</div>
|
||||||
|
<div class="empty-rich__title">
|
||||||
{{ hasActiveFilter ? 'Nenhum documento encontrado' : 'Nenhum documento ainda' }}
|
{{ hasActiveFilter ? 'Nenhum documento encontrado' : 'Nenhum documento ainda' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs opacity-60 mt-1">
|
<div class="empty-rich__sub">
|
||||||
{{ hasActiveFilter ? 'Limpe os filtros ou ajuste a busca' : resolvedPatientId ? 'Faça upload do primeiro documento' : 'Selecione um paciente para adicionar documentos' }}
|
{{ hasActiveFilter ? 'Limpe os filtros ou ajuste a busca pra ver outros resultados.' : resolvedPatientId ? 'Faça upload do primeiro laudo, receita, exame ou termo assinado deste paciente.' : 'Selecione um paciente para adicionar documentos.' }}
|
||||||
</div>
|
</div>
|
||||||
<Button v-if="resolvedPatientId && !hasActiveFilter" label="Enviar primeiro documento" icon="pi pi-upload" severity="secondary" outlined size="small" class="rounded-full mt-3" @click="uploadDlg = true" />
|
<Button v-if="resolvedPatientId && !hasActiveFilter" label="Enviar primeiro documento" icon="pi pi-upload" class="empty-rich__cta rounded-full" @click="uploadDlg = true" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Lista -->
|
<!-- Lista -->
|
||||||
@@ -377,3 +376,50 @@ watch(filters, () => fetchDocuments(), { deep: true })
|
|||||||
<DocumentShareDialog :visible="shareDlg" @update:visible="shareDlg = $event" :doc="selectedDoc" />
|
<DocumentShareDialog :visible="shareDlg" @update:visible="shareDlg = $event" :doc="selectedDoc" />
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Empty state rico — espelha .pp-empty--rich do PatientProntuario.vue.
|
||||||
|
Padroniza visual em ambos os modos (embedded e standalone). */
|
||||||
|
.empty-rich {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 48px 24px;
|
||||||
|
border: 2px dashed color-mix(in srgb, var(--primary-color) 35%, var(--surface-border));
|
||||||
|
border-radius: 16px;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at top, color-mix(in srgb, var(--primary-color) 5%, transparent), transparent 70%),
|
||||||
|
var(--surface-card);
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.empty-rich__icon {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary-color) 25%, transparent);
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.empty-rich__icon .pi { font-size: 2rem; }
|
||||||
|
.empty-rich__title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-color);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.empty-rich__sub {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
max-width: 340px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.empty-rich__cta {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -156,10 +156,10 @@ watch(() => props.patientId, () => { load(); });
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty -->
|
<!-- Empty -->
|
||||||
<div v-else-if="!messages.length" class="flex flex-col items-center justify-center gap-2 py-12 text-center text-[var(--text-color-secondary)]">
|
<div v-else-if="!messages.length" class="empty-rich">
|
||||||
<i class="pi pi-comments text-4xl opacity-30" />
|
<div class="empty-rich__icon"><i class="pi pi-comments" /></div>
|
||||||
<div class="text-sm">Nenhuma conversa registrada com este paciente ainda.</div>
|
<div class="empty-rich__title">Nenhuma conversa registrada</div>
|
||||||
<div class="text-xs opacity-70">
|
<div class="empty-rich__sub">
|
||||||
Quando {{ props.patientName || 'o paciente' }} enviar uma mensagem pelo WhatsApp (ou você enviar uma), vai aparecer aqui.
|
Quando {{ props.patientName || 'o paciente' }} enviar uma mensagem pelo WhatsApp (ou você enviar uma), vai aparecer aqui.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,3 +219,48 @@ watch(() => props.patientId, () => { load(); });
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Empty state rico — espelha .pp-empty--rich do PatientProntuario.vue.
|
||||||
|
Replicado aqui pra que a aparência seja idêntica em qualquer contexto
|
||||||
|
(ficha embedded ou página standalone). */
|
||||||
|
.empty-rich {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 48px 24px;
|
||||||
|
border: 2px dashed color-mix(in srgb, var(--primary-color) 35%, var(--surface-border));
|
||||||
|
border-radius: 16px;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at top, color-mix(in srgb, var(--primary-color) 5%, transparent), transparent 70%),
|
||||||
|
var(--surface-card);
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.empty-rich__icon {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary-color) 25%, transparent);
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.empty-rich__icon .pi { font-size: 2rem; }
|
||||||
|
.empty-rich__title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-color);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.empty-rich__sub {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
max-width: 340px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -25,10 +25,9 @@ import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
|||||||
import { useMelissaEventosRange, useMelissaTodasSessoesPaciente, searchEventosByText } from './composables/useMelissaEventos';
|
import { useMelissaEventosRange, useMelissaTodasSessoesPaciente, searchEventosByText } 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';
|
||||||
import { useFeriados } from '@/composables/useFeriados';
|
|
||||||
import { useTenantStore } from '@/stores/tenantStore';
|
import { useTenantStore } from '@/stores/tenantStore';
|
||||||
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings';
|
|
||||||
import ProximosFeriadosCard from '@/features/agenda/components/ProximosFeriadosCard.vue';
|
import ProximosFeriadosCard from '@/features/agenda/components/ProximosFeriadosCard.vue';
|
||||||
|
import MelissaAgendaHistoricoCard from './MelissaAgendaHistoricoCard.vue';
|
||||||
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue';
|
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue';
|
||||||
import Popover from 'primevue/popover';
|
import Popover from 'primevue/popover';
|
||||||
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
|
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
|
||||||
@@ -531,11 +530,15 @@ const fcOptions = computed(() => ({
|
|||||||
eventDrop: (info) => {
|
eventDrop: (info) => {
|
||||||
if (!M) { info.revert?.(); return; }
|
if (!M) { info.revert?.(); return; }
|
||||||
M.persistMoveOrResize(info, 'Sessão movida');
|
M.persistMoveOrResize(info, 'Sessão movida');
|
||||||
|
// audit_logs grava no AFTER trigger; pequeno delay garante que
|
||||||
|
// a query do histórico já pegue a entrada nova.
|
||||||
|
setTimeout(() => historicoCardRef.value?.refetch(), 700);
|
||||||
},
|
},
|
||||||
// Resize → muda duração da sessão
|
// Resize → muda duração da sessão
|
||||||
eventResize: (info) => {
|
eventResize: (info) => {
|
||||||
if (!M) { info.revert?.(); return; }
|
if (!M) { info.revert?.(); return; }
|
||||||
M.persistMoveOrResize(info, 'Duração alterada');
|
M.persistMoveOrResize(info, 'Duração alterada');
|
||||||
|
setTimeout(() => historicoCardRef.value?.refetch(), 700);
|
||||||
},
|
},
|
||||||
// Click-drag em área vazia → abre dialog pra criar evento novo, com
|
// Click-drag em área vazia → abre dialog pra criar evento novo, com
|
||||||
// start/end pré-preenchidos. AgendaEventDialog cuida do resto (seleção
|
// start/end pré-preenchidos. AgendaEventDialog cuida do resto (seleção
|
||||||
@@ -547,8 +550,18 @@ const fcOptions = computed(() => ({
|
|||||||
eventContent: (arg) => {
|
eventContent: (arg) => {
|
||||||
const ext = arg.event.extendedProps || {};
|
const ext = arg.event.extendedProps || {};
|
||||||
const titulo = arg.event.title || '—';
|
const titulo = arg.event.title || '—';
|
||||||
const time = arg.timeText || '';
|
|
||||||
const isSessao = String(ext.tipo || '').toLowerCase() === 'sessao';
|
const isSessao = String(ext.tipo || '').toLowerCase() === 'sessao';
|
||||||
|
|
||||||
|
const fmtHour = (d) => {
|
||||||
|
if (!d) return '';
|
||||||
|
const h = d.getHours();
|
||||||
|
const m = d.getMinutes();
|
||||||
|
return m === 0 ? `${h}h` : `${h}:${String(m).padStart(2, '0')}h`;
|
||||||
|
};
|
||||||
|
const range = arg.event.start && arg.event.end
|
||||||
|
? `${fmtHour(arg.event.start)}-${fmtHour(arg.event.end)}`
|
||||||
|
: (arg.timeText || '');
|
||||||
|
|
||||||
// Badges só pra sessões — compromissos pessoais/bloqueios/feriados
|
// Badges só pra sessões — compromissos pessoais/bloqueios/feriados
|
||||||
// não têm status nem modalidade relevantes pra exibir.
|
// não têm status nem modalidade relevantes pra exibir.
|
||||||
let badgesHtml = '';
|
let badgesHtml = '';
|
||||||
@@ -567,11 +580,11 @@ const fcOptions = computed(() => ({
|
|||||||
// Pra eventos não-sessão (compromisso, bloqueio etc.) mantém o
|
// Pra eventos não-sessão (compromisso, bloqueio etc.) mantém o
|
||||||
// antigo `__meta` com modalidade ou título secundário.
|
// antigo `__meta` com modalidade ou título secundário.
|
||||||
const metaFallback = !isSessao ? (ext.modalidade || ext.titulo || '') : '';
|
const metaFallback = !isSessao ? (ext.modalidade || ext.titulo || '') : '';
|
||||||
|
const titleLine = `<div class="mc-fc-event__title"><span class="mc-fc-event__name">${escHtml(titulo)}</span>${range ? ` <span class="mc-fc-event__hour">(${escHtml(range)})</span>` : ''}</div>`;
|
||||||
return {
|
return {
|
||||||
html: `
|
html: `
|
||||||
<div class="mc-fc-event">
|
<div class="mc-fc-event">
|
||||||
<div class="mc-fc-event__time">${escHtml(time)}</div>
|
${titleLine}
|
||||||
<div class="mc-fc-event__title">${escHtml(titulo)}</div>
|
|
||||||
${badgesHtml}
|
${badgesHtml}
|
||||||
${metaFallback ? `<div class="mc-fc-event__meta">${escHtml(metaFallback)}</div>` : ''}
|
${metaFallback ? `<div class="mc-fc-event__meta">${escHtml(metaFallback)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -684,6 +697,43 @@ function irParaData(date) {
|
|||||||
searchDateMatch.value = null;
|
searchDateMatch.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Card de histórico (audit_logs) — ref pra disparar refetch após
|
||||||
|
// mutações; handler que abre o evento clicado pelo id.
|
||||||
|
const historicoCardRef = ref(null);
|
||||||
|
async function onHistoricoOpen({ id }) {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('agenda_eventos')
|
||||||
|
.select('*, patients!agenda_eventos_patient_id_fkey(nome_completo, status, avatar_url)')
|
||||||
|
.eq('id', id)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error || !data) {
|
||||||
|
// Evento pode ter sido deletado depois da entrada — fail-soft.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Foca no dia + emite seleção pro MelissaLayout abrir o panel.
|
||||||
|
const ev = {
|
||||||
|
...data,
|
||||||
|
patient_id: data.patient_id,
|
||||||
|
paciente_nome: data.patients?.nome_completo || '',
|
||||||
|
paciente_status: data.patients?.status || '',
|
||||||
|
paciente_avatar: data.patients?.avatar_url || '',
|
||||||
|
startH: new Date(data.inicio_em).getHours() + new Date(data.inicio_em).getMinutes() / 60,
|
||||||
|
endH: new Date(data.fim_em).getHours() + new Date(data.fim_em).getMinutes() / 60,
|
||||||
|
label: data.patients?.nome_completo || data.titulo || data.titulo_custom || '—'
|
||||||
|
};
|
||||||
|
if (data.inicio_em) {
|
||||||
|
fcApi()?.gotoDate(data.inicio_em);
|
||||||
|
refDate.value = new Date(data.inicio_em);
|
||||||
|
}
|
||||||
|
emit('select-evento', ev);
|
||||||
|
} catch (e) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[onHistoricoOpen]', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onSelecionarResultado(ev) {
|
function onSelecionarResultado(ev) {
|
||||||
if (!ev?.inicio_em) return;
|
if (!ev?.inicio_em) return;
|
||||||
fcApi()?.gotoDate(ev.inicio_em);
|
fcApi()?.gotoDate(ev.inicio_em);
|
||||||
@@ -764,12 +814,15 @@ const miniRefDate = ref(new Date());
|
|||||||
|
|
||||||
// ── Feriados (nacionais via algoritmo + municipais/personalizados via DB)
|
// ── Feriados (nacionais via algoritmo + municipais/personalizados via DB)
|
||||||
// + Jornada semanal (workRules) pra marcar dias fechados em cinza no mini-cal.
|
// + Jornada semanal (workRules) pra marcar dias fechados em cinza no mini-cal.
|
||||||
// Pattern espelha AgendaTerapeutaPage:113,129-134,1143.
|
// Reusa as refs do composable injetado M — antes essa página instanciava
|
||||||
// IMPORTANTE: declarado APÓS miniRefDate porque o watch abaixo lê
|
// novamente useFeriados() e useAgendaSettings(), gerando duplicação de
|
||||||
// miniRefDate.value durante o setup (rastreio de dependências).
|
// queries (feriados municipais + agenda_configuracoes + agenda_regras).
|
||||||
const tenantStore = useTenantStore();
|
const tenantStore = useTenantStore();
|
||||||
const { todos: feriadosTodos, load: loadFeriados, ano: feriadosAno, fcEvents: feriadoFcEvents } = useFeriados();
|
const feriadosTodos = M.feriados;
|
||||||
const { workRules, load: loadAgendaSettings } = useAgendaSettings();
|
const feriadoFcEvents = M.feriadoFcEvents;
|
||||||
|
const feriadosAno = M.feriadosAno;
|
||||||
|
const loadFeriados = M.loadFeriadosBase;
|
||||||
|
const workRules = M.workRules;
|
||||||
|
|
||||||
// Set de dias da semana ativos (0=dom..6=sáb). Fallback seg-sex se sem regras
|
// Set de dias da semana ativos (0=dom..6=sáb). Fallback seg-sex se sem regras
|
||||||
// — mesmo default do AgendaTerapeutaPage:370.
|
// — mesmo default do AgendaTerapeutaPage:370.
|
||||||
@@ -779,15 +832,10 @@ const workDowSet = computed(() => {
|
|||||||
return new Set([1, 2, 3, 4, 5]);
|
return new Set([1, 2, 3, 4, 5]);
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
// Carga inicial de feriados/settings já é feita pelo useMelissaAgenda no
|
||||||
const tid = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
|
// mount (watch immediate em clinicTenantId + loadSettings paralelo). Não
|
||||||
if (tid) loadFeriados(tid, new Date().getFullYear());
|
// duplicamos aqui — só mantemos o reload de feriados quando o mini-cal
|
||||||
// workRules é por owner_id (RLS), não precisa do tenant
|
// navega pra outro ano (municipais variam por ano).
|
||||||
loadAgendaSettings();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Recarrega feriados quando mini-cal navega pra outro ano (municipais variam).
|
|
||||||
// Nacionais são puro algoritmo — recomputam automático no useFeriados via `ano`.
|
|
||||||
watch(
|
watch(
|
||||||
() => miniRefDate.value.getFullYear(),
|
() => miniRefDate.value.getFullYear(),
|
||||||
(novoAno) => {
|
(novoAno) => {
|
||||||
@@ -1080,6 +1128,21 @@ function abrirSessoesPaciente() {
|
|||||||
verTodasSessoes.value = true;
|
verTodasSessoes.value = true;
|
||||||
fetchTodasSessoes(pacienteSelecionadoId.value);
|
fetchTodasSessoes(pacienteSelecionadoId.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API pública pro MelissaLayout (botão "Sessões" do MelissaEventoPanel):
|
||||||
|
// seleciona o paciente e abre o overlay "Todas as sessões" no mesmo
|
||||||
|
// fluxo do .ma-dock-actions. Importante: setar pacienteSelecionadoId
|
||||||
|
// ANTES de verTodasSessoes — o watch logo abaixo reseta verTodasSessoes
|
||||||
|
// quando pacienteSelecionadoId muda, então fazemos a ordem inversa.
|
||||||
|
function openSessoesPaciente(patientId) {
|
||||||
|
if (!patientId) return;
|
||||||
|
const id = String(patientId);
|
||||||
|
if (pacienteSelecionadoId.value !== id) {
|
||||||
|
pacienteSelecionadoId.value = id;
|
||||||
|
}
|
||||||
|
verTodasSessoes.value = true;
|
||||||
|
fetchTodasSessoes(id);
|
||||||
|
}
|
||||||
function voltarParaPeriodo() {
|
function voltarParaPeriodo() {
|
||||||
verTodasSessoes.value = false;
|
verTodasSessoes.value = false;
|
||||||
resetTodasSessoes();
|
resetTodasSessoes();
|
||||||
@@ -1125,6 +1188,14 @@ function abrirProntuarioPaciente() {
|
|||||||
prontuarioPatient.value = { ...p };
|
prontuarioPatient.value = { ...p };
|
||||||
prontuarioOpen.value = true;
|
prontuarioOpen.value = true;
|
||||||
}
|
}
|
||||||
|
// API pública pra MelissaLayout chamar via ref (botão "Editar paciente"
|
||||||
|
// do MelissaEventoPanel). Abre o PatientCadastroDialog já no modo edição.
|
||||||
|
function openEditPatient(patientId) {
|
||||||
|
if (!patientId) return;
|
||||||
|
editPatientId.value = String(patientId);
|
||||||
|
cadastroFullDialog.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
function editarPacienteSelecionado() {
|
function editarPacienteSelecionado() {
|
||||||
if (!pacienteSelecionadoId.value) return;
|
if (!pacienteSelecionadoId.value) return;
|
||||||
editPatientId.value = String(pacienteSelecionadoId.value);
|
editPatientId.value = String(pacienteSelecionadoId.value);
|
||||||
@@ -1171,7 +1242,9 @@ function openProntuario(patient) {
|
|||||||
defineExpose({
|
defineExpose({
|
||||||
refetch: refetchEventosFc,
|
refetch: refetchEventosFc,
|
||||||
openProntuario,
|
openProntuario,
|
||||||
setView
|
setView,
|
||||||
|
openSessoesPaciente,
|
||||||
|
openEditPatient
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1887,6 +1960,14 @@ defineExpose({
|
|||||||
@bloqueado="onFeriadoBloqueado"
|
@bloqueado="onFeriadoBloqueado"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Histórico de ações na agenda (audit_logs) — útil pra
|
||||||
|
rastrear movimentações recentes. Click na entrada
|
||||||
|
abre o evento (se ainda existe). -->
|
||||||
|
<MelissaAgendaHistoricoCard
|
||||||
|
ref="historicoCardRef"
|
||||||
|
@open-evento="onHistoricoOpen"
|
||||||
|
/>
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
@@ -3210,22 +3291,26 @@ html.app-dark .ma-tsearch__result--date .ma-tsearch__result-sub {
|
|||||||
color: var(--m-text);
|
color: var(--m-text);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.ma-cal__fc :deep(.mc-fc-event__time) {
|
|
||||||
font-size: 0.6rem;
|
|
||||||
color: var(--m-text-muted);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
.ma-cal__fc :deep(.mc-fc-event__title) {
|
.ma-cal__fc :deep(.mc-fc-event__title) {
|
||||||
/* Mesmo tamanho/peso da .ma-pat__name (lista de pacientes) pra
|
/* Mesmo tamanho/peso da .ma-pat__name (lista de pacientes) pra
|
||||||
alinhar a hierarquia visual entre aside e calendário. */
|
alinhar a hierarquia visual entre aside e calendário.
|
||||||
|
Nome + hora em linha única; ellipsis corta o nome antes da hora. */
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
margin-top: 1px;
|
}
|
||||||
|
.ma-cal__fc :deep(.mc-fc-event__name) {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.ma-cal__fc :deep(.mc-fc-event__hour) {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--m-text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
margin-left: 2px;
|
||||||
}
|
}
|
||||||
.ma-cal__fc :deep(.mc-fc-event__meta) {
|
.ma-cal__fc :deep(.mc-fc-event__meta) {
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
@@ -3495,6 +3580,11 @@ html:not(.app-dark) .ma-cal__fc :deep(.mc-fc-event__badge--modal.is-presencial)
|
|||||||
background: var(--m-bg-medium) !important;
|
background: var(--m-bg-medium) !important;
|
||||||
border-color: var(--m-border) !important;
|
border-color: var(--m-border) !important;
|
||||||
border-radius: 12px !important;
|
border-radius: 12px !important;
|
||||||
|
min-height: 158px;
|
||||||
|
/* flex: 0 0 auto — toma o tamanho natural do conteúdo (incluindo
|
||||||
|
expansão da confirmação inline) e NÃO encolhe quando o histórico
|
||||||
|
crescer. O histórico (flex: 1 abaixo) absorve o restante. */
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
:deep(.ma-w-feriados .border-b) {
|
:deep(.ma-w-feriados .border-b) {
|
||||||
border-color: var(--m-border);
|
border-color: var(--m-border);
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
<!--
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Agência PSI
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Arquivo: src/layout/melissa/MelissaAgendaHistoricoCard.vue
|
||||||
|
| Data: 2026-05-04
|
||||||
|
|
|
||||||
|
| Card de histórico de ações na agenda — mostra movimentações, criações,
|
||||||
|
| status e edições recentes do owner_id logado. Útil quando o user move
|
||||||
|
| várias sessões e quer revisar o que fez.
|
||||||
|
|
|
||||||
|
| Lê de audit_logs via useMelissaAgendaHistorico (já agrega + classifica).
|
||||||
|
| Agrupa por dia (Hoje, Ontem, X dias atrás) pra leitura cronológica.
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
-->
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted } from 'vue';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import { useMelissaAgendaHistorico } from './composables/useMelissaAgendaHistorico';
|
||||||
|
|
||||||
|
const emit = defineEmits(['open-evento']);
|
||||||
|
|
||||||
|
// days=1 já cobre "hoje" no servidor (last 24h); o filtro abaixo refina
|
||||||
|
// pra início do dia local — entradas das últimas 24h que cruzam meia-noite
|
||||||
|
// não devem aparecer no card "de hoje".
|
||||||
|
const { entries, loading, refetch } = useMelissaAgendaHistorico({ limit: 30, days: 1 });
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
onMounted(refetch);
|
||||||
|
|
||||||
|
// Filtra estritamente pro dia atual (00:00 → agora). Audit logs vêm em UTC,
|
||||||
|
// new Date(iso) já normaliza pra timezone local — comparar com início do
|
||||||
|
// dia local (setHours 0,0,0,0) garante consistência.
|
||||||
|
const todaysEntries = computed(() => {
|
||||||
|
const startOfDay = new Date();
|
||||||
|
startOfDay.setHours(0, 0, 0, 0);
|
||||||
|
const startMs = startOfDay.getTime();
|
||||||
|
return entries.value.filter((e) => new Date(e.when).getTime() >= startMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sem agrupamento por dia — só exibe entradas de hoje (sem header de grupo).
|
||||||
|
const items = computed(() => todaysEntries.value);
|
||||||
|
|
||||||
|
function goToHistoricoCompleto() {
|
||||||
|
const target = route.path?.startsWith('/melissa') ? '/melissa/cfg-auditoria' : '/configuracoes/auditoria';
|
||||||
|
router.push(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_META = {
|
||||||
|
create: { icon: 'pi pi-plus-circle', color: '#4ade80', label: 'Criou' },
|
||||||
|
move: { icon: 'pi pi-arrows-alt', color: '#60a5fa', label: 'Moveu' },
|
||||||
|
status: { icon: 'pi pi-tag', color: '#f59e0b', label: 'Status' },
|
||||||
|
edit: { icon: 'pi pi-pencil', color: '#a78bfa', label: 'Editou' },
|
||||||
|
delete: { icon: 'pi pi-trash', color: '#f87171', label: 'Removeu' }
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtRelative(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
|
const min = Math.round(diff / 60000);
|
||||||
|
if (min < 1) return 'agora';
|
||||||
|
if (min < 60) return `${min} min`;
|
||||||
|
const h = Math.round(min / 60);
|
||||||
|
if (h < 24) return `${h}h`;
|
||||||
|
return new Date(iso).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickEntry(entry) {
|
||||||
|
if (entry.kind === 'delete') return; // evento não existe mais
|
||||||
|
if (entry.evento_id) emit('open-evento', { id: entry.evento_id });
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ refetch });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="hist-card">
|
||||||
|
<header class="hist-card__head">
|
||||||
|
<div class="hist-card__title">
|
||||||
|
<i class="pi pi-history" />
|
||||||
|
<span>Histórico</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hist-card__refresh"
|
||||||
|
:disabled="loading"
|
||||||
|
v-tooltip.top="'Atualizar'"
|
||||||
|
@click="refetch"
|
||||||
|
>
|
||||||
|
<i class="pi pi-refresh" :class="{ 'pi-spin': loading }" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="loading && !items.length" class="hist-card__empty">
|
||||||
|
<i class="pi pi-spin pi-spinner" /> Carregando…
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!items.length" class="hist-card__empty">
|
||||||
|
<i class="pi pi-inbox" />
|
||||||
|
<span>Sem ações hoje.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol v-else class="hist-card__list">
|
||||||
|
<li
|
||||||
|
v-for="e in items" :key="e.id"
|
||||||
|
class="hist-card__item"
|
||||||
|
:class="{ 'is-clickable': e.kind !== 'delete' }"
|
||||||
|
:data-kind="e.kind"
|
||||||
|
@click="onClickEntry(e)"
|
||||||
|
>
|
||||||
|
<span class="hist-card__icon" :style="{ color: KIND_META[e.kind]?.color, background: `${KIND_META[e.kind]?.color}1f` }">
|
||||||
|
<i :class="KIND_META[e.kind]?.icon" />
|
||||||
|
</span>
|
||||||
|
<div class="hist-card__body">
|
||||||
|
<div class="hist-card__row1">
|
||||||
|
<span class="hist-card__paciente" v-if="e.paciente">{{ e.paciente }}</span>
|
||||||
|
<span class="hist-card__paciente hist-card__paciente--anon" v-else>—</span>
|
||||||
|
<span class="hist-card__when">{{ fmtRelative(e.when) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="hist-card__label">{{ e.label }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<!-- Atalho pra página de Auditoria completa (todas as entidades,
|
||||||
|
não só agenda — documentos, pacientes, financeiro, etc). -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hist-card__more"
|
||||||
|
v-tooltip.top="'Ver auditoria completa'"
|
||||||
|
@click="goToHistoricoCompleto"
|
||||||
|
>
|
||||||
|
<i class="pi pi-external-link" />
|
||||||
|
<span>Ver histórico completo</span>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hist-card {
|
||||||
|
background: var(--m-bg-soft, rgba(255, 255, 255, 0.04));
|
||||||
|
border: 1px solid var(--m-border, rgba(255, 255, 255, 0.08));
|
||||||
|
border-radius: 14px;
|
||||||
|
backdrop-filter: blur(18px) saturate(140%);
|
||||||
|
-webkit-backdrop-filter: blur(18px) saturate(140%);
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: var(--m-text, white);
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
/* Toma o espaço restante do aside (.ma-widgets é flex column).
|
||||||
|
min-height: 0 é necessário pra flex calcular shrink corretamente
|
||||||
|
quando feriados expande (confirmação inline) e empurra o histórico. */
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.hist-card__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.hist-card__title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--m-text-muted, rgba(255, 255, 255, 0.7));
|
||||||
|
}
|
||||||
|
.hist-card__title i { font-size: 0.78rem; opacity: 0.8; }
|
||||||
|
.hist-card__refresh {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--m-text-muted, rgba(255, 255, 255, 0.55));
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
transition: background-color 140ms ease, color 140ms ease;
|
||||||
|
}
|
||||||
|
.hist-card__refresh:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--m-text, white);
|
||||||
|
}
|
||||||
|
.hist-card__refresh:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hist-card__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 18px 8px;
|
||||||
|
color: var(--m-text-muted, rgba(255, 255, 255, 0.5));
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.hist-card__empty .pi { font-size: 1.2rem; opacity: 0.5; }
|
||||||
|
|
||||||
|
.hist-card__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
/* Toma todo o restante do card. min-height:0 destrava shrink dentro
|
||||||
|
de flex column. Sem max-height fixo: a lista cresce/encolhe junto
|
||||||
|
com o card (que por sua vez balanceia com o feriados acima). */
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.hist-card__list::-webkit-scrollbar { width: 4px; }
|
||||||
|
.hist-card__list::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hist-card__item {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background-color 140ms ease, transform 140ms ease;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.hist-card__item.is-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.hist-card__item.is-clickable:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
transform: translateX(1px);
|
||||||
|
}
|
||||||
|
.hist-card__item[data-kind="delete"] {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.hist-card__icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.hist-card__body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.hist-card__row1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.hist-card__paciente {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--m-text, white);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.hist-card__paciente--anon { font-style: italic; opacity: 0.55; font-weight: 400; }
|
||||||
|
.hist-card__when {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--m-text-muted, rgba(255, 255, 255, 0.5));
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.hist-card__label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--m-text-muted, rgba(255, 255, 255, 0.7));
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Botão "Ver histórico completo" — vai pra AuditoriaPage central
|
||||||
|
(mesmo destino em /melissa/cfg-auditoria ou /configuracoes/auditoria). */
|
||||||
|
.hist-card__more {
|
||||||
|
margin-top: 10px;
|
||||||
|
width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--m-border, rgba(255, 255, 255, 0.18));
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--m-text-muted, rgba(255, 255, 255, 0.7));
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
|
||||||
|
}
|
||||||
|
.hist-card__more:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--m-text, white);
|
||||||
|
border-color: var(--m-border-strong, rgba(255, 255, 255, 0.25));
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
.hist-card__more i { font-size: 0.7rem; opacity: 0.85; }
|
||||||
|
|
||||||
|
/* Light mode adjusts contrast */
|
||||||
|
html:not(.app-dark) .hist-card {
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
border-color: rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
html:not(.app-dark) .hist-card__group-label {
|
||||||
|
background: rgba(248, 250, 252, 0.95);
|
||||||
|
}
|
||||||
|
html:not(.app-dark) .hist-card__item.is-clickable:hover {
|
||||||
|
background: rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -29,7 +29,8 @@ const emit = defineEmits([
|
|||||||
'faltou',
|
'faltou',
|
||||||
'cancelar',
|
'cancelar',
|
||||||
'remarcar',
|
'remarcar',
|
||||||
'edit',
|
'edit-sessao', // botão dedicado ao lado das horas → AgendaEventDialog
|
||||||
|
'edit-paciente', // botão "Editar" do grupo Outras opções → PatientCadastroDialog
|
||||||
'abrir-prontuario',
|
'abrir-prontuario',
|
||||||
'whatsapp',
|
'whatsapp',
|
||||||
'historico'
|
'historico'
|
||||||
@@ -68,9 +69,6 @@ const isSessaoComPaciente = computed(
|
|||||||
() => ev.value.tipo === 'sessao' && (ev.value.patient_id || ev.value.pacienteNome)
|
() => ev.value.tipo === 'sessao' && (ev.value.patient_id || ev.value.pacienteNome)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Status finais não permitem mudar pra outro status (UI mais clara)
|
|
||||||
const statusEhFinal = computed(() => /realizad|faltou|cancelad/i.test(ev.value.status || ''));
|
|
||||||
|
|
||||||
function fmtHora(decimal) {
|
function fmtHora(decimal) {
|
||||||
if (decimal === null || decimal === undefined || Number.isNaN(decimal)) return '—';
|
if (decimal === null || decimal === undefined || Number.isNaN(decimal)) return '—';
|
||||||
const h = Math.floor(decimal);
|
const h = Math.floor(decimal);
|
||||||
@@ -123,6 +121,16 @@ function modalidadeIcon(mod) {
|
|||||||
{{ fmtHora(ev.startH) }} – {{ fmtHora(ev.endH) }}
|
{{ fmtHora(ev.startH) }} – {{ fmtHora(ev.endH) }}
|
||||||
<span v-if="duracaoMin() !== null" class="evento-row__sub">· {{ duracaoMin() }}min</span>
|
<span v-if="duracaoMin() !== null" class="evento-row__sub">· {{ duracaoMin() }}min</span>
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="evento-row__edit"
|
||||||
|
:disabled="busy"
|
||||||
|
v-tooltip.top="'Editar sessão (data, hora, recorrência…)'"
|
||||||
|
@click="emit('edit-sessao')"
|
||||||
|
>
|
||||||
|
<i class="pi pi-pencil" />
|
||||||
|
<span>Editar sessão</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="ev.modalidade" class="evento-row">
|
<div v-if="ev.modalidade" class="evento-row">
|
||||||
@@ -140,83 +148,111 @@ function modalidadeIcon(mod) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action bar — agrupada por contexto -->
|
<!-- Action bar — agrupada por contexto.
|
||||||
|
Cada botão tem ícone + label textual empilhados pra reduzir
|
||||||
|
ambiguidade (tooltip sozinho não é descobrível em touch). -->
|
||||||
<footer class="evento-actions">
|
<footer class="evento-actions">
|
||||||
<!-- Grupo Status — só pra sessão e quando ainda não é status final -->
|
<!-- Grupo Status — sempre visível pra sessão (permite trocar
|
||||||
<div v-if="isSessaoComPaciente && !statusEhFinal" class="evento-actions__group">
|
de status mesmo após marcar realizado/faltou/cancelado).
|
||||||
|
Status atual fica destacado via .is-current. -->
|
||||||
|
<section v-if="isSessaoComPaciente" class="evento-actions__section">
|
||||||
|
<div class="evento-actions__label">Marcar sessão como:</div>
|
||||||
|
<div class="evento-actions__group">
|
||||||
<button
|
<button
|
||||||
class="evento-act evento-act--ok"
|
class="evento-act evento-act--ok"
|
||||||
|
:class="{ 'is-current': statusSlug === 'realizado' }"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
v-tooltip.top="'Marcar como realizada'"
|
|
||||||
@click="emit('concluir')"
|
@click="emit('concluir')"
|
||||||
>
|
>
|
||||||
<i class="pi pi-check-circle" />
|
<i class="pi pi-check-circle" />
|
||||||
|
<span class="evento-act__label">Realizada</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="evento-act evento-act--warn"
|
class="evento-act evento-act--warn"
|
||||||
|
:class="{ 'is-current': statusSlug === 'faltou' }"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
v-tooltip.top="'Marcar como falta'"
|
|
||||||
@click="emit('faltou')"
|
@click="emit('faltou')"
|
||||||
>
|
>
|
||||||
<i class="pi pi-user-minus" />
|
<i class="pi pi-user-minus" />
|
||||||
|
<span class="evento-act__label">Falta</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="evento-act"
|
class="evento-act"
|
||||||
|
:class="{ 'is-current': statusSlug === 'remarcar' || statusSlug === 'remarcado' }"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
v-tooltip.top="'Remarcar'"
|
|
||||||
@click="emit('remarcar')"
|
@click="emit('remarcar')"
|
||||||
>
|
>
|
||||||
<i class="pi pi-calendar-clock" />
|
<i class="pi pi-calendar-clock" />
|
||||||
|
<span class="evento-act__label">Reagendar</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="evento-act evento-act--danger"
|
class="evento-act evento-act--danger"
|
||||||
|
:class="{ 'is-current': statusSlug === 'cancelado' }"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
v-tooltip.top="'Cancelar'"
|
|
||||||
@click="emit('cancelar')"
|
@click="emit('cancelar')"
|
||||||
>
|
>
|
||||||
<i class="pi pi-ban" />
|
<i class="pi pi-ban" />
|
||||||
|
<span class="evento-act__label">Cancelar</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Grupo Paciente — só pra sessão com paciente vinculado -->
|
<!-- Grupo Outras opções — só pra sessão com paciente.
|
||||||
<div v-if="isSessaoComPaciente" class="evento-actions__group">
|
"Editar" abre o cadastro do paciente (não a sessão);
|
||||||
<button
|
pra editar a sessão, usar o botão ao lado das horas. -->
|
||||||
class="evento-act"
|
<section v-if="isSessaoComPaciente" class="evento-actions__section">
|
||||||
:disabled="busy"
|
<div class="evento-actions__label">Outras opções:</div>
|
||||||
v-tooltip.top="'Abrir prontuário'"
|
|
||||||
@click="emit('abrir-prontuario')"
|
|
||||||
>
|
|
||||||
<i class="pi pi-file" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="evento-act"
|
|
||||||
:disabled="busy"
|
|
||||||
v-tooltip.top="'Conversar (WhatsApp)'"
|
|
||||||
@click="emit('whatsapp')"
|
|
||||||
>
|
|
||||||
<i class="pi pi-whatsapp" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="evento-act"
|
|
||||||
:disabled="busy"
|
|
||||||
v-tooltip.top="'Histórico de sessões'"
|
|
||||||
@click="emit('historico')"
|
|
||||||
>
|
|
||||||
<i class="pi pi-history" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Grupo Geral — Editar sempre disponível -->
|
|
||||||
<div class="evento-actions__group">
|
<div class="evento-actions__group">
|
||||||
<button
|
<button
|
||||||
class="evento-act"
|
class="evento-act"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
v-tooltip.top="'Editar evento'"
|
@click="emit('abrir-prontuario')"
|
||||||
@click="emit('edit')"
|
|
||||||
>
|
>
|
||||||
<i class="pi pi-pencil" />
|
<i class="pi pi-file" />
|
||||||
|
<span class="evento-act__label">Prontuário</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="evento-act"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="emit('historico')"
|
||||||
|
>
|
||||||
|
<i class="pi pi-history" />
|
||||||
|
<span class="evento-act__label">Sessões</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="evento-act"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="emit('whatsapp')"
|
||||||
|
>
|
||||||
|
<i class="pi pi-whatsapp" />
|
||||||
|
<span class="evento-act__label">Conversar</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="evento-act"
|
||||||
|
:disabled="busy"
|
||||||
|
v-tooltip.top="'Editar cadastro do paciente'"
|
||||||
|
@click="emit('edit-paciente')"
|
||||||
|
>
|
||||||
|
<i class="pi pi-user-edit" />
|
||||||
|
<span class="evento-act__label">Editar</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Grupo Geral (não-sessão: bloqueio/compromisso/etc).
|
||||||
|
Aqui "Editar" abre o evento em si (não tem paciente). -->
|
||||||
|
<section v-else class="evento-actions__section">
|
||||||
|
<div class="evento-actions__group">
|
||||||
|
<button
|
||||||
|
class="evento-act"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="emit('edit-sessao')"
|
||||||
|
>
|
||||||
|
<i class="pi pi-pencil" />
|
||||||
|
<span class="evento-act__label">Editar</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -333,6 +369,34 @@ function modalidadeIcon(mod) {
|
|||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
/* Botão "Editar sessão" inline na linha das horas. Discreto na largura
|
||||||
|
padrão, ganha destaque no hover. Margin-left auto pra alinhar à direita. */
|
||||||
|
.evento-row__edit {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: var(--m-bg-soft);
|
||||||
|
border: 1px solid var(--m-border);
|
||||||
|
border-radius: 100px;
|
||||||
|
color: var(--m-text-muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
|
||||||
|
}
|
||||||
|
.evento-row__edit:hover:not(:disabled) {
|
||||||
|
background: var(--m-bg-soft-hover);
|
||||||
|
color: var(--m-text);
|
||||||
|
border-color: var(--m-accent, var(--primary-color, #7c6af7));
|
||||||
|
}
|
||||||
|
.evento-row__edit:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.evento-row__edit i { font-size: 0.65rem; }
|
||||||
.evento-status {
|
.evento-status {
|
||||||
padding: 2px 10px;
|
padding: 2px 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -378,25 +442,41 @@ function modalidadeIcon(mod) {
|
|||||||
/* ─── Action bar ────────────────────────────────── */
|
/* ─── Action bar ────────────────────────────────── */
|
||||||
.evento-actions {
|
.evento-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
padding-top: 14px;
|
padding-top: 14px;
|
||||||
border-top: 1px solid var(--m-border);
|
border-top: 1px solid var(--m-border);
|
||||||
justify-content: space-between;
|
}
|
||||||
|
.evento-actions__section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.evento-actions__label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--m-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
font-weight: 600;
|
||||||
|
padding-left: 2px;
|
||||||
}
|
}
|
||||||
.evento-actions__group {
|
.evento-actions__group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
background: var(--m-bg-soft);
|
background: var(--m-bg-soft);
|
||||||
border: 1px solid var(--m-border);
|
border: 1px solid var(--m-border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
.evento-act {
|
.evento-act {
|
||||||
width: 38px;
|
flex: 1;
|
||||||
height: 38px;
|
min-width: 0;
|
||||||
display: grid;
|
display: flex;
|
||||||
place-items: center;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 4px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--m-text);
|
color: var(--m-text);
|
||||||
@@ -406,6 +486,13 @@ function modalidadeIcon(mod) {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
transition: background-color 140ms ease, color 140ms ease, transform 140ms ease;
|
transition: background-color 140ms ease, color 140ms ease, transform 140ms ease;
|
||||||
}
|
}
|
||||||
|
.evento-act__label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
.evento-act:hover:not(:disabled) {
|
.evento-act:hover:not(:disabled) {
|
||||||
background: var(--m-bg-soft-hover);
|
background: var(--m-bg-soft-hover);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
@@ -431,6 +518,29 @@ function modalidadeIcon(mod) {
|
|||||||
background: rgba(239, 68, 68, 0.15);
|
background: rgba(239, 68, 68, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Estado .is-current — sinaliza o status atual da sessão dentro do
|
||||||
|
grupo de actions. Permite que o usuário troque o status mesmo após
|
||||||
|
marcar realizado/faltou/cancelado, vendo qual está ativo. */
|
||||||
|
.evento-act.is-current {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
.evento-act--ok.is-current {
|
||||||
|
color: rgb(16, 185, 129);
|
||||||
|
background: rgba(16, 185, 129, 0.18);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(16, 185, 129, 0.55);
|
||||||
|
}
|
||||||
|
.evento-act--warn.is-current {
|
||||||
|
color: rgb(245, 158, 11);
|
||||||
|
background: rgba(245, 158, 11, 0.18);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.55);
|
||||||
|
}
|
||||||
|
.evento-act--danger.is-current {
|
||||||
|
color: rgb(239, 68, 68);
|
||||||
|
background: rgba(239, 68, 68, 0.18);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
/* Light mode — overlay ainda mais discreto */
|
/* Light mode — overlay ainda mais discreto */
|
||||||
html:not(.app-dark) .evento-layer {
|
html:not(.app-dark) .evento-layer {
|
||||||
background: rgba(15, 23, 42, 0.18);
|
background: rgba(15, 23, 42, 0.18);
|
||||||
|
|||||||
@@ -181,8 +181,15 @@ export function useMelissaAgenda() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── Settings + workRules ────────────────────────────────────
|
// ── Settings + workRules ────────────────────────────────────
|
||||||
const { settings, workRules, load: loadSettings } = useAgendaSettings();
|
// cache: stale-while-revalidate via melissaCacheStore — abertura
|
||||||
const ownerId = computed(() => settings.value?.owner_id || '');
|
// subsequente da Agenda na mesma sessão usa cache instantâneo.
|
||||||
|
const { settings, workRules, load: loadSettings } = useAgendaSettings({ cache: true });
|
||||||
|
// _bootUid: pegado em paralelo no mount via supabase.auth.getUser().
|
||||||
|
// Sem isso, ownerId ficava null até loadSettings completar (~300ms),
|
||||||
|
// bloqueando o primeiro fetch dos eventos. Como owner_id da agenda
|
||||||
|
// é literalmente o uid do user logado, podemos resolver imediato.
|
||||||
|
const _bootUid = ref('');
|
||||||
|
const ownerId = computed(() => settings.value?.owner_id || _bootUid.value || '');
|
||||||
|
|
||||||
// ── Eventos reais (CRUD) ────────────────────────────────────
|
// ── Eventos reais (CRUD) ────────────────────────────────────
|
||||||
const {
|
const {
|
||||||
@@ -245,7 +252,16 @@ export function useMelissaAgenda() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Feriados + commitment services ──────────────────────────
|
// ── Feriados + commitment services ──────────────────────────
|
||||||
const { todos: feriados, fcEvents: feriadoFcEvents, load: loadFeriadosBase } = useFeriados();
|
// Instância única de useFeriados — antes MelissaAgenda.vue criava
|
||||||
|
// sua própria também, fazendo dupla requisição de feriados municipais
|
||||||
|
// toda vez que a agenda abria. Agora MelissaAgenda lê esses refs do
|
||||||
|
// composable injetado (M.feriadosAno, M.loadFeriadosBase, etc).
|
||||||
|
const {
|
||||||
|
todos: feriados,
|
||||||
|
fcEvents: feriadoFcEvents,
|
||||||
|
load: loadFeriadosBase,
|
||||||
|
ano: feriadosAno
|
||||||
|
} = useFeriados({ cache: true });
|
||||||
const { saveRuleItems, propagateToSerie } = useCommitmentServices();
|
const { saveRuleItems, propagateToSerie } = useCommitmentServices();
|
||||||
|
|
||||||
// ── Linhas combinadas (real + virtual) ──────────────────────
|
// ── Linhas combinadas (real + virtual) ──────────────────────
|
||||||
@@ -294,13 +310,18 @@ export function useMelissaAgenda() {
|
|||||||
const e = viewEnd.value;
|
const e = viewEnd.value;
|
||||||
if (!s || !e) return;
|
if (!s || !e) return;
|
||||||
|
|
||||||
// Aguarda ownerId — settings é async
|
// Espera ownerId E tenant — qualquer um faltando significa boot
|
||||||
if (!ownerId.value) {
|
// ainda em curso (auth/tenantStore/settings async). Watcher one-shot
|
||||||
const unwatch = watch(ownerId, async (v) => {
|
// re-dispara assim que o último ficar disponível, sem polling.
|
||||||
if (!v) return;
|
if (!ownerId.value || !clinicTenantId.value) {
|
||||||
|
const unwatch = watch(
|
||||||
|
() => [ownerId.value, clinicTenantId.value],
|
||||||
|
([uid, tid]) => {
|
||||||
|
if (!uid || !tid) return;
|
||||||
unwatch();
|
unwatch();
|
||||||
await _reloadRange();
|
_reloadRange();
|
||||||
});
|
}
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,9 +329,14 @@ export function useMelissaAgenda() {
|
|||||||
const end = new Date(e);
|
const end = new Date(e);
|
||||||
const tid = clinicTenantId.value;
|
const tid = clinicTenantId.value;
|
||||||
|
|
||||||
|
// Etapa 1: eventos reais — `rows` é reativo, FullCalendar re-renderiza
|
||||||
|
// assim que esse await resolve (o user já vê as sessões agendadas).
|
||||||
await loadMyRange(start.toISOString(), end.toISOString(), ownerId.value);
|
await loadMyRange(start.toISOString(), end.toISOString(), ownerId.value);
|
||||||
|
|
||||||
// Expande regras + merge com sessões reais
|
// Etapa 2: ocorrências virtuais (regras de recorrência expandidas).
|
||||||
|
// Continuamos awaitando porque saveRule/cancel dependem do estado
|
||||||
|
// final estar pronto pra UI consistente, mas a janela visual onde
|
||||||
|
// o usuário vê só eventos reais é a metade do tempo de antes.
|
||||||
const merged = await loadAndExpand(ownerId.value, start, end, rows.value, tid);
|
const merged = await loadAndExpand(ownerId.value, start, end, rows.value, tid);
|
||||||
_occurrenceRows.value = merged.filter((r) => r.is_occurrence);
|
_occurrenceRows.value = merged.filter((r) => r.is_occurrence);
|
||||||
}
|
}
|
||||||
@@ -320,8 +346,37 @@ export function useMelissaAgenda() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Inicialização ───────────────────────────────────────────
|
// ── Inicialização ───────────────────────────────────────────
|
||||||
onMounted(async () => {
|
// Boot paralelo: auth uid + tenant + settings todos disparam ao mesmo
|
||||||
await loadSettings();
|
// tempo. Antes era serial (loadSettings precisava terminar pra ownerId
|
||||||
|
// ficar disponível e o watch disparar _reloadRange) — adicionava ~300ms
|
||||||
|
// de waterfall antes da primeira query de eventos sair.
|
||||||
|
onMounted(() => {
|
||||||
|
// 1) Resolve o uid o quanto antes — destrava _reloadRange.
|
||||||
|
// getSession() lê do storage local (fast path, <10ms);
|
||||||
|
// getUser() faria round-trip pro auth server. Fallback pro
|
||||||
|
// getUser só se a sessão ainda não estiver no storage.
|
||||||
|
supabase.auth.getSession()
|
||||||
|
.then(({ data }) => {
|
||||||
|
const uid = data?.session?.user?.id;
|
||||||
|
if (uid) {
|
||||||
|
_bootUid.value = uid;
|
||||||
|
} else {
|
||||||
|
// Cold start sem sessão hidratada — fallback pro round-trip.
|
||||||
|
return supabase.auth.getUser().then(({ data: u }) => {
|
||||||
|
if (u?.user?.id) _bootUid.value = u.user.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { /* noop — settings ainda pode resolver */ });
|
||||||
|
|
||||||
|
// 2) Garante que o tenant está hidratado (idempotente — se já
|
||||||
|
// estiver carregado, retorna imediato).
|
||||||
|
if (typeof tenantStore.ensureLoaded === 'function') {
|
||||||
|
tenantStore.ensureLoaded().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Settings em paralelo (não bloqueia mais nada)
|
||||||
|
loadSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refetch settings + workRules quando o user salva jornada/ritmo/online
|
// Refetch settings + workRules quando o user salva jornada/ritmo/online
|
||||||
@@ -354,11 +409,10 @@ export function useMelissaAgenda() {
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reload quando view muda OU quando settings/ownerId aparece
|
// Reload quando o range visível muda. _reloadRange já tem guard
|
||||||
|
// interno pra esperar uid+tenant (one-shot watcher) — sem necessidade
|
||||||
|
// de outro watch global em ownerId, que disparava _reloadRange duplicado.
|
||||||
watch([viewStart, viewEnd], _reloadRange);
|
watch([viewStart, viewEnd], _reloadRange);
|
||||||
watch(ownerId, (v) => {
|
|
||||||
if (v) _reloadRange();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────
|
||||||
// Handlers — populados na Stage 2
|
// Handlers — populados na Stage 2
|
||||||
@@ -405,6 +459,8 @@ export function useMelissaAgenda() {
|
|||||||
commitmentOptions,
|
commitmentOptions,
|
||||||
feriados,
|
feriados,
|
||||||
feriadoFcEvents,
|
feriadoFcEvents,
|
||||||
|
feriadosAno,
|
||||||
|
loadFeriadosBase,
|
||||||
allEventsForDialog,
|
allEventsForDialog,
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Agência PSI
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Arquivo: src/layout/melissa/composables/useMelissaAgendaHistorico.js
|
||||||
|
| Data: 2026-05-04
|
||||||
|
|
|
||||||
|
| Histórico recente de ações na agenda do terapeuta logado.
|
||||||
|
|
|
||||||
|
| Lê de `audit_logs` (populado automaticamente pela trigger
|
||||||
|
| `trg_audit_agenda_eventos`). Não precisa criar nada — todas as ações
|
||||||
|
| INSERT/UPDATE/DELETE em agenda_eventos já viram linhas auditadas.
|
||||||
|
|
|
||||||
|
| Filtros aplicados:
|
||||||
|
| - entity_type = 'agenda_eventos'
|
||||||
|
| - user_id = uid do user logado (mostra só ações dele)
|
||||||
|
| - created_at >= 7 dias atrás
|
||||||
|
| - tenant_id = tenant ativo
|
||||||
|
| - LIMIT 20 (mais recentes primeiro)
|
||||||
|
|
|
||||||
|
| Pra exibir nome do paciente, fazemos um lookup separado em `patients`
|
||||||
|
| usando os IDs extraídos de new_values/old_values (não dá pra fazer JOIN
|
||||||
|
| na audit_logs porque entity_id é dinâmico).
|
||||||
|
|
|
||||||
|
| Returns:
|
||||||
|
| - entries: ref de objetos normalizados:
|
||||||
|
| { id, kind, label, when, paciente, evento_id, raw }
|
||||||
|
| onde kind ∈ { 'create' | 'move' | 'status' | 'edit' | 'delete' }
|
||||||
|
| - loading: ref<boolean>
|
||||||
|
| - refetch: function()
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { supabase } from '@/lib/supabase/client';
|
||||||
|
import { useTenantStore } from '@/stores/tenantStore';
|
||||||
|
|
||||||
|
const STATUS_LABEL = {
|
||||||
|
agendado: 'Agendado',
|
||||||
|
realizado: 'Realizada',
|
||||||
|
realizada: 'Realizada',
|
||||||
|
faltou: 'Falta',
|
||||||
|
cancelado: 'Cancelada',
|
||||||
|
cancelada: 'Cancelada',
|
||||||
|
remarcar: 'Remarcar',
|
||||||
|
remarcado: 'Remarcado',
|
||||||
|
confirmado: 'Confirmada'
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtTime(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
function fmtDateBR(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classifica a entrada pra um dos 5 "kinds" visuais. Decisão por
|
||||||
|
// changed_fields quando action=update — ordem importa: hora primeiro
|
||||||
|
// (mais frequente em movimentação), depois status, depois "edit" genérico.
|
||||||
|
function classify(row) {
|
||||||
|
const action = String(row.action || '').toLowerCase();
|
||||||
|
if (action === 'insert') return 'create';
|
||||||
|
if (action === 'delete') return 'delete';
|
||||||
|
if (action === 'update') {
|
||||||
|
const fields = new Set(row.changed_fields || []);
|
||||||
|
if (fields.has('inicio_em') || fields.has('fim_em')) return 'move';
|
||||||
|
if (fields.has('status')) return 'status';
|
||||||
|
return 'edit';
|
||||||
|
}
|
||||||
|
return 'edit';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLabel(kind, row) {
|
||||||
|
const oldV = row.old_values || {};
|
||||||
|
const newV = row.new_values || {};
|
||||||
|
switch (kind) {
|
||||||
|
case 'create': {
|
||||||
|
const ini = newV.inicio_em ? `${fmtDateBR(newV.inicio_em)} ${fmtTime(newV.inicio_em)}` : '—';
|
||||||
|
return `Criou sessão em ${ini}`;
|
||||||
|
}
|
||||||
|
case 'delete': {
|
||||||
|
const ini = oldV.inicio_em ? `${fmtDateBR(oldV.inicio_em)} ${fmtTime(oldV.inicio_em)}` : '—';
|
||||||
|
return `Removeu sessão de ${ini}`;
|
||||||
|
}
|
||||||
|
case 'move': {
|
||||||
|
const from = oldV.inicio_em ? `${fmtDateBR(oldV.inicio_em)} ${fmtTime(oldV.inicio_em)}` : '—';
|
||||||
|
const to = newV.inicio_em ? `${fmtDateBR(newV.inicio_em)} ${fmtTime(newV.inicio_em)}` : '—';
|
||||||
|
return `Moveu ${from} → ${to}`;
|
||||||
|
}
|
||||||
|
case 'status': {
|
||||||
|
const lbl = STATUS_LABEL[String(newV.status || '').toLowerCase()] || newV.status || '—';
|
||||||
|
return `Status: ${lbl}`;
|
||||||
|
}
|
||||||
|
case 'edit':
|
||||||
|
default: {
|
||||||
|
const fields = (row.changed_fields || []).filter((f) => f !== 'updated_at');
|
||||||
|
if (!fields.length) return 'Editou';
|
||||||
|
return `Editou ${fields.join(', ')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve o ID do paciente a partir de new_values/old_values (delete usa OLD).
|
||||||
|
function extractPatientId(row) {
|
||||||
|
return row.new_values?.patient_id || row.old_values?.patient_id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMelissaAgendaHistorico(opts = {}) {
|
||||||
|
const limit = opts.limit ?? 20;
|
||||||
|
const days = opts.days ?? 7;
|
||||||
|
|
||||||
|
const tenantStore = useTenantStore();
|
||||||
|
const entries = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
|
async function _ensureUid() {
|
||||||
|
const { data: ses } = await supabase.auth.getSession();
|
||||||
|
if (ses?.session?.user?.id) return ses.session.user.id;
|
||||||
|
const { data, error: err } = await supabase.auth.getUser();
|
||||||
|
if (err) return null;
|
||||||
|
return data?.user?.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refetch() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const userId = await _ensureUid();
|
||||||
|
if (typeof tenantStore.ensureLoaded === 'function') {
|
||||||
|
await tenantStore.ensureLoaded();
|
||||||
|
}
|
||||||
|
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null;
|
||||||
|
if (!userId || !tid) {
|
||||||
|
entries.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
const { data: rows, error: err } = await supabase
|
||||||
|
.from('audit_logs')
|
||||||
|
.select('id, action, entity_id, changed_fields, old_values, new_values, created_at, user_id, tenant_id')
|
||||||
|
.eq('entity_type', 'agenda_eventos')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.eq('tenant_id', tid)
|
||||||
|
.gte('created_at', since)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
if (err) throw err;
|
||||||
|
|
||||||
|
const list = rows || [];
|
||||||
|
|
||||||
|
// Resolve nomes dos pacientes em uma única query.
|
||||||
|
const patientIds = [...new Set(list.map(extractPatientId).filter(Boolean))];
|
||||||
|
const patientMap = new Map();
|
||||||
|
if (patientIds.length) {
|
||||||
|
const { data: pats } = await supabase
|
||||||
|
.from('patients')
|
||||||
|
.select('id, nome_completo')
|
||||||
|
.in('id', patientIds);
|
||||||
|
for (const p of pats || []) patientMap.set(p.id, p.nome_completo);
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.value = list.map((r) => {
|
||||||
|
const kind = classify(r);
|
||||||
|
const pid = extractPatientId(r);
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
kind,
|
||||||
|
label: buildLabel(kind, r),
|
||||||
|
when: r.created_at,
|
||||||
|
paciente: pid ? (patientMap.get(pid) || '') : '',
|
||||||
|
evento_id: r.entity_id,
|
||||||
|
raw: r
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e?.message || 'Falha ao carregar histórico';
|
||||||
|
entries.value = [];
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[useMelissaAgendaHistorico]', e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { entries, loading, error, refetch };
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Agência PSI
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Arquivo: src/layout/melissa/composables/useMelissaDockPins.js
|
||||||
|
| Data: 2026-05-04
|
||||||
|
|
|
||||||
|
| Pins dinâmicos do dock Melissa — modelo híbrido:
|
||||||
|
|
|
||||||
|
| - PINNED (manual, max 4): user fixa via menu de contexto, persiste
|
||||||
|
| entre sessões em localStorage. Sempre visíveis, ordenados por ordem
|
||||||
|
| de fixação.
|
||||||
|
| - RECENT (MRU automático, max 3): toda vez que o user abre uma seção
|
||||||
|
| que NÃO é built-in (agenda/conversas) e NÃO tá pinned, vira o pin
|
||||||
|
| temporário mais recente, empurrando os mais antigos pra fora.
|
||||||
|
|
|
||||||
|
| Persistência: localStorage com chave `melissa.dock.pins.v1`. Salva só
|
||||||
|
| slugs de seção (string), nada de dado clínico — LGPD-safe. Singleton via
|
||||||
|
| módulo (estado fora da função) pra todas as instâncias compartilharem.
|
||||||
|
|
|
||||||
|
| Builtin (não-pinnável, não-recente): agenda, conversas — esses já têm
|
||||||
|
| pin permanente próprio no template (.dock-pin com hardcode).
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'melissa.dock.pins.v1';
|
||||||
|
const MAX_PINNED = 4;
|
||||||
|
const MAX_RECENT = 3;
|
||||||
|
const BUILTIN_SLUGS = new Set(['agenda', 'conversas']);
|
||||||
|
|
||||||
|
// Estado singleton compartilhado entre todas as instâncias.
|
||||||
|
const pinned = ref([]);
|
||||||
|
const recent = ref([]);
|
||||||
|
let _hydrated = false;
|
||||||
|
|
||||||
|
function _hydrate() {
|
||||||
|
if (_hydrated) return;
|
||||||
|
_hydrated = true;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed?.pinned)) {
|
||||||
|
pinned.value = parsed.pinned.filter((s) => typeof s === 'string').slice(0, MAX_PINNED);
|
||||||
|
}
|
||||||
|
if (Array.isArray(parsed?.recent)) {
|
||||||
|
recent.value = parsed.recent.filter((s) => typeof s === 'string').slice(0, MAX_RECENT);
|
||||||
|
}
|
||||||
|
} catch { /* localStorage corrompido — ignora silenciosamente */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _persist() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||||
|
pinned: pinned.value,
|
||||||
|
recent: recent.value
|
||||||
|
}));
|
||||||
|
} catch { /* quota excedida ou storage desabilitado — ok, em memória */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
let _persistWatcherActive = false;
|
||||||
|
function _ensurePersistWatcher() {
|
||||||
|
if (_persistWatcherActive) return;
|
||||||
|
_persistWatcherActive = true;
|
||||||
|
watch([pinned, recent], _persist, { deep: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMelissaDockPins() {
|
||||||
|
_hydrate();
|
||||||
|
_ensurePersistWatcher();
|
||||||
|
|
||||||
|
function isBuiltin(slug) {
|
||||||
|
return BUILTIN_SLUGS.has(slug);
|
||||||
|
}
|
||||||
|
function isPinned(slug) {
|
||||||
|
return pinned.value.includes(slug);
|
||||||
|
}
|
||||||
|
function isRecent(slug) {
|
||||||
|
return recent.value.includes(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chamado quando o user abre uma seção. Builtins e já-pinned não viram
|
||||||
|
// recent (não duplica). Mais recente entra no topo, expulsa o mais
|
||||||
|
// antigo se passar do limite.
|
||||||
|
function pushRecent(slug) {
|
||||||
|
if (!slug || isBuiltin(slug) || isPinned(slug)) return;
|
||||||
|
recent.value = [slug, ...recent.value.filter((s) => s !== slug)].slice(0, MAX_RECENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move um slug de "recent" pra "pinned" (ou cria pinned direto).
|
||||||
|
// Retorna { ok, reason } — reason='full' quando já tem 4 pinned.
|
||||||
|
function pin(slug) {
|
||||||
|
if (!slug || isBuiltin(slug)) return { ok: false, reason: 'builtin' };
|
||||||
|
if (isPinned(slug)) return { ok: true, reason: 'already' };
|
||||||
|
if (pinned.value.length >= MAX_PINNED) return { ok: false, reason: 'full' };
|
||||||
|
recent.value = recent.value.filter((s) => s !== slug);
|
||||||
|
pinned.value = [...pinned.value, slug];
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tira de "pinned" — não volta automaticamente pra recent (o user
|
||||||
|
// explicitamente desafixou). Próxima abertura da seção vai pra recent
|
||||||
|
// pelo fluxo normal de pushRecent.
|
||||||
|
function unpin(slug) {
|
||||||
|
pinned.value = pinned.value.filter((s) => s !== slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove completamente (de ambas as listas). Usado pelo "Remover" do menu.
|
||||||
|
function remove(slug) {
|
||||||
|
pinned.value = pinned.value.filter((s) => s !== slug);
|
||||||
|
recent.value = recent.value.filter((s) => s !== slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
pinned.value = [];
|
||||||
|
recent.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pinned,
|
||||||
|
recent,
|
||||||
|
isBuiltin,
|
||||||
|
isPinned,
|
||||||
|
isRecent,
|
||||||
|
pushRecent,
|
||||||
|
pin,
|
||||||
|
unpin,
|
||||||
|
remove,
|
||||||
|
clearAll,
|
||||||
|
MAX_PINNED,
|
||||||
|
MAX_RECENT
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
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';
|
||||||
|
|
||||||
// ── 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) {
|
||||||
@@ -319,17 +320,49 @@ export function useMelissaTodasSessoesPaciente() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── COMPOSABLE 2: apenas hoje (MelissaLayout) ──────────────────
|
// ── COMPOSABLE 2: apenas hoje (MelissaLayout) ──────────────────
|
||||||
export function useMelissaEventosHoje() {
|
// opts: { autoFetch=true } — passar false pra adiar o fetch inicial
|
||||||
|
// (MelissaLayout faz isso quando a URL inicial já tem uma seção, pra
|
||||||
|
// não competir com o fetch da seção que vai cobrir o resumo).
|
||||||
|
export function useMelissaEventosHoje(opts = {}) {
|
||||||
|
const autoFetch = opts.autoFetch !== false;
|
||||||
|
const cache = useMelissaCacheStore();
|
||||||
const eventos = ref([]);
|
const eventos = ref([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref(null);
|
const error = ref(null);
|
||||||
|
|
||||||
async function fetch() {
|
async function _doFetch(cacheKey) {
|
||||||
|
const { start, end } = rangeHoje();
|
||||||
|
const data = await _fetchRange(start, end);
|
||||||
|
cache.set('eventosHoje', data, cacheKey);
|
||||||
|
eventos.value = data;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// useCache=true (boot/auto): stale-while-revalidate.
|
||||||
|
// useCache=false (refetch pós-mutation: status sessão, etc): força.
|
||||||
|
async function _fetch({ useCache = true } = {}) {
|
||||||
|
const today = new Date();
|
||||||
|
// Cache key amarra ao dia — depois de 00:00 vira automaticamente outro slot.
|
||||||
|
const cacheKey = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
|
||||||
|
|
||||||
|
if (useCache) {
|
||||||
|
const cached = cache.get('eventosHoje', cacheKey, MELISSA_CACHE_TTL.eventosHoje);
|
||||||
|
if (cached) {
|
||||||
|
eventos.value = cached;
|
||||||
|
_doFetch(cacheKey).catch((e) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[useMelissaEventosHoje] revalidate', e);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cache.invalidate('eventosHoje');
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
const { start, end } = rangeHoje();
|
await _doFetch(cacheKey);
|
||||||
eventos.value = await _fetchRange(start, end);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e?.message || 'Erro ao carregar agenda';
|
error.value = e?.message || 'Erro ao carregar agenda';
|
||||||
eventos.value = [];
|
eventos.value = [];
|
||||||
@@ -340,7 +373,15 @@ export function useMelissaEventosHoje() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(fetch);
|
if (autoFetch) onMounted(() => _fetch({ useCache: true }));
|
||||||
|
|
||||||
return { eventos, loading, error, refetch: fetch };
|
return {
|
||||||
|
eventos,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
// refetch força query nova (após status update etc).
|
||||||
|
refetch: () => _fetch({ useCache: false }),
|
||||||
|
// fetchCached é stale-while-revalidate (idle/defer).
|
||||||
|
fetchCached: () => _fetch({ useCache: true })
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Agência PSI
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Arquivo: src/stores/melissaCacheStore.js
|
||||||
|
| Data: 2026-05-04
|
||||||
|
|
|
||||||
|
| Cache in-memory (Pinia) com stale-while-revalidate pra dados que o
|
||||||
|
| Melissa Layout consome em todas as visitas e raramente mudam:
|
||||||
|
| - pacientesTimeline: lista de pacientes do tenant (1000)
|
||||||
|
| - eventosHoje: eventos do dia (resumo)
|
||||||
|
| - feriados: municipais + globais por (tenant_id, ano)
|
||||||
|
| - agendaSettings: configurações + workRules do owner
|
||||||
|
|
|
||||||
|
| LGPD: tudo só em RAM. Some ao recarregar a aba ou trocar de sessão. Nunca
|
||||||
|
| persistido em localStorage/IndexedDB porque contém dados clínicos.
|
||||||
|
|
|
||||||
|
| Pattern de uso (composable):
|
||||||
|
| const cached = cache.get('pacientesTimeline', key, TTL.pacientes);
|
||||||
|
| if (cached) { ref.value = cached; refetchInBackground(); return; }
|
||||||
|
| const fresh = await fetch();
|
||||||
|
| cache.set('pacientesTimeline', fresh, key);
|
||||||
|
|
|
||||||
|
| Invalidação manual: chamar `cache.invalidate('slot')` em mutations
|
||||||
|
| (ex: criar paciente → invalidate('pacientesTimeline')).
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
|
||||||
|
// Time-to-live por slot (ms). Slots de dados que mudam pouco ganham TTL
|
||||||
|
// mais longo; eventos do dia ganham TTL curto pra não mostrar lista
|
||||||
|
// desatualizada se uma sessão foi marcada/cancelada em outra aba.
|
||||||
|
export const MELISSA_CACHE_TTL = {
|
||||||
|
pacientesTimeline: 5 * 60 * 1000, // 5 min
|
||||||
|
eventosHoje: 90 * 1000, // 90 s
|
||||||
|
feriados: 60 * 60 * 1000, // 1 h
|
||||||
|
agendaSettings: 5 * 60 * 1000 // 5 min
|
||||||
|
};
|
||||||
|
|
||||||
|
function emptySlot() {
|
||||||
|
return { data: null, ts: 0, key: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMelissaCacheStore = defineStore('melissaCache', {
|
||||||
|
state: () => ({
|
||||||
|
pacientesTimeline: emptySlot(),
|
||||||
|
eventosHoje: emptySlot(),
|
||||||
|
feriados: emptySlot(),
|
||||||
|
agendaSettings: emptySlot()
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
// Retorna data se houver cache válido pro `slot` E se a `key` bater
|
||||||
|
// (key encapsula contexto: uid, tenant, ano, dia — o que mudar
|
||||||
|
// invalida o slot automaticamente). Retorna null se inválido/expirado.
|
||||||
|
get(slot, key, ttl) {
|
||||||
|
const s = this[slot];
|
||||||
|
if (!s?.ts) return null;
|
||||||
|
if (key !== undefined && s.key !== key) return null;
|
||||||
|
if (Date.now() - s.ts > ttl) return null;
|
||||||
|
return s.data;
|
||||||
|
},
|
||||||
|
set(slot, data, key) {
|
||||||
|
this[slot] = { data, ts: Date.now(), key: key ?? null };
|
||||||
|
},
|
||||||
|
invalidate(slot) {
|
||||||
|
this[slot] = emptySlot();
|
||||||
|
},
|
||||||
|
invalidateAll() {
|
||||||
|
for (const k of Object.keys(this.$state)) this.invalidate(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user