Files
agenciapsilmno/src/components/ui/ContactPhonesEditor.vue
T
Leonardo 86311ef305 Melissa: hub Configuracoes + Embed + 9 Pages novas + dialog blueprint dark
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>
2026-05-04 11:41:19 -03:00

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>