Files
agenciapsilmno/src/features/patients/prontuario/PatientConversationsTab.vue
T
Leonardo 957e912a7f 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>
2026-05-06 09:11:55 -03:00

267 lines
12 KiB
Vue

<!--
|--------------------------------------------------------------------------
| Agência PSI Histórico de conversas na ficha do paciente (CRM 3.6)
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import { formatDistanceToNow, format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
const props = defineProps({
patientId: { type: String, required: true },
patientName: { type: String, default: '' }
});
const drawerStore = useConversationDrawerStore();
const loading = ref(false);
const messages = ref([]);
const mediaUrls = ref({});
const filter = ref('all'); // all | inbound | outbound
const CHANNEL_ICON = {
whatsapp: 'pi pi-whatsapp',
sms: 'pi pi-comment',
email: 'pi pi-envelope'
};
async function load() {
if (!props.patientId) return;
loading.value = true;
try {
const { data, error } = await supabase
.from('conversation_messages')
.select('id, channel, direction, from_number, to_number, body, media_url, media_mime, provider, kanban_status, received_at, created_at, responded_at, delivery_status')
.eq('patient_id', props.patientId)
.order('created_at', { ascending: true })
.limit(500);
if (error) throw error;
messages.value = data || [];
// Resolve signed URLs pra mídia do bucket
for (const m of messages.value) {
if (m.media_url && !/^https?:\/\//i.test(m.media_url)) {
const { data: signed } = await supabase.storage.from('whatsapp-media').createSignedUrl(m.media_url, 3600);
if (signed?.signedUrl) mediaUrls.value[m.id] = signed.signedUrl;
} else if (m.media_url) {
mediaUrls.value[m.id] = m.media_url;
}
}
} catch (e) {
console.error('[PatientConversationsTab] load:', e?.message);
messages.value = [];
} finally {
loading.value = false;
}
}
const stats = computed(() => {
const total = messages.value.length;
const inbound = messages.value.filter((m) => m.direction === 'inbound').length;
const outbound = messages.value.filter((m) => m.direction === 'outbound').length;
const first = messages.value[0]?.created_at || null;
const last = messages.value[messages.value.length - 1]?.created_at || null;
const channels = new Set(messages.value.map((m) => m.channel));
return { total, inbound, outbound, first, last, channels: [...channels] };
});
const filtered = computed(() => {
if (filter.value === 'all') return messages.value;
return messages.value.filter((m) => m.direction === filter.value);
});
function fmtTime(iso) {
if (!iso) return '';
try {
return format(new Date(iso), "dd 'de' MMM HH:mm", { locale: ptBR });
} catch { return ''; }
}
function fmtRelative(iso) {
if (!iso) return '';
try {
return formatDistanceToNow(new Date(iso), { addSuffix: true, locale: ptBR });
} catch { return ''; }
}
function isImage(mime) { return !!mime && mime.startsWith('image/'); }
function isAudio(mime) { return !!mime && mime.startsWith('audio/'); }
function isVideo(mime) { return !!mime && mime.startsWith('video/'); }
function openInDrawer() {
drawerStore.openForPatient(props.patientId);
}
onMounted(() => { load(); });
watch(() => props.patientId, () => { load(); });
</script>
<template>
<div class="flex flex-col gap-3 p-2">
<!-- Header com stats + ação -->
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="flex items-center gap-4 flex-wrap">
<div class="flex flex-col">
<span class="text-[0.7rem] uppercase tracking-wider font-semibold text-[var(--text-color-secondary)]">Mensagens</span>
<span class="text-xl font-bold leading-none">{{ stats.total }}</span>
</div>
<div class="h-8 w-px bg-[var(--surface-border)]" />
<div class="flex flex-col">
<span class="text-[0.7rem] uppercase tracking-wider font-semibold text-[var(--text-color-secondary)]">Recebidas</span>
<span class="text-base font-bold text-green-600 leading-none">{{ stats.inbound }}</span>
</div>
<div class="flex flex-col">
<span class="text-[0.7rem] uppercase tracking-wider font-semibold text-[var(--text-color-secondary)]">Enviadas</span>
<span class="text-base font-bold text-sky-600 leading-none">{{ stats.outbound }}</span>
</div>
<div v-if="stats.first" class="flex flex-col">
<span class="text-[0.7rem] uppercase tracking-wider font-semibold text-[var(--text-color-secondary)]">Primeira</span>
<span class="text-xs leading-tight">{{ fmtTime(stats.first) }}</span>
</div>
<div v-if="stats.last" class="flex flex-col">
<span class="text-[0.7rem] uppercase tracking-wider font-semibold text-[var(--text-color-secondary)]">Última</span>
<span class="text-xs leading-tight">{{ fmtRelative(stats.last) }}</span>
</div>
</div>
<div class="flex items-center gap-2">
<SelectButton
v-model="filter"
:options="[
{ label: 'Todas', value: 'all' },
{ label: 'Recebidas', value: 'inbound' },
{ label: 'Enviadas', value: 'outbound' }
]"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
size="small"
/>
<Button
label="Abrir no CRM"
icon="pi pi-external-link"
severity="primary"
size="small"
class="rounded-full"
:disabled="!messages.length"
v-tooltip.top="'Abre o drawer de conversa com histórico completo + compose'"
@click="openInDrawer"
/>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="flex flex-col gap-2">
<Skeleton v-for="n in 5" :key="n" height="4rem" class="rounded-md" />
</div>
<!-- Empty -->
<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>
<!-- Timeline -->
<div v-else class="flex flex-col gap-1.5">
<div
v-for="m in filtered"
:key="m.id"
class="flex gap-2 items-start p-2 rounded-md border"
:class="m.direction === 'inbound'
? 'bg-[var(--surface-ground)] border-[var(--surface-border)]'
: 'bg-emerald-500/5 border-emerald-500/20'"
>
<div class="w-7 h-7 rounded-full grid place-items-center shrink-0"
:class="m.direction === 'inbound' ? 'bg-sky-500/15 text-sky-500' : 'bg-emerald-500/15 text-emerald-600'">
<i :class="m.direction === 'inbound' ? 'pi pi-arrow-down' : 'pi pi-arrow-up'" class="text-[0.7rem]" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 text-[0.68rem] text-[var(--text-color-secondary)] mb-0.5 flex-wrap">
<i :class="CHANNEL_ICON[m.channel] || 'pi pi-comment'" class="text-[0.65rem]" />
<span class="font-semibold">{{ m.direction === 'inbound' ? 'Paciente' : 'Clínica' }}</span>
<span class="opacity-50">·</span>
<span>{{ fmtTime(m.created_at) }}</span>
<span v-if="m.direction === 'outbound' && m.delivery_status === 'read'" class="text-sky-500" v-tooltip.top="'Lida'">
<i class="pi pi-check-double text-[0.6rem]" />
</span>
<span v-else-if="m.direction === 'outbound' && m.delivery_status === 'delivered'" v-tooltip.top="'Entregue'">
<i class="pi pi-check-double text-[0.6rem] opacity-60" />
</span>
</div>
<!-- Mídia -->
<template v-if="m.media_url">
<div v-if="!mediaUrls[m.id]" class="text-xs italic text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner mr-1" /> carregando mídia
</div>
<template v-else>
<img v-if="isImage(m.media_mime)" :src="mediaUrls[m.id]" class="max-w-[200px] rounded-md mt-0.5" />
<audio v-else-if="isAudio(m.media_mime)" :src="mediaUrls[m.id]" controls preload="metadata" class="block max-w-full mt-0.5" />
<video v-else-if="isVideo(m.media_mime)" :src="mediaUrls[m.id]" controls class="max-w-[260px] rounded-md mt-0.5" />
<a v-else :href="mediaUrls[m.id]" target="_blank" rel="noopener" class="text-xs underline text-[var(--primary-color)]">
<i class="pi pi-paperclip text-[0.6rem] mr-1" />Baixar anexo
</a>
</template>
</template>
<div v-if="m.body" class="text-sm whitespace-pre-wrap break-words">{{ m.body }}</div>
</div>
</div>
</div>
<!-- Footer info -->
<div v-if="messages.length && messages.length >= 500" class="text-[0.7rem] text-center italic text-[var(--text-color-secondary)] py-2">
Exibindo as últimas 500 mensagens. Abra no CRM pra navegar na conversa completa.
</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>