Melissa: 6 Pages aplicando blueprint + dialogs unificados + Conversa estilo WhatsApp
Sprint F (05-06). Blueprint tabular aplicado nas 6 paginas restantes;
dialogs harmonizados (FloatLabel + IconField + variant=filled + section
dividers, espelhando PatientsCadastroPage Identidade); ConversationDrawer
repaginado pra visual estilo WhatsApp.
Pages refatoradas (cada uma com subheader, sidebar __scroll + __footer
fixo "Limpar filtros", Xs inline pra zerar filtro individual, mobile
drawer com sticky footer):
- MelissaCompromissos: blueprint mantendo row design original (color
stripe + name + badges + descricao + meta inline). Filtros Status
(Ativos/Inativos) + Tipo (Nativos/Meus). Coluna Acoes frozen 140px
com toggle+pencil+trash.
- MelissaGrupos / MelissaTags: pattern completo + dialog "Pacientes
do grupo/tag" com lista vinculada via patient_group_patient /
patient_patient_tag. Avatar primary nos pacientes, header colorido
com cor da entidade, X de fechar igual .mc-close. Dialog de
criar/editar com FloatLabel + section dividers.
- MelissaMedicos: blueprint + dialog "Pacientes encaminhados" usando
cor primary do tema (medicos nao tem cor propria); dialog de
criar/editar com 4 secoes (Identificacao/Contato/Localizacao/Obs)
espelhando PatientsCadastroPage. Service ja tinha
fetchPatientsByMedicoNome (ILIKE em encaminhado_por).
- MelissaConversas: subheader, sidebar com bg-soft + border-right e
cards com sombra (mw-w--side), Limpar filtros global no footer fixo
(fix bug: filters era ref({...}) e eu lia filters.search direto, agora
usa .value), alerta de unlinked movido pro topo, kanban mobile com
min-height nas colunas pra mostrar mensagens.
- MelissaRecorrencias: subheader, button list de status (Ativas verde/
Encerradas vermelho/Todas) substitui SelectButton, busca por nome do
paciente, footer Limpar filtros, X inline no filtro Status.
ConversationDrawer redesign (WhatsApp-style):
- Header com avatar circular primary + iniciais + numero formatado
- Container de mensagens com bg "papel de parede" (color-mix com bege
esverdeado WA + radial-gradient pattern)
- Bolhas com cantos certos (top-left ou top-right zerado simulando
tail), sombra sutil, cores autenticas (#d9fdd3 light/#005c4b dark
outbound; #fff/#202c33 inbound), detecao dark via :global
- Time HH:MM + status overlay no canto inferior direito DENTRO do
balao; checks azuis quando lida (#53bdeb)
- Compose pill rounded-full + botao Send circular verde #00a884
- Removido fmtDateTime obsoleto (substituido por fmtTimeOnly)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -336,12 +336,19 @@ const KANBAN_COLUMNS = [
|
||||
];
|
||||
|
||||
// ── Formatters ─────────────────────────────────────────────
|
||||
function fmtDateTime(iso) {
|
||||
// HH:MM curto pra exibir dentro da bolha (estilo WhatsApp).
|
||||
function fmtTimeOnly(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleString('pt-BR', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
return new Date(iso).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// Iniciais pro avatar circular do header.
|
||||
function iniciaisChat(name) {
|
||||
if (!name) return '?';
|
||||
const parts = String(name).trim().split(/\s+/).filter(Boolean);
|
||||
if (!parts.length) return '?';
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
function channelIcon(ch) {
|
||||
@@ -699,11 +706,16 @@ function insertEmoji(emoji) {
|
||||
<ConfirmDialog group="conversation-drawer" />
|
||||
<Drawer v-model:visible="isOpen" position="right" class="!w-full md:!w-[520px]">
|
||||
<template #header>
|
||||
<div v-if="store.thread" class="flex items-center gap-2">
|
||||
<i :class="['pi', channelIcon(store.thread.channel)]" />
|
||||
<div class="flex flex-col">
|
||||
<span class="font-semibold">{{ contactLabel() }}</span>
|
||||
<span v-if="store.thread.contact_number" class="text-xs text-[var(--text-color-secondary)]">{{ store.thread.contact_number }}</span>
|
||||
<div v-if="store.thread" class="flex items-center gap-3 min-w-0">
|
||||
<span class="cd-avatar">
|
||||
{{ iniciaisChat(contactLabel()) }}
|
||||
</span>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="font-semibold truncate text-[0.95rem]">{{ contactLabel() }}</span>
|
||||
<span v-if="store.thread.contact_number" class="text-[0.72rem] text-[var(--text-color-secondary)] flex items-center gap-1.5">
|
||||
<i :class="['pi', channelIcon(store.thread.channel), 'text-[0.65rem]']" />
|
||||
<span class="font-mono">{{ formatPhoneShort(store.thread.contact_number) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1030,8 +1042,8 @@ function insertEmoji(emoji) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mensagens -->
|
||||
<div ref="messagesContainerRef" class="flex-1 overflow-y-auto flex flex-col gap-2 p-1">
|
||||
<!-- Mensagens — container com bg WhatsApp-like -->
|
||||
<div ref="messagesContainerRef" class="cd-msgs flex-1 overflow-y-auto flex flex-col gap-1.5 p-3">
|
||||
<div v-if="store.loading" class="text-xs text-[var(--text-color-secondary)] text-center py-6">
|
||||
<i class="pi pi-spin pi-spinner mr-2" />Carregando mensagens...
|
||||
</div>
|
||||
@@ -1042,12 +1054,12 @@ function insertEmoji(emoji) {
|
||||
<div
|
||||
v-for="m in store.messages"
|
||||
:key="m.id"
|
||||
class="flex flex-col gap-0.5"
|
||||
:class="m.direction === 'inbound' ? 'items-start' : 'items-end'"
|
||||
class="cd-bubble-wrap"
|
||||
:class="m.direction === 'inbound' ? 'cd-bubble-wrap--in' : 'cd-bubble-wrap--out'"
|
||||
>
|
||||
<div
|
||||
class="max-w-[85%] px-3 py-2 rounded-lg text-sm whitespace-pre-wrap break-words"
|
||||
:class="m.direction === 'inbound' ? 'bg-[var(--surface-ground)] rounded-tl-none' : 'bg-emerald-500/10 text-emerald-900 dark:text-emerald-100 rounded-tr-none'"
|
||||
class="cd-bubble"
|
||||
:class="m.direction === 'inbound' ? 'cd-bubble--in' : 'cd-bubble--out'"
|
||||
>
|
||||
<template v-if="m.media_url">
|
||||
<!-- Loading enquanto resolve signed URL -->
|
||||
@@ -1061,7 +1073,7 @@ function insertEmoji(emoji) {
|
||||
:preview="true"
|
||||
alt="imagem"
|
||||
imageClass="max-w-full rounded-md cursor-zoom-in"
|
||||
class="mb-1 block"
|
||||
class="mb-1 block cd-bubble__media"
|
||||
/>
|
||||
<audio
|
||||
v-else-if="isAudio(m.media_mime)"
|
||||
@@ -1074,7 +1086,7 @@ function insertEmoji(emoji) {
|
||||
v-else-if="isVideo(m.media_mime)"
|
||||
:src="mediaUrls[m.id]"
|
||||
controls
|
||||
class="max-w-full rounded-md mb-1"
|
||||
class="max-w-full rounded-md mb-1 cd-bubble__media"
|
||||
/>
|
||||
<a
|
||||
v-else
|
||||
@@ -1088,18 +1100,19 @@ function insertEmoji(emoji) {
|
||||
</a>
|
||||
</template>
|
||||
</template>
|
||||
<div v-if="m.body">{{ m.body }}</div>
|
||||
</div>
|
||||
<div class="text-[0.65rem] text-[var(--text-color-secondary)] opacity-75 px-1 flex items-center gap-1">
|
||||
<span>{{ fmtDateTime(m.created_at) }}</span>
|
||||
<span v-if="m.direction === 'outbound'" class="flex items-center">
|
||||
<i v-if="m.delivery_status === 'read'" class="pi pi-check-double text-sky-500" v-tooltip.top="'Lida'" />
|
||||
<i v-else-if="m.delivery_status === 'delivered'" class="pi pi-check-double" v-tooltip.top="'Entregue'" />
|
||||
<i v-else-if="m.delivery_status === 'sent'" class="pi pi-check" v-tooltip.top="'Enviada'" />
|
||||
<i v-else-if="m.delivery_status === 'failed'" class="pi pi-exclamation-circle text-red-500" v-tooltip.top="'Falhou'" />
|
||||
</span>
|
||||
<span v-if="m.direction === 'inbound' && !m.patient_id" class="italic">· número não vinculado</span>
|
||||
<div v-if="m.body" class="cd-bubble__body">{{ m.body }}</div>
|
||||
<!-- Time + status overlay no canto inferior direito (estilo WhatsApp) -->
|
||||
<div class="cd-bubble__meta">
|
||||
<span>{{ fmtTimeOnly(m.created_at) }}</span>
|
||||
<span v-if="m.direction === 'outbound'" class="cd-bubble__status">
|
||||
<i v-if="m.delivery_status === 'read'" class="pi pi-check-double cd-bubble__status--read" v-tooltip.top="'Lida'" />
|
||||
<i v-else-if="m.delivery_status === 'delivered'" class="pi pi-check-double" v-tooltip.top="'Entregue'" />
|
||||
<i v-else-if="m.delivery_status === 'sent'" class="pi pi-check" v-tooltip.top="'Enviada'" />
|
||||
<i v-else-if="m.delivery_status === 'failed'" class="pi pi-exclamation-circle text-red-500" v-tooltip.top="'Falhou'" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="m.direction === 'inbound' && !m.patient_id" class="cd-bubble__unlinked">não vinculado</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1142,34 +1155,48 @@ function insertEmoji(emoji) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Compose -->
|
||||
<div v-if="store.thread.channel === 'whatsapp' && store.thread.contact_number" class="border-t border-[var(--surface-border)] pt-2 flex flex-col gap-2">
|
||||
<div class="flex items-end gap-2">
|
||||
<!-- Compose — barra estilo WhatsApp (input pill + send circular) -->
|
||||
<div v-if="store.thread.channel === 'whatsapp' && store.thread.contact_number" class="cd-compose">
|
||||
<div class="cd-compose__row">
|
||||
<button
|
||||
class="cd-compose__icon-btn"
|
||||
v-tooltip.top="'Emoji'"
|
||||
@click="toggleEmojiPopover"
|
||||
>
|
||||
<i class="pi pi-face-smile" />
|
||||
</button>
|
||||
<button
|
||||
class="cd-compose__icon-btn"
|
||||
v-tooltip.top="'Templates'"
|
||||
@click="openTemplatesPopover"
|
||||
>
|
||||
<i class="pi pi-bookmark" />
|
||||
</button>
|
||||
<Textarea
|
||||
ref="composeTextareaRef"
|
||||
v-model="composeText"
|
||||
autoResize
|
||||
rows="1"
|
||||
placeholder="Digite sua mensagem... (Enter envia, Shift+Enter quebra linha)"
|
||||
class="flex-1 !text-sm !resize-none"
|
||||
placeholder="Digite uma mensagem"
|
||||
class="cd-compose__input flex-1"
|
||||
:disabled="store.sending"
|
||||
:maxlength="4000"
|
||||
@keydown="onComposeKeydown"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-send"
|
||||
severity="success"
|
||||
class="!w-10 !h-10 shrink-0"
|
||||
:loading="store.sending"
|
||||
<button
|
||||
class="cd-compose__send"
|
||||
:class="{ 'is-disabled': !composeText.trim() || store.sending }"
|
||||
:disabled="!composeText.trim() || store.sending"
|
||||
v-tooltip.top="'Enviar (Enter)'"
|
||||
@click="sendMessage"
|
||||
/>
|
||||
>
|
||||
<i v-if="store.sending" class="pi pi-spin pi-spinner" />
|
||||
<i v-else class="pi pi-send" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-[var(--text-color-secondary)]">
|
||||
<Button icon="pi pi-bookmark" text rounded size="small" class="!w-8 !h-8" v-tooltip.top="'Templates'" @click="openTemplatesPopover" />
|
||||
<Button icon="pi pi-face-smile" text rounded size="small" class="!w-8 !h-8" v-tooltip.top="'Emoji'" @click="toggleEmojiPopover" />
|
||||
<span class="ml-auto text-[0.65rem] opacity-60">{{ composeText.length }}/4000</span>
|
||||
<div class="cd-compose__hint">
|
||||
<span>Enter envia · Shift+Enter quebra linha</span>
|
||||
<span class="ml-auto">{{ composeText.length }}/4000</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-[0.7rem] text-[var(--text-color-secondary)] italic text-center py-2 border-t border-[var(--surface-border)]">
|
||||
@@ -1214,3 +1241,221 @@ function insertEmoji(emoji) {
|
||||
</Popover>
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ─── Avatar do header (iniciais sobre primary) ─── */
|
||||
.cd-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--p-primary-color);
|
||||
color: var(--p-primary-contrast-color, white);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* ─── Container de mensagens (bg estilo "papel de parede" WhatsApp) ─── */
|
||||
/* Light: bege esverdeado clássico do WA. Dark: cinza profundo tipo
|
||||
wallpaper de modo escuro. Adapta via CSS variable `--p-content-background`
|
||||
pra harmonizar com o tema do app. */
|
||||
.cd-msgs {
|
||||
background-color: color-mix(in srgb, var(--p-content-background) 85%, #efeae2);
|
||||
background-image:
|
||||
radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--p-text-color) 4%, transparent) 1px, transparent 0);
|
||||
background-size: 18px 18px;
|
||||
border-radius: 8px;
|
||||
margin: 0 -2px;
|
||||
}
|
||||
|
||||
/* ─── Bolha (wrapper + content + meta) ─── */
|
||||
.cd-bubble-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-width: 80%;
|
||||
}
|
||||
.cd-bubble-wrap--in { align-self: flex-start; }
|
||||
.cd-bubble-wrap--out { align-self: flex-end; align-items: flex-end; }
|
||||
|
||||
.cd-bubble {
|
||||
position: relative;
|
||||
padding: 6px 10px 18px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.42;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
box-shadow: 0 1px 0.5px rgba(0, 0, 0, 0.13);
|
||||
min-width: 80px;
|
||||
}
|
||||
/* Inbound: surface-card adapta light/dark; canto top-left "achatado" vira o tail */
|
||||
.cd-bubble--in {
|
||||
background: var(--p-surface-0, #ffffff);
|
||||
color: var(--p-text-color);
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
:global(.p-dark) .cd-bubble--in,
|
||||
:global(html.dark) .cd-bubble--in,
|
||||
:global([data-theme="dark"]) .cd-bubble--in,
|
||||
.cd-bubble--in:where(.p-dark *, html.dark *, [data-theme="dark"] *) {
|
||||
background: #202c33;
|
||||
color: rgb(233, 237, 239);
|
||||
}
|
||||
|
||||
/* Outbound: verde claro WA (light) / verde escuro WA (dark) */
|
||||
.cd-bubble--out {
|
||||
background: #d9fdd3;
|
||||
color: rgb(17, 27, 33);
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
:global(.p-dark) .cd-bubble--out,
|
||||
:global(html.dark) .cd-bubble--out,
|
||||
:global([data-theme="dark"]) .cd-bubble--out,
|
||||
.cd-bubble--out:where(.p-dark *, html.dark *, [data-theme="dark"] *) {
|
||||
background: #005c4b;
|
||||
color: rgb(233, 237, 239);
|
||||
}
|
||||
|
||||
.cd-bubble__body {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.cd-bubble__media {
|
||||
border-radius: 6px;
|
||||
margin: -2px -4px 6px -4px;
|
||||
max-width: calc(100% + 8px);
|
||||
}
|
||||
|
||||
/* Meta (HH:MM + status checks) overlay no canto inferior direito */
|
||||
.cd-bubble__meta {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
}
|
||||
.cd-bubble--in .cd-bubble__meta { color: rgba(0, 0, 0, 0.45); }
|
||||
.cd-bubble--out .cd-bubble__meta { color: rgba(0, 0, 0, 0.55); }
|
||||
:global(.p-dark) .cd-bubble--in .cd-bubble__meta,
|
||||
:global(html.dark) .cd-bubble--in .cd-bubble__meta,
|
||||
:global([data-theme="dark"]) .cd-bubble--in .cd-bubble__meta,
|
||||
.cd-bubble--in:where(.p-dark *, html.dark *, [data-theme="dark"] *) .cd-bubble__meta {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
:global(.p-dark) .cd-bubble--out .cd-bubble__meta,
|
||||
:global(html.dark) .cd-bubble--out .cd-bubble__meta,
|
||||
:global([data-theme="dark"]) .cd-bubble--out .cd-bubble__meta,
|
||||
.cd-bubble--out:where(.p-dark *, html.dark *, [data-theme="dark"] *) .cd-bubble__meta {
|
||||
color: rgba(233, 237, 239, 0.6);
|
||||
}
|
||||
|
||||
.cd-bubble__status > i {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.cd-bubble__status--read {
|
||||
color: rgb(83, 189, 235) !important; /* azul WA quando lido */
|
||||
}
|
||||
|
||||
.cd-bubble__unlinked {
|
||||
font-size: 0.62rem;
|
||||
color: var(--p-text-muted-color, var(--text-color-secondary));
|
||||
font-style: italic;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
/* ─── Compose bar estilo WhatsApp ─── */
|
||||
.cd-compose {
|
||||
border-top: 1px solid var(--p-content-border-color, var(--surface-border));
|
||||
padding: 8px 4px 6px;
|
||||
background: color-mix(in srgb, var(--p-content-background) 80%, transparent);
|
||||
}
|
||||
.cd-compose__row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
.cd-compose__icon-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--p-text-muted-color);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 140ms ease, color 140ms ease;
|
||||
}
|
||||
.cd-compose__icon-btn:hover {
|
||||
background: var(--p-content-hover-background, color-mix(in srgb, var(--p-text-color) 8%, transparent));
|
||||
color: var(--p-text-color);
|
||||
}
|
||||
.cd-compose__icon-btn > i { font-size: 1rem; }
|
||||
|
||||
.cd-compose__input :deep(textarea),
|
||||
.cd-compose__input.p-textarea {
|
||||
border-radius: 22px !important;
|
||||
background: var(--p-surface-0) !important;
|
||||
border: 1px solid var(--p-content-border-color) !important;
|
||||
padding: 9px 16px !important;
|
||||
font-size: 0.88rem !important;
|
||||
line-height: 1.4 !important;
|
||||
resize: none !important;
|
||||
min-height: 40px;
|
||||
max-height: 120px;
|
||||
transition: border-color 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
.cd-compose__input :deep(textarea:focus) {
|
||||
border-color: var(--p-primary-color) !important;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--p-primary-color) 22%, transparent) !important;
|
||||
}
|
||||
|
||||
.cd-compose__send {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #00a884; /* verde send do WA */
|
||||
border: none;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 140ms ease, transform 140ms ease, opacity 140ms ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.cd-compose__send:hover:not(.is-disabled) {
|
||||
background: #06cf9c;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.cd-compose__send.is-disabled,
|
||||
.cd-compose__send:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background: var(--p-content-hover-background);
|
||||
color: var(--p-text-muted-color);
|
||||
}
|
||||
.cd-compose__send > i { font-size: 1rem; }
|
||||
|
||||
.cd-compose__hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px 0;
|
||||
font-size: 0.62rem;
|
||||
color: var(--p-text-muted-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user