Layout 100%, Notificações, SetupWizard
This commit is contained in:
@@ -4,6 +4,9 @@ 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';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -13,8 +16,9 @@ import StatsWidget from '@/components/dashboard/StatsWidget.vue';
|
||||
<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 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>
|
||||
@@ -30,4 +34,4 @@ import StatsWidget from '@/components/dashboard/StatsWidget.vue';
|
||||
<NotificationsWidget />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -324,268 +324,256 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 md:p-6">
|
||||
<Toast />
|
||||
<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>
|
||||
<!-- Sentinel -->
|
||||
<div class=”h-px” />
|
||||
|
||||
<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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- ⚠️ Banner: acesso somente leitura para terapeutas -->
|
||||
<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=”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=”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="mb-4 flex items-center gap-3 rounded-[2rem] border border-amber-400/40 bg-amber-400/10 px-5 py-4 text-sm"
|
||||
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 text-base shrink-0" />
|
||||
<span class="opacity-90">
|
||||
<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="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 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>
|
||||
<Button
|
||||
size="small"
|
||||
label="Aplicar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:loading="applyingPreset"
|
||||
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
|
||||
@click="applyPreset('coworking')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Button
|
||||
size=”small”
|
||||
label=”Aplicar”
|
||||
severity=”secondary”
|
||||
outlined
|
||||
:loading=”applyingPreset”
|
||||
:disabled=”!isOwner || !tenantReady || loading || !!savingKey”
|
||||
@click=”applyPreset('coworking')”
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
<Button
|
||||
size="small"
|
||||
label="Aplicar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:loading="applyingPreset"
|
||||
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
|
||||
@click="applyPreset('reception')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Button
|
||||
size=”small”
|
||||
label=”Aplicar”
|
||||
severity=”secondary”
|
||||
outlined
|
||||
:loading=”applyingPreset”
|
||||
:disabled=”!isOwner || !tenantReady || loading || !!savingKey”
|
||||
@click=”applyPreset('reception')”
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
<Button
|
||||
size="small"
|
||||
label="Aplicar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:loading="applyingPreset"
|
||||
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
|
||||
@click="applyPreset('full')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<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">
|
||||
<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 cá.</li>
|
||||
<li>RLS bloqueia acesso direto no banco.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<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 cá.</li>
|
||||
<li>RLS bloqueia acesso direto no banco.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 é só 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>
|
||||
<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 é só 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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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ê já tem páginas de link externo. Isso vira o controle fino: a clínica decide se usa ou não.
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<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ê já tem páginas de link externo. Isso vira o controle fino: a clínica decide se usa ou não.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* (sem estilos adicionais por enquanto) */
|
||||
</style>
|
||||
@@ -51,6 +51,12 @@ const loadingHistory = ref(false)
|
||||
const loadHistoryError = ref('')
|
||||
const historySearch = ref('')
|
||||
|
||||
const HIGHLIGHT_MS = 24 * 60 * 60 * 1000
|
||||
function isRecent (row) {
|
||||
if (!row?.created_at) return false
|
||||
return Date.now() - new Date(row.created_at).getTime() < HIGHLIGHT_MS
|
||||
}
|
||||
|
||||
const filteredHistory = computed(() => {
|
||||
const q = (historySearch.value || '').trim().toLowerCase()
|
||||
const base = history.value || []
|
||||
@@ -96,8 +102,12 @@ const activeTenantKind = ref(null)
|
||||
const canManage = computed(() => {
|
||||
const r = (effectiveRole.value || '').toString()
|
||||
const isAdmin = r === 'clinic_admin' || r === 'tenant_admin'
|
||||
// só pode gerenciar se for admin E o tenant for uma clínica (não pessoal/saas)
|
||||
return isAdmin && activeTenantKind.value === 'clinic'
|
||||
if (!isAdmin) return false
|
||||
// Aceita qualquer kind de clínica: 'clinic', 'clinic_coworking', 'clinic_reception', 'clinic_full'
|
||||
// Se activeTenantKind ainda não carregou (null), confia no role já normalizado
|
||||
const k = String(activeTenantKind.value || '')
|
||||
if (!k) return true
|
||||
return k === 'clinic' || k.startsWith('clinic_')
|
||||
})
|
||||
|
||||
const DEV_TEST_EMAILS = [
|
||||
@@ -733,91 +743,76 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 md:p-6">
|
||||
<Toast />
|
||||
<ConfirmDialog />
|
||||
<Toast />
|
||||
<ConfirmDialog />
|
||||
|
||||
<!-- 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" />
|
||||
<!-- 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)]">Profissionais da clínica</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">
|
||||
Gerencie terapeutas e secretarias vinculados ao seu tenant.
|
||||
</div>
|
||||
</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">
|
||||
<div class="text-xl md:text-2xl font-semibold leading-tight">
|
||||
Profissionais da clínica
|
||||
</div>
|
||||
<div class="opacity-70 text-sm">
|
||||
Gerencie terapeutas e secretarias vinculados ao seu tenant.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
label="Convidar terapeuta"
|
||||
icon="pi pi-user-plus"
|
||||
@click="openInvite('therapist')"
|
||||
:disabled="!tenantReady || !canManage"
|
||||
/>
|
||||
<Button
|
||||
label="Adicionar secretária"
|
||||
icon="pi pi-users"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="openInvite('secretary')"
|
||||
:disabled="!tenantReady || !canManage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aviso (regra futura) -->
|
||||
<div class="mt-3 rounded-2xl border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_30%)] p-3">
|
||||
<div class="flex gap-3">
|
||||
<i class="pi pi-info-circle mt-0.5 opacity-70" />
|
||||
<div class="text-sm">
|
||||
<div class="font-semibold">Atenção</div>
|
||||
<div class="opacity-80 leading-relaxed">
|
||||
A regra “impedir desvincular terapeuta com atendimentos agendados” será ativada quando a agenda
|
||||
registrar o terapeuta no evento (ex.: <span class="font-mono">agenda_eventos.terapeuta_id</span>)
|
||||
ou quando existir a tabela de sessões/appointments. Por enquanto, a ação de desvincular apenas
|
||||
desativa o vínculo.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading leve do tenant -->
|
||||
<div v-if="!tenantReady" class="mt-2 text-sm opacity-70">
|
||||
Carregando permissões da clínica…
|
||||
</div>
|
||||
|
||||
<!-- Aviso de permissão -->
|
||||
<div v-else-if="!canManage" class="mt-2 text-sm text-orange-600">
|
||||
Sua conta não tem permissão para gerenciar profissionais (apenas <b>clinic_admin</b>).
|
||||
</div>
|
||||
|
||||
<!-- Debug (opcional) -->
|
||||
<div v-if="debug" class="mt-2 text-xs opacity-70">
|
||||
tenantId={{ tenantId }} | role={{ effectiveRole || '(vazio)' }} | canManage={{ canManage }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap shrink-0">
|
||||
<Button
|
||||
label="Convidar terapeuta"
|
||||
icon="pi pi-user-plus"
|
||||
@click="openInvite('therapist')"
|
||||
:disabled="!tenantReady || !canManage"
|
||||
/>
|
||||
<Button
|
||||
label="Adicionar secretária"
|
||||
icon="pi pi-users"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="openInvite('secretary')"
|
||||
:disabled="!tenantReady || !canManage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading leve do tenant -->
|
||||
<div v-if="!tenantReady" class="text-[1rem] text-[var(--text-color-secondary)] opacity-70">
|
||||
Carregando permissões da clínica…
|
||||
</div>
|
||||
|
||||
<!-- Aviso de permissão (somente se carregou e não tem permissão) -->
|
||||
<div v-else-if="!canManage" class="text-[1rem] text-orange-600">
|
||||
Sua conta não tem permissão para gerenciar profissionais (apenas <b>clinic_admin</b>).
|
||||
</div>
|
||||
|
||||
<!-- Debug (opcional) -->
|
||||
<div v-if="debug" class="text-[1rem] opacity-70">
|
||||
tenantId={{ tenantId }} | role={{ effectiveRole || '(vazio)' }} | canManage={{ canManage }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
|
||||
|
||||
<!-- 🔎 Aviso sobre logins de teste -->
|
||||
<!-- 🔎 Aviso sobre logins de teste + atalhos de convite -->
|
||||
<div
|
||||
class="mb-4 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_18%)]"
|
||||
>
|
||||
<!-- 🔎 Aviso sobre logins de teste + atalhos de convite -->
|
||||
<div
|
||||
class="overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)]"
|
||||
>
|
||||
<div class="p-5 md:p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shrink-0">
|
||||
<div class="h-10 w-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shrink-0">
|
||||
<i class="pi pi-info-circle opacity-70" />
|
||||
</div>
|
||||
|
||||
@@ -826,18 +821,18 @@ onMounted(async () => {
|
||||
Logins de teste (ambiente de desenvolvimento)
|
||||
</div>
|
||||
|
||||
<div class="text-sm opacity-80 mt-1 leading-relaxed">
|
||||
<div class="text-[1rem] opacity-80 mt-1 leading-relaxed">
|
||||
As credenciais fixas para testes (clinic, therapist, therapist2, therapist3, secretary, patient e saas)
|
||||
estão disponíveis na tela inicial do sistema (<span class="font-mono">HomeCards</span>).
|
||||
</div>
|
||||
|
||||
<div class="text-sm opacity-80 mt-2 leading-relaxed">
|
||||
<div class="text-[1rem] opacity-80 mt-2 leading-relaxed">
|
||||
Para utilizá-las, basta realizar <b>logout da sessão atual</b> e selecionar o perfil desejado na tela inicial.
|
||||
</div>
|
||||
|
||||
<!-- ✅ Atalhos: abrir dialog e já preencher o email -->
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs opacity-70 mr-1">Atalhos (DEV):</span>
|
||||
<span class="text-[1rem] opacity-70 mr-1">Atalhos (DEV):</span>
|
||||
|
||||
<Button
|
||||
label="Convidar therapist2"
|
||||
@@ -872,13 +867,13 @@ onMounted(async () => {
|
||||
|
||||
<!-- Links de convite pendentes para testes -->
|
||||
<div class="mt-3 flex flex-col gap-1">
|
||||
<span class="text-xs opacity-70 mb-1">Links de convite pendentes (DEV):</span>
|
||||
<span class="text-[1rem] opacity-70 mb-1">Links de convite pendentes (DEV):</span>
|
||||
<div
|
||||
v-for="item in devInviteLinks"
|
||||
:key="item.email"
|
||||
class="flex items-center gap-2 flex-wrap"
|
||||
>
|
||||
<span class="text-xs font-mono opacity-70 w-56 truncate">{{ item.email }}</span>
|
||||
<span class="text-[1rem] font-mono opacity-70 w-56 truncate">{{ item.email }}</span>
|
||||
<Button
|
||||
label="Copiar link de convite"
|
||||
icon="pi pi-copy"
|
||||
@@ -888,7 +883,7 @@ onMounted(async () => {
|
||||
:disabled="!item.token"
|
||||
@click="copyInviteLink(item)"
|
||||
/>
|
||||
<span v-if="!item.token" class="text-xs opacity-50 italic">sem convite pendente</span>
|
||||
<span v-if="!item.token" class="text-[1rem] opacity-50 italic">sem convite pendente</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -897,16 +892,16 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<!-- ✅ DOCUMENTAÇÃO INTERNA (visível na tela, para QA) -->
|
||||
<div class="mb-4 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_12%)]">
|
||||
<div class="overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<div class="p-5 md:p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shrink-0">
|
||||
<div class="h-10 w-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shrink-0">
|
||||
<i class="pi pi-book opacity-70" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-lg leading-tight">Guia rápido — Convites (Modelo B) e como testar</div>
|
||||
<div class="text-sm opacity-80 mt-1 leading-relaxed">
|
||||
<div class="text-[1rem] opacity-80 mt-1 leading-relaxed">
|
||||
Esta área existe para facilitar o QA/validação do fluxo de convites no SaaS multi-tenant.
|
||||
A tela pública de aceite está em:
|
||||
<span class="font-mono">/accept-invite?token=<uuid></span>.
|
||||
@@ -914,9 +909,9 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="font-semibold text-sm mb-2">Rotas e comportamento esperado</div>
|
||||
<ul class="text-sm opacity-80 leading-relaxed list-disc pl-5 space-y-1">
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="font-semibold text-[1rem] mb-2">Rotas e comportamento esperado</div>
|
||||
<ul class="text-[1rem] opacity-80 leading-relaxed list-disc pl-5 space-y-1">
|
||||
<li><b>Aceite público:</b> <span class="font-mono">/accept-invite?token=<uuid></span></li>
|
||||
<li><b>Login:</b> <span class="font-mono">/auth/login</span></li>
|
||||
<li>
|
||||
@@ -930,16 +925,16 @@ onMounted(async () => {
|
||||
<li><b>Erros esperados:</b> token inválido/expirado, convite já usado, e-mail diferente (mismatch).</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-3 text-xs opacity-70 leading-relaxed">
|
||||
<div class="mt-3 text-[1rem] opacity-70 leading-relaxed">
|
||||
<b>Nota:</b> o backend foi corrigido para não depender do claim de email no JWT
|
||||
(erro antigo <span class="font-mono">missing_email_claim</span>). O email é resolvido via
|
||||
<span class="font-mono">auth.users</span> usando <span class="font-mono">SECURITY DEFINER</span>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="font-semibold text-sm mb-2">Como testar (prático)</div>
|
||||
<ol class="text-sm opacity-80 leading-relaxed list-decimal pl-5 space-y-1">
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="font-semibold text-[1rem] mb-2">Como testar (prático)</div>
|
||||
<ol class="text-[1rem] opacity-80 leading-relaxed list-decimal pl-5 space-y-1">
|
||||
<li>Convidar alguém nesta tela (botões acima).</li>
|
||||
<li>Abrir a aba <b>Convites</b> e copiar o link.</li>
|
||||
<li>Abrir o link em aba anônima → logar com o mesmo email → aceitar.</li>
|
||||
@@ -965,7 +960,7 @@ onMounted(async () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-xs opacity-70">
|
||||
<div class="mt-2 text-[1rem] opacity-70">
|
||||
Dica: use aba anônima para testar o fluxo completo sem interferência de sessão.
|
||||
</div>
|
||||
</div>
|
||||
@@ -974,18 +969,18 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<!-- Abas -->
|
||||
<TabView class="rounded-[2rem] overflow-hidden">
|
||||
<TabView class="rounded-md overflow-hidden">
|
||||
<!-- =========================
|
||||
ABA 1: EQUIPE
|
||||
========================= -->
|
||||
<TabPanel header="Equipe">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<Card class="rounded-[2rem]">
|
||||
<Card class="rounded-md">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold">Equipe</div>
|
||||
<div class="text-sm opacity-70">
|
||||
<div class="text-[1rem] opacity-70">
|
||||
Membros ativos/inativos do tenant (somente <b>tenant_members</b>).
|
||||
</div>
|
||||
</div>
|
||||
@@ -1015,7 +1010,7 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div v-if="loadError" class="mb-3 rounded-2xl border border-red-200 bg-red-50 p-3 text-red-700 text-sm">
|
||||
<div v-if="loadError" class="mb-3 rounded-md border border-red-200 bg-red-50 p-3 text-red-700 text-[1rem]">
|
||||
{{ loadError }}
|
||||
</div>
|
||||
|
||||
@@ -1024,14 +1019,15 @@ onMounted(async () => {
|
||||
:loading="loading"
|
||||
dataKey="user_id"
|
||||
responsiveLayout="scroll"
|
||||
class="p-datatable-sm"
|
||||
class="p-datatable-sm prof-datatable"
|
||||
sortField="role_sort"
|
||||
:sortOrder="1"
|
||||
:rowClass="(r) => isRecent(r) ? 'row-new-highlight' : ''"
|
||||
>
|
||||
<Column header="Pessoa" style="min-width: 18rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center">
|
||||
<div class="h-10 w-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center">
|
||||
<i class="pi pi-user opacity-70" />
|
||||
</div>
|
||||
|
||||
@@ -1040,15 +1036,15 @@ onMounted(async () => {
|
||||
{{ data.full_name || 'Sem nome' }}
|
||||
</div>
|
||||
|
||||
<div class="text-sm opacity-70 truncate">
|
||||
<div class="text-[1rem] opacity-70 truncate">
|
||||
{{ data.email || 'Sem email' }}
|
||||
</div>
|
||||
|
||||
<div class="text-xs opacity-60 font-mono truncate">
|
||||
<div class="text-[1rem] opacity-60 font-mono truncate">
|
||||
tenant: {{ data.tenant_id }}
|
||||
</div>
|
||||
|
||||
<div class="text-xs opacity-60 font-mono truncate">
|
||||
<div class="text-[1rem] opacity-60 font-mono truncate">
|
||||
uid: {{ data.user_id }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1074,16 +1070,16 @@ onMounted(async () => {
|
||||
|
||||
<Column header="Vínculo" style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<span v-if="canManage" class="text-sm opacity-70 italic">vinculado nesta clínica</span>
|
||||
<span v-if="canManage" class="text-[1rem] opacity-70 italic">vinculado nesta clínica</span>
|
||||
<template v-else>
|
||||
<div v-if="myLinks.length > 0" class="flex flex-col gap-1">
|
||||
<span
|
||||
v-for="link in myLinks"
|
||||
:key="link.tenant_id"
|
||||
class="text-sm"
|
||||
class="text-[1rem]"
|
||||
>{{ link.clinic_name }}</span>
|
||||
</div>
|
||||
<span v-else class="text-sm opacity-50">—</span>
|
||||
<span v-else class="text-[1rem] opacity-50">—</span>
|
||||
</template>
|
||||
</template>
|
||||
</Column>
|
||||
@@ -1113,7 +1109,7 @@ onMounted(async () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="data.is_self" class="text-xs opacity-60 mt-1 text-right">
|
||||
<div v-if="data.is_self" class="text-[1rem] opacity-60 mt-1 text-right">
|
||||
Você
|
||||
</div>
|
||||
</template>
|
||||
@@ -1126,26 +1122,26 @@ onMounted(async () => {
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<small class="block mt-3 opacity-70">
|
||||
<div class="text-[1rem] block mt-3 opacity-70">
|
||||
Papel real salvo em <span class="font-mono">tenant_members.role</span>:
|
||||
<b>tenant_admin</b>, <b>therapist</b>, <b>secretary</b>, <b>patient</b>.
|
||||
No front, normalizamos <b>tenant_admin → clinic_admin</b> (apenas para UI).
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<small class="block mt-2 opacity-70">
|
||||
<div class="text-[1rem] block mt-2 opacity-70">
|
||||
<b>Status:</b> <span class="font-mono">active</span> = acesso liberado.
|
||||
<span class="font-mono">inactive</span> = vínculo desativado (histórico).
|
||||
</small>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Meus Vínculos (visível apenas para terapeutas e secretárias) -->
|
||||
<Card v-if="(effectiveRole === 'therapist' || effectiveRole === 'secretary') && (myLinks.length > 0 || loadingMyLinks)" class="rounded-[2rem]">
|
||||
<Card v-if="(effectiveRole === 'therapist' || effectiveRole === 'secretary') && (myLinks.length > 0 || loadingMyLinks)" class="rounded-md">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold">Meus vínculos</div>
|
||||
<div class="text-sm opacity-70">
|
||||
<div class="text-[1rem] opacity-70">
|
||||
Clínicas às quais sua conta está associada.
|
||||
</div>
|
||||
</div>
|
||||
@@ -1161,7 +1157,7 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div v-if="loadMyLinksError" class="mb-3 rounded-2xl border border-red-200 bg-red-50 p-3 text-red-700 text-sm">
|
||||
<div v-if="loadMyLinksError" class="mb-3 rounded-md border border-red-200 bg-red-50 p-3 text-red-700 text-[1rem]">
|
||||
{{ loadMyLinksError }}
|
||||
</div>
|
||||
|
||||
@@ -1169,15 +1165,15 @@ onMounted(async () => {
|
||||
<div
|
||||
v-for="link in myLinks"
|
||||
:key="link.tenant_id"
|
||||
class="flex items-center justify-between gap-4 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4"
|
||||
class="flex items-center justify-between gap-4 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_30%)] grid place-items-center shrink-0">
|
||||
<div class="h-10 w-10 rounded-md border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_30%)] grid place-items-center shrink-0">
|
||||
<i class="pi pi-building opacity-70" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold truncate">{{ link.clinic_name }}</div>
|
||||
<div class="text-xs font-mono opacity-50 truncate">{{ link.tenant_id }}</div>
|
||||
<div class="text-[1rem] font-mono opacity-50 truncate">{{ link.tenant_id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1192,9 +1188,9 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<small class="block mt-3 opacity-70">
|
||||
<div class="text-[1rem] block mt-3 opacity-70">
|
||||
Um profissional pode estar vinculado a múltiplas clínicas simultaneamente.
|
||||
</small>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1202,12 +1198,12 @@ onMounted(async () => {
|
||||
========================= -->
|
||||
<TabPanel header="Convites">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<Card class="rounded-[2rem]">
|
||||
<Card class="rounded-md">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold">Convites pendentes</div>
|
||||
<div class="text-sm opacity-70">
|
||||
<div class="text-[1rem] opacity-70">
|
||||
Convites do tenant que ainda não foram aceitos (tabela <b>tenant_invites</b>).
|
||||
</div>
|
||||
</div>
|
||||
@@ -1237,7 +1233,7 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div v-if="loadInvitesError" class="mb-3 rounded-2xl border border-red-200 bg-red-50 p-3 text-red-700 text-sm">
|
||||
<div v-if="loadInvitesError" class="mb-3 rounded-md border border-red-200 bg-red-50 p-3 text-red-700 text-[1rem]">
|
||||
{{ loadInvitesError }}
|
||||
</div>
|
||||
|
||||
@@ -1246,18 +1242,19 @@ onMounted(async () => {
|
||||
:loading="loadingInvites"
|
||||
dataKey="token"
|
||||
responsiveLayout="scroll"
|
||||
class="p-datatable-sm"
|
||||
class="p-datatable-sm invites-datatable"
|
||||
sortField="created_at"
|
||||
:sortOrder="-1"
|
||||
:rowClass="(r) => isRecent(r) ? 'row-new-highlight' : ''"
|
||||
>
|
||||
<Column header="Email" style="min-width: 18rem">
|
||||
<template #body="{ data }">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold truncate">{{ data.email }}</div>
|
||||
<div class="text-xs opacity-60 font-mono truncate">
|
||||
<div class="text-[1rem] opacity-60 font-mono truncate">
|
||||
token: {{ data.token }}
|
||||
</div>
|
||||
<div class="text-xs opacity-60 font-mono truncate">
|
||||
<div class="text-[1rem] opacity-60 font-mono truncate">
|
||||
tenant: {{ data.tenant_id }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1272,13 +1269,13 @@ onMounted(async () => {
|
||||
|
||||
<Column header="Expira" style="width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-sm opacity-80">{{ formatDate(data.expires_at) }}</span>
|
||||
<span class="text-[1rem] opacity-80">{{ formatDate(data.expires_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Criado em" style="width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-sm opacity-80">{{ formatDate(data.created_at) }}</span>
|
||||
<span class="text-[1rem] opacity-80">{{ formatDate(data.created_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
@@ -1325,15 +1322,15 @@ onMounted(async () => {
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<small class="block mt-3 opacity-70">
|
||||
<div class="text-[1rem] block mt-3 opacity-70">
|
||||
<b>Modelo B:</b> convidar não cria membership. O membership só aparece na aba <b>Equipe</b> após o aceite em
|
||||
<span class="font-mono">/accept-invite?token=...</span>.
|
||||
</small>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- QA TOOL: Auth Users (TEMPORÁRIO) -->
|
||||
<Card class="rounded-[2rem] mt-2">
|
||||
<Card class="rounded-md mt-2">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold">Usuários cadastrados (Auth)</div>
|
||||
@@ -1349,10 +1346,10 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="mb-4 rounded-2xl border border-orange-200 bg-orange-50 p-4">
|
||||
<div class="mb-4 rounded-md border border-orange-200 bg-orange-50 p-4">
|
||||
<div class="flex gap-3">
|
||||
<i class="pi pi-exclamation-triangle mt-0.5 text-orange-600" />
|
||||
<div class="text-sm text-orange-800 leading-relaxed">
|
||||
<div class="text-[1rem] text-orange-800 leading-relaxed">
|
||||
<div class="font-semibold mb-1">
|
||||
Aviso técnico — View temporária para testes (QA)
|
||||
</div>
|
||||
@@ -1372,14 +1369,14 @@ onMounted(async () => {
|
||||
⚠️ Remover após validação:
|
||||
</div>
|
||||
|
||||
<div class="mt-2 font-mono text-xs bg-white/60 border border-orange-200 rounded-xl p-2">
|
||||
<div class="mt-2 font-mono text-[1rem] bg-white/60 border border-orange-200 rounded-md p-2">
|
||||
drop view if exists public.v_auth_users_public;
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadUsersError" class="mb-3 rounded-2xl border border-red-200 bg-red-50 p-3 text-red-700 text-sm">
|
||||
<div v-if="loadUsersError" class="mb-3 rounded-md border border-red-200 bg-red-50 p-3 text-red-700 text-[1rem]">
|
||||
{{ loadUsersError }}
|
||||
</div>
|
||||
|
||||
@@ -1393,7 +1390,7 @@ onMounted(async () => {
|
||||
<Column field="email" header="Email" style="min-width: 18rem" />
|
||||
<Column header="User ID" style="min-width: 22rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-xs font-mono opacity-70 break-all">{{ data.user_id }}</span>
|
||||
<span class="text-[1rem] font-mono opacity-70 break-all">{{ data.user_id }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="created_at" header="Criado em" />
|
||||
@@ -1409,12 +1406,12 @@ onMounted(async () => {
|
||||
========================= -->
|
||||
<TabPanel header="Histórico">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<Card class="rounded-[2rem]">
|
||||
<Card class="rounded-md">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold">Histórico de desvinculados</div>
|
||||
<div class="text-sm opacity-70">
|
||||
<div class="text-[1rem] opacity-70">
|
||||
Membros inativos e convites revogados ou expirados deste tenant.
|
||||
</div>
|
||||
</div>
|
||||
@@ -1444,7 +1441,7 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div v-if="loadHistoryError" class="mb-3 rounded-2xl border border-red-200 bg-red-50 p-3 text-red-700 text-sm">
|
||||
<div v-if="loadHistoryError" class="mb-3 rounded-md border border-red-200 bg-red-50 p-3 text-red-700 text-[1rem]">
|
||||
{{ loadHistoryError }}
|
||||
</div>
|
||||
|
||||
@@ -1460,13 +1457,13 @@ onMounted(async () => {
|
||||
<Column header="Pessoa / Email" style="min-width: 18rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shrink-0">
|
||||
<div class="h-10 w-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shrink-0">
|
||||
<i :class="data.kind === 'member' ? 'pi pi-user opacity-70' : 'pi pi-envelope opacity-70'" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold truncate">{{ data.full_name || data.email || 'Sem identificação' }}</div>
|
||||
<div v-if="data.full_name" class="text-sm opacity-70 truncate">{{ data.email }}</div>
|
||||
<div class="text-xs opacity-50 font-mono truncate">
|
||||
<div v-if="data.full_name" class="text-[1rem] opacity-70 truncate">{{ data.email }}</div>
|
||||
<div class="text-[1rem] opacity-50 font-mono truncate">
|
||||
{{ data.kind === 'member' ? 'uid: ' + data.user_id : 'token: ' + data.token }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1502,10 +1499,10 @@ onMounted(async () => {
|
||||
|
||||
<Column header="Data" style="width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<div class="text-sm opacity-80">
|
||||
<div class="text-[1rem] opacity-80">
|
||||
<div>Criado: {{ formatDate(data.created_at) }}</div>
|
||||
<div v-if="data.revoked_at" class="text-xs text-red-500 mt-0.5">Revogado: {{ formatDate(data.revoked_at) }}</div>
|
||||
<div v-else-if="data.expires_at && data.kind !== 'member'" class="text-xs opacity-60 mt-0.5">Expirou: {{ formatDate(data.expires_at) }}</div>
|
||||
<div v-if="data.revoked_at" class="text-[1rem] text-red-500 mt-0.5">Revogado: {{ formatDate(data.revoked_at) }}</div>
|
||||
<div v-else-if="data.expires_at && data.kind !== 'member'" class="text-[1rem] opacity-60 mt-0.5">Expirou: {{ formatDate(data.expires_at) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
@@ -1532,9 +1529,9 @@ onMounted(async () => {
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<small class="block mt-3 opacity-70">
|
||||
<div class="text-[1rem] block mt-3 opacity-70">
|
||||
Membros inativos podem ser reativados a qualquer momento. Convites revogados/expirados são apenas registro histórico.
|
||||
</small>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1548,10 +1545,9 @@ onMounted(async () => {
|
||||
dismissableMask
|
||||
:style="{ width: 'min(520px, 94vw)' }"
|
||||
:header="inviteHeader"
|
||||
class="rounded-[2rem] overflow-hidden"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="text-sm opacity-80 leading-relaxed">
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
|
||||
Informe o email. Este fluxo cria um convite pendente (Modelo B) e só ativa o vínculo após o aceite em
|
||||
<span class="font-mono">/accept-invite</span>.
|
||||
</div>
|
||||
@@ -1577,7 +1573,7 @@ onMounted(async () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="invite.error" class="rounded-2xl border border-red-200 bg-red-50 p-3 text-red-700 text-sm">
|
||||
<div v-if="invite.error" class="rounded-md border border-red-200 bg-red-50 p-3 text-[1rem] text-red-700">
|
||||
{{ invite.error }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1586,4 +1582,8 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.prof-datatable :deep(tr.row-new-highlight td) { background-color: #f0fdf4 !important; }
|
||||
.invites-datatable :deep(tr.row-new-highlight td) { background-color: #f0fdf4 !important; }
|
||||
:global(.app-dark) .prof-datatable :deep(tr.row-new-highlight td) { background-color: rgba(16,185,129,0.08) !important; }
|
||||
:global(.app-dark) .invites-datatable :deep(tr.row-new-highlight td) { background-color: rgba(16,185,129,0.08) !important; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user