957e912a7f
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>
267 lines
12 KiB
Vue
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>
|