M1: features/medicos + features/insurance + ComponentCadastroRapido refactor

Modulo 1 da Fase 1 de padronizacao. Novos features/medicos (services
+ composable useMedicos) e features/insurance (idem). 3 cadastros
rapidos (medicos, convenios, ComponentCadastroRapido + Insurance
PlanQuickCreateDialog) migrados pra usar os composables novos —
zero supabase.from() em UI components. TEST_ACCOUNTS extraido pra
src/config/devTestAccounts.js. Topbar ganhou switcher de layout
+ atalhos M1 via novo useTopbarDevMenuExtras. M1.6 MelissaLayout
90 imports deferida pra sessao dedicada (memoria padronizacao_sweep).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-21 04:19:57 -03:00
parent f94a4ae97f
commit 27467bbb68
17 changed files with 901 additions and 223 deletions
@@ -18,18 +18,19 @@
<script setup>
import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useInsurancePlans } from '@/features/insurance/composables/useInsurancePlans';
const props = defineProps({
modelValue: { type: Boolean, default: false },
// ownerId mantido por compat — repository sempre injeta owner_id = auth.uid() logado.
// Nos fluxos atuais (AgendaEventDialog), o usuário logado já é o owner.
ownerId: { type: String, default: '' },
initialName: { type: String, default: '' }
});
const emit = defineEmits(['update:modelValue', 'created']);
const toast = useToast();
const tenantStore = useTenantStore();
const insuranceStore = useInsurancePlans();
const visible = ref(props.modelValue);
watch(() => props.modelValue, (v) => { visible.value = v; });
@@ -56,29 +57,25 @@ const canSave = () => !!form.value.name?.trim();
async function onSave() {
if (!canSave()) return;
const ownerId = props.ownerId || (await supabase.auth.getUser()).data?.user?.id;
const tid = tenantStore.activeTenantId || tenantStore.tenantId;
if (!ownerId || !tid) {
toast.add({ severity: 'error', summary: 'Sem contexto', detail: 'Owner ou tenant ausentes.', life: 3500 });
return;
}
saving.value = true;
try {
const payload = {
owner_id: ownerId,
tenant_id: tid,
name: form.value.name.trim().slice(0, 120),
default_value: form.value.default_value != null ? Number(form.value.default_value) : null,
notes: form.value.notes?.trim().slice(0, 500) || null,
active: true
};
const { data, error } = await supabase.from('insurance_plans').insert(payload).select().single();
if (error) throw error;
// Repository injeta owner_id + tenant_id, sanitiza, e faz uniqueness check.
const data = await insuranceStore.create({
name: form.value.name,
default_value: form.value.default_value,
notes: form.value.notes
});
toast.add({ severity: 'success', summary: 'Convênio criado', life: 2200 });
emit('created', data);
visible.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha ao criar convênio', detail: e?.message || 'Erro inesperado', life: 4000 });
const isDup = /existe um convênio/i.test(e?.message || '');
toast.add({
severity: isDup ? 'warn' : 'error',
summary: isDup ? 'Nome em uso' : 'Falha ao criar convênio',
detail: e?.message || 'Erro inesperado',
life: 4000
});
} finally {
saving.value = false;
}
@@ -0,0 +1,97 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/insurance/composables/useInsurancePlans.js
|
| Thin wrapper sobre insurancePlansRepository.
| Pattern: composable-blueprint Tipo A (default).
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { listForOwner, getById, findByName, create as repoCreate, update as repoUpdate, softDelete as repoSoftDelete } from '@/features/insurance/services/insurancePlansRepository';
export function useInsurancePlans() {
const rows = ref([]);
const loading = ref(false);
const error = ref('');
async function loadForOwner(opts = {}) {
loading.value = true;
error.value = '';
try {
rows.value = await listForOwner(opts);
} catch (e) {
error.value = e?.message || 'Falha ao carregar convênios.';
rows.value = [];
} finally {
loading.value = false;
}
}
async function fetchById(id, opts = {}) {
loading.value = true;
error.value = '';
try {
return await getById(id, opts);
} catch (e) {
error.value = e?.message || 'Falha ao carregar convênio.';
return null;
} finally {
loading.value = false;
}
}
async function create(payload) {
loading.value = true;
error.value = '';
try {
const created = await repoCreate(payload);
if (created.active) {
rows.value = [...rows.value, created].sort((a, b) => (a.name || '').localeCompare(b.name || ''));
}
return created;
} catch (e) {
error.value = e?.message || 'Falha ao criar convênio.';
throw e;
} finally {
loading.value = false;
}
}
async function update(id, patch, opts = {}) {
loading.value = true;
error.value = '';
try {
const updated = await repoUpdate(id, patch, opts);
const idx = rows.value.findIndex((r) => r.id === id);
if (idx >= 0) {
if (updated.active) rows.value[idx] = { ...rows.value[idx], ...updated };
else rows.value.splice(idx, 1);
}
return updated;
} catch (e) {
error.value = e?.message || 'Falha ao atualizar convênio.';
throw e;
} finally {
loading.value = false;
}
}
async function softDelete(id, opts = {}) {
loading.value = true;
error.value = '';
try {
await repoSoftDelete(id, opts);
rows.value = rows.value.filter((r) => r.id !== id);
return true;
} catch (e) {
error.value = e?.message || 'Falha ao remover convênio.';
throw e;
} finally {
loading.value = false;
}
}
return { rows, loading, error, loadForOwner, fetchById, findByName, create, update, softDelete };
}
@@ -0,0 +1,25 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/insurance/services/_tenantGuards.js
|
| Guards compartilhados entre repositories do feature insurance.
| Pattern canônico — ver blueprints/repository-blueprint.md seção 3.
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
export function assertTenantId(tenantId) {
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de operar.');
}
}
export async function getUid() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Usuário não autenticado.');
return uid;
}
@@ -0,0 +1,166 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/insurance/services/insurancePlansRepository.js
|
| Repository da tabela public.insurance_plans.
| Pure functions seguindo blueprints/repository-blueprint.md.
|
| Schema (servicos_prontuarios.sql):
| id, owner_id, tenant_id,
| name text, notes text, default_value numeric(10,2),
| active boolean DEFAULT true, created_at, updated_at
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId, getUid } from './_tenantGuards';
import { INSURANCE_PLAN_SELECT } from './insurancePlansSelects';
function resolveTenantId(tenantIdArg) {
const tenantStore = useTenantStore();
const tenantId = tenantIdArg || tenantStore.activeTenantId || tenantStore.tenantId;
assertTenantId(tenantId);
return tenantId;
}
/**
* Lista convênios ativos do owner. Ordenados por name ascending.
*
* @param {Object} [opts]
* @param {string} [opts.ownerId]
* @param {string} [opts.tenantId]
* @param {boolean} [opts.includeInactive=false]
*/
export async function listForOwner({ ownerId, tenantId, includeInactive = false } = {}) {
const tid = resolveTenantId(tenantId);
const uid = ownerId || (await getUid());
let q = supabase.from('insurance_plans').select(INSURANCE_PLAN_SELECT).eq('tenant_id', tid).eq('owner_id', uid).order('name', { ascending: true });
if (!includeInactive) q = q.eq('active', true);
const { data, error } = await q;
if (error) throw error;
return data || [];
}
/**
* Lê convênio por id. Filtra owner_id + tenant_id por segurança.
*/
export async function getById(id, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.');
const tid = resolveTenantId(tenantId);
const uid = await getUid();
const { data, error } = await supabase.from('insurance_plans').select(INSURANCE_PLAN_SELECT).eq('id', id).eq('tenant_id', tid).eq('owner_id', uid).maybeSingle();
if (error) throw error;
return data || null;
}
/**
* Procura convênio ativo por nome (case-insensitive). Usado pra duplicate check
* antes de criar (uniqueness check do quick-create blueprint).
*
* @param {Object} opts
* @param {string} opts.name
* @param {string} [opts.ownerId]
* @param {string} [opts.tenantId]
*/
export async function findByName({ name, ownerId, tenantId } = {}) {
if (!name) return null;
const tid = resolveTenantId(tenantId);
const uid = ownerId || (await getUid());
const safeName = String(name).trim();
if (!safeName) return null;
const { data, error } = await supabase.from('insurance_plans').select(INSURANCE_PLAN_SELECT).eq('tenant_id', tid).eq('owner_id', uid).eq('active', true).ilike('name', safeName).limit(1).maybeSingle();
if (error) throw error;
return data || null;
}
/**
* Cria convênio. Pré-checa duplicidade por nome (case-insensitive) — se já
* existe ativo, lança erro PT-BR. Repository injeta owner_id + tenant_id.
*/
export async function create(payload) {
if (!payload) throw new Error('Payload vazio.');
const name = String(payload.name || '').trim();
if (!name) throw new Error('Nome do convênio é obrigatório.');
if (name.length > 120) throw new Error('Nome do convênio muito longo (máx 120).');
const uid = await getUid();
const tid = resolveTenantId();
// Uniqueness check (quick-create blueprint)
const dup = await findByName({ name, ownerId: uid, tenantId: tid });
if (dup) {
throw new Error('Já existe um convênio com esse nome.');
}
const insertPayload = {
owner_id: uid,
tenant_id: tid,
name: name.slice(0, 120),
notes: payload.notes ? String(payload.notes).trim().slice(0, 500) || null : null,
default_value: payload.default_value != null && payload.default_value !== '' ? Number(payload.default_value) : null,
active: payload.active !== false
};
const { data, error } = await supabase.from('insurance_plans').insert([insertPayload]).select(INSURANCE_PLAN_SELECT).single();
if (error) throw error;
return data;
}
/**
* Atualiza convênio. Filtra por id + tenant_id.
*/
export async function update(id, patch, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.');
if (!patch) throw new Error('Patch vazio.');
const tid = resolveTenantId(tenantId);
const safePatch = sanitize(patch);
safePatch.updated_at = new Date().toISOString();
const { data, error } = await supabase.from('insurance_plans').update(safePatch).eq('id', id).eq('tenant_id', tid).select(INSURANCE_PLAN_SELECT).single();
if (error) throw error;
return data;
}
/**
* Soft delete: marca active=false. Preserva histórico.
*/
export async function softDelete(id, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.');
const tid = resolveTenantId(tenantId);
const { error } = await supabase.from('insurance_plans').update({ active: false, updated_at: new Date().toISOString() }).eq('id', id).eq('tenant_id', tid);
if (error) throw error;
return true;
}
// ─── helpers internos ────────────────────────────────────────────────────────
function sanitize(payload) {
const out = { ...payload };
if ('name' in out && typeof out.name === 'string') {
const t = out.name.trim();
out.name = t === '' ? null : t.slice(0, 120);
}
if ('notes' in out && typeof out.notes === 'string') {
const t = out.notes.trim();
out.notes = t === '' ? null : t.slice(0, 500);
}
if ('default_value' in out) {
const v = out.default_value;
out.default_value = v == null || v === '' ? null : Number(v);
}
return out;
}
@@ -0,0 +1,15 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/insurance/services/insurancePlansSelects.js
|
| SELECT canônico da tabela insurance_plans.
|--------------------------------------------------------------------------
*/
export const INSURANCE_PLAN_SELECT = `
id, owner_id, tenant_id,
name, notes, default_value, active,
created_at, updated_at
`.trim();
@@ -0,0 +1,101 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/medicos/composables/useMedicos.js
|
| Thin wrapper sobre medicosRepository.
| Pattern: composable-blueprint Tipo A (default).
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { listForOwner, getById, create as repoCreate, update as repoUpdate, softDelete as repoSoftDelete } from '@/features/medicos/services/medicosRepository';
export function useMedicos() {
const rows = ref([]);
const loading = ref(false);
const error = ref('');
async function loadForOwner(opts = {}) {
loading.value = true;
error.value = '';
try {
rows.value = await listForOwner(opts);
} catch (e) {
error.value = e?.message || 'Falha ao carregar médicos.';
rows.value = [];
} finally {
loading.value = false;
}
}
async function fetchById(id, opts = {}) {
loading.value = true;
error.value = '';
try {
return await getById(id, opts);
} catch (e) {
error.value = e?.message || 'Falha ao carregar médico.';
return null;
} finally {
loading.value = false;
}
}
async function create(payload) {
loading.value = true;
error.value = '';
try {
const created = await repoCreate(payload);
// Adiciona na lista local mantendo ordenação por nome
if (created.ativo) {
rows.value = [...rows.value, created].sort((a, b) => (a.nome || '').localeCompare(b.nome || ''));
}
return created;
} catch (e) {
error.value = e?.message || 'Falha ao criar médico.';
throw e;
} finally {
loading.value = false;
}
}
async function update(id, patch, opts = {}) {
loading.value = true;
error.value = '';
try {
const updated = await repoUpdate(id, patch, opts);
const idx = rows.value.findIndex((r) => r.id === id);
if (idx >= 0) {
if (updated.ativo) {
rows.value[idx] = { ...rows.value[idx], ...updated };
} else {
rows.value.splice(idx, 1);
}
}
return updated;
} catch (e) {
error.value = e?.message || 'Falha ao atualizar médico.';
throw e;
} finally {
loading.value = false;
}
}
async function softDelete(id, opts = {}) {
loading.value = true;
error.value = '';
try {
await repoSoftDelete(id, opts);
rows.value = rows.value.filter((r) => r.id !== id);
return true;
} catch (e) {
error.value = e?.message || 'Falha ao remover médico.';
throw e;
} finally {
loading.value = false;
}
}
return { rows, loading, error, loadForOwner, fetchById, create, update, softDelete };
}
@@ -0,0 +1,25 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/medicos/services/_tenantGuards.js
|
| Guards compartilhados entre repositories do feature medicos.
| Pattern canônico — ver blueprints/repository-blueprint.md seção 3.
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
export function assertTenantId(tenantId) {
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de operar.');
}
}
export async function getUid() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Usuário não autenticado.');
return uid;
}
@@ -0,0 +1,160 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/medicos/services/medicosRepository.js
|
| Repository da tabela public.medicos. Pure functions seguindo
| blueprints/repository-blueprint.md.
|
| Schema (servicos_prontuarios.sql):
| id, owner_id, tenant_id, nome, crm, especialidade,
| telefone_profissional, telefone_pessoal, email, clinica,
| cidade, estado='SP', observacoes, ativo=true, created_at, updated_at
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId, getUid } from './_tenantGuards';
import { MEDICO_LIST_SELECT, MEDICO_FULL_SELECT } from './medicosSelects';
function resolveTenantId(tenantIdArg) {
const tenantStore = useTenantStore();
const tenantId = tenantIdArg || tenantStore.activeTenantId || tenantStore.tenantId;
assertTenantId(tenantId);
return tenantId;
}
/**
* Lista médicos ativos do owner (escopo terapeuta solo).
* Ordenados por nome ascending.
*
* @param {Object} [opts]
* @param {string} [opts.ownerId] - default: uid logado
* @param {string} [opts.tenantId]
* @param {boolean} [opts.includeInactive=false]
*/
export async function listForOwner({ ownerId, tenantId, includeInactive = false } = {}) {
const tid = resolveTenantId(tenantId);
const uid = ownerId || (await getUid());
let q = supabase.from('medicos').select(MEDICO_LIST_SELECT).eq('tenant_id', tid).eq('owner_id', uid).order('nome', { ascending: true });
if (!includeInactive) q = q.eq('ativo', true);
const { data, error } = await q;
if (error) throw error;
return data || [];
}
/**
* Lê um médico completo (pra edit). Filtra owner_id + tenant_id por segurança.
*
* @param {string} id
* @param {Object} [opts]
* @param {string} [opts.tenantId]
*/
export async function getById(id, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.');
const tid = resolveTenantId(tenantId);
const uid = await getUid();
const { data, error } = await supabase.from('medicos').select(MEDICO_FULL_SELECT).eq('id', id).eq('tenant_id', tid).eq('owner_id', uid).maybeSingle();
if (error) throw error;
return data || null;
}
/**
* Cria médico. Injeta owner_id (uid logado) + tenant_id (store).
* Payload aceita os campos canônicos da tabela; o repository sanitiza
* trims e nullif vazio.
*
* @param {Object} payload
*/
export async function create(payload) {
if (!payload) throw new Error('Payload vazio.');
if (!payload.nome || !String(payload.nome).trim()) {
throw new Error('Nome do médico é obrigatório.');
}
const uid = await getUid();
const tid = resolveTenantId();
const insertPayload = {
...sanitize(payload),
owner_id: uid,
tenant_id: tid,
ativo: payload.ativo !== false
};
const { data, error } = await supabase.from('medicos').insert([insertPayload]).select(MEDICO_FULL_SELECT).single();
if (error) throw error;
return data;
}
/**
* Atualiza médico. Filtra por id + tenant_id (defesa em profundidade — RLS reforça).
* updated_at é atualizado server-side ou aqui se não houver trigger.
*
* @param {string} id
* @param {Object} patch
* @param {Object} [opts]
* @param {string} [opts.tenantId]
*/
export async function update(id, patch, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.');
if (!patch) throw new Error('Patch vazio.');
const tid = resolveTenantId(tenantId);
const safePatch = {
...sanitize(patch),
updated_at: new Date().toISOString()
};
const { data, error } = await supabase.from('medicos').update(safePatch).eq('id', id).eq('tenant_id', tid).select(MEDICO_FULL_SELECT).single();
if (error) throw error;
return data;
}
/**
* Soft delete: marca ativo=false em vez de DELETE. Preserva histórico
* de encaminhamentos antigos referentes a este médico.
*
* @param {string} id
* @param {Object} [opts]
* @param {string} [opts.tenantId]
*/
export async function softDelete(id, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.');
const tid = resolveTenantId(tenantId);
const { error } = await supabase.from('medicos').update({ ativo: false, updated_at: new Date().toISOString() }).eq('id', id).eq('tenant_id', tid);
if (error) throw error;
return true;
}
// ─── helpers internos ────────────────────────────────────────────────────────
/**
* Sanitiza payload: trim em strings, nullif vazio.
* Não sanitiza telefones (já chegam digits-only do componente)
* nem owner_id/tenant_id/ativo (controlados pelo repository).
*/
function sanitize(payload) {
const stringFields = ['nome', 'crm', 'especialidade', 'telefone_profissional', 'telefone_pessoal', 'email', 'clinica', 'cidade', 'estado', 'observacoes'];
const out = { ...payload };
for (const f of stringFields) {
if (f in out) {
const v = out[f];
if (typeof v === 'string') {
const trimmed = v.trim();
out[f] = trimmed === '' ? null : trimmed;
}
}
}
return out;
}
@@ -0,0 +1,30 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/medicos/services/medicosSelects.js
|
| Fonte única de SELECTs da tabela medicos.
|--------------------------------------------------------------------------
*/
/**
* SELECT pra listas (sem campos pesados/sensíveis: telefone_pessoal,
* observacoes, email — só carregados em getById/edit).
*/
export const MEDICO_LIST_SELECT = `
id, nome, crm, especialidade,
telefone_profissional, clinica, cidade, estado, ativo
`.trim();
/**
* SELECT completo pra edição (todos os campos).
*/
export const MEDICO_FULL_SELECT = `
id, owner_id, tenant_id,
nome, crm, especialidade,
telefone_profissional, telefone_pessoal,
email, clinica, cidade, estado,
observacoes, ativo,
created_at, updated_at
`.trim();