86311ef305
Sprints 04-29 + 04-30 acumuladas. - MelissaConfiguracoes: hub 2-col com 6 grupos (Layout/Conta/Agenda/ Financeiro/WhatsApp/Sistema), tudo embedado via MelissaEmbed. - MelissaEmbed: wrapper generico que injeta layout-variant=melissa e remove cromos pra reaproveitar Pages tradicionais. - 9 Melissa Pages novas: CadastrosRecebidos, Compromissos, Configuracoes, Conversas, Embed, Grupos, Medicos, Recorrencias, Tags. - Dialog blueprint atualizado: bg-gray-100 (hardcoded light) -> bg-[var(--surface-ground)] (tema-aware). 22 dialogs migrados em 9 arquivos. Anti-pattern documentado. - PatientsCadastroPage: bug fix dropdown Grupo (optionLabel nome->name), toggle vertical/abas com persist localStorage, sticky margin-top. - Surface picker no popover do MelissaLayout (8 swatches). - useTopbarPlanMenu, useMelissaWhatsapp, useMelissaPacientesAside novos. - Migration: status agenda remarcado/confirmado. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
345 lines
16 KiB
Vue
345 lines
16 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI — Editor de telefones polimórfico
|
|
|--------------------------------------------------------------------------
|
|
| Uso:
|
|
| <ContactPhonesEditor entity-type="patient" :entity-id="patient.id" />
|
|
|
|
|
| Auto-salva mudanças no banco (contact_phones) via useContactPhones.
|
|
|--------------------------------------------------------------------------
|
|
-->
|
|
<script setup>
|
|
import { ref, computed, onMounted, watch, reactive } from 'vue';
|
|
import { useToast } from 'primevue/usetoast';
|
|
import { useConfirm } from 'primevue/useconfirm';
|
|
import { useContactPhones } from '@/composables/useContactPhones';
|
|
|
|
const props = defineProps({
|
|
entityType: { type: String, required: true }, // 'patient' | 'medico'
|
|
entityId: { type: String, required: false, default: null }, // null = modo pendente (antes de criar entidade)
|
|
readonly: { type: Boolean, default: false },
|
|
confirmGroup: { type: String, default: '' } // grupo do ConfirmDialog (pra isolar de outros na página)
|
|
});
|
|
|
|
const emit = defineEmits(['change']);
|
|
|
|
const toast = useToast();
|
|
const confirm = useConfirm();
|
|
const api = useContactPhones();
|
|
|
|
// ── Mascaras ─────────────────────────────────────────────────
|
|
const MASK_MOBILE = '(99) 99999-9999';
|
|
const MASK_FIXED = '(99) 9999-9999';
|
|
|
|
function maskForType(typeId) {
|
|
const t = api.typeById(typeId);
|
|
return t?.is_mobile ? MASK_MOBILE : MASK_FIXED;
|
|
}
|
|
|
|
// ── Formulário de nova linha ─────────────────────────────────
|
|
const showAddForm = ref(false);
|
|
const newForm = reactive({ contact_type_id: null, number: '', notes: '' });
|
|
|
|
function openAddForm() {
|
|
// Default: primeiro tipo system (celular)
|
|
const defaultType = api.typeBySlug('celular') || api.types.value[0];
|
|
newForm.contact_type_id = defaultType?.id || null;
|
|
newForm.number = '';
|
|
newForm.notes = '';
|
|
showAddForm.value = true;
|
|
}
|
|
|
|
function cancelAddForm() {
|
|
showAddForm.value = false;
|
|
}
|
|
|
|
async function submitAddForm() {
|
|
if (!newForm.contact_type_id || !newForm.number.trim()) return;
|
|
const res = await api.addPhone(props.entityType, props.entityId, {
|
|
contact_type_id: newForm.contact_type_id,
|
|
number: newForm.number,
|
|
is_primary: api.phones.value.length === 0, // primeiro vira primary
|
|
notes: newForm.notes || null
|
|
});
|
|
if (res.ok) {
|
|
showAddForm.value = false;
|
|
toast.add({ severity: 'success', summary: 'Telefone adicionado', life: 1800 });
|
|
emit('change', api.phones.value);
|
|
} else {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
|
|
}
|
|
}
|
|
|
|
// ── Edição inline ────────────────────────────────────────────
|
|
const editingId = ref(null);
|
|
const editForm = reactive({ contact_type_id: null, number: '', notes: '' });
|
|
|
|
function startEdit(phone) {
|
|
editingId.value = phone.id;
|
|
editForm.contact_type_id = phone.contact_type_id;
|
|
editForm.number = phone.number;
|
|
editForm.notes = phone.notes || '';
|
|
}
|
|
|
|
function cancelEdit() {
|
|
editingId.value = null;
|
|
}
|
|
|
|
async function saveEdit(phone) {
|
|
const res = await api.updatePhone(props.entityType, props.entityId, phone.id, {
|
|
contact_type_id: editForm.contact_type_id,
|
|
number: editForm.number,
|
|
notes: editForm.notes.trim() || null
|
|
});
|
|
if (res.ok) {
|
|
editingId.value = null;
|
|
toast.add({ severity: 'success', summary: 'Telefone atualizado', life: 1800 });
|
|
emit('change', api.phones.value);
|
|
} else {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
|
|
}
|
|
}
|
|
|
|
// ── Principal ────────────────────────────────────────────────
|
|
async function setPrimary(phone) {
|
|
if (phone.is_primary) return;
|
|
const res = await api.setPrimary(props.entityType, props.entityId, phone.id);
|
|
if (res.ok) {
|
|
toast.add({ severity: 'success', summary: 'Telefone principal atualizado', life: 1800 });
|
|
emit('change', api.phones.value);
|
|
}
|
|
}
|
|
|
|
// ── Remover ──────────────────────────────────────────────────
|
|
function confirmRemove(phone) {
|
|
const typeName = api.typeById(phone.contact_type_id)?.name || 'telefone';
|
|
confirm.require({
|
|
group: props.confirmGroup || undefined,
|
|
message: `Remover este ${typeName}${phone.is_primary ? ' (principal)' : ''}?`,
|
|
header: 'Remover telefone',
|
|
icon: 'pi pi-exclamation-triangle',
|
|
acceptClass: 'p-button-danger',
|
|
acceptLabel: 'Remover',
|
|
rejectLabel: 'Cancelar',
|
|
accept: async () => {
|
|
const res = await api.removePhone(props.entityType, props.entityId, phone.id);
|
|
if (res.ok) {
|
|
toast.add({ severity: 'success', summary: 'Removido', life: 1800 });
|
|
emit('change', api.phones.value);
|
|
} else {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: res.error, life: 3500 });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────
|
|
function formatDisplay(number) {
|
|
const s = String(number || '').replace(/\D/g, '');
|
|
if (s.length === 11) return `(${s.slice(0, 2)}) ${s.slice(2, 7)}-${s.slice(7)}`;
|
|
if (s.length === 10) return `(${s.slice(0, 2)}) ${s.slice(2, 6)}-${s.slice(6)}`;
|
|
if (s.length === 13 && s.startsWith('55')) return `+55 (${s.slice(2, 4)}) ${s.slice(4, 9)}-${s.slice(9)}`;
|
|
if (s.length === 12 && s.startsWith('55')) return `+55 (${s.slice(2, 4)}) ${s.slice(4, 8)}-${s.slice(8)}`;
|
|
return number;
|
|
}
|
|
|
|
// ── Lifecycle ────────────────────────────────────────────────
|
|
onMounted(async () => {
|
|
await api.loadTypes();
|
|
if (props.entityId) await api.loadPhones(props.entityType, props.entityId);
|
|
});
|
|
|
|
watch(() => props.entityId, async (v) => {
|
|
if (v) await api.loadPhones(props.entityType, v);
|
|
else api.phones.value = [];
|
|
});
|
|
|
|
// Re-emite `change` sempre que a lista mudar (load, add, edit, remove,
|
|
// setPrimary). Permite que o parent trackee count e faça validação de
|
|
// "pelo menos 1 telefone obrigatório" sem precisar inspeccionar o
|
|
// componente. immediate:true garante emit no load inicial.
|
|
watch(api.phones, (arr) => emit('change', arr), { deep: true, immediate: true });
|
|
|
|
// Exposto pro parent — usado pelo PatientsCadastroPage no fluxo de criação:
|
|
// telefones inseridos antes de salvar o paciente ficam em modo pendente
|
|
// (id: 'pending_*') e são gravados em lote depois que o paciente recebe id.
|
|
async function flushPending(entityType, entityId) {
|
|
return api.flushPending(entityType, entityId);
|
|
}
|
|
defineExpose({ flushPending });
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex flex-col gap-2">
|
|
<!-- Lista de telefones -->
|
|
<div v-if="api.loading.value" class="text-xs text-center py-3 text-[var(--text-color-secondary)]">
|
|
<i class="pi pi-spin pi-spinner mr-1" /> Carregando…
|
|
</div>
|
|
|
|
<div v-else-if="!api.phones.value.length && !showAddForm" class="text-xs text-center py-4 italic text-[var(--text-color-secondary)] border border-dashed border-[var(--surface-border)] rounded-md">
|
|
Nenhum telefone cadastrado.
|
|
</div>
|
|
|
|
<div v-else class="flex flex-col gap-1.5">
|
|
<div
|
|
v-for="phone in api.phones.value"
|
|
:key="phone.id"
|
|
class="flex items-center gap-2 p-2 rounded-md border border-[var(--surface-border)]"
|
|
:class="phone.is_primary ? 'bg-[var(--primary-color)]/5 border-[var(--primary-color)]/30' : 'bg-[var(--surface-card)]'"
|
|
>
|
|
<!-- Modo leitura -->
|
|
<template v-if="editingId !== phone.id">
|
|
<!-- Ícone do tipo -->
|
|
<div class="w-8 h-8 rounded grid place-items-center shrink-0 bg-[var(--surface-ground)]">
|
|
<i :class="api.typeById(phone.contact_type_id)?.icon || 'pi pi-phone'" class="text-sm text-[var(--text-color-secondary)]" />
|
|
</div>
|
|
|
|
<!-- Tipo + número + badges -->
|
|
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
|
|
<div class="flex items-center gap-1.5 flex-wrap">
|
|
<span class="text-xs font-semibold text-[var(--text-color-secondary)]">{{ api.typeById(phone.contact_type_id)?.name || 'Telefone' }}</span>
|
|
<span v-if="phone.is_primary" class="inline-flex items-center px-1.5 py-px rounded-full text-[0.6rem] font-bold uppercase tracking-wide bg-[var(--primary-color)] text-white">Principal</span>
|
|
<span v-if="phone.whatsapp_linked_at" class="inline-flex items-center gap-0.5 px-1.5 py-px rounded-full text-[0.6rem] font-bold uppercase tracking-wide bg-emerald-500/15 text-emerald-600">
|
|
<i class="pi pi-link text-[0.55rem]" /> Vinculado
|
|
</span>
|
|
</div>
|
|
<span class="text-sm font-mono font-semibold">{{ formatDisplay(phone.number) }}</span>
|
|
<span v-if="phone.notes" class="text-[0.7rem] italic text-[var(--text-color-secondary)]">{{ phone.notes }}</span>
|
|
</div>
|
|
|
|
<!-- Ações -->
|
|
<div v-if="!readonly" class="flex items-center gap-0.5 shrink-0">
|
|
<Button
|
|
v-if="!phone.is_primary"
|
|
icon="pi pi-star"
|
|
text
|
|
size="small"
|
|
class="h-7 w-7"
|
|
v-tooltip.top="'Marcar como principal'"
|
|
:disabled="api.saving.value"
|
|
@click="setPrimary(phone)"
|
|
/>
|
|
<Button
|
|
icon="pi pi-pencil"
|
|
text
|
|
size="small"
|
|
class="h-7 w-7"
|
|
v-tooltip.top="'Editar'"
|
|
:disabled="api.saving.value"
|
|
@click="startEdit(phone)"
|
|
/>
|
|
<Button
|
|
icon="pi pi-trash"
|
|
text
|
|
severity="danger"
|
|
size="small"
|
|
class="h-7 w-7"
|
|
v-tooltip.top="'Remover'"
|
|
:disabled="api.saving.value"
|
|
@click="confirmRemove(phone)"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Modo edição -->
|
|
<template v-else>
|
|
<div class="flex flex-col gap-1.5 flex-1 min-w-0">
|
|
<div class="flex items-center gap-1.5 flex-wrap">
|
|
<Select
|
|
v-model="editForm.contact_type_id"
|
|
:options="api.types.value"
|
|
optionLabel="name"
|
|
optionValue="id"
|
|
class="text-xs"
|
|
style="width: 140px"
|
|
appendTo="body"
|
|
>
|
|
<template #option="slotProps">
|
|
<div class="flex items-center gap-1.5">
|
|
<i :class="slotProps.option.icon || 'pi pi-phone'" class="text-xs" />
|
|
<span>{{ slotProps.option.name }}</span>
|
|
</div>
|
|
</template>
|
|
</Select>
|
|
<InputMask
|
|
v-model="editForm.number"
|
|
:mask="maskForType(editForm.contact_type_id)"
|
|
class="flex-1 text-sm font-mono"
|
|
style="min-width: 140px"
|
|
/>
|
|
</div>
|
|
<InputText
|
|
v-model="editForm.notes"
|
|
placeholder="Observação (opcional)"
|
|
class="w-full text-xs"
|
|
:maxlength="200"
|
|
/>
|
|
</div>
|
|
<div class="flex items-center gap-0.5 shrink-0">
|
|
<Button icon="pi pi-check" severity="primary" size="small" class="h-7 w-7" v-tooltip.top="'Salvar'" :loading="api.saving.value" @click="saveEdit(phone)" />
|
|
<Button icon="pi pi-times" severity="secondary" text size="small" class="h-7 w-7" v-tooltip.top="'Cancelar'" :disabled="api.saving.value" @click="cancelEdit" />
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Formulário de nova linha -->
|
|
<div v-if="showAddForm" class="flex flex-col gap-2 p-2 rounded-md border border-dashed border-[var(--primary-color)]/50 bg-[var(--primary-color)]/5">
|
|
<div class="flex items-center gap-1.5 flex-wrap">
|
|
<Select
|
|
v-model="newForm.contact_type_id"
|
|
:options="api.types.value"
|
|
optionLabel="name"
|
|
optionValue="id"
|
|
class="text-xs"
|
|
style="width: 140px"
|
|
appendTo="body"
|
|
>
|
|
<template #option="slotProps">
|
|
<div class="flex items-center gap-1.5">
|
|
<i :class="slotProps.option.icon || 'pi pi-phone'" class="text-xs" />
|
|
<span>{{ slotProps.option.name }}</span>
|
|
</div>
|
|
</template>
|
|
</Select>
|
|
<InputMask
|
|
v-model="newForm.number"
|
|
:mask="maskForType(newForm.contact_type_id)"
|
|
class="flex-1 text-sm font-mono"
|
|
style="min-width: 140px"
|
|
autofocus
|
|
/>
|
|
</div>
|
|
<InputText
|
|
v-model="newForm.notes"
|
|
placeholder="Observação (opcional)"
|
|
class="w-full text-xs"
|
|
:maxlength="200"
|
|
/>
|
|
<div class="flex items-center gap-1 justify-end">
|
|
<Button label="Cancelar" severity="secondary" text size="small" :disabled="api.saving.value" @click="cancelAddForm" />
|
|
<Button
|
|
label="Adicionar"
|
|
icon="pi pi-check"
|
|
size="small"
|
|
:loading="api.saving.value"
|
|
:disabled="!newForm.contact_type_id || !newForm.number.trim()"
|
|
@click="submitAddForm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Botão + (sempre habilitado: sem entityId vai pro modo pendente) -->
|
|
<Button
|
|
v-if="!readonly && !showAddForm"
|
|
label="Adicionar telefone"
|
|
icon="pi pi-plus"
|
|
severity="secondary"
|
|
outlined
|
|
size="small"
|
|
class="self-start rounded-full"
|
|
@click="openAddForm"
|
|
/>
|
|
</div>
|
|
</template>
|