This commit is contained in:
Leonardo
2026-03-06 06:37:13 -03:00
parent d58dc21297
commit f733db8436
146 changed files with 43436 additions and 12779 deletions
@@ -0,0 +1,33 @@
<script setup>
import BestSellingWidget from '@/components/dashboard/BestSellingWidget.vue';
import NotificationsWidget from '@/components/dashboard/NotificationsWidget.vue';
import RecentSalesWidget from '@/components/dashboard/RecentSalesWidget.vue';
import RevenueStreamWidget from '@/components/dashboard/RevenueStreamWidget.vue';
import StatsWidget from '@/components/dashboard/StatsWidget.vue';
</script>
<template>
<div class="card mb-0">
<div class="flex justify-between mb-4">
<div>
<span class="text-primary font-medium">Área</span>
<span class="text-muted-color"> da Clínica</span>
</div>
<div class="flex items-center justify-center bg-blue-100 dark:bg-blue-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
<i class="pi pi-user text-blue-500 text-xl!"></i>
</div>
</div>
</div>
<div class="grid grid-cols-12 gap-8">
<StatsWidget />
<div class="col-span-12 xl:col-span-6">
<RecentSalesWidget />
<BestSellingWidget />
</div>
<div class="col-span-12 xl:col-span-6">
<RevenueStreamWidget />
<NotificationsWidget />
</div>
</div>
</template>
@@ -0,0 +1,9 @@
<template>
<div class="p-4">
<h1>Online Scheduling (Manage)</h1>
</div>
</template>
<script setup>
// placeholder para futura implementação
</script>
@@ -0,0 +1,591 @@
<!-- src/views/pages/admin/ClinicTypesPage.vue -->
<script setup>
import { computed, onMounted, ref, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import ModuleRow from '@/features/clinic/components/ModuleRow.vue'
import { useTenantStore } from '@/stores/tenantStore'
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
import { useMenuStore } from '@/stores/menuStore'
const toast = useToast()
const route = useRoute()
const tenantStore = useTenantStore()
const tf = useTenantFeaturesStore()
const menuStore = useMenuStore()
const savingKey = ref(null)
const applyingPreset = ref(false)
// evita cliques enquanto o contexto inicial ainda tá montando
const booting = ref(true)
// guarda features que o plano bloqueou (pra não ficar “clicando e errando”)
const planDenied = ref(new Set())
const tenantId = computed(() =>
tenantStore.activeTenantId ||
tenantStore.tenantId ||
tenantStore.currentTenantId ||
tenantStore.tenant?.id ||
null
)
const role = computed(() => tenantStore.activeRole || tenantStore.role || null)
// ✅ Somente owners/admins da clínica podem alterar features.
// Terapeutas enxergam a página em modo somente-leitura (sem toggles, sem presets).
const isOwner = computed(() =>
role.value === 'owner' || role.value === 'admin'
)
const loading = computed(() => tf.loading || tenantStore.loading || booting.value)
const tenantReady = computed(() => !!tenantId.value && tenantStore.loaded)
function isOn (key) {
if (!tenantId.value) return false
try { return !!tf.isEnabled(key, tenantId.value) } catch { return false }
}
function labelOf (key) {
if (key === 'patients') return 'Pacientes'
if (key === 'shared_reception') return 'Recepção / Secretária'
if (key === 'rooms') return 'Salas / Coworking'
if (key === 'intake_public') return 'Link externo de cadastro'
return key
}
function isPlanDeniedError (e) {
const msg = String(e?.message || e || '')
return msg.toLowerCase().includes('não permitida') && msg.toLowerCase().includes('plano')
}
function markPlanDenied (key, e) {
if (!key) return
if (!isPlanDeniedError(e)) return
const s = new Set(planDenied.value)
s.add(key)
planDenied.value = s
}
function clearPlanDenied () {
planDenied.value = new Set()
}
function isLocked (key) {
return (
!isOwner.value ||
!tenantReady.value ||
loading.value ||
applyingPreset.value ||
!!savingKey.value ||
planDenied.value.has(key)
)
}
// ===============================
// 🧠 Menu refresh (debounced)
// evita "menu sumindo" ao resetar durante loading
// ===============================
let menuRefreshT = null
function requestMenuRefresh () {
if (menuRefreshT) clearTimeout(menuRefreshT)
menuRefreshT = setTimeout(() => {
if (tf.loading || tenantStore.loading || booting.value) {
return requestMenuRefresh()
}
if (typeof menuStore.reset === 'function') menuStore.reset()
}, 150)
}
/**
* ✅ Recalcular menu SEM router.replace().
* O menu some quando o reset acontece enquanto stores ainda carregam.
*/
async function afterFeaturesChanged () {
if (!tenantId.value) return
// ✅ refresh suave (evita “pisca vazio”)
await tf.fetchForTenant(tenantId.value, { force: false })
// ✅ nunca navegar/replace aqui
requestMenuRefresh()
await nextTick()
}
async function reload () {
if (!tenantId.value) return
clearPlanDenied()
await tf.fetchForTenant(tenantId.value, { force: true })
requestMenuRefresh()
toast.add({
severity: 'info',
summary: 'Atualizado',
detail: 'Módulos recarregados.',
life: 2000
})
}
async function toggle (key) {
if (!isOwner.value) {
toast.add({
severity: 'warn',
summary: 'Acesso restrito',
detail: 'Apenas o administrador da clínica pode alterar módulos.',
life: 3000
})
return
}
if (!tenantId.value) {
toast.add({
severity: 'warn',
summary: 'Sem tenant ativo',
detail: 'Selecione/ative um tenant primeiro.',
life: 2500
})
return
}
if (planDenied.value.has(key)) {
toast.add({
severity: 'warn',
summary: 'Indisponível no plano',
detail: `${labelOf(key)} não está disponível no plano atual.`,
life: 2800
})
return
}
if (savingKey.value) return
savingKey.value = key
try {
const next = !isOn(key)
await tf.setForTenant(tenantId.value, key, next)
await afterFeaturesChanged()
toast.add({
severity: 'success',
summary: 'Atualizado',
detail: `${labelOf(key)}: ${next ? 'Ativado' : 'Desativado'}`,
life: 2500
})
} catch (e) {
markPlanDenied(key, e)
toast.add({
severity: isPlanDeniedError(e) ? 'warn' : 'error',
summary: isPlanDeniedError(e) ? 'Bloqueado pelo plano' : 'Erro',
detail: e?.message || 'Falha ao atualizar módulo',
life: 3800
})
} finally {
savingKey.value = null
}
}
async function applyPreset (preset) {
if (!isOwner.value) {
toast.add({
severity: 'warn',
summary: 'Acesso restrito',
detail: 'Apenas o administrador da clínica pode aplicar presets.',
life: 3000
})
return
}
if (!tenantId.value) return
if (applyingPreset.value) return
clearPlanDenied()
applyingPreset.value = true
try {
const map = {
coworking: {
patients: false,
shared_reception: false,
rooms: true,
intake_public: false
},
reception: {
patients: false,
shared_reception: true,
rooms: false,
intake_public: false
},
full: {
patients: true,
shared_reception: true,
rooms: true,
intake_public: true
}
}
const cfg = map[preset]
if (!cfg) return
for (const [k, v] of Object.entries(cfg)) {
try {
await tf.setForTenant(tenantId.value, k, v)
} catch (e) {
markPlanDenied(k, e)
toast.add({
severity: isPlanDeniedError(e) ? 'warn' : 'error',
summary: isPlanDeniedError(e) ? 'Bloqueado pelo plano' : 'Erro',
detail: `${labelOf(k)}: ${e?.message || 'falha ao aplicar'}`,
life: 4200
})
}
}
await afterFeaturesChanged()
toast.add({
severity: 'success',
summary: 'Preset aplicado',
detail: 'Configuração atualizada.',
life: 2500
})
} catch (e) {
toast.add({
severity: 'error',
summary: 'Erro',
detail: e?.message || 'Falha ao aplicar preset',
life: 3500
})
} finally {
applyingPreset.value = false
}
}
// Carrega tenant/session se necessário
onMounted(async () => {
try {
if (!tenantStore.loaded && !tenantStore.loading) {
await tenantStore.loadSessionAndTenant()
}
} finally {
// o watch do tenantId fará o fetch; aqui só destrava a tela
booting.value = false
}
})
// Busca features sempre que o tenant ficar disponível (e no mount)
watch(
() => tenantId.value,
async (id) => {
if (!id) return
booting.value = true
clearPlanDenied()
try {
// ✅ não force no mount para evitar “pisca”
await tf.fetchForTenant(id, { force: false })
// ✅ reset só quando estiver estável (debounced)
requestMenuRefresh()
await nextTick()
} catch (e) {
toast.add({
severity: 'error',
summary: 'Erro ao carregar módulos',
detail: e?.message || 'Falha ao buscar tenant_features',
life: 4000
})
} finally {
booting.value = false
}
},
{ immediate: true }
)
// blindagem: se a rota mudar dentro da área admin e o menu tiver resetado,
// solicita refresh leve (sem navigation)
watch(
() => route.fullPath,
async () => {
requestMenuRefresh()
}
)
</script>
<template>
<div class="p-4 md:p-6">
<Toast />
<!-- Header -->
<div class="mb-4 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-5 md:p-7">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-16 -right-20 h-72 w-72 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute top-10 -left-24 h-80 w-80 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute -bottom-20 right-24 h-72 w-72 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<div class="relative flex flex-col gap-2">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<h1 class="text-xl md:text-2xl font-semibold leading-tight">Tipos de Clínica</h1>
<p class="mt-1 text-sm opacity-80">
Ative/desative recursos por clínica. Isso controla menu, rotas (guard) e acesso no banco (RLS).
</p>
</div>
<div class="shrink-0 flex items-center gap-2">
<Button
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
:disabled="applyingPreset || !!savingKey"
@click="reload"
/>
</div>
</div>
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs opacity-80">
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1">
<i class="pi pi-building" />
Tenant: <b class="font-mono">{{ tenantId || '—' }}</b>
</span>
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1">
<i class="pi pi-user" />
Role: <b>{{ role || '—' }}</b>
</span>
<span
v-if="!tenantReady"
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 opacity-70"
>
<i class="pi pi-spin pi-spinner" />
Carregando contexto
</span>
<span
v-else-if="loading"
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 opacity-70"
>
<i class="pi pi-spin pi-spinner" />
Atualizando módulos
</span>
</div>
</div>
</div>
</div>
<!-- Banner: acesso somente leitura para terapeutas -->
<div
v-if="!isOwner && tenantReady"
class="mb-4 flex items-center gap-3 rounded-[2rem] border border-amber-400/40 bg-amber-400/10 px-5 py-4 text-sm"
>
<i class="pi pi-lock text-amber-400 text-base shrink-0" />
<span class="opacity-90">
Você está visualizando as configurações da clínica em <b>modo somente leitura</b>.
Apenas o administrador pode ativar ou desativar módulos.
</span>
</div>
<!-- Presets -->
<div class="mb-4 grid grid-cols-1 md:grid-cols-3 gap-3">
<Card class="rounded-[2rem]">
<template #content>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-semibold">Preset: Coworking</div>
<div class="mt-1 text-xs opacity-80">
Para aluguel de salas: sem pacientes, com salas.
</div>
</div>
<Button
size="small"
label="Aplicar"
severity="secondary"
outlined
:loading="applyingPreset"
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
@click="applyPreset('coworking')"
/>
</div>
</template>
</Card>
<Card class="rounded-[2rem]">
<template #content>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-semibold">Preset: Clínica com recepção</div>
<div class="mt-1 text-xs opacity-80">
Para secretária gerenciar agenda (pacientes opcional).
</div>
</div>
<Button
size="small"
label="Aplicar"
severity="secondary"
outlined
:loading="applyingPreset"
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
@click="applyPreset('reception')"
/>
</div>
</template>
</Card>
<Card class="rounded-[2rem]">
<template #content>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-semibold">Preset: Clínica completa</div>
<div class="mt-1 text-xs opacity-80">
Pacientes + recepção + salas (se quiser).
</div>
</div>
<Button
size="small"
label="Aplicar"
severity="secondary"
outlined
:loading="applyingPreset"
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
@click="applyPreset('full')"
/>
</div>
</template>
</Card>
</div>
<!-- Modules -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<Card class="rounded-[2rem]">
<template #content>
<ModuleRow
title="Pacientes"
desc="Habilita gestão de pacientes por clínica. Todo paciente tem um responsável (therapist)."
icon="pi pi-users"
:enabled="isOn('patients')"
:loading="savingKey === 'patients'"
:disabled="isLocked('patients')"
@toggle="toggle('patients')"
/>
<div
v-if="planDenied.has('patients')"
class="mt-3 text-xs rounded-2xl border border-[var(--surface-border)] p-3 opacity-90"
>
<i class="pi pi-lock mr-2" />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<Divider class="my-4" />
<div class="text-xs opacity-80 leading-relaxed">
Quando desligado:
<ul class="mt-2 list-disc pl-5 space-y-1">
<li>Menu Pacientes some.</li>
<li>Rotas com <span class="font-mono">meta.tenantFeature = 'patients'</span> redirecionam pra .</li>
<li>RLS bloqueia acesso direto no banco.</li>
</ul>
</div>
</template>
</Card>
<Card class="rounded-[2rem]">
<template #content>
<ModuleRow
title="Recepção / Secretária"
desc="Permite um papel de secretária gerenciar a agenda dos profissionais (sem precisar ver tudo do paciente)."
icon="pi pi-briefcase"
:enabled="isOn('shared_reception')"
:loading="savingKey === 'shared_reception'"
:disabled="isLocked('shared_reception')"
@toggle="toggle('shared_reception')"
/>
<div
v-if="planDenied.has('shared_reception')"
class="mt-3 text-xs rounded-2xl border border-[var(--surface-border)] p-3 opacity-90"
>
<i class="pi pi-lock mr-2" />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<Divider class="my-4" />
<div class="text-xs opacity-80 leading-relaxed">
Observação: este módulo é produto (UX + permissões). A base aqui é o toggle.
Depois a gente cria:
<ul class="mt-2 list-disc pl-5 space-y-1">
<li>role <span class="font-mono">secretary</span> em <span class="font-mono">tenant_members</span></li>
<li>policies e telas para a secretária</li>
<li>nível de visibilidade do paciente na agenda</li>
</ul>
</div>
</template>
</Card>
<Card class="rounded-[2rem]">
<template #content>
<ModuleRow
title="Salas / Coworking"
desc="Habilita cadastro e reserva de salas/recursos no agendamento."
icon="pi pi-building"
:enabled="isOn('rooms')"
:loading="savingKey === 'rooms'"
:disabled="isLocked('rooms')"
@toggle="toggle('rooms')"
/>
<div
v-if="planDenied.has('rooms')"
class="mt-3 text-xs rounded-2xl border border-[var(--surface-border)] p-3 opacity-90"
>
<i class="pi pi-lock mr-2" />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<Divider class="my-4" />
<div class="text-xs opacity-80 leading-relaxed">
Isso prepara o terreno para a clínica operar como locação de sala, com agenda vinculando sala + profissional.
</div>
</template>
</Card>
<Card class="rounded-[2rem]">
<template #content>
<ModuleRow
title="Link externo de cadastro"
desc="Libera fluxo público de intake/cadastro externo para a clínica."
icon="pi pi-link"
:enabled="isOn('intake_public')"
:loading="savingKey === 'intake_public'"
:disabled="isLocked('intake_public')"
@toggle="toggle('intake_public')"
/>
<div
v-if="planDenied.has('intake_public')"
class="mt-3 text-xs rounded-2xl border border-[var(--surface-border)] p-3 opacity-90"
>
<i class="pi pi-lock mr-2" />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<Divider class="my-4" />
<div class="text-xs opacity-80 leading-relaxed">
Você tem páginas de link externo. Isso vira o controle fino: a clínica decide se usa ou não.
</div>
</template>
</Card>
</div>
</div>
</template>
<style scoped>
/* (sem estilos adicionais por enquanto) */
</style>
File diff suppressed because it is too large Load Diff