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:
@@ -24,8 +24,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, 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({
|
||||
@@ -35,34 +34,12 @@ const props = defineProps({
|
||||
const emit = defineEmits(['update:modelValue', 'update:visible', 'selected'])
|
||||
|
||||
const toast = useToast()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Auth / tenant helpers
|
||||
// ─────────────────────────────────────────────────────────
|
||||
async function getOwnerId () {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error) throw error
|
||||
const uid = data?.user?.id
|
||||
if (!uid) throw new Error('Sessão inválida.')
|
||||
return uid
|
||||
}
|
||||
async function getTenantId () {
|
||||
const tid = tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
|
||||
if (tid) return tid
|
||||
const ownerId = await getOwnerId()
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_members').select('tenant_id')
|
||||
.eq('user_id', ownerId).eq('status', 'active')
|
||||
.order('created_at', { ascending: false }).limit(1).single()
|
||||
if (error) throw error
|
||||
return data?.tenant_id
|
||||
}
|
||||
const insuranceStore = useInsurancePlans()
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Estado
|
||||
// ─────────────────────────────────────────────────────────
|
||||
const plans = ref([])
|
||||
const plans = insuranceStore.rows // alias reativo da lista do composable
|
||||
const loading = ref(false)
|
||||
const searchTerm = ref('')
|
||||
|
||||
@@ -93,19 +70,11 @@ const selectedPlan = computed(() =>
|
||||
// ─────────────────────────────────────────────────────────
|
||||
async function loadPlans () {
|
||||
loading.value = true
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
const { data, error } = await supabase
|
||||
.from('insurance_plans')
|
||||
.select('id, name, notes, default_value, active')
|
||||
.eq('owner_id', ownerId)
|
||||
.eq('active', true)
|
||||
.order('name', { ascending: true })
|
||||
if (error) throw error
|
||||
plans.value = data || []
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar convênios.', life: 3500 })
|
||||
} finally { loading.value = false }
|
||||
await insuranceStore.loadForOwner()
|
||||
if (insuranceStore.error.value) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: insuranceStore.error.value, life: 3500 })
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
watch(() => props.visible, (v) => {
|
||||
@@ -143,26 +112,17 @@ async function savePlan () {
|
||||
if (!name) { formErr.value = 'Informe o nome do convênio.'; return }
|
||||
saving.value = true; formErr.value = ''
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
const tenantId = await getTenantId()
|
||||
const payload = {
|
||||
owner_id: ownerId,
|
||||
tenant_id: tenantId,
|
||||
// Repository injeta owner_id + tenant_id, sanitiza strings, faz uniqueness check (case-insensitive).
|
||||
const data = await insuranceStore.create({
|
||||
name,
|
||||
notes: String(newPlan.value.notes || '').trim() || null,
|
||||
default_value: newPlan.value.default_value !== '' ? Number(newPlan.value.default_value) : null,
|
||||
active: true,
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from('insurance_plans').insert(payload)
|
||||
.select('id, name, notes, default_value, active').single()
|
||||
if (error) throw error
|
||||
plans.value = [...plans.value, data].sort((a, b) => a.name.localeCompare(b.name))
|
||||
notes: newPlan.value.notes || null,
|
||||
default_value: newPlan.value.default_value !== '' ? newPlan.value.default_value : null,
|
||||
})
|
||||
toast.add({ severity: 'success', summary: 'Convênio criado', detail: `"${data.name}" adicionado.`, life: 2500 })
|
||||
selectPlan(data)
|
||||
} catch (e) {
|
||||
const msg = e?.message || ''
|
||||
formErr.value = /duplicate/i.test(msg) ? 'Já existe um convênio com esse nome.' : (msg || 'Falha ao criar.')
|
||||
formErr.value = msg || 'Falha ao criar.'
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,8 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { digitsOnly, fmtPhone } from '@/utils/validators'
|
||||
import { useMedicos } from '@/features/medicos/composables/useMedicos'
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
const props = defineProps({
|
||||
@@ -34,36 +33,14 @@ const props = defineProps({
|
||||
const emit = defineEmits(['update:visible', 'created', 'selected'])
|
||||
|
||||
const toast = useToast()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Auth / tenant
|
||||
// ─────────────────────────────────────────────────────────
|
||||
async function getOwnerId () {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error) throw error
|
||||
const uid = data?.user?.id
|
||||
if (!uid) throw new Error('Sessão inválida.')
|
||||
return uid
|
||||
}
|
||||
async function getTenantId () {
|
||||
const tid = tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
|
||||
if (tid) return tid
|
||||
const ownerId = await getOwnerId()
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_members').select('tenant_id')
|
||||
.eq('user_id', ownerId).eq('status', 'active')
|
||||
.order('created_at', { ascending: false }).limit(1).single()
|
||||
if (error) throw error
|
||||
return data?.tenant_id
|
||||
}
|
||||
const medicosStore = useMedicos()
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Views: 'list' | 'create' | 'edit'
|
||||
// ─────────────────────────────────────────────────────────
|
||||
const view = ref('list')
|
||||
const medicos = ref([])
|
||||
const loading = ref(false)
|
||||
const medicos = medicosStore.rows // alias reativo da lista carregada pelo composable
|
||||
const loading = ref(false) // local — só pra UI da list view (composable usa loading próprio internamente)
|
||||
const searchTerm = ref('')
|
||||
const editingId = ref(null) // uuid do médico sendo editado
|
||||
|
||||
@@ -134,19 +111,11 @@ const filteredMedicos = computed(() => {
|
||||
// ─────────────────────────────────────────────────────────
|
||||
async function loadMedicos () {
|
||||
loading.value = true
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
const { data, error } = await supabase
|
||||
.from('medicos')
|
||||
.select('id, nome, crm, especialidade, telefone_profissional, clinica, cidade, estado')
|
||||
.eq('owner_id', ownerId)
|
||||
.eq('ativo', true)
|
||||
.order('nome', { ascending: true })
|
||||
if (error) throw error
|
||||
medicos.value = data || []
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar médicos.', life: 3500 })
|
||||
} finally { loading.value = false }
|
||||
await medicosStore.loadForOwner()
|
||||
if (medicosStore.error.value) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: medicosStore.error.value, life: 3500 })
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
watch(() => props.visible, async (v) => {
|
||||
@@ -166,11 +135,13 @@ watch(() => props.visible, async (v) => {
|
||||
})
|
||||
|
||||
async function loadMedicoForEdit (id) {
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
const { data, error } = await supabase
|
||||
.from('medicos').select('*').eq('id', id).eq('owner_id', ownerId).single()
|
||||
if (error) throw error
|
||||
const data = await medicosStore.fetchById(id)
|
||||
if (!data) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: medicosStore.error.value || 'Médico não encontrado.', life: 3000 })
|
||||
view.value = 'list'
|
||||
loadMedicos()
|
||||
return
|
||||
}
|
||||
form.value = {
|
||||
nome: data.nome || '',
|
||||
crm: data.crm || '',
|
||||
@@ -186,11 +157,6 @@ async function loadMedicoForEdit (id) {
|
||||
}
|
||||
editingId.value = id
|
||||
view.value = 'edit'
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar médico.', life: 3000 })
|
||||
view.value = 'list'
|
||||
loadMedicos()
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
@@ -229,44 +195,31 @@ async function saveMedico () {
|
||||
const isUpdate = !!editingId.value
|
||||
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
const tenantId = await getTenantId()
|
||||
|
||||
// Payload: owner_id e tenant_id são injetados pelo repository.
|
||||
// Repository sanitiza trim/nullif em strings; componente só normaliza telefones (digits-only).
|
||||
const payload = {
|
||||
owner_id: ownerId,
|
||||
tenant_id: tenantId,
|
||||
nome,
|
||||
crm: String(form.value.crm || '').trim() || null,
|
||||
crm: form.value.crm || null,
|
||||
especialidade: especialidadeFinal.value,
|
||||
telefone_profissional: form.value.telefone_profissional ? digitsOnly(form.value.telefone_profissional) : null,
|
||||
telefone_pessoal: form.value.telefone_pessoal ? digitsOnly(form.value.telefone_pessoal) : null,
|
||||
email: String(form.value.email || '').trim() || null,
|
||||
clinica: String(form.value.clinica || '').trim() || null,
|
||||
cidade: String(form.value.cidade || '').trim() || null,
|
||||
estado: String(form.value.estado || '').trim() || null,
|
||||
observacoes: String(form.value.observacoes || '').trim() || null,
|
||||
ativo: true,
|
||||
email: form.value.email || null,
|
||||
clinica: form.value.clinica || null,
|
||||
cidade: form.value.cidade || null,
|
||||
estado: form.value.estado || null,
|
||||
observacoes: form.value.observacoes || null,
|
||||
}
|
||||
|
||||
let data
|
||||
if (isUpdate) {
|
||||
const { data: d, error } = await supabase
|
||||
.from('medicos').update({ ...payload, updated_at: new Date().toISOString() })
|
||||
.eq('id', editingId.value).eq('owner_id', ownerId)
|
||||
.select('id, nome, crm, especialidade, telefone_profissional, clinica, cidade, estado')
|
||||
.single()
|
||||
if (error) throw error
|
||||
data = d
|
||||
toast.add({ severity: 'success', summary: 'Médico atualizado', detail: `Dr(a). ${data.nome} atualizado.`, life: 2500 })
|
||||
} else {
|
||||
const { data: d, error } = await supabase
|
||||
.from('medicos').insert(payload)
|
||||
.select('id, nome, crm, especialidade, telefone_profissional, clinica, cidade, estado')
|
||||
.single()
|
||||
if (error) throw error
|
||||
data = d
|
||||
toast.add({ severity: 'success', summary: 'Médico cadastrado', detail: `Dr(a). ${data.nome} adicionado.`, life: 2500 })
|
||||
}
|
||||
const data = isUpdate
|
||||
? await medicosStore.update(editingId.value, payload)
|
||||
: await medicosStore.create(payload)
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: isUpdate ? 'Médico atualizado' : 'Médico cadastrado',
|
||||
detail: `Dr(a). ${data.nome} ${isUpdate ? 'atualizado' : 'adicionado'}.`,
|
||||
life: 2500
|
||||
})
|
||||
|
||||
emit(isUpdate ? 'selected' : 'created', data)
|
||||
emit('selected', data)
|
||||
|
||||
@@ -25,10 +25,13 @@ import { useToast } from 'primevue/usetoast';
|
||||
import InputMask from 'primevue/inputmask';
|
||||
import Message from 'primevue/message';
|
||||
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
// Audit alta (2026-05-20): substituir supabase direto por repository pattern.
|
||||
import { usePatients } from '@/features/patients/composables/usePatients';
|
||||
import { getMyActiveMember } from '@/features/tenantship/services/tenantMembersRepository';
|
||||
const { canSee } = useRoleGuard();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const patientsStore = usePatients();
|
||||
|
||||
const isOnPatientsPage = computed(() => {
|
||||
const p = String(route.path || '');
|
||||
@@ -145,31 +148,6 @@ function normalizePhoneDigits(v) {
|
||||
return sanitizeDigits(v);
|
||||
}
|
||||
|
||||
async function getOwnerId() {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
const user = data?.user;
|
||||
if (!user?.id) throw new Error('Usuário não encontrado. Faça login novamente.');
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pega tenant_id + member_id do usuário logado.
|
||||
*/
|
||||
async function resolveTenantContextOrFail() {
|
||||
const { data: authData, error: authError } = await supabase.auth.getUser();
|
||||
if (authError) throw authError;
|
||||
const uid = authData?.user?.id;
|
||||
if (!uid) throw new Error('Sessão inválida.');
|
||||
|
||||
const { data, error } = await supabase.from('tenant_members').select('id, tenant_id').eq('user_id', uid).eq('status', 'active').order('created_at', { ascending: false }).limit(1).single();
|
||||
|
||||
if (error) throw error;
|
||||
if (!data?.tenant_id || !data?.id) throw new Error('Responsible member not found');
|
||||
|
||||
return { tenantId: data.tenant_id, memberId: data.id };
|
||||
}
|
||||
|
||||
/* ----------------------------
|
||||
* Gerador (nome/email/telefone)
|
||||
* ---------------------------- */
|
||||
@@ -240,29 +218,30 @@ async function submit(mode = 'only') {
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const ownerId = await getOwnerId();
|
||||
const { tenantId, memberId } = await resolveTenantContextOrFail();
|
||||
// Resolve tenant_member ativo via repository (audit: não mais supabase direto)
|
||||
const member = await getMyActiveMember();
|
||||
if (!member?.id || !member?.tenant_id) {
|
||||
throw new Error('Responsible member not found');
|
||||
}
|
||||
|
||||
// extraPayload antes; tenant/responsible forçados depois (não podem ser sobrescritos sem querer)
|
||||
// Payload canônico — campos hardcoded da tabela patients.
|
||||
// Props deprecated (tableName/ownerField/etc) são ignoradas internamente.
|
||||
// owner_id é injetado pelo repository (auth.uid()) — não passamos aqui.
|
||||
// extraPayload pode trazer status, observações, etc.
|
||||
const payload = {
|
||||
...props.extraPayload,
|
||||
|
||||
[props.ownerField]: ownerId,
|
||||
[props.tenantField]: tenantId,
|
||||
[props.responsibleMemberField]: memberId,
|
||||
|
||||
[props.nameField]: nome,
|
||||
[props.emailField]: email.toLowerCase(),
|
||||
[props.phoneField]: normalizePhoneDigits(tel)
|
||||
tenant_id: member.tenant_id,
|
||||
responsible_member_id: member.id,
|
||||
nome_completo: nome,
|
||||
email_principal: email.toLowerCase(),
|
||||
telefone: normalizePhoneDigits(tel)
|
||||
};
|
||||
|
||||
Object.keys(payload).forEach((k) => {
|
||||
if (payload[k] === undefined) delete payload[k];
|
||||
});
|
||||
|
||||
const { data, error } = await supabase.from(props.tableName).insert(payload).select().single();
|
||||
|
||||
if (error) throw error;
|
||||
const data = await patientsStore.create(payload);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/composables/useTopbarDevMenuExtras.js
|
||||
|
|
||||
| Extras DEV-only que aparecem dentro do mesmo botão "sliders" do topbar
|
||||
| (junto com o switcher de planos do useTopbarPlanMenu). Adiciona:
|
||||
|
|
||||
| 1) Switcher de layout (rail | melissa) — UPDATE em user_settings +
|
||||
| localStorage + hard reload pra router decidir redirect.
|
||||
|
|
||||
| 2) Atalhos rápidos pra testar M1.3 (ComponentCadastroRapido nos
|
||||
| diversos callers) e M1.1/M1.2 (CadastroRapidoMedico/Convenio).
|
||||
|
|
||||
| Visibilidade: assume que o caller só renderiza o menu se `showPlanDevMenu`
|
||||
| já estiver true (DEV + permissão). Não duplica essa lógica aqui.
|
||||
|
|
||||
| Uso em AppTopbar.vue / MelissaLayout.vue:
|
||||
| const { devExtrasModel } = useTopbarDevMenuExtras();
|
||||
| const combinedDevMenuModel = computed(() => [
|
||||
| ...planMenuModel.value,
|
||||
| { separator: true },
|
||||
| ...devExtrasModel.value
|
||||
| ]);
|
||||
| <Menu :model="combinedDevMenuModel" ... />
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
|
||||
export function useTopbarDevMenuExtras() {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const { layoutConfig } = useLayout();
|
||||
|
||||
const currentVariant = computed(() => layoutConfig.variant || 'classic');
|
||||
const isRailLike = computed(() => currentVariant.value === 'rail' || currentVariant.value === 'classic');
|
||||
const isMelissa = computed(() => currentVariant.value === 'melissa');
|
||||
|
||||
async function setLayoutAndReload(variant) {
|
||||
try {
|
||||
const { data, error: authErr } = await supabase.auth.getUser();
|
||||
if (authErr) throw authErr;
|
||||
const uid = data?.user?.id;
|
||||
if (!uid) throw new Error('Sem sessão.');
|
||||
|
||||
const { error } = await supabase
|
||||
.from('user_settings')
|
||||
.upsert(
|
||||
{
|
||||
user_id: uid,
|
||||
layout_variant: variant,
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{ onConflict: 'user_id' }
|
||||
);
|
||||
if (error) throw error;
|
||||
|
||||
// Fast path: router.beforeEach/guards lêem localStorage antes do fetch
|
||||
try {
|
||||
localStorage.setItem('layout_variant', variant);
|
||||
} catch (_) {
|
||||
// ignore (Safari private mode etc.)
|
||||
}
|
||||
|
||||
toast.add({ severity: 'success', summary: `Layout: ${variant}`, life: 1200 });
|
||||
|
||||
// Hard reload pra router redirecionar pra raiz correta
|
||||
setTimeout(() => window.location.assign('/'), 250);
|
||||
} catch (e) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro ao trocar layout',
|
||||
detail: e?.message || 'Falha desconhecida.',
|
||||
life: 4500
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function goto(path) {
|
||||
// router.push em vez de location.assign — mantém SPA + dev tools abertos.
|
||||
// Se o guard redirecionar (ex: Melissa mode bloqueia /therapist/...),
|
||||
// o usuário verá o redirect — sinal de que precisa trocar layout primeiro.
|
||||
router.push(path).catch(() => {});
|
||||
}
|
||||
|
||||
const devExtrasModel = computed(() => [
|
||||
// ─── Layout ────────────────────────────────────────
|
||||
{ label: 'Layout (DEV)', icon: 'pi pi-th-large', disabled: true },
|
||||
{
|
||||
label: isRailLike.value ? 'Rail (atual)' : 'Rail',
|
||||
icon: isRailLike.value ? 'pi pi-check' : 'pi pi-bars',
|
||||
disabled: isRailLike.value,
|
||||
command: () => setLayoutAndReload('rail')
|
||||
},
|
||||
{
|
||||
label: isMelissa.value ? 'Melissa (atual)' : 'Melissa',
|
||||
icon: isMelissa.value ? 'pi pi-check' : 'pi pi-window-maximize',
|
||||
disabled: isMelissa.value,
|
||||
command: () => setLayoutAndReload('melissa')
|
||||
},
|
||||
|
||||
{ separator: true },
|
||||
|
||||
// ─── Atalhos pra testar Módulo 1 ───────────────────
|
||||
{ label: 'Testar Módulo 1 (DEV)', icon: 'pi pi-bullseye', disabled: true },
|
||||
{
|
||||
label: '→ Cadastro Paciente (M1.1 + M1.2)',
|
||||
icon: 'pi pi-user-plus',
|
||||
command: () => goto('/therapist/patients/cadastro')
|
||||
},
|
||||
{
|
||||
label: '→ Lista de Pacientes (M1.3-E)',
|
||||
icon: 'pi pi-list',
|
||||
command: () => goto('/therapist/patients')
|
||||
},
|
||||
{
|
||||
label: '→ Melissa Agenda (M1.3-B)',
|
||||
icon: 'pi pi-calendar',
|
||||
command: () => goto('/melissa/agenda')
|
||||
},
|
||||
{
|
||||
label: '→ Melissa Pacientes (M1.3-B)',
|
||||
icon: 'pi pi-users',
|
||||
command: () => goto('/melissa/pacientes')
|
||||
}
|
||||
]);
|
||||
|
||||
return { devExtrasModel };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/config/devTestAccounts.js
|
||||
|
|
||||
| Contas de teste seedadas pelo banco para QA/dev. Usado em HomeCards.vue
|
||||
| pra prefill de login (botão "Entrar como..." em dev).
|
||||
|
|
||||
| ⚠️ Senhas em texto — só vale porque o banco local tem essas mesmas senhas
|
||||
| seedadas (seed_001/002/003). Em produção, este arquivo segue compilado
|
||||
| mas o flag `isDev` em HomeCards.vue garante que os botões não aparecem.
|
||||
|
|
||||
| Pra gate em build de produção (remover do bundle), tratar como import
|
||||
| dinâmico no futuro ou condicional via `import.meta.env.DEV` no consumidor.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const TEST_ACCOUNTS = {
|
||||
clinic_admin: { email: 'clinica3@agenciapsi.com.br', password: 'Teste@123' },
|
||||
therapist: { email: 'terapeuta@agenciapsi.com.br', password: 'Teste@123' },
|
||||
supervisor: { email: 'supervisor@agenciapsi.com.br', password: 'Teste@123' },
|
||||
patient: { email: 'paciente@agenciapsi.com.br', password: 'Teste@123' },
|
||||
saas: { email: 'saas@agenciapsi.com.br', password: 'Teste@123' },
|
||||
editor: { email: 'editor@agenciapsi.com.br', password: 'Teste@123' },
|
||||
therapist2: { email: 'therapist2@agenciapsi.com.br', password: 'Teste@123' },
|
||||
therapist3: { email: 'therapist3@agenciapsi.com.br', password: 'Teste@123' },
|
||||
secretary: { email: 'secretary@agenciapsi.com.br', password: 'Teste@123' }
|
||||
};
|
||||
@@ -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();
|
||||
@@ -42,6 +42,7 @@ function toggleAjuda() {
|
||||
}
|
||||
|
||||
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence';
|
||||
import { useTopbarDevMenuExtras } from '@/composables/useTopbarDevMenuExtras';
|
||||
import { applyThemeEngine } from '@/theme/theme.options';
|
||||
|
||||
import { fetchAllNotices } from '@/features/notices/noticeService';
|
||||
@@ -415,6 +416,14 @@ const planMenuModel = computed(() => {
|
||||
return [header, subInfo, { separator: true }, ...items];
|
||||
});
|
||||
|
||||
// ─── Extras do menu DEV (layout switcher + atalhos M1) ────────────
|
||||
const { devExtrasModel } = useTopbarDevMenuExtras();
|
||||
const combinedDevMenuModel = computed(() => [
|
||||
...planMenuModel.value,
|
||||
{ separator: true },
|
||||
...devExtrasModel.value
|
||||
]);
|
||||
|
||||
async function openPlanMenu(event) {
|
||||
if (!showPlanDevMenu.value) return;
|
||||
|
||||
@@ -598,7 +607,7 @@ onMounted(async () => {
|
||||
<i class="pi pi-sliders-h" />
|
||||
</Button>
|
||||
|
||||
<Menu ref="planMenu" :model="planMenuModel" popup appendTo="body" :baseZIndex="3000" />
|
||||
<Menu ref="planMenu" :model="combinedDevMenuModel" popup appendTo="body" :baseZIndex="3000" />
|
||||
|
||||
<!-- Notificações -->
|
||||
<div class="relative">
|
||||
|
||||
@@ -106,6 +106,7 @@ import { useNotifications } from '@/composables/useNotifications';
|
||||
import { useNotificationStore } from '@/stores/notificationStore';
|
||||
import { useAjuda } from '@/composables/useAjuda';
|
||||
import { useTopbarPlanMenu } from '@/composables/useTopbarPlanMenu';
|
||||
import { useTopbarDevMenuExtras } from '@/composables/useTopbarDevMenuExtras';
|
||||
|
||||
// Pacientes + eventos do dia.
|
||||
//
|
||||
@@ -1819,6 +1820,14 @@ const {
|
||||
openPlanMenu
|
||||
} = useTopbarPlanMenu();
|
||||
|
||||
// Extras DEV (layout switcher + atalhos M1) que aparecem no MESMO menu do botão sliders.
|
||||
const { devExtrasModel } = useTopbarDevMenuExtras();
|
||||
const combinedDevMenuModel = computed(() => [
|
||||
...planMenuModel.value,
|
||||
{ separator: true },
|
||||
...devExtrasModel.value
|
||||
]);
|
||||
|
||||
// Recebíveis derivados de agenda_eventos.{price,billed}: aproximação MVP.
|
||||
// `billed=true` é o flag de "marcado como pago/cobrado" no agenda — não
|
||||
// é a fonte de verdade financeira (essa é financial_records.status='paid'),
|
||||
@@ -3227,7 +3236,7 @@ function onKeydown(e) {
|
||||
<NotificationDrawer />
|
||||
|
||||
<!-- Plan menu DEV — popup ancorado no botão da topbar -->
|
||||
<Menu ref="planMenu" :model="planMenuModel" popup appendTo="body" :baseZIndex="3000" />
|
||||
<Menu ref="planMenu" :model="combinedDevMenuModel" popup appendTo="body" :baseZIndex="3000" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { supabase } from '../../lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { TEST_ACCOUNTS } from '@/config/devTestAccounts';
|
||||
const router = useRouter();
|
||||
const tenant = useTenantStore();
|
||||
|
||||
@@ -20,18 +21,6 @@ const storageTenantId = ref(null);
|
||||
const storageTenant = ref(null);
|
||||
const storageCurrentTenantId = ref(null);
|
||||
|
||||
const TEST_ACCOUNTS = {
|
||||
clinic_admin: { email: 'clinica3@agenciapsi.com.br', password: 'Teste@123' },
|
||||
therapist: { email: 'terapeuta@agenciapsi.com.br', password: 'Teste@123' },
|
||||
supervisor: { email: 'supervisor@agenciapsi.com.br', password: 'Teste@123' },
|
||||
patient: { email: 'paciente@agenciapsi.com.br', password: 'Teste@123' },
|
||||
saas: { email: 'saas@agenciapsi.com.br', password: 'Teste@123' },
|
||||
editor: { email: 'editor@agenciapsi.com.br', password: 'Teste@123' },
|
||||
therapist2: { email: 'therapist2@agenciapsi.com.br', password: 'Teste@123' },
|
||||
therapist3: { email: 'therapist3@agenciapsi.com.br', password: 'Teste@123' },
|
||||
secretary: { email: 'secretary@agenciapsi.com.br', password: 'Teste@123' }
|
||||
};
|
||||
|
||||
const PROFILE_CARDS = [
|
||||
{
|
||||
key: 'patient',
|
||||
|
||||
Reference in New Issue
Block a user