Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras
This commit is contained in:
@@ -23,86 +23,81 @@
|
||||
// // No lugar onde saasMenu() é chamado:
|
||||
// saasMenu(sessionCtx, { mismatchCount, docsAtencaoCount: countAtencao.value })
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const THRESHOLD_NEGATIVO = 0.30 // 30%
|
||||
const MIN_VOTOS = 3 // mínimo de votos para considerar
|
||||
const THRESHOLD_NEGATIVO = 0.3; // 30%
|
||||
const MIN_VOTOS = 3; // mínimo de votos para considerar
|
||||
|
||||
// Estado singleton — alimentado pelo SaasDocsPage após load()
|
||||
const _docs = ref([])
|
||||
const _docs = ref([]);
|
||||
|
||||
// ── Classificação individual (exportada standalone p/ uso externo) ─
|
||||
export function saudeDocItem (doc) {
|
||||
const total = (doc.votos_util || 0) + (doc.votos_nao_util || 0)
|
||||
if (total < MIN_VOTOS) return 'sem_dados'
|
||||
const pctNeg = (doc.votos_nao_util || 0) / total
|
||||
return pctNeg > THRESHOLD_NEGATIVO ? 'atencao' : 'ok'
|
||||
export function saudeDocItem(doc) {
|
||||
const total = (doc.votos_util || 0) + (doc.votos_nao_util || 0);
|
||||
if (total < MIN_VOTOS) return 'sem_dados';
|
||||
const pctNeg = (doc.votos_nao_util || 0) / total;
|
||||
return pctNeg > THRESHOLD_NEGATIVO ? 'atencao' : 'ok';
|
||||
}
|
||||
|
||||
// ── Singleton reativo — use no menu ou em qualquer lugar ──────
|
||||
export const countAtencao = computed(() =>
|
||||
_docs.value.filter(d => saudeDocItem(d) === 'atencao').length
|
||||
)
|
||||
export const countAtencao = computed(() => _docs.value.filter((d) => saudeDocItem(d) === 'atencao').length);
|
||||
|
||||
export function useDocsHealth () {
|
||||
export function useDocsHealth() {
|
||||
function setDocs(docs) {
|
||||
_docs.value = docs;
|
||||
}
|
||||
|
||||
function setDocs (docs) {
|
||||
_docs.value = docs
|
||||
}
|
||||
function saudeDoc(doc) {
|
||||
return saudeDocItem(doc);
|
||||
}
|
||||
|
||||
function saudeDoc (doc) {
|
||||
return saudeDocItem(doc)
|
||||
}
|
||||
function pctNegativo(doc) {
|
||||
const total = (doc.votos_util || 0) + (doc.votos_nao_util || 0);
|
||||
if (!total) return 0;
|
||||
return Math.round(((doc.votos_nao_util || 0) / total) * 100);
|
||||
}
|
||||
|
||||
function pctNegativo (doc) {
|
||||
const total = (doc.votos_util || 0) + (doc.votos_nao_util || 0)
|
||||
if (!total) return 0
|
||||
return Math.round(((doc.votos_nao_util || 0) / total) * 100)
|
||||
}
|
||||
// ── Métricas globais ───────────────────────────────────────
|
||||
const totalDocs = computed(() => _docs.value.length);
|
||||
const docsAtencao = computed(() => _docs.value.filter((d) => saudeDoc(d) === 'atencao'));
|
||||
const docsOk = computed(() => _docs.value.filter((d) => saudeDoc(d) === 'ok'));
|
||||
const docsSemDados = computed(() => _docs.value.filter((d) => saudeDoc(d) === 'sem_dados'));
|
||||
|
||||
// ── Métricas globais ───────────────────────────────────────
|
||||
const totalDocs = computed(() => _docs.value.length)
|
||||
const docsAtencao = computed(() => _docs.value.filter(d => saudeDoc(d) === 'atencao'))
|
||||
const docsOk = computed(() => _docs.value.filter(d => saudeDoc(d) === 'ok'))
|
||||
const docsSemDados = computed(() => _docs.value.filter(d => saudeDoc(d) === 'sem_dados'))
|
||||
// Doc mais útil (maior % positivo com mínimo de votos)
|
||||
const docMaisUtil = computed(() => {
|
||||
const comVotos = _docs.value.filter((d) => (d.votos_util || 0) + (d.votos_nao_util || 0) >= MIN_VOTOS);
|
||||
if (!comVotos.length) return null;
|
||||
return comVotos.reduce((best, d) => {
|
||||
const pct = (d.votos_util || 0) / ((d.votos_util || 0) + (d.votos_nao_util || 0));
|
||||
const bestPct = (best.votos_util || 0) / ((best.votos_util || 0) + (best.votos_nao_util || 0));
|
||||
return pct > bestPct ? d : best;
|
||||
});
|
||||
});
|
||||
|
||||
// Doc mais útil (maior % positivo com mínimo de votos)
|
||||
const docMaisUtil = computed(() => {
|
||||
const comVotos = _docs.value.filter(d =>
|
||||
(d.votos_util || 0) + (d.votos_nao_util || 0) >= MIN_VOTOS
|
||||
)
|
||||
if (!comVotos.length) return null
|
||||
return comVotos.reduce((best, d) => {
|
||||
const pct = (d.votos_util || 0) / ((d.votos_util || 0) + (d.votos_nao_util || 0))
|
||||
const bestPct = (best.votos_util || 0) / ((best.votos_util || 0) + (best.votos_nao_util || 0))
|
||||
return pct > bestPct ? d : best
|
||||
})
|
||||
})
|
||||
// ── Ordenação por saúde ────────────────────────────────────
|
||||
// Problemáticas primeiro → ok → sem dados
|
||||
function sortBySaude(lista) {
|
||||
const ordem = { atencao: 0, ok: 1, sem_dados: 2 };
|
||||
return [...lista].sort((a, b) => {
|
||||
const sa = saudeDoc(a);
|
||||
const sb = saudeDoc(b);
|
||||
if (ordem[sa] !== ordem[sb]) return ordem[sa] - ordem[sb];
|
||||
return pctNegativo(b) - pctNegativo(a);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Ordenação por saúde ────────────────────────────────────
|
||||
// Problemáticas primeiro → ok → sem dados
|
||||
function sortBySaude (lista) {
|
||||
const ordem = { atencao: 0, ok: 1, sem_dados: 2 }
|
||||
return [...lista].sort((a, b) => {
|
||||
const sa = saudeDoc(a)
|
||||
const sb = saudeDoc(b)
|
||||
if (ordem[sa] !== ordem[sb]) return ordem[sa] - ordem[sb]
|
||||
return pctNegativo(b) - pctNegativo(a)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
setDocs,
|
||||
saudeDoc,
|
||||
pctNegativo,
|
||||
totalDocs,
|
||||
docsAtencao,
|
||||
docsOk,
|
||||
docsSemDados,
|
||||
countAtencao,
|
||||
docMaisUtil,
|
||||
sortBySaude,
|
||||
THRESHOLD_NEGATIVO,
|
||||
MIN_VOTOS,
|
||||
}
|
||||
}
|
||||
return {
|
||||
setDocs,
|
||||
saudeDoc,
|
||||
pctNegativo,
|
||||
totalDocs,
|
||||
docsAtencao,
|
||||
docsOk,
|
||||
docsSemDados,
|
||||
countAtencao,
|
||||
docMaisUtil,
|
||||
sortBySaude,
|
||||
THRESHOLD_NEGATIVO,
|
||||
MIN_VOTOS
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,243 +29,227 @@
|
||||
* await handleStatusChange(eventoOriginal, novoStatus, agendaEvents.update)
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
// ─── Cache de exceções financeiras (vive enquanto o módulo estiver carregado) ─
|
||||
// Chave: `${tenantId}:${exceptionType}` → FinancialException | null
|
||||
const _exceptionsCache = new Map()
|
||||
const _exceptionsCache = new Map();
|
||||
|
||||
// ─── helper ──────────────────────────────────────────────────────────────────
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── mapeamento: status anterior → tipo de exceção a consultar ───────────────
|
||||
const STATUS_TO_EXCEPTION = {
|
||||
faltou: 'patient_no_show',
|
||||
cancelado: 'patient_cancellation',
|
||||
}
|
||||
faltou: 'patient_no_show',
|
||||
cancelado: 'patient_cancellation'
|
||||
};
|
||||
|
||||
// ─── calcular valor cobrado por charge_mode ───────────────────────────────────
|
||||
function calcChargeAmount (originalAmount, rule) {
|
||||
if (!rule || rule.charge_mode === 'none') return 0
|
||||
if (rule.charge_mode === 'full') return originalAmount
|
||||
if (rule.charge_mode === 'fixed') return rule.charge_value ?? 0
|
||||
if (rule.charge_mode === 'percentage') {
|
||||
const pct = rule.charge_pct ?? 0
|
||||
return parseFloat(((originalAmount * pct) / 100).toFixed(2))
|
||||
}
|
||||
return originalAmount
|
||||
function calcChargeAmount(originalAmount, rule) {
|
||||
if (!rule || rule.charge_mode === 'none') return 0;
|
||||
if (rule.charge_mode === 'full') return originalAmount;
|
||||
if (rule.charge_mode === 'fixed') return rule.charge_value ?? 0;
|
||||
if (rule.charge_mode === 'percentage') {
|
||||
const pct = rule.charge_pct ?? 0;
|
||||
return parseFloat(((originalAmount * pct) / 100).toFixed(2));
|
||||
}
|
||||
return originalAmount;
|
||||
}
|
||||
|
||||
// ─── composable ──────────────────────────────────────────────────────────────
|
||||
export function useAgendaFinanceiro () {
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
export function useAgendaFinanceiro() {
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
// ── getFinancialExceptionRule ─────────────────────────────────────────────
|
||||
/**
|
||||
* Busca a regra de exceção financeira para um tipo, com cache em memória.
|
||||
* Prioridade: regra própria do owner > regra global do tenant (owner_id IS NULL)
|
||||
*
|
||||
* @param {string} tenantId
|
||||
* @param {'patient_no_show'|'patient_cancellation'|'professional_cancellation'} exceptionType
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function getFinancialExceptionRule (tenantId, exceptionType) {
|
||||
const cacheKey = `${tenantId}:${exceptionType}`
|
||||
if (_exceptionsCache.has(cacheKey)) return _exceptionsCache.get(cacheKey)
|
||||
// ── getFinancialExceptionRule ─────────────────────────────────────────────
|
||||
/**
|
||||
* Busca a regra de exceção financeira para um tipo, com cache em memória.
|
||||
* Prioridade: regra própria do owner > regra global do tenant (owner_id IS NULL)
|
||||
*
|
||||
* @param {string} tenantId
|
||||
* @param {'patient_no_show'|'patient_cancellation'|'professional_cancellation'} exceptionType
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function getFinancialExceptionRule(tenantId, exceptionType) {
|
||||
const cacheKey = `${tenantId}:${exceptionType}`;
|
||||
if (_exceptionsCache.has(cacheKey)) return _exceptionsCache.get(cacheKey);
|
||||
|
||||
const uid = await getUid()
|
||||
const uid = await getUid();
|
||||
|
||||
const { data, error: err } = await supabase
|
||||
.from('financial_exceptions')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('exception_type', exceptionType)
|
||||
.or(`owner_id.eq.${uid},owner_id.is.null`)
|
||||
.order('owner_id', { ascending: false, nullsLast: true }) // owner próprio tem prioridade
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
const { data, error: err } = await supabase
|
||||
.from('financial_exceptions')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('exception_type', exceptionType)
|
||||
.or(`owner_id.eq.${uid},owner_id.is.null`)
|
||||
.order('owner_id', { ascending: false, nullsLast: true }) // owner próprio tem prioridade
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (err) {
|
||||
console.warn('[useAgendaFinanceiro] getFinancialExceptionRule:', err.message)
|
||||
return null
|
||||
if (err) {
|
||||
console.warn('[useAgendaFinanceiro] getFinancialExceptionRule:', err.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
_exceptionsCache.set(cacheKey, data ?? null);
|
||||
return data ?? null;
|
||||
}
|
||||
|
||||
_exceptionsCache.set(cacheKey, data ?? null)
|
||||
return data ?? null
|
||||
}
|
||||
// ── gerarCobrancaManual ───────────────────────────────────────────────────
|
||||
/**
|
||||
* Gera cobrança para uma sessão existente com `billed = false`.
|
||||
* Chama a RPC `create_financial_record_for_session`.
|
||||
*
|
||||
* @param {Object} evento - linha de agenda_eventos (com campo price)
|
||||
* @returns {Promise<{ok: boolean, data?: Object, error?: string}>}
|
||||
*/
|
||||
async function gerarCobrancaManual(evento) {
|
||||
if (evento.billing_contract_id) {
|
||||
// sessão de pacote — não gera cobrança individual
|
||||
return { ok: false, error: 'Sessão faz parte de um pacote. Cobrança gerenciada pelo contrato.' };
|
||||
}
|
||||
|
||||
// ── gerarCobrancaManual ───────────────────────────────────────────────────
|
||||
/**
|
||||
* Gera cobrança para uma sessão existente com `billed = false`.
|
||||
* Chama a RPC `create_financial_record_for_session`.
|
||||
*
|
||||
* @param {Object} evento - linha de agenda_eventos (com campo price)
|
||||
* @returns {Promise<{ok: boolean, data?: Object, error?: string}>}
|
||||
*/
|
||||
async function gerarCobrancaManual (evento) {
|
||||
if (evento.billing_contract_id) {
|
||||
// sessão de pacote — não gera cobrança individual
|
||||
return { ok: false, error: 'Sessão faz parte de um pacote. Cobrança gerenciada pelo contrato.' }
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) return { ok: false, error: 'Tenant não identificado.' };
|
||||
|
||||
const ownerId = await getUid();
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const amount = evento.price ?? 0;
|
||||
const dueDate = evento.inicio_em ? new Date(evento.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
|
||||
|
||||
const { data, error: err } = await supabase.rpc('create_financial_record_for_session', {
|
||||
p_tenant_id: tenantId,
|
||||
p_owner_id: ownerId,
|
||||
p_patient_id: evento.patient_id ?? evento.paciente_id ?? null,
|
||||
p_agenda_evento_id: evento.id,
|
||||
p_amount: amount,
|
||||
p_due_date: dueDate
|
||||
});
|
||||
|
||||
if (err) throw err;
|
||||
|
||||
return { ok: true, data };
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao gerar cobrança.';
|
||||
return { ok: false, error: error.value };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const tenantStore = useTenantStore()
|
||||
const tenantId = tenantStore.activeTenantId
|
||||
if (!tenantId) return { ok: false, error: 'Tenant não identificado.' }
|
||||
// ── handleStatusChange ────────────────────────────────────────────────────
|
||||
/**
|
||||
* Orquestra a mudança de status de uma sessão + consequências financeiras.
|
||||
*
|
||||
* @param {Object} evento - linha atual de agenda_eventos (ANTES da mudança)
|
||||
* @param {string} novoStatus - novo status a aplicar
|
||||
* @param {Function} agendaUpdateFn - função que aplica o update na agenda (ex: agendaEvents.update)
|
||||
* signature: (id, patch) => Promise<void>
|
||||
* @returns {Promise<{ok: boolean, error?: string}>}
|
||||
*/
|
||||
async function handleStatusChange(evento, novoStatus, agendaUpdateFn) {
|
||||
// bloqueios e sessões de pacote não têm cobrança individual
|
||||
const ignorar = evento.tipo !== 'sessao' || !!evento.billing_contract_id;
|
||||
const statusAnterior = evento.status;
|
||||
|
||||
const ownerId = await getUid()
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
// 1. Aplica o update na agenda sempre (fonte da verdade é a agenda)
|
||||
await agendaUpdateFn(evento.id, { status: novoStatus });
|
||||
|
||||
try {
|
||||
const amount = evento.price ?? 0
|
||||
const dueDate = evento.inicio_em
|
||||
? new Date(evento.inicio_em).toISOString().slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10)
|
||||
if (ignorar) return { ok: true };
|
||||
if (statusAnterior === novoStatus) return { ok: true };
|
||||
|
||||
const { data, error: err } = await supabase.rpc('create_financial_record_for_session', {
|
||||
p_tenant_id: tenantId,
|
||||
p_owner_id: ownerId,
|
||||
p_patient_id: evento.patient_id ?? evento.paciente_id ?? null,
|
||||
p_agenda_evento_id: evento.id,
|
||||
p_amount: amount,
|
||||
p_due_date: dueDate,
|
||||
})
|
||||
// 2. Lógica financeira por transição
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
|
||||
if (err) throw err
|
||||
// ── faltou / cancelado → consultar exceção financeira ──────────────
|
||||
const exceptionType = STATUS_TO_EXCEPTION[novoStatus];
|
||||
|
||||
return { ok: true, data }
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao gerar cobrança.'
|
||||
return { ok: false, error: error.value }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
if (exceptionType) {
|
||||
const rule = await getFinancialExceptionRule(tenantId, exceptionType);
|
||||
|
||||
// ── handleStatusChange ────────────────────────────────────────────────────
|
||||
/**
|
||||
* Orquestra a mudança de status de uma sessão + consequências financeiras.
|
||||
*
|
||||
* @param {Object} evento - linha atual de agenda_eventos (ANTES da mudança)
|
||||
* @param {string} novoStatus - novo status a aplicar
|
||||
* @param {Function} agendaUpdateFn - função que aplica o update na agenda (ex: agendaEvents.update)
|
||||
* signature: (id, patch) => Promise<void>
|
||||
* @returns {Promise<{ok: boolean, error?: string}>}
|
||||
*/
|
||||
async function handleStatusChange (evento, novoStatus, agendaUpdateFn) {
|
||||
// bloqueios e sessões de pacote não têm cobrança individual
|
||||
const ignorar = evento.tipo !== 'sessao' || !!evento.billing_contract_id
|
||||
const statusAnterior = evento.status
|
||||
if (!rule || rule.charge_mode === 'none') {
|
||||
// Cancelar cobrança existente, se houver
|
||||
if (evento.billed) {
|
||||
const { data: existingRec } = await supabase.from('financial_records').select('id, status').eq('agenda_evento_id', evento.id).in('status', ['pending', 'overdue']).maybeSingle();
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
if (existingRec) {
|
||||
await supabase.from('financial_records').update({ status: 'cancelled', updated_at: new Date().toISOString() }).eq('id', existingRec.id);
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Aplica o update na agenda sempre (fonte da verdade é a agenda)
|
||||
await agendaUpdateFn(evento.id, { status: novoStatus })
|
||||
// charge_mode != 'none' → ajustar valor da cobrança existente ou criar nova
|
||||
const chargeAmount = calcChargeAmount(evento.price ?? 0, rule);
|
||||
|
||||
if (ignorar) return { ok: true }
|
||||
if (statusAnterior === novoStatus) return { ok: true }
|
||||
if (evento.billed) {
|
||||
// Atualiza o valor da cobrança existente
|
||||
const { data: existingRec } = await supabase.from('financial_records').select('id').eq('agenda_evento_id', evento.id).in('status', ['pending', 'overdue']).maybeSingle();
|
||||
|
||||
// 2. Lógica financeira por transição
|
||||
const tenantStore = useTenantStore()
|
||||
const tenantId = tenantStore.activeTenantId
|
||||
if (existingRec) {
|
||||
await supabase
|
||||
.from('financial_records')
|
||||
.update({
|
||||
amount: chargeAmount,
|
||||
final_amount: chargeAmount,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', existingRec.id);
|
||||
}
|
||||
} else if (chargeAmount > 0) {
|
||||
// Sessão sem cobrança: gera uma nova com o valor ajustado
|
||||
await gerarCobrancaManual({ ...evento, price: chargeAmount });
|
||||
}
|
||||
|
||||
// ── faltou / cancelado → consultar exceção financeira ──────────────
|
||||
const exceptionType = STATUS_TO_EXCEPTION[novoStatus]
|
||||
|
||||
if (exceptionType) {
|
||||
const rule = await getFinancialExceptionRule(tenantId, exceptionType)
|
||||
|
||||
if (!rule || rule.charge_mode === 'none') {
|
||||
// Cancelar cobrança existente, se houver
|
||||
if (evento.billed) {
|
||||
const { data: existingRec } = await supabase
|
||||
.from('financial_records')
|
||||
.select('id, status')
|
||||
.eq('agenda_evento_id', evento.id)
|
||||
.in('status', ['pending', 'overdue'])
|
||||
.maybeSingle()
|
||||
|
||||
if (existingRec) {
|
||||
await supabase
|
||||
.from('financial_records')
|
||||
.update({ status: 'cancelled', updated_at: new Date().toISOString() })
|
||||
.eq('id', existingRec.id)
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
|
||||
// ── remarcar → atualizar due_date da cobrança existente ────────────
|
||||
if (novoStatus === 'remarcar' && evento.billed) {
|
||||
// due_date mantém a data da sessão original por enquanto
|
||||
// (a nova data virá quando a sessão for reagendada)
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── agendado → realizado: nenhuma ação financeira automática ────────
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao processar mudança de status.';
|
||||
return { ok: false, error: error.value };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// charge_mode != 'none' → ajustar valor da cobrança existente ou criar nova
|
||||
const chargeAmount = calcChargeAmount(evento.price ?? 0, rule)
|
||||
|
||||
if (evento.billed) {
|
||||
// Atualiza o valor da cobrança existente
|
||||
const { data: existingRec } = await supabase
|
||||
.from('financial_records')
|
||||
.select('id')
|
||||
.eq('agenda_evento_id', evento.id)
|
||||
.in('status', ['pending', 'overdue'])
|
||||
.maybeSingle()
|
||||
|
||||
if (existingRec) {
|
||||
await supabase
|
||||
.from('financial_records')
|
||||
.update({
|
||||
amount: chargeAmount,
|
||||
final_amount: chargeAmount,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', existingRec.id)
|
||||
}
|
||||
} else if (chargeAmount > 0) {
|
||||
// Sessão sem cobrança: gera uma nova com o valor ajustado
|
||||
await gerarCobrancaManual({ ...evento, price: chargeAmount })
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// ── remarcar → atualizar due_date da cobrança existente ────────────
|
||||
if (novoStatus === 'remarcar' && evento.billed) {
|
||||
// due_date mantém a data da sessão original por enquanto
|
||||
// (a nova data virá quando a sessão for reagendada)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// ── agendado → realizado: nenhuma ação financeira automática ────────
|
||||
return { ok: true }
|
||||
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao processar mudança de status.'
|
||||
return { ok: false, error: error.value }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── invalidar cache (use quando o usuário altera exceções financeiras) ───
|
||||
function invalidateExceptionsCache () {
|
||||
_exceptionsCache.clear()
|
||||
}
|
||||
// ── invalidar cache (use quando o usuário altera exceções financeiras) ───
|
||||
function invalidateExceptionsCache() {
|
||||
_exceptionsCache.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
handleStatusChange,
|
||||
gerarCobrancaManual,
|
||||
getFinancialExceptionRule,
|
||||
invalidateExceptionsCache,
|
||||
}
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
handleStatusChange,
|
||||
gerarCobrancaManual,
|
||||
getFinancialExceptionRule,
|
||||
invalidateExceptionsCache
|
||||
};
|
||||
}
|
||||
|
||||
+293
-276
@@ -18,351 +18,368 @@
|
||||
// - Navegação interna com stack (voltar)
|
||||
// - Votação por usuário (útil / não útil)
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { sessionRole, sessionIsSaasAdmin } from '@/app/session'
|
||||
import { ref, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { sessionRole, sessionIsSaasAdmin } from '@/app/session';
|
||||
|
||||
const ADMIN_ROLES = ['clinic_admin', 'tenant_admin']
|
||||
const ADMIN_ROLES = ['clinic_admin', 'tenant_admin'];
|
||||
|
||||
function isCurrentUserAdmin () {
|
||||
return sessionIsSaasAdmin.value || ADMIN_ROLES.includes(sessionRole.value)
|
||||
function isCurrentUserAdmin() {
|
||||
return sessionIsSaasAdmin.value || ADMIN_ROLES.includes(sessionRole.value);
|
||||
}
|
||||
|
||||
// ── Singleton state ────────────────────────────────────────────
|
||||
const cache = new Map() // path → { docs, relatedDocs, faqItens }
|
||||
const docCache = new Map() // id → { docs, relatedDocs, faqItens }
|
||||
const cache = new Map(); // path → { docs, relatedDocs, faqItens }
|
||||
const docCache = new Map(); // id → { docs, relatedDocs, faqItens }
|
||||
|
||||
// Estado da sessão atual
|
||||
const sessionDocs = ref([])
|
||||
const sessionFaq = ref([])
|
||||
const sessionPath = ref('')
|
||||
const sessionDocs = ref([]);
|
||||
const sessionFaq = ref([]);
|
||||
const sessionPath = ref('');
|
||||
|
||||
// Estado da home (todos os docs paginados + FAQ global)
|
||||
const allDocs = ref([])
|
||||
const allDocsTotal = ref(0)
|
||||
const allDocsPage = ref(0)
|
||||
const allDocsLoading = ref(false)
|
||||
const ALL_DOCS_PAGE_SIZE = 8
|
||||
const allDocs = ref([]);
|
||||
const allDocsTotal = ref(0);
|
||||
const allDocsPage = ref(0);
|
||||
const allDocsLoading = ref(false);
|
||||
const ALL_DOCS_PAGE_SIZE = 8;
|
||||
|
||||
const globalFaq = ref([])
|
||||
const globalFaqLoading = ref(false)
|
||||
const globalFaq = ref([]);
|
||||
const globalFaqLoading = ref(false);
|
||||
|
||||
// Drawer
|
||||
const drawerOpen = ref(false)
|
||||
const loading = ref(false)
|
||||
const drawerOpen = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
// Stack de navegação — cada entrada: { currentDoc, label }
|
||||
const navStack = ref([])
|
||||
const navStack = ref([]);
|
||||
// null = home, objeto = doc aberto
|
||||
const currentDoc = ref(null)
|
||||
const currentDoc = ref(null);
|
||||
|
||||
const isHome = computed(() => currentDoc.value === null)
|
||||
const isNavigating = computed(() => navStack.value.length > 0)
|
||||
const isHome = computed(() => currentDoc.value === null);
|
||||
const isNavigating = computed(() => navStack.value.length > 0);
|
||||
|
||||
// Votos do usuário { [docId]: true | false | null }
|
||||
const meusVotos = ref({})
|
||||
const meusVotos = ref({});
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
function normalizePath (path) {
|
||||
return String(path || '').split('?')[0].split('#')[0].replace(/\/$/, '') || '/'
|
||||
function normalizePath(path) {
|
||||
return (
|
||||
String(path || '')
|
||||
.split('?')[0]
|
||||
.split('#')[0]
|
||||
.replace(/\/$/, '') || '/'
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchFaqItensForDocs (docIds) {
|
||||
if (!docIds.length) return []
|
||||
const { data } = await supabase
|
||||
.from('saas_faq_itens')
|
||||
.select('id, doc_id, pergunta, resposta, ordem')
|
||||
.in('doc_id', docIds)
|
||||
.eq('ativo', true)
|
||||
.order('doc_id')
|
||||
.order('ordem')
|
||||
return data || []
|
||||
async function fetchFaqItensForDocs(docIds) {
|
||||
if (!docIds.length) return [];
|
||||
const { data } = await supabase.from('saas_faq_itens').select('id, doc_id, pergunta, resposta, ordem').in('doc_id', docIds).eq('ativo', true).order('doc_id').order('ordem');
|
||||
return data || [];
|
||||
}
|
||||
|
||||
// ── Fetch por rota (chamado pelo AppLayout ao mudar de rota) ──
|
||||
export async function fetchDocsForPath (rawPath) {
|
||||
const path = normalizePath(rawPath)
|
||||
sessionPath.value = path
|
||||
currentDoc.value = null
|
||||
navStack.value = []
|
||||
// Reseta paginação de outros docs ao mudar de rota
|
||||
allDocs.value = []
|
||||
allDocsPage.value = 0
|
||||
export async function fetchDocsForPath(rawPath) {
|
||||
const path = normalizePath(rawPath);
|
||||
sessionPath.value = path;
|
||||
currentDoc.value = null;
|
||||
navStack.value = [];
|
||||
// Reseta paginação de outros docs ao mudar de rota
|
||||
allDocs.value = [];
|
||||
allDocsPage.value = 0;
|
||||
|
||||
if (cache.has(path)) {
|
||||
const cached = cache.get(path)
|
||||
sessionDocs.value = cached.docs
|
||||
sessionFaq.value = cached.faqItens
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('saas_docs')
|
||||
.select('id, titulo, conteudo, medias, tipo_acesso, docs_relacionados, ordem, categoria, exibir_no_faq, votos_util, votos_nao_util')
|
||||
.eq('pagina_path', path)
|
||||
.eq('ativo', true)
|
||||
.order('ordem')
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const userIsAdmin = isCurrentUserAdmin()
|
||||
const mainDocs = (data || []).filter(d =>
|
||||
d.tipo_acesso === 'usuario' || userIsAdmin
|
||||
)
|
||||
|
||||
const allIds = [...new Set(mainDocs.flatMap(d => d.docs_relacionados || []))]
|
||||
let related = []
|
||||
if (allIds.length) {
|
||||
const { data: relData } = await supabase
|
||||
.from('saas_docs')
|
||||
.select('id, titulo, pagina_path')
|
||||
.in('id', allIds)
|
||||
.eq('ativo', true)
|
||||
related = relData || []
|
||||
if (cache.has(path)) {
|
||||
const cached = cache.get(path);
|
||||
sessionDocs.value = cached.docs;
|
||||
sessionFaq.value = cached.faqItens;
|
||||
return;
|
||||
}
|
||||
|
||||
const itens = await fetchFaqItensForDocs(mainDocs.map(d => d.id))
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('saas_docs')
|
||||
.select('id, titulo, conteudo, medias, tipo_acesso, docs_relacionados, ordem, categoria, exibir_no_faq, votos_util, votos_nao_util')
|
||||
.eq('pagina_path', path)
|
||||
.eq('ativo', true)
|
||||
.order('ordem');
|
||||
|
||||
cache.set(path, { docs: mainDocs, relatedDocs: related, faqItens: itens })
|
||||
sessionDocs.value = mainDocs
|
||||
sessionFaq.value = itens
|
||||
} catch (e) {
|
||||
console.warn('[useAjuda] erro ao buscar docs:', e?.message || e)
|
||||
sessionDocs.value = []
|
||||
sessionFaq.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
if (error) throw error;
|
||||
|
||||
const userIsAdmin = isCurrentUserAdmin();
|
||||
const mainDocs = (data || []).filter((d) => d.tipo_acesso === 'usuario' || userIsAdmin);
|
||||
|
||||
const allIds = [...new Set(mainDocs.flatMap((d) => d.docs_relacionados || []))];
|
||||
let related = [];
|
||||
if (allIds.length) {
|
||||
const { data: relData } = await supabase.from('saas_docs').select('id, titulo, pagina_path').in('id', allIds).eq('ativo', true);
|
||||
related = relData || [];
|
||||
}
|
||||
|
||||
const itens = await fetchFaqItensForDocs(mainDocs.map((d) => d.id));
|
||||
|
||||
cache.set(path, { docs: mainDocs, relatedDocs: related, faqItens: itens });
|
||||
sessionDocs.value = mainDocs;
|
||||
sessionFaq.value = itens;
|
||||
} catch (e) {
|
||||
console.warn('[useAjuda] erro ao buscar docs:', e?.message || e);
|
||||
sessionDocs.value = [];
|
||||
sessionFaq.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Todos os docs paginados (para a home do drawer) ───────────
|
||||
export async function loadAllDocs (page = 0) {
|
||||
allDocsLoading.value = true
|
||||
try {
|
||||
const from = page * ALL_DOCS_PAGE_SIZE
|
||||
const to = from + ALL_DOCS_PAGE_SIZE - 1
|
||||
export async function loadAllDocs(page = 0) {
|
||||
allDocsLoading.value = true;
|
||||
try {
|
||||
const from = page * ALL_DOCS_PAGE_SIZE;
|
||||
const to = from + ALL_DOCS_PAGE_SIZE - 1;
|
||||
|
||||
const userIsAdmin = isCurrentUserAdmin()
|
||||
let query = supabase
|
||||
.from('saas_docs')
|
||||
.select('id, titulo, pagina_path, categoria, votos_util, votos_nao_util', { count: 'exact' })
|
||||
.eq('ativo', true)
|
||||
.order('titulo')
|
||||
.range(from, to)
|
||||
const userIsAdmin = isCurrentUserAdmin();
|
||||
let query = supabase.from('saas_docs').select('id, titulo, pagina_path, categoria, votos_util, votos_nao_util', { count: 'exact' }).eq('ativo', true).order('titulo').range(from, to);
|
||||
|
||||
if (!userIsAdmin) query = query.eq('tipo_acesso', 'usuario')
|
||||
if (!userIsAdmin) query = query.eq('tipo_acesso', 'usuario');
|
||||
|
||||
const { data, count, error } = await query
|
||||
if (error) throw error
|
||||
const { data, count, error } = await query;
|
||||
if (error) throw error;
|
||||
|
||||
// Exclui docs da sessão atual
|
||||
const sessionIds = new Set(sessionDocs.value.map(d => d.id))
|
||||
const filtered = (data || []).filter(d => !sessionIds.has(d.id))
|
||||
// Exclui docs da sessão atual
|
||||
const sessionIds = new Set(sessionDocs.value.map((d) => d.id));
|
||||
const filtered = (data || []).filter((d) => !sessionIds.has(d.id));
|
||||
|
||||
allDocs.value = page === 0 ? filtered : [...allDocs.value, ...filtered]
|
||||
allDocsTotal.value = count || 0
|
||||
allDocsPage.value = page
|
||||
} catch (e) {
|
||||
console.warn('[useAjuda] erro ao carregar todos os docs:', e?.message || e)
|
||||
} finally {
|
||||
allDocsLoading.value = false
|
||||
}
|
||||
allDocs.value = page === 0 ? filtered : [...allDocs.value, ...filtered];
|
||||
allDocsTotal.value = count || 0;
|
||||
allDocsPage.value = page;
|
||||
} catch (e) {
|
||||
console.warn('[useAjuda] erro ao carregar todos os docs:', e?.message || e);
|
||||
} finally {
|
||||
allDocsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── FAQ global (itens fora da sessão atual) ───────────────────
|
||||
export async function loadGlobalFaq () {
|
||||
globalFaqLoading.value = true
|
||||
try {
|
||||
const userIsAdmin = isCurrentUserAdmin()
|
||||
let q = supabase
|
||||
.from('saas_docs')
|
||||
.select('id, titulo')
|
||||
.eq('ativo', true)
|
||||
.eq('exibir_no_faq', true)
|
||||
export async function loadGlobalFaq() {
|
||||
globalFaqLoading.value = true;
|
||||
try {
|
||||
const userIsAdmin = isCurrentUserAdmin();
|
||||
let q = supabase.from('saas_docs').select('id, titulo').eq('ativo', true).eq('exibir_no_faq', true);
|
||||
|
||||
if (!userIsAdmin) q = q.eq('tipo_acesso', 'usuario')
|
||||
if (!userIsAdmin) q = q.eq('tipo_acesso', 'usuario');
|
||||
|
||||
const { data: faqDocs } = await q
|
||||
if (!faqDocs?.length) { globalFaq.value = []; return }
|
||||
const { data: faqDocs } = await q;
|
||||
if (!faqDocs?.length) {
|
||||
globalFaq.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionIds = new Set(sessionDocs.value.map(d => d.id))
|
||||
const outrosIds = faqDocs.filter(d => !sessionIds.has(d.id)).map(d => d.id)
|
||||
const sessionIds = new Set(sessionDocs.value.map((d) => d.id));
|
||||
const outrosIds = faqDocs.filter((d) => !sessionIds.has(d.id)).map((d) => d.id);
|
||||
|
||||
if (!outrosIds.length) { globalFaq.value = []; return }
|
||||
if (!outrosIds.length) {
|
||||
globalFaq.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const itens = await fetchFaqItensForDocs(outrosIds)
|
||||
globalFaq.value = itens.map(item => ({
|
||||
...item,
|
||||
_docTitulo: faqDocs.find(d => d.id === item.doc_id)?.titulo || ''
|
||||
}))
|
||||
} catch (e) {
|
||||
console.warn('[useAjuda] erro ao carregar FAQ global:', e?.message || e)
|
||||
globalFaq.value = []
|
||||
} finally {
|
||||
globalFaqLoading.value = false
|
||||
}
|
||||
const itens = await fetchFaqItensForDocs(outrosIds);
|
||||
globalFaq.value = itens.map((item) => ({
|
||||
...item,
|
||||
_docTitulo: faqDocs.find((d) => d.id === item.doc_id)?.titulo || ''
|
||||
}));
|
||||
} catch (e) {
|
||||
console.warn('[useAjuda] erro ao carregar FAQ global:', e?.message || e);
|
||||
globalFaq.value = [];
|
||||
} finally {
|
||||
globalFaqLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Votos do usuário ──────────────────────────────────────────
|
||||
export async function loadMeusVotos () {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return
|
||||
const { data } = await supabase
|
||||
.from('saas_doc_votos')
|
||||
.select('doc_id, util')
|
||||
.eq('user_id', user.id)
|
||||
meusVotos.value = {}
|
||||
for (const v of (data || [])) meusVotos.value[v.doc_id] = v.util
|
||||
} catch (e) {
|
||||
console.warn('[useAjuda] erro ao carregar votos:', e?.message || e)
|
||||
}
|
||||
export async function loadMeusVotos() {
|
||||
try {
|
||||
const {
|
||||
data: { user }
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) return;
|
||||
const { data } = await supabase.from('saas_doc_votos').select('doc_id, util').eq('user_id', user.id);
|
||||
meusVotos.value = {};
|
||||
for (const v of data || []) meusVotos.value[v.doc_id] = v.util;
|
||||
} catch (e) {
|
||||
console.warn('[useAjuda] erro ao carregar votos:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function votar (docId, util) {
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('saas_votar_doc', {
|
||||
p_doc_id: docId,
|
||||
p_util: util
|
||||
})
|
||||
if (error) throw error
|
||||
export async function votar(docId, util) {
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('saas_votar_doc', {
|
||||
p_doc_id: docId,
|
||||
p_util: util
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
const acao = data?.acao
|
||||
meusVotos.value = {
|
||||
...meusVotos.value,
|
||||
[docId]: acao === 'removido' ? null : util
|
||||
const acao = data?.acao;
|
||||
meusVotos.value = {
|
||||
...meusVotos.value,
|
||||
[docId]: acao === 'removido' ? null : util
|
||||
};
|
||||
_atualizarContadorLocal(docId, util, acao);
|
||||
} catch (e) {
|
||||
console.warn('[useAjuda] erro ao votar:', e?.message || e);
|
||||
}
|
||||
_atualizarContadorLocal(docId, util, acao)
|
||||
} catch (e) {
|
||||
console.warn('[useAjuda] erro ao votar:', e?.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
function _atualizarContadorLocal (docId, util, acao) {
|
||||
const ajustar = (doc) => {
|
||||
if (!doc || doc.id !== docId) return doc
|
||||
let { votos_util = 0, votos_nao_util = 0 } = doc
|
||||
if (acao === 'registrado') {
|
||||
if (util) votos_util++; else votos_nao_util++
|
||||
} else if (acao === 'removido') {
|
||||
if (util) votos_util = Math.max(0, votos_util - 1)
|
||||
else votos_nao_util = Math.max(0, votos_nao_util - 1)
|
||||
} else if (acao === 'atualizado') {
|
||||
if (util) { votos_util++; votos_nao_util = Math.max(0, votos_nao_util - 1) }
|
||||
else { votos_nao_util++; votos_util = Math.max(0, votos_util - 1) }
|
||||
function _atualizarContadorLocal(docId, util, acao) {
|
||||
const ajustar = (doc) => {
|
||||
if (!doc || doc.id !== docId) return doc;
|
||||
let { votos_util = 0, votos_nao_util = 0 } = doc;
|
||||
if (acao === 'registrado') {
|
||||
if (util) votos_util++;
|
||||
else votos_nao_util++;
|
||||
} else if (acao === 'removido') {
|
||||
if (util) votos_util = Math.max(0, votos_util - 1);
|
||||
else votos_nao_util = Math.max(0, votos_nao_util - 1);
|
||||
} else if (acao === 'atualizado') {
|
||||
if (util) {
|
||||
votos_util++;
|
||||
votos_nao_util = Math.max(0, votos_nao_util - 1);
|
||||
} else {
|
||||
votos_nao_util++;
|
||||
votos_util = Math.max(0, votos_util - 1);
|
||||
}
|
||||
}
|
||||
return { ...doc, votos_util, votos_nao_util };
|
||||
};
|
||||
sessionDocs.value = sessionDocs.value.map(ajustar);
|
||||
allDocs.value = allDocs.value.map(ajustar);
|
||||
if (currentDoc.value) {
|
||||
currentDoc.value = { ...currentDoc.value, docs: currentDoc.value.docs.map(ajustar) };
|
||||
}
|
||||
return { ...doc, votos_util, votos_nao_util }
|
||||
}
|
||||
sessionDocs.value = sessionDocs.value.map(ajustar)
|
||||
allDocs.value = allDocs.value.map(ajustar)
|
||||
if (currentDoc.value) {
|
||||
currentDoc.value = { ...currentDoc.value, docs: currentDoc.value.docs.map(ajustar) }
|
||||
}
|
||||
cache.forEach((val, key) => {
|
||||
if (val.docs.some(d => d.id === docId)) cache.delete(key)
|
||||
})
|
||||
docCache.delete(docId)
|
||||
cache.forEach((val, key) => {
|
||||
if (val.docs.some((d) => d.id === docId)) cache.delete(key);
|
||||
});
|
||||
docCache.delete(docId);
|
||||
}
|
||||
|
||||
// ── Navegação interna ─────────────────────────────────────────
|
||||
export async function navigateToDoc (docId, label = '') {
|
||||
if (!docId) return
|
||||
export async function navigateToDoc(docId, label = '') {
|
||||
if (!docId) return;
|
||||
|
||||
navStack.value.push({ currentDoc: currentDoc.value, label: label || 'Voltar' })
|
||||
loading.value = true
|
||||
try {
|
||||
if (docCache.has(docId)) {
|
||||
currentDoc.value = docCache.get(docId)
|
||||
return
|
||||
navStack.value.push({ currentDoc: currentDoc.value, label: label || 'Voltar' });
|
||||
loading.value = true;
|
||||
try {
|
||||
if (docCache.has(docId)) {
|
||||
currentDoc.value = docCache.get(docId);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.from('saas_docs').select('id, titulo, conteudo, medias, tipo_acesso, docs_relacionados, ordem, categoria, exibir_no_faq, votos_util, votos_nao_util').eq('id', docId).eq('ativo', true).maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
if (!data) {
|
||||
navStack.value.pop();
|
||||
return;
|
||||
}
|
||||
|
||||
const userIsAdmin = isCurrentUserAdmin();
|
||||
if (data.tipo_acesso !== 'usuario' && !userIsAdmin) {
|
||||
navStack.value.pop();
|
||||
return;
|
||||
}
|
||||
|
||||
const relIds = data.docs_relacionados || [];
|
||||
let related = [];
|
||||
if (relIds.length) {
|
||||
const { data: relData } = await supabase.from('saas_docs').select('id, titulo, pagina_path').in('id', relIds).eq('ativo', true);
|
||||
related = relData || [];
|
||||
}
|
||||
|
||||
const itens = await fetchFaqItensForDocs([data.id]);
|
||||
const entry = { docs: [data], relatedDocs: related, faqItens: itens };
|
||||
docCache.set(docId, entry);
|
||||
currentDoc.value = entry;
|
||||
} catch (e) {
|
||||
console.warn('[useAjuda] erro ao navegar para doc:', e?.message || e);
|
||||
navStack.value.pop();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('saas_docs')
|
||||
.select('id, titulo, conteudo, medias, tipo_acesso, docs_relacionados, ordem, categoria, exibir_no_faq, votos_util, votos_nao_util')
|
||||
.eq('id', docId)
|
||||
.eq('ativo', true)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
if (!data) { navStack.value.pop(); return }
|
||||
|
||||
const userIsAdmin = isCurrentUserAdmin()
|
||||
if (data.tipo_acesso !== 'usuario' && !userIsAdmin) {
|
||||
navStack.value.pop(); return
|
||||
}
|
||||
|
||||
const relIds = data.docs_relacionados || []
|
||||
let related = []
|
||||
if (relIds.length) {
|
||||
const { data: relData } = await supabase
|
||||
.from('saas_docs')
|
||||
.select('id, titulo, pagina_path')
|
||||
.in('id', relIds)
|
||||
.eq('ativo', true)
|
||||
related = relData || []
|
||||
}
|
||||
|
||||
const itens = await fetchFaqItensForDocs([data.id])
|
||||
const entry = { docs: [data], relatedDocs: related, faqItens: itens }
|
||||
docCache.set(docId, entry)
|
||||
currentDoc.value = entry
|
||||
} catch (e) {
|
||||
console.warn('[useAjuda] erro ao navegar para doc:', e?.message || e)
|
||||
navStack.value.pop()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
export function navBack () {
|
||||
const prev = navStack.value.pop()
|
||||
if (prev) currentDoc.value = prev.currentDoc
|
||||
export function navBack() {
|
||||
const prev = navStack.value.pop();
|
||||
if (prev) currentDoc.value = prev.currentDoc;
|
||||
}
|
||||
|
||||
export function invalidateAjudaCache (path) {
|
||||
if (path) cache.delete(normalizePath(path))
|
||||
else cache.clear()
|
||||
docCache.clear()
|
||||
export function invalidateAjudaCache(path) {
|
||||
if (path) cache.delete(normalizePath(path));
|
||||
else cache.clear();
|
||||
docCache.clear();
|
||||
}
|
||||
|
||||
export function resetAjuda() {
|
||||
drawerOpen.value = false;
|
||||
navStack.value = [];
|
||||
currentDoc.value = null;
|
||||
sessionDocs.value = [];
|
||||
sessionFaq.value = [];
|
||||
sessionPath.value = '';
|
||||
allDocs.value = [];
|
||||
allDocsTotal.value = 0;
|
||||
allDocsPage.value = 0;
|
||||
globalFaq.value = [];
|
||||
meusVotos.value = {};
|
||||
cache.clear();
|
||||
docCache.clear();
|
||||
}
|
||||
|
||||
// ── Composable público ────────────────────────────────────────
|
||||
export function useAjuda () {
|
||||
const hasAjuda = computed(() => true) // sempre habilitado
|
||||
export function useAjuda() {
|
||||
const hasAjuda = computed(() => true); // sempre habilitado
|
||||
|
||||
const allDocsHasMore = computed(() =>
|
||||
allDocs.value.length < allDocsTotal.value - sessionDocs.value.length
|
||||
)
|
||||
const allDocsHasMore = computed(() => allDocs.value.length < allDocsTotal.value - sessionDocs.value.length);
|
||||
|
||||
function openDrawer () {
|
||||
drawerOpen.value = true
|
||||
currentDoc.value = null
|
||||
if (!allDocs.value.length) loadAllDocs(0)
|
||||
if (!globalFaq.value.length) loadGlobalFaq()
|
||||
if (!Object.keys(meusVotos.value).length) loadMeusVotos()
|
||||
}
|
||||
function openDrawer() {
|
||||
drawerOpen.value = true;
|
||||
currentDoc.value = null;
|
||||
if (!allDocs.value.length) loadAllDocs(0);
|
||||
if (!globalFaq.value.length) loadGlobalFaq();
|
||||
if (!Object.keys(meusVotos.value).length) loadMeusVotos();
|
||||
}
|
||||
|
||||
function closeDrawer () {
|
||||
drawerOpen.value = false
|
||||
navStack.value = []
|
||||
currentDoc.value = null
|
||||
}
|
||||
function closeDrawer() {
|
||||
drawerOpen.value = false;
|
||||
navStack.value = [];
|
||||
currentDoc.value = null;
|
||||
}
|
||||
|
||||
function loadMoreDocs () {
|
||||
loadAllDocs(allDocsPage.value + 1)
|
||||
}
|
||||
function loadMoreDocs() {
|
||||
loadAllDocs(allDocsPage.value + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
sessionDocs, sessionFaq, sessionPath,
|
||||
allDocs, allDocsTotal, allDocsLoading, allDocsHasMore,
|
||||
globalFaq, globalFaqLoading,
|
||||
currentDoc, isHome, isNavigating, navStack,
|
||||
drawerOpen, loading,
|
||||
hasAjuda,
|
||||
meusVotos,
|
||||
openDrawer, closeDrawer,
|
||||
navigateToDoc, navBack,
|
||||
votar, loadMoreDocs,
|
||||
}
|
||||
}
|
||||
return {
|
||||
sessionDocs,
|
||||
sessionFaq,
|
||||
sessionPath,
|
||||
allDocs,
|
||||
allDocsTotal,
|
||||
allDocsLoading,
|
||||
allDocsHasMore,
|
||||
globalFaq,
|
||||
globalFaqLoading,
|
||||
currentDoc,
|
||||
isHome,
|
||||
isNavigating,
|
||||
navStack,
|
||||
drawerOpen,
|
||||
loading,
|
||||
hasAjuda,
|
||||
meusVotos,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
navigateToDoc,
|
||||
navBack,
|
||||
votar,
|
||||
loadMoreDocs
|
||||
};
|
||||
}
|
||||
|
||||
+13
-13
@@ -61,23 +61,23 @@
|
||||
*
|
||||
* Esse composable é apenas a base de identidade do sistema.
|
||||
*/
|
||||
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
const user = ref(null)
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
const user = ref(null);
|
||||
|
||||
export function useAuth() {
|
||||
const init = async () => {
|
||||
const { data } = await supabase.auth.getSession()
|
||||
user.value = data.session?.user || null
|
||||
}
|
||||
const init = async () => {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
user.value = data.session?.user || null;
|
||||
};
|
||||
|
||||
supabase.auth.onAuthStateChange((_, session) => {
|
||||
user.value = session?.user || null
|
||||
})
|
||||
supabase.auth.onAuthStateChange((_, session) => {
|
||||
user.value = session?.user || null;
|
||||
});
|
||||
|
||||
onMounted(init)
|
||||
onMounted(init);
|
||||
|
||||
return { user }
|
||||
return { user };
|
||||
}
|
||||
|
||||
@@ -16,20 +16,20 @@
|
||||
*/
|
||||
// a partir de outra página (ex: SaasFaqPage → SaasDocsPage).
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { ref } from 'vue';
|
||||
|
||||
const pendingEditDocId = ref(null)
|
||||
const pendingEditDocId = ref(null);
|
||||
|
||||
export function useDocsAdmin () {
|
||||
function requestEditDoc (docId) {
|
||||
pendingEditDocId.value = docId
|
||||
}
|
||||
export function useDocsAdmin() {
|
||||
function requestEditDoc(docId) {
|
||||
pendingEditDocId.value = docId;
|
||||
}
|
||||
|
||||
function consumePendingEdit () {
|
||||
const id = pendingEditDocId.value
|
||||
pendingEditDocId.value = null
|
||||
return id
|
||||
}
|
||||
function consumePendingEdit() {
|
||||
const id = pendingEditDocId.value;
|
||||
pendingEditDocId.value = null;
|
||||
return id;
|
||||
}
|
||||
|
||||
return { pendingEditDocId, requestEditDoc, consumePendingEdit }
|
||||
}
|
||||
return { pendingEditDocId, requestEditDoc, consumePendingEdit };
|
||||
}
|
||||
|
||||
+89
-104
@@ -15,119 +15,104 @@
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { getFeriadosNacionais } from '@/utils/feriadosBR'
|
||||
import { ref, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { getFeriadosNacionais } from '@/utils/feriadosBR';
|
||||
|
||||
export function useFeriados () {
|
||||
const ano = ref(new Date().getFullYear())
|
||||
const loading = ref(false)
|
||||
const municipais = ref([]) // linhas da tabela `feriados`
|
||||
export function useFeriados() {
|
||||
const ano = ref(new Date().getFullYear());
|
||||
const loading = ref(false);
|
||||
const municipais = ref([]); // linhas da tabela `feriados`
|
||||
|
||||
// ── Nacionais (algoritmo, sem DB) ─────────────────────────
|
||||
const nacionais = computed(() =>
|
||||
getFeriadosNacionais(ano.value).map(f => ({ ...f, tipo: 'nacional' }))
|
||||
)
|
||||
// ── Nacionais (algoritmo, sem DB) ─────────────────────────
|
||||
const nacionais = computed(() => getFeriadosNacionais(ano.value).map((f) => ({ ...f, tipo: 'nacional' })));
|
||||
|
||||
// ── Todos juntos, ordenados por data ─────────────────────
|
||||
const todos = computed(() => [
|
||||
...nacionais.value,
|
||||
...municipais.value.map(f => ({ ...f, tipo: f.tipo || 'municipal' }))
|
||||
].sort((a, b) => a.data.localeCompare(b.data)))
|
||||
// ── Todos juntos, ordenados por data ─────────────────────
|
||||
const todos = computed(() => [...nacionais.value, ...municipais.value.map((f) => ({ ...f, tipo: f.tipo || 'municipal' }))].sort((a, b) => a.data.localeCompare(b.data)));
|
||||
|
||||
// ── Feriados de um mês (1–12) ─────────────────────────────
|
||||
function doMes (mes) {
|
||||
const m = String(mes).padStart(2, '0')
|
||||
const prefix = `${ano.value}-${m}`
|
||||
return todos.value.filter(f => f.data.startsWith(prefix))
|
||||
}
|
||||
|
||||
// ── Próximos N dias ───────────────────────────────────────
|
||||
function proximos (dias = 30) {
|
||||
const hoje = new Date()
|
||||
const limite = new Date(hoje)
|
||||
limite.setDate(limite.getDate() + dias)
|
||||
const hojeISO = toISO(hoje)
|
||||
const limiteISO = toISO(limite)
|
||||
return todos.value.filter(f => f.data >= hojeISO && f.data <= limiteISO)
|
||||
}
|
||||
|
||||
function toISO (d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
}
|
||||
|
||||
// ── Load municipais do Supabase ───────────────────────────
|
||||
async function load (tenantId, year) {
|
||||
if (year) ano.value = year
|
||||
if (!tenantId) return
|
||||
loading.value = true
|
||||
try {
|
||||
// Busca feriados do tenant + feriados globais (tenant_id null, cadastrados pelo SAAS)
|
||||
const { data, error } = await supabase
|
||||
.from('feriados')
|
||||
.select('*')
|
||||
.or(`tenant_id.eq.${tenantId},tenant_id.is.null`)
|
||||
.gte('data', `${ano.value}-01-01`)
|
||||
.lte('data', `${ano.value}-12-31`)
|
||||
.order('data')
|
||||
if (error) throw error
|
||||
municipais.value = data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
// ── Feriados de um mês (1–12) ─────────────────────────────
|
||||
function doMes(mes) {
|
||||
const m = String(mes).padStart(2, '0');
|
||||
const prefix = `${ano.value}-${m}`;
|
||||
return todos.value.filter((f) => f.data.startsWith(prefix));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Criar feriado municipal ───────────────────────────────
|
||||
async function criar (payload) {
|
||||
const { data, error } = await supabase
|
||||
.from('feriados')
|
||||
.insert(payload)
|
||||
.select()
|
||||
.single()
|
||||
if (error) throw error
|
||||
municipais.value = [...municipais.value, data].sort((a, b) => a.data.localeCompare(b.data))
|
||||
return data
|
||||
}
|
||||
// ── Próximos N dias ───────────────────────────────────────
|
||||
function proximos(dias = 30) {
|
||||
const hoje = new Date();
|
||||
const limite = new Date(hoje);
|
||||
limite.setDate(limite.getDate() + dias);
|
||||
const hojeISO = toISO(hoje);
|
||||
const limiteISO = toISO(limite);
|
||||
return todos.value.filter((f) => f.data >= hojeISO && f.data <= limiteISO);
|
||||
}
|
||||
|
||||
// ── Remover feriado municipal ─────────────────────────────
|
||||
async function remover (id) {
|
||||
const { error } = await supabase.from('feriados').delete().eq('id', id)
|
||||
if (error) throw error
|
||||
municipais.value = municipais.value.filter(f => f.id !== id)
|
||||
}
|
||||
function toISO(d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ── Verificar duplicata ───────────────────────────────────
|
||||
function isDuplicata (data, nome) {
|
||||
return todos.value.some(f => f.data === data && f.nome.trim().toLowerCase() === nome.trim().toLowerCase())
|
||||
}
|
||||
// ── Load municipais do Supabase ───────────────────────────
|
||||
async function load(tenantId, year) {
|
||||
if (year) ano.value = year;
|
||||
if (!tenantId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
// Busca feriados do tenant + feriados globais (tenant_id null, cadastrados pelo SAAS)
|
||||
const { data, error } = await supabase.from('feriados').select('*').or(`tenant_id.eq.${tenantId},tenant_id.is.null`).gte('data', `${ano.value}-01-01`).lte('data', `${ano.value}-12-31`).order('data');
|
||||
if (error) throw error;
|
||||
municipais.value = data || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Converter para eventos do FullCalendar (background) ──
|
||||
function toFcEvents (lista) {
|
||||
return lista.map(f => ({
|
||||
id: `feriado_${f.id || f.data}_${f.nome}`,
|
||||
title: f.nome,
|
||||
start: f.data,
|
||||
allDay: true,
|
||||
display: 'background',
|
||||
color: 'rgba(251, 191, 36, 0.25)',
|
||||
extendedProps: { _feriado: true, tipo: f.tipo }
|
||||
}))
|
||||
}
|
||||
// ── Criar feriado municipal ───────────────────────────────
|
||||
async function criar(payload) {
|
||||
const { data, error } = await supabase.from('feriados').insert(payload).select().single();
|
||||
if (error) throw error;
|
||||
municipais.value = [...municipais.value, data].sort((a, b) => a.data.localeCompare(b.data));
|
||||
return data;
|
||||
}
|
||||
|
||||
const fcEvents = computed(() => toFcEvents(todos.value))
|
||||
// ── Remover feriado municipal ─────────────────────────────
|
||||
async function remover(id) {
|
||||
const { error } = await supabase.from('feriados').delete().eq('id', id);
|
||||
if (error) throw error;
|
||||
municipais.value = municipais.value.filter((f) => f.id !== id);
|
||||
}
|
||||
|
||||
return {
|
||||
ano,
|
||||
loading,
|
||||
nacionais,
|
||||
municipais,
|
||||
todos,
|
||||
fcEvents,
|
||||
load,
|
||||
criar,
|
||||
remover,
|
||||
doMes,
|
||||
proximos,
|
||||
isDuplicata
|
||||
}
|
||||
// ── Verificar duplicata ───────────────────────────────────
|
||||
function isDuplicata(data, nome) {
|
||||
return todos.value.some((f) => f.data === data && f.nome.trim().toLowerCase() === nome.trim().toLowerCase());
|
||||
}
|
||||
|
||||
// ── Converter para eventos do FullCalendar (background) ──
|
||||
function toFcEvents(lista) {
|
||||
return lista.map((f) => ({
|
||||
id: `feriado_${f.id || f.data}_${f.nome}`,
|
||||
title: f.nome,
|
||||
start: f.data,
|
||||
allDay: true,
|
||||
display: 'background',
|
||||
color: 'rgba(251, 191, 36, 0.25)',
|
||||
extendedProps: { _feriado: true, tipo: f.tipo }
|
||||
}));
|
||||
}
|
||||
|
||||
const fcEvents = computed(() => toFcEvents(todos.value));
|
||||
|
||||
return {
|
||||
ano,
|
||||
loading,
|
||||
nacionais,
|
||||
municipais,
|
||||
todos,
|
||||
fcEvents,
|
||||
load,
|
||||
criar,
|
||||
remover,
|
||||
doMes,
|
||||
proximos,
|
||||
isDuplicata
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,24 +15,24 @@
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { ref, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
// ─── helpers internos ────────────────────────────────────────────────────────
|
||||
|
||||
function assertTenantId (tenantId) {
|
||||
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
|
||||
throw new Error('Tenant ativo inválido. Selecione a clínica antes de operar no financeiro.')
|
||||
}
|
||||
function assertTenantId(tenantId) {
|
||||
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
|
||||
throw new Error('Tenant ativo inválido. Selecione a clínica antes de operar no financeiro.');
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── select base com joins ───────────────────────────────────────────────────
|
||||
@@ -48,281 +48,280 @@ const BASE_SELECT = `
|
||||
agenda_eventos!agenda_evento_id (
|
||||
id, inicio_em, status, tipo
|
||||
)
|
||||
`.trim()
|
||||
`.trim();
|
||||
|
||||
// ─── composable ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function useFinancialRecords () {
|
||||
const records = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
export function useFinancialRecords() {
|
||||
const records = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
// ── computed: resumo financeiro ─────────────────────────────────────────
|
||||
// ── computed: resumo financeiro ─────────────────────────────────────────
|
||||
|
||||
const summary = computed(() => {
|
||||
const now = new Date()
|
||||
const thisYear = now.getFullYear()
|
||||
const thisMonth = now.getMonth() // 0-indexed
|
||||
const summary = computed(() => {
|
||||
const now = new Date();
|
||||
const thisYear = now.getFullYear();
|
||||
const thisMonth = now.getMonth(); // 0-indexed
|
||||
|
||||
const countByStatus = { pending: 0, paid: 0, overdue: 0, cancelled: 0 }
|
||||
const countByStatus = { pending: 0, paid: 0, overdue: 0, cancelled: 0 };
|
||||
|
||||
let totalPending = 0
|
||||
let totalOverdue = 0
|
||||
let totalPaidThisMonth = 0
|
||||
let totalPending = 0;
|
||||
let totalOverdue = 0;
|
||||
let totalPaidThisMonth = 0;
|
||||
|
||||
for (const r of records.value) {
|
||||
countByStatus[r.status] = (countByStatus[r.status] ?? 0) + 1
|
||||
for (const r of records.value) {
|
||||
countByStatus[r.status] = (countByStatus[r.status] ?? 0) + 1;
|
||||
|
||||
if (r.status === 'pending') {
|
||||
totalPending += r.final_amount ?? r.amount ?? 0
|
||||
}
|
||||
if (r.status === 'pending') {
|
||||
totalPending += r.final_amount ?? r.amount ?? 0;
|
||||
}
|
||||
|
||||
if (r.status === 'overdue') {
|
||||
totalOverdue += r.final_amount ?? r.amount ?? 0
|
||||
}
|
||||
if (r.status === 'overdue') {
|
||||
totalOverdue += r.final_amount ?? r.amount ?? 0;
|
||||
}
|
||||
|
||||
if (r.status === 'paid' && r.paid_at) {
|
||||
const d = new Date(r.paid_at)
|
||||
if (d.getFullYear() === thisYear && d.getMonth() === thisMonth) {
|
||||
totalPaidThisMonth += r.final_amount ?? r.amount ?? 0
|
||||
if (r.status === 'paid' && r.paid_at) {
|
||||
const d = new Date(r.paid_at);
|
||||
if (d.getFullYear() === thisYear && d.getMonth() === thisMonth) {
|
||||
totalPaidThisMonth += r.final_amount ?? r.amount ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { totalPending, totalOverdue, totalPaidThisMonth, countByStatus }
|
||||
})
|
||||
return { totalPending, totalOverdue, totalPaidThisMonth, countByStatus };
|
||||
});
|
||||
|
||||
// ── fetchRecords ─────────────────────────────────────────────────────────
|
||||
// ── fetchRecords ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {Object} [filters]
|
||||
* @param {string} [filters.status] - 'pending'|'paid'|'overdue'|'partial'|'cancelled'|'refunded'
|
||||
* @param {string} [filters.type] - 'receita'|'despesa'
|
||||
* @param {string} [filters.patient_id] - UUID
|
||||
* @param {string} [filters.due_date_from] - ISO date string
|
||||
* @param {string} [filters.due_date_to] - ISO date string
|
||||
* @param {number} [filters.limit] - rows per page (default 50)
|
||||
* @param {number} [filters.offset] - row offset for pagination (default 0)
|
||||
* @returns {Promise<{total: number}>}
|
||||
*/
|
||||
async function fetchRecords (filters = {}) {
|
||||
const tenantStore = useTenantStore()
|
||||
const tenantId = tenantStore.activeTenantId
|
||||
assertTenantId(tenantId)
|
||||
/**
|
||||
* @param {Object} [filters]
|
||||
* @param {string} [filters.status] - 'pending'|'paid'|'overdue'|'partial'|'cancelled'|'refunded'
|
||||
* @param {string} [filters.type] - 'receita'|'despesa'
|
||||
* @param {string} [filters.patient_id] - UUID
|
||||
* @param {string} [filters.due_date_from] - ISO date string
|
||||
* @param {string} [filters.due_date_to] - ISO date string
|
||||
* @param {number} [filters.limit] - rows per page (default 50)
|
||||
* @param {number} [filters.offset] - row offset for pagination (default 0)
|
||||
* @returns {Promise<{total: number}>}
|
||||
*/
|
||||
async function fetchRecords(filters = {}) {
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
assertTenantId(tenantId);
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
const limit = filters.limit ?? 50
|
||||
const offset = filters.offset ?? 0
|
||||
const limit = filters.limit ?? 50;
|
||||
const offset = filters.offset ?? 0;
|
||||
|
||||
try {
|
||||
let query = supabase
|
||||
.from('financial_records')
|
||||
.select(BASE_SELECT, { count: 'exact' })
|
||||
.eq('tenant_id', tenantId)
|
||||
.is('deleted_at', null)
|
||||
.order('due_date', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
try {
|
||||
let query = supabase
|
||||
.from('financial_records')
|
||||
.select(BASE_SELECT, { count: 'exact' })
|
||||
.eq('tenant_id', tenantId)
|
||||
.is('deleted_at', null)
|
||||
.order('due_date', { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (filters.status) query = query.eq('status', filters.status)
|
||||
if (filters.type) query = query.eq('type', filters.type)
|
||||
if (filters.patient_id) query = query.eq('patient_id', filters.patient_id)
|
||||
if (filters.due_date_from) query = query.gte('due_date', filters.due_date_from)
|
||||
if (filters.due_date_to) query = query.lte('due_date', filters.due_date_to)
|
||||
if (filters.status) query = query.eq('status', filters.status);
|
||||
if (filters.type) query = query.eq('type', filters.type);
|
||||
if (filters.patient_id) query = query.eq('patient_id', filters.patient_id);
|
||||
if (filters.due_date_from) query = query.gte('due_date', filters.due_date_from);
|
||||
if (filters.due_date_to) query = query.lte('due_date', filters.due_date_to);
|
||||
|
||||
const { data, error: err, count } = await query
|
||||
if (err) throw err
|
||||
const { data, error: err, count } = await query;
|
||||
if (err) throw err;
|
||||
|
||||
records.value = data ?? []
|
||||
return { total: count ?? 0 }
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar registros financeiros.'
|
||||
records.value = []
|
||||
return { total: 0 }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── createRecord (via RPC — para sessões) ────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {Object} payload
|
||||
* @param {string} payload.patient_id
|
||||
* @param {string} payload.agenda_evento_id
|
||||
* @param {number} payload.amount
|
||||
* @param {string} payload.due_date - ISO date string
|
||||
*/
|
||||
async function createRecord (payload) {
|
||||
const tenantStore = useTenantStore()
|
||||
const tenantId = tenantStore.activeTenantId
|
||||
assertTenantId(tenantId)
|
||||
|
||||
const ownerId = await getUid()
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const { data, error: err } = await supabase.rpc('create_financial_record_for_session', {
|
||||
p_tenant_id: tenantId,
|
||||
p_owner_id: ownerId,
|
||||
p_patient_id: payload.patient_id,
|
||||
p_agenda_evento_id: payload.agenda_evento_id,
|
||||
p_amount: payload.amount,
|
||||
p_due_date: payload.due_date,
|
||||
})
|
||||
|
||||
if (err) throw err
|
||||
|
||||
// Re-fetch para garantir joins completos
|
||||
await fetchRecords()
|
||||
|
||||
return { ok: true, data }
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao criar cobrança.'
|
||||
return { ok: false, error: e?.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── createManualRecord (INSERT direto — sem sessão) ──────────────────────
|
||||
|
||||
/**
|
||||
* @param {Object} payload
|
||||
* @param {string} payload.patient_id
|
||||
* @param {number} payload.amount
|
||||
* @param {number} [payload.discount_amount]
|
||||
* @param {string} payload.due_date
|
||||
* @param {string} [payload.status] - default 'pending'
|
||||
* @param {string} [payload.payment_method]
|
||||
* @param {string} [payload.notes]
|
||||
*/
|
||||
async function createManualRecord (payload) {
|
||||
const tenantStore = useTenantStore()
|
||||
const tenantId = tenantStore.activeTenantId
|
||||
assertTenantId(tenantId)
|
||||
|
||||
const ownerId = await getUid()
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const discount = payload.discount_amount ?? 0
|
||||
const amount = payload.amount ?? 0
|
||||
|
||||
const { data, error: err } = await supabase
|
||||
.from('financial_records')
|
||||
.insert([{
|
||||
tenant_id: tenantId,
|
||||
owner_id: ownerId,
|
||||
patient_id: payload.patient_id ?? null,
|
||||
agenda_evento_id: null,
|
||||
amount,
|
||||
discount_amount: discount,
|
||||
final_amount: amount - discount,
|
||||
status: payload.status ?? 'pending',
|
||||
due_date: payload.due_date,
|
||||
payment_method: payload.payment_method ?? null,
|
||||
notes: payload.notes ?? null,
|
||||
}])
|
||||
.select(BASE_SELECT)
|
||||
.single()
|
||||
|
||||
if (err) throw err
|
||||
|
||||
records.value = [data, ...records.value]
|
||||
|
||||
return { ok: true, data }
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao criar lançamento manual.'
|
||||
return { ok: false, error: e?.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── markAsPaid ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {string} recordId
|
||||
* @param {string} paymentMethod - 'pix' | 'deposito' | 'dinheiro' | 'cartao' | 'convenio'
|
||||
*/
|
||||
async function markAsPaid (recordId, paymentMethod) {
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const { data, error: err } = await supabase.rpc('mark_as_paid', {
|
||||
p_financial_record_id: recordId,
|
||||
p_payment_method: paymentMethod,
|
||||
})
|
||||
|
||||
if (err) throw err
|
||||
|
||||
// RPC retorna SETOF (array) — patch local direto, sem depender do retorno
|
||||
const idx = records.value.findIndex(r => r.id === recordId)
|
||||
if (idx !== -1) {
|
||||
records.value[idx] = {
|
||||
...records.value[idx],
|
||||
status: 'paid',
|
||||
paid_at: new Date().toISOString(),
|
||||
payment_method: paymentMethod,
|
||||
records.value = data ?? [];
|
||||
return { total: count ?? 0 };
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar registros financeiros.';
|
||||
records.value = [];
|
||||
return { total: 0 };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao marcar como pago.'
|
||||
return { ok: false, error: e?.message }
|
||||
}
|
||||
}
|
||||
|
||||
// ── cancelRecord ─────────────────────────────────────────────────────────
|
||||
// ── createRecord (via RPC — para sessões) ────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {string} recordId
|
||||
*/
|
||||
async function cancelRecord (recordId) {
|
||||
error.value = null
|
||||
/**
|
||||
* @param {Object} payload
|
||||
* @param {string} payload.patient_id
|
||||
* @param {string} payload.agenda_evento_id
|
||||
* @param {number} payload.amount
|
||||
* @param {string} payload.due_date - ISO date string
|
||||
*/
|
||||
async function createRecord(payload) {
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
assertTenantId(tenantId);
|
||||
|
||||
try {
|
||||
const { error: err } = await supabase
|
||||
.from('financial_records')
|
||||
.update({ status: 'cancelled', updated_at: new Date().toISOString() })
|
||||
.eq('id', recordId)
|
||||
const ownerId = await getUid();
|
||||
|
||||
if (err) throw err
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
// Atualiza localmente sem re-fetch
|
||||
const idx = records.value.findIndex(r => r.id === recordId)
|
||||
if (idx !== -1) {
|
||||
records.value[idx] = { ...records.value[idx], status: 'cancelled' }
|
||||
}
|
||||
try {
|
||||
const { data, error: err } = await supabase.rpc('create_financial_record_for_session', {
|
||||
p_tenant_id: tenantId,
|
||||
p_owner_id: ownerId,
|
||||
p_patient_id: payload.patient_id,
|
||||
p_agenda_evento_id: payload.agenda_evento_id,
|
||||
p_amount: payload.amount,
|
||||
p_due_date: payload.due_date
|
||||
});
|
||||
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao cancelar registro.'
|
||||
return { ok: false, error: e?.message }
|
||||
if (err) throw err;
|
||||
|
||||
// Re-fetch para garantir joins completos
|
||||
await fetchRecords();
|
||||
|
||||
return { ok: true, data };
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao criar cobrança.';
|
||||
return { ok: false, error: e?.message };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// ── createManualRecord (INSERT direto — sem sessão) ──────────────────────
|
||||
|
||||
return {
|
||||
// estado
|
||||
records,
|
||||
loading,
|
||||
error,
|
||||
// computed
|
||||
summary,
|
||||
// ações
|
||||
fetchRecords,
|
||||
createRecord,
|
||||
createManualRecord,
|
||||
markAsPaid,
|
||||
cancelRecord,
|
||||
}
|
||||
/**
|
||||
* @param {Object} payload
|
||||
* @param {string} payload.patient_id
|
||||
* @param {number} payload.amount
|
||||
* @param {number} [payload.discount_amount]
|
||||
* @param {string} payload.due_date
|
||||
* @param {string} [payload.status] - default 'pending'
|
||||
* @param {string} [payload.payment_method]
|
||||
* @param {string} [payload.notes]
|
||||
*/
|
||||
async function createManualRecord(payload) {
|
||||
const tenantStore = useTenantStore();
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
assertTenantId(tenantId);
|
||||
|
||||
const ownerId = await getUid();
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const discount = payload.discount_amount ?? 0;
|
||||
const amount = payload.amount ?? 0;
|
||||
|
||||
const { data, error: err } = await supabase
|
||||
.from('financial_records')
|
||||
.insert([
|
||||
{
|
||||
tenant_id: tenantId,
|
||||
owner_id: ownerId,
|
||||
patient_id: payload.patient_id ?? null,
|
||||
agenda_evento_id: null,
|
||||
amount,
|
||||
discount_amount: discount,
|
||||
final_amount: amount - discount,
|
||||
status: payload.status ?? 'pending',
|
||||
due_date: payload.due_date,
|
||||
payment_method: payload.payment_method ?? null,
|
||||
notes: payload.notes ?? null
|
||||
}
|
||||
])
|
||||
.select(BASE_SELECT)
|
||||
.single();
|
||||
|
||||
if (err) throw err;
|
||||
|
||||
records.value = [data, ...records.value];
|
||||
|
||||
return { ok: true, data };
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao criar lançamento manual.';
|
||||
return { ok: false, error: e?.message };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── markAsPaid ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {string} recordId
|
||||
* @param {string} paymentMethod - 'pix' | 'deposito' | 'dinheiro' | 'cartao' | 'convenio'
|
||||
*/
|
||||
async function markAsPaid(recordId, paymentMethod) {
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const { data, error: err } = await supabase.rpc('mark_as_paid', {
|
||||
p_financial_record_id: recordId,
|
||||
p_payment_method: paymentMethod
|
||||
});
|
||||
|
||||
if (err) throw err;
|
||||
|
||||
// RPC retorna SETOF (array) — patch local direto, sem depender do retorno
|
||||
const idx = records.value.findIndex((r) => r.id === recordId);
|
||||
if (idx !== -1) {
|
||||
records.value[idx] = {
|
||||
...records.value[idx],
|
||||
status: 'paid',
|
||||
paid_at: new Date().toISOString(),
|
||||
payment_method: paymentMethod
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao marcar como pago.';
|
||||
return { ok: false, error: e?.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ── cancelRecord ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {string} recordId
|
||||
*/
|
||||
async function cancelRecord(recordId) {
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const { error: err } = await supabase.from('financial_records').update({ status: 'cancelled', updated_at: new Date().toISOString() }).eq('id', recordId);
|
||||
|
||||
if (err) throw err;
|
||||
|
||||
// Atualiza localmente sem re-fetch
|
||||
const idx = records.value.findIndex((r) => r.id === recordId);
|
||||
if (idx !== -1) {
|
||||
records.value[idx] = { ...records.value[idx], status: 'cancelled' };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao cancelar registro.';
|
||||
return { ok: false, error: e?.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
// estado
|
||||
records,
|
||||
loading,
|
||||
error,
|
||||
// computed
|
||||
summary,
|
||||
// ações
|
||||
fetchRecords,
|
||||
createRecord,
|
||||
createManualRecord,
|
||||
markAsPaid,
|
||||
cancelRecord
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,85 +15,77 @@
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
// ─── estado compartilhado ──────────────────────────────────
|
||||
const agendaHoje = ref(0)
|
||||
const cadastrosRecebidos = ref(0)
|
||||
const agendamentosRecebidos = ref(0)
|
||||
const agendaHoje = ref(0);
|
||||
const cadastrosRecebidos = ref(0);
|
||||
const agendamentosRecebidos = ref(0);
|
||||
|
||||
let _timer = null
|
||||
let _started = false
|
||||
let _timer = null;
|
||||
let _started = false;
|
||||
|
||||
async function _refresh () {
|
||||
try {
|
||||
const tenantStore = useTenantStore()
|
||||
const role = tenantStore.role
|
||||
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null
|
||||
async function _refresh() {
|
||||
try {
|
||||
const tenantStore = useTenantStore();
|
||||
const role = tenantStore.role;
|
||||
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
|
||||
|
||||
const { data: authData } = await supabase.auth.getUser()
|
||||
const ownerId = authData?.user?.id
|
||||
if (!ownerId) return
|
||||
const { data: authData } = await supabase.auth.getUser();
|
||||
const ownerId = authData?.user?.id;
|
||||
if (!ownerId) return;
|
||||
|
||||
const isClinic = role === 'clinic_admin' || role === 'tenant_admin' || role === 'clinic'
|
||||
const isClinic = role === 'clinic_admin' || role === 'tenant_admin' || role === 'clinic';
|
||||
|
||||
const now = new Date()
|
||||
const y = now.getFullYear(), mo = now.getMonth(), d = now.getDate()
|
||||
const startDay = new Date(y, mo, d).toISOString()
|
||||
const endDay = new Date(y, mo, d + 1).toISOString()
|
||||
const now = new Date();
|
||||
const y = now.getFullYear(),
|
||||
mo = now.getMonth(),
|
||||
d = now.getDate();
|
||||
const startDay = new Date(y, mo, d).toISOString();
|
||||
const endDay = new Date(y, mo, d + 1).toISOString();
|
||||
|
||||
// 1. Agenda hoje
|
||||
{
|
||||
let q = supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.gte('inicio_em', startDay)
|
||||
.lt('inicio_em', endDay)
|
||||
if (isClinic && tenantId) q = q.eq('tenant_id', tenantId)
|
||||
else q = q.eq('owner_id', ownerId)
|
||||
const { count } = await q
|
||||
agendaHoje.value = count || 0
|
||||
// 1. Agenda hoje
|
||||
{
|
||||
let q = supabase.from('agenda_eventos').select('id', { count: 'exact', head: true }).gte('inicio_em', startDay).lt('inicio_em', endDay);
|
||||
if (isClinic && tenantId) q = q.eq('tenant_id', tenantId);
|
||||
else q = q.eq('owner_id', ownerId);
|
||||
const { count } = await q;
|
||||
agendaHoje.value = count || 0;
|
||||
}
|
||||
|
||||
// 2. Cadastros recebidos (status = 'new') — RLS filtra pelo owner
|
||||
{
|
||||
const { count } = await supabase.from('patient_intake_requests').select('id', { count: 'exact', head: true }).eq('status', 'new');
|
||||
cadastrosRecebidos.value = count || 0;
|
||||
}
|
||||
|
||||
// 3. Agendamentos recebidos (status = 'pendente')
|
||||
{
|
||||
let q = supabase.from('agendador_solicitacoes').select('id', { count: 'exact', head: true }).eq('status', 'pendente');
|
||||
if (isClinic && tenantId) q = q.eq('tenant_id', tenantId);
|
||||
else q = q.eq('owner_id', ownerId);
|
||||
const { count } = await q;
|
||||
agendamentosRecebidos.value = count || 0;
|
||||
}
|
||||
} catch {
|
||||
// badge falhar não deve quebrar a navegação
|
||||
}
|
||||
|
||||
// 2. Cadastros recebidos (status = 'new') — RLS filtra pelo owner
|
||||
{
|
||||
const { count } = await supabase
|
||||
.from('patient_intake_requests')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('status', 'new')
|
||||
cadastrosRecebidos.value = count || 0
|
||||
}
|
||||
|
||||
// 3. Agendamentos recebidos (status = 'pendente')
|
||||
{
|
||||
let q = supabase
|
||||
.from('agendador_solicitacoes')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('status', 'pendente')
|
||||
if (isClinic && tenantId) q = q.eq('tenant_id', tenantId)
|
||||
else q = q.eq('owner_id', ownerId)
|
||||
const { count } = await q
|
||||
agendamentosRecebidos.value = count || 0
|
||||
}
|
||||
} catch {
|
||||
// badge falhar não deve quebrar a navegação
|
||||
}
|
||||
}
|
||||
|
||||
// ─── API pública ───────────────────────────────────────────
|
||||
export function useMenuBadges () {
|
||||
if (!_started) {
|
||||
_started = true
|
||||
_refresh()
|
||||
_timer = setInterval(_refresh, 5 * 60 * 1000) // atualiza a cada 5 min
|
||||
}
|
||||
export function useMenuBadges() {
|
||||
if (!_started) {
|
||||
_started = true;
|
||||
_refresh();
|
||||
_timer = setInterval(_refresh, 5 * 60 * 1000); // atualiza a cada 5 min
|
||||
}
|
||||
|
||||
return {
|
||||
agendaHoje,
|
||||
cadastrosRecebidos,
|
||||
agendamentosRecebidos,
|
||||
refresh: _refresh
|
||||
}
|
||||
return {
|
||||
agendaHoje,
|
||||
cadastrosRecebidos,
|
||||
agendamentosRecebidos,
|
||||
refresh: _refresh
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,25 +14,25 @@
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useNotificationStore } from '@/stores/notificationStore'
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useNotificationStore } from '@/stores/notificationStore';
|
||||
|
||||
export function useNotifications () {
|
||||
const store = useNotificationStore()
|
||||
export function useNotifications() {
|
||||
const store = useNotificationStore();
|
||||
|
||||
onMounted(async () => {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error || !data?.user?.id) return
|
||||
onMounted(async () => {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error || !data?.user?.id) return;
|
||||
|
||||
const ownerId = data.user.id
|
||||
await store.load(ownerId)
|
||||
store.subscribeRealtime(ownerId)
|
||||
})
|
||||
const ownerId = data.user.id;
|
||||
await store.load(ownerId);
|
||||
store.subscribeRealtime(ownerId);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
store.unsubscribe()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
store.unsubscribe();
|
||||
});
|
||||
|
||||
return store
|
||||
return store;
|
||||
}
|
||||
|
||||
@@ -14,76 +14,57 @@
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
export function usePatientLifecycle () {
|
||||
|
||||
async function canDelete (patientId) {
|
||||
const { data, error } = await supabase.rpc('can_delete_patient', { p_patient_id: patientId })
|
||||
if (error) return false
|
||||
return !!data
|
||||
}
|
||||
|
||||
async function deletePatient (patientId) {
|
||||
const { data, error } = await supabase.rpc('safe_delete_patient', { p_patient_id: patientId })
|
||||
if (error) return { ok: false, error: 'rpc_error', message: error.message }
|
||||
return data // { ok, error?, message? }
|
||||
}
|
||||
|
||||
async function checkActiveSchedule (patientId) {
|
||||
const now = new Date().toISOString()
|
||||
const [evts, recs] = await Promise.all([
|
||||
supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('patient_id', patientId)
|
||||
.eq('status', 'agendado')
|
||||
.gt('inicio_em', now),
|
||||
supabase
|
||||
.from('recurrence_rules')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('patient_id', patientId)
|
||||
.eq('status', 'ativo')
|
||||
])
|
||||
return {
|
||||
hasFutureSessions: (evts.count ?? 0) > 0,
|
||||
hasActiveRecurrence: (recs.count ?? 0) > 0
|
||||
export function usePatientLifecycle() {
|
||||
async function canDelete(patientId) {
|
||||
const { data, error } = await supabase.rpc('can_delete_patient', { p_patient_id: patientId });
|
||||
if (error) return false;
|
||||
return !!data;
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivatePatient (patientId) {
|
||||
const { error } = await supabase
|
||||
.from('patients')
|
||||
.update({ status: 'Inativo', updated_at: new Date().toISOString() })
|
||||
.eq('id', patientId)
|
||||
return error ? { ok: false, error } : { ok: true }
|
||||
}
|
||||
async function deletePatient(patientId) {
|
||||
const { data, error } = await supabase.rpc('safe_delete_patient', { p_patient_id: patientId });
|
||||
if (error) return { ok: false, error: 'rpc_error', message: error.message };
|
||||
return data; // { ok, error?, message? }
|
||||
}
|
||||
|
||||
async function archivePatient (patientId) {
|
||||
const { error } = await supabase
|
||||
.from('patients')
|
||||
.update({ status: 'Arquivado', updated_at: new Date().toISOString() })
|
||||
.eq('id', patientId)
|
||||
return error ? { ok: false, error } : { ok: true }
|
||||
}
|
||||
async function checkActiveSchedule(patientId) {
|
||||
const now = new Date().toISOString();
|
||||
const [evts, recs] = await Promise.all([
|
||||
supabase.from('agenda_eventos').select('id', { count: 'exact', head: true }).eq('patient_id', patientId).eq('status', 'agendado').gt('inicio_em', now),
|
||||
supabase.from('recurrence_rules').select('id', { count: 'exact', head: true }).eq('patient_id', patientId).eq('status', 'ativo')
|
||||
]);
|
||||
return {
|
||||
hasFutureSessions: (evts.count ?? 0) > 0,
|
||||
hasActiveRecurrence: (recs.count ?? 0) > 0
|
||||
};
|
||||
}
|
||||
|
||||
async function reactivatePatient (patientId) {
|
||||
const { error } = await supabase
|
||||
.from('patients')
|
||||
.update({ status: 'Ativo', updated_at: new Date().toISOString() })
|
||||
.eq('id', patientId)
|
||||
return error ? { ok: false, error } : { ok: true }
|
||||
}
|
||||
async function deactivatePatient(patientId) {
|
||||
const { error } = await supabase.from('patients').update({ status: 'Inativo', updated_at: new Date().toISOString() }).eq('id', patientId);
|
||||
return error ? { ok: false, error } : { ok: true };
|
||||
}
|
||||
|
||||
return { canDelete, deletePatient, checkActiveSchedule, deactivatePatient, archivePatient, reactivatePatient }
|
||||
async function archivePatient(patientId) {
|
||||
const { error } = await supabase.from('patients').update({ status: 'Arquivado', updated_at: new Date().toISOString() }).eq('id', patientId);
|
||||
return error ? { ok: false, error } : { ok: true };
|
||||
}
|
||||
|
||||
async function reactivatePatient(patientId) {
|
||||
const { error } = await supabase.from('patients').update({ status: 'Ativo', updated_at: new Date().toISOString() }).eq('id', patientId);
|
||||
return error ? { ok: false, error } : { ok: true };
|
||||
}
|
||||
|
||||
return { canDelete, deletePatient, checkActiveSchedule, deactivatePatient, archivePatient, reactivatePatient };
|
||||
}
|
||||
|
||||
// ─── Helper puro — não precisa de instância do composable ───────────────────
|
||||
export function getPatientAgendaPermissions (status) {
|
||||
return {
|
||||
canCreateSession: !['Inativo', 'Arquivado'].includes(status),
|
||||
canReschedule: !['Inativo'].includes(status),
|
||||
canEditPastSession: !['Arquivado'].includes(status),
|
||||
canCreateRecurrence: !['Inativo', 'Arquivado'].includes(status),
|
||||
}
|
||||
export function getPatientAgendaPermissions(status) {
|
||||
return {
|
||||
canCreateSession: !['Inativo', 'Arquivado'].includes(status),
|
||||
canReschedule: !['Inativo'].includes(status),
|
||||
canEditPastSession: !['Arquivado'].includes(status),
|
||||
canCreateRecurrence: !['Inativo', 'Arquivado'].includes(status)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,69 +26,65 @@
|
||||
// 'editor' — pode criar e gerenciar cursos/módulos da plataforma de microlearning.
|
||||
//
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { sessionUser } from '@/app/session'
|
||||
import { ref, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { sessionUser } from '@/app/session';
|
||||
|
||||
// cache em módulo (evita queries repetidas por navegação)
|
||||
let _cachedUid = null
|
||||
let _cachedRoles = null
|
||||
let _cachedUid = null;
|
||||
let _cachedRoles = null;
|
||||
|
||||
export function usePlatformPermissions () {
|
||||
const platformRoles = ref(_cachedRoles ?? [])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
export function usePlatformPermissions() {
|
||||
const platformRoles = ref(_cachedRoles ?? []);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
async function load (force = false) {
|
||||
const uid = sessionUser.value?.id
|
||||
if (!uid) {
|
||||
platformRoles.value = []
|
||||
return
|
||||
async function load(force = false) {
|
||||
const uid = sessionUser.value?.id;
|
||||
if (!uid) {
|
||||
platformRoles.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// cache por uid (invalida se usuário mudou)
|
||||
if (!force && _cachedUid === uid && _cachedRoles !== null) {
|
||||
platformRoles.value = _cachedRoles;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const { data, err } = await supabase.from('profiles').select('platform_roles').eq('id', uid).single();
|
||||
|
||||
const roles = !err && Array.isArray(data?.platform_roles) ? data.platform_roles : [];
|
||||
_cachedUid = uid;
|
||||
_cachedRoles = roles;
|
||||
platformRoles.value = roles;
|
||||
} catch (e) {
|
||||
console.warn('[usePlatformPermissions] load falhou:', e);
|
||||
error.value = e;
|
||||
platformRoles.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// cache por uid (invalida se usuário mudou)
|
||||
if (!force && _cachedUid === uid && _cachedRoles !== null) {
|
||||
platformRoles.value = _cachedRoles
|
||||
return
|
||||
function invalidate() {
|
||||
_cachedUid = null;
|
||||
_cachedRoles = null;
|
||||
platformRoles.value = [];
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const isEditor = computed(() => platformRoles.value.includes('editor'));
|
||||
|
||||
try {
|
||||
const { data, err } = await supabase
|
||||
.from('profiles')
|
||||
.select('platform_roles')
|
||||
.eq('id', uid)
|
||||
.single()
|
||||
|
||||
const roles = !err && Array.isArray(data?.platform_roles) ? data.platform_roles : []
|
||||
_cachedUid = uid
|
||||
_cachedRoles = roles
|
||||
platformRoles.value = roles
|
||||
} catch (e) {
|
||||
console.warn('[usePlatformPermissions] load falhou:', e)
|
||||
error.value = e
|
||||
platformRoles.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function invalidate () {
|
||||
_cachedUid = null
|
||||
_cachedRoles = null
|
||||
platformRoles.value = []
|
||||
}
|
||||
|
||||
const isEditor = computed(() => platformRoles.value.includes('editor'))
|
||||
|
||||
return {
|
||||
platformRoles,
|
||||
loading,
|
||||
error,
|
||||
isEditor,
|
||||
load,
|
||||
invalidate
|
||||
}
|
||||
return {
|
||||
platformRoles,
|
||||
loading,
|
||||
error,
|
||||
isEditor,
|
||||
load,
|
||||
invalidate
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { computed } from 'vue';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
/**
|
||||
* ---------------------------------------------------------
|
||||
@@ -118,70 +118,64 @@ import { useTenantStore } from '@/stores/tenantStore'
|
||||
* ---------------------------------------------------------
|
||||
*/
|
||||
|
||||
export function useRoleGuard () {
|
||||
const tenantStore = useTenantStore()
|
||||
export function useRoleGuard() {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const ROLES = Object.freeze({
|
||||
SAAS: 'saas',
|
||||
ADMIN: 'clinic_admin',
|
||||
SUPERVISOR: 'supervisor',
|
||||
THERAPIST: 'therapist',
|
||||
PATIENT: 'patient'
|
||||
})
|
||||
const ROLES = Object.freeze({
|
||||
SAAS: 'saas',
|
||||
ADMIN: 'clinic_admin',
|
||||
SUPERVISOR: 'supervisor',
|
||||
THERAPIST: 'therapist',
|
||||
PATIENT: 'patient'
|
||||
});
|
||||
|
||||
const role = computed(() => tenantStore.membership?.role ?? tenantStore.activeRole ?? null)
|
||||
const isReady = computed(() => !!role.value)
|
||||
const role = computed(() => tenantStore.membership?.role ?? tenantStore.activeRole ?? null);
|
||||
const isReady = computed(() => !!role.value);
|
||||
|
||||
const isSaas = computed(() => role.value === ROLES.SAAS)
|
||||
const isTenantAdmin = computed(() => role.value === ROLES.ADMIN)
|
||||
const isSupervisor = computed(() => role.value === ROLES.SUPERVISOR)
|
||||
const isTherapist = computed(() => role.value === ROLES.THERAPIST)
|
||||
const isPatient = computed(() => role.value === ROLES.PATIENT)
|
||||
const isStaff = computed(() => [ROLES.ADMIN, ROLES.SUPERVISOR, ROLES.THERAPIST].includes(role.value))
|
||||
const isSaas = computed(() => role.value === ROLES.SAAS);
|
||||
const isTenantAdmin = computed(() => role.value === ROLES.ADMIN);
|
||||
const isSupervisor = computed(() => role.value === ROLES.SUPERVISOR);
|
||||
const isTherapist = computed(() => role.value === ROLES.THERAPIST);
|
||||
const isPatient = computed(() => role.value === ROLES.PATIENT);
|
||||
const isStaff = computed(() => [ROLES.ADMIN, ROLES.SUPERVISOR, ROLES.THERAPIST].includes(role.value));
|
||||
|
||||
const TEST_MODE_ROLES = Object.freeze([
|
||||
ROLES.ADMIN,
|
||||
ROLES.SUPERVISOR,
|
||||
ROLES.THERAPIST,
|
||||
ROLES.SAAS,
|
||||
ROLES.PATIENT
|
||||
])
|
||||
const TEST_MODE_ROLES = Object.freeze([ROLES.ADMIN, ROLES.SUPERVISOR, ROLES.THERAPIST, ROLES.SAAS, ROLES.PATIENT]);
|
||||
|
||||
const rbac = Object.freeze({
|
||||
'testMODE': TEST_MODE_ROLES,
|
||||
const rbac = Object.freeze({
|
||||
testMODE: TEST_MODE_ROLES,
|
||||
|
||||
'settings.view': [ROLES.ADMIN, ROLES.SUPERVISOR, ROLES.THERAPIST],
|
||||
'profile.view': [ROLES.ADMIN, ROLES.SUPERVISOR, ROLES.THERAPIST, ROLES.PATIENT],
|
||||
'security.view': [ROLES.ADMIN, ROLES.SUPERVISOR, ROLES.THERAPIST, ROLES.PATIENT]
|
||||
})
|
||||
'settings.view': [ROLES.ADMIN, ROLES.SUPERVISOR, ROLES.THERAPIST],
|
||||
'profile.view': [ROLES.ADMIN, ROLES.SUPERVISOR, ROLES.THERAPIST, ROLES.PATIENT],
|
||||
'security.view': [ROLES.ADMIN, ROLES.SUPERVISOR, ROLES.THERAPIST, ROLES.PATIENT]
|
||||
});
|
||||
|
||||
function canSee (key) {
|
||||
const r = role.value
|
||||
if (!r) return false
|
||||
function canSee(key) {
|
||||
const r = role.value;
|
||||
if (!r) return false;
|
||||
|
||||
const allowed = rbac[key]
|
||||
if (!allowed) return false
|
||||
const allowed = rbac[key];
|
||||
if (!allowed) return false;
|
||||
|
||||
return allowed.includes(r)
|
||||
}
|
||||
return allowed.includes(r);
|
||||
}
|
||||
|
||||
function canSeeOrTest (key) {
|
||||
return canSee(key) || canSee('testMODE')
|
||||
}
|
||||
function canSeeOrTest(key) {
|
||||
return canSee(key) || canSee('testMODE');
|
||||
}
|
||||
|
||||
return {
|
||||
role,
|
||||
isReady,
|
||||
return {
|
||||
role,
|
||||
isReady,
|
||||
|
||||
ROLES,
|
||||
isSaas,
|
||||
isTenantAdmin,
|
||||
isSupervisor,
|
||||
isTherapist,
|
||||
isPatient,
|
||||
isStaff,
|
||||
ROLES,
|
||||
isSaas,
|
||||
isTenantAdmin,
|
||||
isSupervisor,
|
||||
isTherapist,
|
||||
isPatient,
|
||||
isStaff,
|
||||
|
||||
canSee,
|
||||
canSeeOrTest
|
||||
}
|
||||
}
|
||||
canSee,
|
||||
canSeeOrTest
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,118 +14,113 @@
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useLayout } from '@/layout/composables/layout'
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
|
||||
export function useUserSettingsPersistence() {
|
||||
const { layoutConfig } = useLayout()
|
||||
const { layoutConfig } = useLayout();
|
||||
|
||||
const userId = ref('')
|
||||
const saveTimer = ref(null)
|
||||
const pendingPatch = ref({})
|
||||
const initializing = ref(false)
|
||||
const userId = ref('');
|
||||
const saveTimer = ref(null);
|
||||
const pendingPatch = ref({});
|
||||
const initializing = ref(false);
|
||||
|
||||
function isDarkNow() {
|
||||
return document.documentElement.classList.contains('app-dark')
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (initializing.value) return
|
||||
initializing.value = true
|
||||
try {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error) throw error
|
||||
userId.value = data?.user?.id || ''
|
||||
console.log('[user_settings] init userId =', userId.value)
|
||||
} finally {
|
||||
initializing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
if (!userId.value) {
|
||||
console.warn('[user_settings] flush cancelado: sem userId')
|
||||
return
|
||||
function isDarkNow() {
|
||||
return document.documentElement.classList.contains('app-dark');
|
||||
}
|
||||
|
||||
const patch = { ...pendingPatch.value }
|
||||
pendingPatch.value = {}
|
||||
|
||||
const payload = {
|
||||
user_id: userId.value,
|
||||
theme_mode: patch.theme_mode ?? (isDarkNow() ? 'dark' : 'light'),
|
||||
preset: patch.preset ?? layoutConfig.preset ?? 'Aura',
|
||||
primary_color: patch.primary_color ?? layoutConfig.primary ?? 'noir',
|
||||
surface_color: patch.surface_color ?? layoutConfig.surface ?? 'slate',
|
||||
menu_mode: patch.menu_mode ?? layoutConfig.menuMode ?? 'static',
|
||||
layout_variant: patch.layout_variant ?? layoutConfig.variant ?? 'classic',
|
||||
updated_at: new Date().toISOString()
|
||||
async function init() {
|
||||
if (initializing.value) return;
|
||||
initializing.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) throw error;
|
||||
userId.value = data?.user?.id || '';
|
||||
console.log('[user_settings] init userId =', userId.value);
|
||||
} finally {
|
||||
initializing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[user_settings] flush payload =', payload)
|
||||
async function flush() {
|
||||
if (!userId.value) {
|
||||
console.warn('[user_settings] flush cancelado: sem userId');
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('user_settings')
|
||||
.upsert(payload, { onConflict: 'user_id' })
|
||||
const patch = { ...pendingPatch.value };
|
||||
pendingPatch.value = {};
|
||||
|
||||
if (error) {
|
||||
console.error('[user_settings] flush falhou:', error.message || error)
|
||||
throw error
|
||||
const payload = {
|
||||
user_id: userId.value,
|
||||
theme_mode: patch.theme_mode ?? (isDarkNow() ? 'dark' : 'light'),
|
||||
preset: patch.preset ?? layoutConfig.preset ?? 'Aura',
|
||||
primary_color: patch.primary_color ?? layoutConfig.primary ?? 'noir',
|
||||
surface_color: patch.surface_color ?? layoutConfig.surface ?? 'slate',
|
||||
menu_mode: patch.menu_mode ?? layoutConfig.menuMode ?? 'static',
|
||||
layout_variant: patch.layout_variant ?? layoutConfig.variant ?? 'classic',
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
console.log('[user_settings] flush payload =', payload);
|
||||
|
||||
const { error } = await supabase.from('user_settings').upsert(payload, { onConflict: 'user_id' });
|
||||
|
||||
if (error) {
|
||||
console.error('[user_settings] flush falhou:', error.message || error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} patch
|
||||
* @param {object} opts
|
||||
* @param {number} opts.debounceMs
|
||||
* @param {boolean} opts.flushNow
|
||||
*/
|
||||
function queuePatch(patch, opts = {}) {
|
||||
const debounceMs = typeof opts.debounceMs === 'number' ? opts.debounceMs : 500
|
||||
const flushNow = !!opts.flushNow
|
||||
/**
|
||||
* @param {object} patch
|
||||
* @param {object} opts
|
||||
* @param {number} opts.debounceMs
|
||||
* @param {boolean} opts.flushNow
|
||||
*/
|
||||
function queuePatch(patch, opts = {}) {
|
||||
const debounceMs = typeof opts.debounceMs === 'number' ? opts.debounceMs : 500;
|
||||
const flushNow = !!opts.flushNow;
|
||||
|
||||
pendingPatch.value = { ...pendingPatch.value, ...patch }
|
||||
pendingPatch.value = { ...pendingPatch.value, ...patch };
|
||||
|
||||
if (saveTimer.value) clearTimeout(saveTimer.value)
|
||||
if (saveTimer.value) clearTimeout(saveTimer.value);
|
||||
|
||||
const run = async () => {
|
||||
if (!userId.value) return
|
||||
const run = async () => {
|
||||
if (!userId.value) return;
|
||||
|
||||
const payload = {
|
||||
user_id: userId.value,
|
||||
theme_mode: pendingPatch.value.theme_mode ?? (isDarkNow() ? 'dark' : 'light'),
|
||||
preset: pendingPatch.value.preset ?? layoutConfig.preset,
|
||||
primary_color: pendingPatch.value.primary_color ?? layoutConfig.primary,
|
||||
surface_color: pendingPatch.value.surface_color ?? layoutConfig.surface,
|
||||
menu_mode: pendingPatch.value.menu_mode ?? layoutConfig.menuMode,
|
||||
layout_variant: pendingPatch.value.layout_variant ?? layoutConfig.variant ?? 'classic',
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
const payload = {
|
||||
user_id: userId.value,
|
||||
theme_mode: pendingPatch.value.theme_mode ?? (isDarkNow() ? 'dark' : 'light'),
|
||||
preset: pendingPatch.value.preset ?? layoutConfig.preset,
|
||||
primary_color: pendingPatch.value.primary_color ?? layoutConfig.primary,
|
||||
surface_color: pendingPatch.value.surface_color ?? layoutConfig.surface,
|
||||
menu_mode: pendingPatch.value.menu_mode ?? layoutConfig.menuMode,
|
||||
layout_variant: pendingPatch.value.layout_variant ?? layoutConfig.variant ?? 'classic',
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
pendingPatch.value = {}
|
||||
pendingPatch.value = {};
|
||||
|
||||
const { error } = await supabase
|
||||
.from('user_settings')
|
||||
.upsert(payload, { onConflict: 'user_id' })
|
||||
const { error } = await supabase.from('user_settings').upsert(payload, { onConflict: 'user_id' });
|
||||
|
||||
if (error) {
|
||||
console.error('[user_settings] save falhou:', error?.message || error, payload)
|
||||
throw error
|
||||
}
|
||||
if (error) {
|
||||
console.error('[user_settings] save falhou:', error?.message || error, payload);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log('[user_settings] saved:', payload)
|
||||
}
|
||||
console.log('[user_settings] saved:', payload);
|
||||
};
|
||||
|
||||
if (flushNow) return run()
|
||||
if (flushNow) return run();
|
||||
|
||||
saveTimer.value = setTimeout(run, debounceMs)
|
||||
}
|
||||
saveTimer.value = setTimeout(run, debounceMs);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
init,
|
||||
queuePatch,
|
||||
flush
|
||||
}
|
||||
return {
|
||||
init,
|
||||
queuePatch,
|
||||
flush
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user