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:
Leonardo
2026-03-24 21:26:58 -03:00
parent a89d1f5560
commit 53a4980396
453 changed files with 121427 additions and 174407 deletions
+13 -13
View File
@@ -26,18 +26,18 @@ const router = useRouter();
</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 gap-2">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Recarregar" @click="$router.go(0)" />
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Configurações" @click="router.push('/configuracoes')" />
</div>
</div>
</div>
<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 gap-2">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Recarregar" @click="$router.go(0)" />
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Configurações" @click="router.push('/configuracoes')" />
</div>
</div>
</div>
<div class="grid grid-cols-12 gap-8">
<StatsWidget />
@@ -50,4 +50,4 @@ const router = useRouter();
<NotificationsWidget />
</div>
</div>
</template>
</template>
@@ -14,12 +14,12 @@
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<div class="p-4">
<h1>Online Scheduling (Manage)</h1>
</div>
</template>
<script setup>
// placeholder para futura implementação
</script>
<template>
<div class="p-4">
<h1>Online Scheduling (Manage)</h1>
</div>
</template>
@@ -15,579 +15,492 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, onMounted, ref, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { computed, onMounted, ref, watch, nextTick } from 'vue';
import { useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast'
import { useToast } from 'primevue/usetoast';
import ModuleRow from '@/features/clinic/components/ModuleRow.vue'
import ModuleRow from '@/features/clinic/components/ModuleRow.vue';
import { useTenantStore } from '@/stores/tenantStore'
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
import { useMenuStore } from '@/stores/menuStore'
import { useTenantStore } from '@/stores/tenantStore';
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore';
import { useMenuStore } from '@/stores/menuStore';
const toast = useToast()
const route = useRoute()
const toast = useToast();
const route = useRoute();
const tenantStore = useTenantStore()
const tf = useTenantFeaturesStore()
const menuStore = useMenuStore()
const tenantStore = useTenantStore();
const tf = useTenantFeaturesStore();
const menuStore = useMenuStore();
const savingKey = ref(null)
const applyingPreset = ref(false)
const savingKey = ref(null);
const applyingPreset = ref(false);
// evita cliques enquanto o contexto inicial ainda tá montando
const booting = ref(true)
const booting = ref(true);
// guarda features que o plano bloqueou (pra não ficar "clicando e errando")
const planDenied = ref(new Set())
const planDenied = ref(new Set());
const tenantId = computed(() =>
tenantStore.activeTenantId ||
tenantStore.tenantId ||
tenantStore.currentTenantId ||
tenantStore.tenant?.id ||
null
)
const tenantId = computed(() => tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id || null);
const role = computed(() => tenantStore.activeRole || tenantStore.role || 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 isOwner = computed(() => role.value === 'owner' || role.value === 'admin');
const loading = computed(() => tf.loading || tenantStore.loading || booting.value)
const loading = computed(() => tf.loading || tenantStore.loading || booting.value);
const tenantReady = computed(() => !!tenantId.value && tenantStore.loaded)
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 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 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 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 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 clearPlanDenied() {
planDenied.value = new Set();
}
function isLocked (key) {
return (
!isOwner.value ||
!tenantReady.value ||
loading.value ||
applyingPreset.value ||
!!savingKey.value ||
planDenied.value.has(key)
)
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)
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
async function afterFeaturesChanged() {
if (!tenantId.value) return;
// ✅ refresh suave (evita "pisca vazio")
await tf.fetchForTenant(tenantId.value, { force: false })
// ✅ refresh suave (evita "pisca vazio")
await tf.fetchForTenant(tenantId.value, { force: false });
// ✅ nunca navegar/replace aqui
requestMenuRefresh()
// ✅ nunca navegar/replace aqui
requestMenuRefresh();
await nextTick()
await nextTick();
}
async function reload () {
if (!tenantId.value) return
clearPlanDenied()
async function reload() {
if (!tenantId.value) return;
clearPlanDenied();
await tf.fetchForTenant(tenantId.value, { force: true })
requestMenuRefresh()
await tf.fetchForTenant(tenantId.value, { force: true });
requestMenuRefresh();
toast.add({
severity: 'info',
summary: 'Atualizado',
detail: 'Módulos recarregados.',
life: 2000
})
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)
async function toggle(key) {
if (!isOwner.value) {
toast.add({
severity: isPlanDeniedError(e) ? 'warn' : 'error',
summary: isPlanDeniedError(e) ? 'Bloqueado pelo plano' : 'Erro',
detail: `${labelOf(k)}: ${e?.message || 'falha ao aplicar'}`,
life: 4200
})
}
severity: 'warn',
summary: 'Acesso restrito',
detail: 'Apenas o administrador da clínica pode alterar módulos.',
life: 3000
});
return;
}
await afterFeaturesChanged()
if (!tenantId.value) {
toast.add({
severity: 'warn',
summary: 'Sem tenant ativo',
detail: 'Selecione/ative um tenant primeiro.',
life: 2500
});
return;
}
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
}
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()
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;
}
} 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
() => tenantId.value,
async (id) => {
if (!id) return;
booting.value = true
clearPlanDenied()
booting.value = true;
clearPlanDenied();
try {
// ✅ não force no mount para evitar "pisca"
await tf.fetchForTenant(id, { force: false })
try {
// ✅ não force no mount para evitar "pisca"
await tf.fetchForTenant(id, { force: false });
// ✅ reset só quando estiver estável (debounced)
requestMenuRefresh()
// ✅ 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 }
)
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()
}
)
() => route.fullPath,
async () => {
requestMenuRefresh();
}
);
</script>
<template>
<!-- Sentinel -->
<div class="h-px" />
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex flex-col gap-2">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Tipos de Clínica</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">
Ative/desative recursos por clínica. Controla menu, rotas e acesso no banco (RLS).
</div>
<!-- Hero sticky -->
<div class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5" :style="{ top: 'var(--layout-sticky-top, 56px)' }">
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</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="relative z-10 flex flex-col gap-2">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Tipos de Clínica</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Ative/desative recursos por clínica. Controla menu, rotas e acesso no banco (RLS).</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)]">
<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 text-[1rem] text-[var(--text-color-secondary)]">
<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 text-[1rem] text-[var(--text-color-secondary)] 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 text-[1rem] text-[var(--text-color-secondary)] opacity-70"
>
<i class="pi pi-spin pi-spinner" />
Atualizando módulos
</span>
</div>
</div>
</div>
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Banner: somente leitura -->
<div
v-if="!isOwner && tenantReady"
class="flex items-center gap-3 rounded-md border border-amber-400/40 bg-amber-400/10 px-5 py-4 text-[1rem]"
>
<i class="pi pi-lock text-amber-400 shrink-0" />
<span class="text-[1rem] text-[var(--text-color)] 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="grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Preset: Coworking</div>
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">
Para aluguel de salas: sem pacientes, com salas.
<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>
<Button
size="small"
label="Aplicar"
severity="secondary"
outlined
:loading="applyingPreset"
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
@click="applyPreset('coworking')"
/>
</div>
</div>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Preset: Clínica com recepção</div>
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">
Para secretária gerenciar agenda (pacientes opcional).
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)]">
<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 text-[1rem] text-[var(--text-color-secondary)]">
<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 text-[1rem] text-[var(--text-color-secondary)] 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 text-[1rem] text-[var(--text-color-secondary)] opacity-70">
<i class="pi pi-spin pi-spinner" />
Atualizando módulos
</span>
</div>
</div>
<Button
size="small"
label="Aplicar"
severity="secondary"
outlined
:loading="applyingPreset"
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
@click="applyPreset('reception')"
/>
</div>
</div>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Preset: Clínica completa</div>
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">
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>
</div>
</div>
<!-- Modules -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<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-[1rem] rounded-md 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 class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Banner: somente leitura -->
<div v-if="!isOwner && tenantReady" class="flex items-center gap-3 rounded-md border border-amber-400/40 bg-amber-400/10 px-5 py-4 text-[1rem]">
<i class="pi pi-lock text-amber-400 shrink-0" />
<span class="text-[1rem] text-[var(--text-color)] 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>
<Divider class="my-4" />
<div class="text-[1rem] text-[var(--text-color-secondary)] 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>
</div>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<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-[1rem] rounded-md 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-[1rem] text-[var(--text-color-secondary)] 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>
</div>
<!-- Presets -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Preset: Coworking</div>
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">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>
</div>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<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-[1rem] rounded-md 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-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
Isso prepara o terreno para a clínica operar como locação de sala, com agenda vinculando sala + profissional.
</div>
</div>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Preset: Clínica com recepção</div>
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">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>
</div>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<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-[1rem] rounded-md 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 class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Preset: Clínica completa</div>
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">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>
</div>
</div>
<Divider class="my-4" />
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
Você tem páginas de link externo. Isso vira o controle fino: a clínica decide se usa ou não.
<!-- Modules -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<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-[1rem] rounded-md 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-[1rem] text-[var(--text-color-secondary)] 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>
</div>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<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-[1rem] rounded-md 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-[1rem] text-[var(--text-color-secondary)] 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>
</div>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<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-[1rem] rounded-md 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-[1rem] text-[var(--text-color-secondary)] leading-relaxed">Isso prepara o terreno para a clínica operar como locação de sala, com agenda vinculando sala + profissional.</div>
</div>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<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-[1rem] rounded-md 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-[1rem] text-[var(--text-color-secondary)] leading-relaxed">Você tem páginas de link externo. Isso vira o controle fino: a clínica decide se usa ou não.</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>
<style scoped></style>
File diff suppressed because it is too large Load Diff