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:
Leonardo
2026-05-06 09:11:55 -03:00
parent 86311ef305
commit 957e912a7f
19 changed files with 5203 additions and 285 deletions
@@ -309,7 +309,6 @@ function buildFcOptions(ownerId) {
const nome = ext.paciente_nome || '';
const obs = ext.observacoes || '';
const title = arg.event.title || '';
const timeText = arg.timeText || '';
const pacienteStatus = ext.paciente_status || '';
const esc = (s) =>
@@ -326,21 +325,28 @@ function buildFcOptions(ownerId) {
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 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 =
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 {
html: `<div class="ev-custom">
${avatarHtml}
<div class="ev-body">
${timeHtml}
<div class="ev-title">${esc(title)}</div>
${titleLine}
${statusBadge}
${obsHtml}
</div>
@@ -475,20 +481,34 @@ function buildFcOptions(ownerId) {
flex: 1;
overflow: hidden;
}
.ev-time {
font-size: 10px;
opacity: 0.8;
.ev-title {
font-size: 11px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ev-title {
font-size: 11px;
.ev-name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.ev-hour {
font-weight: 400;
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 {
font-size: 10px;
@@ -16,20 +16,53 @@
*/
import { ref } from 'vue';
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 error = ref('');
const settings = ref(null);
const workRules = ref([]); // [{ dia_semana, hora_inicio, hora_fim }]
async function _doFetch() {
const [cfg, rules] = await Promise.all([getMyAgendaSettings(), getMyWorkSchedule()]);
settings.value = cfg;
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 {
const [cfg, rules] = await Promise.all([getMyAgendaSettings(), getMyWorkSchedule()]);
settings.value = cfg;
workRules.value = rules;
await _doFetch();
} catch (e) {
error.value = e?.message || 'Falha ao carregar configurações da agenda.';
settings.value = null;
@@ -742,7 +742,6 @@ const fcOptions = computed(() => ({
const nome = ext.paciente_nome || '';
const obs = ext.observacoes || '';
const title = arg.event.title || '';
const timeText = arg.timeText || '';
const pacienteStatus = ext.paciente_status || '';
const esc = (s) =>
@@ -759,21 +758,28 @@ const fcOptions = computed(() => ({
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 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 =
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 {
html: `<div class="ev-custom">
${avatarHtml}
<div class="ev-body">
${timeHtml}
<div class="ev-title">${esc(title)}</div>
${titleLine}
${inativoBadge}
${obsHtml}
</div>
@@ -3424,20 +3430,34 @@ onBeforeUnmount(() => {
flex: 1;
overflow: hidden;
}
.ev-time {
font-size: 10px;
opacity: 0.8;
.ev-title {
font-size: 11px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ev-title {
font-size: 11px;
.ev-name {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.ev-hour {
font-weight: 400;
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 {
font-size: 10px;
+64 -18
View File
@@ -180,13 +180,10 @@ watch(filters, () => fetchDocuments(), { deep: true })
EMBEDDED MODE dentro do prontuário (sem hero, layout compacto)
-->
<div v-if="embedded">
<!-- Header compacto -->
<div class="flex items-center justify-between gap-2 mb-4">
<span class="text-sm font-semibold text-[var(--text-color-secondary)] uppercase tracking-wider">Documentos</span>
<div class="flex gap-1.5">
<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>
<!-- Header compacto: ações alinhadas à direita, sem label -->
<div class="flex items-center justify-end gap-2 mb-4">
<Button label="Upload" icon="pi pi-upload" size="small" outlined class="rounded-full" @click="uploadDlg = true" />
<Button label="Template" icon="pi pi-file-pdf" size="small" class="rounded-full" @click="generateDlg = true" />
</div>
<!-- Loading -->
@@ -195,11 +192,11 @@ watch(filters, () => fetchDocuments(), { deep: true })
</div>
<!-- Empty -->
<div v-else-if="!documents.length" class="py-10 px-6 text-center">
<i class="pi pi-inbox text-2xl opacity-20 mb-2 block" />
<div class="font-semibold text-sm">Nenhum documento ainda</div>
<div class="text-xs opacity-60 mt-1">Faça upload do primeiro documento 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" />
<div v-else-if="!documents.length" class="empty-rich">
<div class="empty-rich__icon"><i class="pi pi-folder-open" /></div>
<div class="empty-rich__title">Nenhum documento ainda</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" class="empty-rich__cta rounded-full" @click="uploadDlg = true" />
</div>
<!-- Lista -->
@@ -331,15 +328,17 @@ watch(filters, () => fetchDocuments(), { deep: true })
</div>
<!-- Empty -->
<div v-else-if="!documents.length" class="py-10 px-6 text-center">
<i class="pi pi-inbox text-2xl opacity-20 mb-2 block" />
<div class="font-semibold text-sm">
<div v-else-if="!documents.length" class="empty-rich m-4">
<div class="empty-rich__icon">
<i :class="hasActiveFilter ? 'pi pi-filter-slash' : 'pi pi-folder-open'" />
</div>
<div class="empty-rich__title">
{{ hasActiveFilter ? 'Nenhum documento encontrado' : 'Nenhum documento ainda' }}
</div>
<div class="text-xs opacity-60 mt-1">
{{ hasActiveFilter ? 'Limpe os filtros ou ajuste a busca' : resolvedPatientId ? 'Faça upload do primeiro documento' : 'Selecione um paciente para adicionar documentos' }}
<div class="empty-rich__sub">
{{ 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>
<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>
<!-- Lista -->
@@ -377,3 +376,50 @@ watch(filters, () => fetchDocuments(), { deep: true })
<DocumentShareDialog :visible="shareDlg" @update:visible="shareDlg = $event" :doc="selectedDoc" />
<ConfirmDialog />
</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>
<!-- 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)]">
<i class="pi pi-comments text-4xl opacity-30" />
<div class="text-sm">Nenhuma conversa registrada com este paciente ainda.</div>
<div class="text-xs opacity-70">
<div v-else-if="!messages.length" class="empty-rich">
<div class="empty-rich__icon"><i class="pi pi-comments" /></div>
<div class="empty-rich__title">Nenhuma conversa registrada</div>
<div class="empty-rich__sub">
Quando {{ props.patientName || 'o paciente' }} enviar uma mensagem pelo WhatsApp (ou você enviar uma), vai aparecer aqui.
</div>
</div>
@@ -219,3 +219,48 @@ watch(() => props.patientId, () => { load(); });
</div>
</div>
</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