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:
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user