Setup Wizard

This commit is contained in:
Leonardo
2026-03-14 19:09:44 -03:00
parent 587079e414
commit ee09b30987
16 changed files with 25276 additions and 62 deletions

View File

@@ -15,7 +15,8 @@
"Bash(where python:*)",
"Bash(cd \"/d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai\" && C:/Users/lmnohama/AppData/Local/Programs/Python/Python310/python.exe -c \"\nimport zipfile\nimport xml.etree.ElementTree as ET\n\nfor fname in ['spec-wizard.docx', 'spec-v2.docx']:\n print\\('=== ' + fname + ' ==='\\)\n try:\n with zipfile.ZipFile\\(fname, 'r'\\) as z:\n with z.open\\('word/document.xml'\\) as f:\n tree = ET.parse\\(f\\)\n root = tree.getroot\\(\\)\n texts = []\n for para in root.iter\\('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p'\\):\n parts = []\n for t in para.iter\\('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t'\\):\n if t.text:\n parts.append\\(t.text\\)\n line = ''.join\\(parts\\)\n texts.append\\(line\\)\n print\\('\\\\n'.join\\(texts\\)\\)\n except Exception as e:\n print\\('Error: ' + str\\(e\\)\\)\n print\\(\\)\n\")",
"Bash(C:/Users/lmnohama/AppData/Local/Programs/Python/Python310/python.exe -c \"\nimport sys\nwith open\\('/d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/DBS/2026-03-12/schema.sql', 'r', encoding='utf-8'\\) as f:\n lines = f.readlines\\(\\)\nprint\\(f'Total lines: {len\\(lines\\)}'\\)\n\" 2>&1)",
"Bash(C:/Users/lmnohama/AppData/Local/Programs/Python/Python310/python.exe -c \"\nimport sys\nfpath = 'D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/DBS/2026-03-12/schema.sql'\nwith open\\(fpath, 'r', encoding='utf-8'\\) as f:\n lines = f.readlines\\(\\)\nsys.stdout.buffer.write\\(\\('Total lines: ' + str\\(len\\(lines\\)\\) + '\\\\n'\\).encode\\('utf-8'\\)\\)\n\" 2>&1)"
"Bash(C:/Users/lmnohama/AppData/Local/Programs/Python/Python310/python.exe -c \"\nimport sys\nfpath = 'D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/DBS/2026-03-12/schema.sql'\nwith open\\(fpath, 'r', encoding='utf-8'\\) as f:\n lines = f.readlines\\(\\)\nsys.stdout.buffer.write\\(\\('Total lines: ' + str\\(len\\(lines\\)\\) + '\\\\n'\\).encode\\('utf-8'\\)\\)\n\" 2>&1)",
"Bash(find /d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai -type f \\\\\\( -name \"*convenio*\" -o -name \"*Convenio*\" \\\\\\) 2>/dev/null | head -20)"
]
}
}

21773
DBS/2026-03-14/schema.sql Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
<script setup>
import { onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
@@ -8,6 +8,7 @@ import AjudaDrawer from '@/components/AjudaDrawer.vue'
import { fetchDocsForPath } from '@/composables/useAjuda'
const route = useRoute()
const router = useRouter()
const tenantStore = useTenantStore()
const entStore = useEntitlementsStore()
@@ -23,6 +24,36 @@ function isSaasArea (path = '') {
return path.startsWith('/saas')
}
// ── Setup Wizard redirect ────────────────────────────────────────
async function checkSetupWizard () {
// Só verifica em área de tenant
if (!isTenantArea(route.path)) return
// Não redireciona se já está no setup
if (route.path.includes('/setup')) return
const uid = tenantStore.user?.id
if (!uid) return
const { data } = await supabase
.from('agenda_configuracoes')
.select('setup_concluido, setup_clinica_concluido')
.eq('owner_id', uid)
.maybeSingle()
if (!data) return
// Determina o kind do tenant ativo para saber qual flag checar
const activeMembership = tenantStore.memberships?.find(m => m.id === tenantStore.activeTenantId)
const kind = activeMembership?.kind ?? tenantStore.activeRole ?? ''
const isClinic = kind.startsWith('clinic')
const setupDone = isClinic ? data.setup_clinica_concluido : data.setup_concluido
if (!setupDone) {
const dest = isClinic ? '/admin/setup' : '/therapist/setup'
router.push(dest)
}
}
async function debugSnapshot (label = 'snapshot') {
console.group(`🧭 [APP DEBUG] ${label}`)
try {
@@ -96,6 +127,9 @@ async function debugSnapshot (label = 'snapshot') {
console.log('tenantStore.memberships:', tenantStore.memberships)
console.log("entStore.can('online_scheduling.manage'):", entStore.can?.('online_scheduling.manage'))
console.groupEnd()
// Redireciona para o wizard se setup não foi concluído
await checkSetupWizard()
} else if (isPortalArea(path)) {
console.log('🟣 Portal area detected → SKIP tenantStore.loadSessionAndTenant()')
} else if (isSaasArea(path)) {
@@ -128,6 +162,10 @@ watch(
await debugSnapshot(`route change: ${from} -> ${to}`)
// Atualiza docs de ajuda ao navegar
fetchDocsForPath(route.path)
// Verifica setup sempre que entrar em área tenant
if (isTenantArea(route.path) && tenantStore.loaded) {
await checkSetupWizard()
}
}
)
</script>

View File

@@ -1704,7 +1704,7 @@ async function onDialogSave (arg) {
start_time: recorrencia.horaInicio ?? startDate.toTimeString().slice(0, 8),
end_time: _addMinutesToTime(recorrencia.horaInicio ?? startDate.toTimeString().slice(0, 5), recorrencia.duracaoMin ?? 50),
duration_min: recorrencia.duracaoMin ?? 50,
timezone: 'America/Sao_Paulo',
timezone: settings.value?.timezone || 'America/Sao_Paulo',
start_date: basePayload.inicio_em?.slice(0, 10),
end_date: recorrencia.dataFim ? new Date(recorrencia.dataFim).toISOString().slice(0, 10) : null,
max_occurrences: recorrencia.qtdSessoes ?? null,
@@ -1844,7 +1844,7 @@ async function onDialogSave (arg) {
insurance_guide_number: basePayload.insurance_guide_number ?? null,
insurance_value: basePayload.insurance_value ?? null,
insurance_plan_service_id: basePayload.insurance_plan_service_id ?? null,
}
})
// Opção C — atualizar template e propagar para a nova sub-série
const serviceItemsE = arg.serviceItems

View File

@@ -1860,7 +1860,7 @@ async function onDialogSave (arg) {
start_time: recorrencia.horaInicio ?? startDate.toTimeString().slice(0, 8),
end_time: _addMinutesToTime(recorrencia.horaInicio ?? startDate.toTimeString().slice(0, 5), recorrencia.duracaoMin ?? 50),
duration_min: recorrencia.duracaoMin ?? 50,
timezone: 'America/Sao_Paulo',
timezone: settings.value?.timezone || 'America/Sao_Paulo',
start_date: firstRecISO,
end_date: recorrencia.dataFim ? new Date(recorrencia.dataFim).toISOString().slice(0, 10) : null,
max_occurrences: recorrencia.qtdSessoes ?? null,

File diff suppressed because it is too large Load Diff

View File

@@ -100,8 +100,14 @@ onBeforeUnmount(() => {
</script>
<template>
<!-- Fullscreen: setup wizard (sem sidebar/topbar/footer) -->
<template v-if="route.meta?.fullscreen">
<router-view />
<Toast />
</template>
<!-- Layout 2: Rail + Painel + Main (full-width) -->
<template v-if="layoutConfig.variant === 'rail' && isDesktop()">
<template v-else-if="layoutConfig.variant === 'rail' && isDesktop()">
<div class="l2-root">
<AppRail />
<div class="l2-body">

View File

@@ -38,9 +38,21 @@ const cfg = ref({
online_ativo: false,
setup_clinica_concluido: false,
setup_clinica_concluido_em: null,
jornada_igual_todos: true
jornada_igual_todos: true,
timezone: 'America/Sao_Paulo',
is_conveniado: false,
})
const timezones = [
{ label: 'Brasília (BRT)', value: 'America/Sao_Paulo' },
{ label: 'Manaus (AMT)', value: 'America/Manaus' },
{ label: 'Fortaleza (BRT)', value: 'America/Fortaleza' },
{ label: 'Belém (BRT)', value: 'America/Belem' },
{ label: 'Rio Branco (ACT)', value: 'America/Rio_Branco' },
{ label: 'Noronha (FNT)', value: 'America/Noronha' },
{ label: 'Lisboa (WET)', value: 'Europe/Lisbon' },
]
// ── Jornada ────────────────────────────────────────────────────
const regras = ref([])
const workDays = ref({ 1: false, 2: false, 3: false, 4: false, 5: false, 6: false, 0: false })
@@ -86,7 +98,8 @@ const diasSemana = [
{ label: 'Domingo', short: 'Dom', value: 0 }
]
const selectedDays = computed(() => diasSemana.filter(d => !!workDays.value[d.value]))
const selectedDays = computed(() => diasSemana.filter(d => !!workDays.value[d.value]))
const selectedWeekdays = computed(() => diasSemana.filter(d => d.value >= 1 && d.value <= 5 && !!workDays.value[d.value]))
function hhmmToMin (hhmm) {
const [h, m] = String(hhmm).split(':').map(Number)
@@ -196,7 +209,10 @@ const orphanSlotDays = computed(() => {
watch([selectedDays, jornadaStart, jornadaEnd], () => {
if (!isValidHHMM(jornadaStart.value) || !isValidHHMM(jornadaEnd.value)) return
if (jornadaIgualTodos.value !== false) {
selectedDays.value.forEach(d => { jornadaPorDia.value[d.value] = { inicio: jornadaStart.value, fim: jornadaEnd.value } })
// Sync apenas SegSex; Sáb e Dom têm horário próprio
selectedDays.value
.filter(d => d.value >= 1 && d.value <= 5)
.forEach(d => { jornadaPorDia.value[d.value] = { inicio: jornadaStart.value, fim: jornadaEnd.value } })
}
}, { immediate: true })
@@ -208,10 +224,15 @@ function getPausasForDay (dayValue) {
// ── Toggle igual/diferente ─────────────────────────────────────
function switchToIgual () {
// Copia global para todos os dias (zera divergências por dia)
if (isValidHHMM(jornadaStart.value) && isValidHHMM(jornadaEnd.value)) {
selectedDays.value.forEach(d => {
jornadaPorDia.value[d.value] = { inicio: jornadaStart.value, fim: jornadaEnd.value }
// Sync apenas SegSex; Sáb e Dom mantêm horário próprio
selectedDays.value
.filter(d => d.value >= 1 && d.value <= 5)
.forEach(d => { jornadaPorDia.value[d.value] = { inicio: jornadaStart.value, fim: jornadaEnd.value } })
// Inicializa Sáb/Dom com horário global se ainda não tiver valor
;[6, 0].forEach(v => {
if (workDays.value[v] && !jornadaPorDia.value[v]?.inicio)
jornadaPorDia.value[v] = { inicio: jornadaStart.value, fim: jornadaEnd.value }
})
}
// Copia pausas globais para todos os dias e usa apenas pausasGlobais
@@ -423,6 +444,7 @@ async function saveJornada () {
tenant_id: tenantId,
pausas_semanais: pausasToSave,
jornada_igual_todos: igualTodos,
timezone: cfg.value.timezone || 'America/Sao_Paulo',
setup_clinica_concluido: true,
setup_clinica_concluido_em: cfg.value.setup_clinica_concluido_em || new Date().toISOString()
}, { onConflict: 'owner_id' })
@@ -431,7 +453,8 @@ async function saveJornada () {
// 2. regras semanais
const rows = selectedDays.value.map(d => {
const t = jornadaIgualTodos.value === false
const isWeekend = d.value === 6 || d.value === 0
const t = (jornadaIgualTodos.value === false || isWeekend)
? (jornadaPorDia.value[d.value] || { inicio: jornadaStart.value, fim: jornadaEnd.value })
: { inicio: jornadaStart.value, fim: jornadaEnd.value }
return { owner_id: uid, tenant_id: tenantId, dia_semana: d.value, hora_inicio: normalizeTime(t.inicio), hora_fim: normalizeTime(t.fim), modalidade: 'ambos', ativo: true }
@@ -797,7 +820,19 @@ const jornadaEndDate = computed({
<div v-show="expandedCard === 'jornada'" class="cfg-card__body">
<div class="border-t border-[var(--surface-border)] pt-4">
<!-- Início das sessões (alinhamento de horário) -->
<!-- Fuso horário -->
<div class="mb-5">
<div class="cfg-label mb-2">Fuso horário</div>
<Select
v-model="cfg.timezone"
:options="timezones"
optionLabel="label"
optionValue="value"
class="w-full max-w-xs"
placeholder="Selecione o fuso..."
/>
</div>
<!-- Dias da semana -->
<div class="mb-5">
<div class="cfg-label mb-2">Quais dias você trabalha?</div>
@@ -837,23 +872,77 @@ const jornadaEndDate = computed({
</div>
<!-- Igual para todos -->
<div v-if="jornadaIgualTodos !== false" class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<span class="text-sm text-[var(--text-color-secondary)]">Das</span>
<div class="w-32">
<DatePicker v-model="jornadaStartDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false">
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
</template>
</DatePicker>
<div v-if="jornadaIgualTodos !== false" class="flex flex-col gap-3">
<!-- SegSex -->
<div v-if="selectedWeekdays.length > 0" class="cfg-equal-group">
<div class="cfg-equal-chips">
<span v-for="d in selectedWeekdays" :key="d.value" class="day-chip day-chip--active">{{ d.short }}</span>
</div>
<span class="text-sm text-[var(--text-color-secondary)]">até</span>
<div class="w-32">
<DatePicker v-model="jornadaEndDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false">
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
</template>
</DatePicker>
<div class="flex items-center gap-2">
<span class="text-sm text-[var(--text-color-secondary)]">Das</span>
<div class="w-32">
<DatePicker v-model="jornadaStartDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false">
<template #inputicon="slotProps"><i class="pi pi-clock" @click="slotProps.clickCallback" /></template>
</DatePicker>
</div>
<span class="text-sm text-[var(--text-color-secondary)]">até</span>
<div class="w-32">
<DatePicker v-model="jornadaEndDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false">
<template #inputicon="slotProps"><i class="pi pi-clock" @click="slotProps.clickCallback" /></template>
</DatePicker>
</div>
</div>
</div>
<!-- Sábado -->
<div v-if="workDays[6]" class="cfg-equal-group">
<div class="cfg-equal-chips">
<span class="day-chip day-chip--active">Sáb</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-[var(--text-color-secondary)]">Das</span>
<div class="w-32">
<DatePicker
:modelValue="hhmmToDate(jornadaPorDia[6]?.inicio)"
@update:modelValue="v => { const h = dateToHHMM(v); if (h) jornadaPorDia[6] = { ...jornadaPorDia[6], inicio: h } }"
showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false">
<template #inputicon="slotProps"><i class="pi pi-clock" @click="slotProps.clickCallback" /></template>
</DatePicker>
</div>
<span class="text-sm text-[var(--text-color-secondary)]">até</span>
<div class="w-32">
<DatePicker
:modelValue="hhmmToDate(jornadaPorDia[6]?.fim)"
@update:modelValue="v => { const h = dateToHHMM(v); if (h) jornadaPorDia[6] = { ...jornadaPorDia[6], fim: h } }"
showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false">
<template #inputicon="slotProps"><i class="pi pi-clock" @click="slotProps.clickCallback" /></template>
</DatePicker>
</div>
</div>
</div>
<!-- Domingo -->
<div v-if="workDays[0]" class="cfg-equal-group">
<div class="cfg-equal-chips">
<span class="day-chip day-chip--active">Dom</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-[var(--text-color-secondary)]">Das</span>
<div class="w-32">
<DatePicker
:modelValue="hhmmToDate(jornadaPorDia[0]?.inicio)"
@update:modelValue="v => { const h = dateToHHMM(v); if (h) jornadaPorDia[0] = { ...jornadaPorDia[0], inicio: h } }"
showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false">
<template #inputicon="slotProps"><i class="pi pi-clock" @click="slotProps.clickCallback" /></template>
</DatePicker>
</div>
<span class="text-sm text-[var(--text-color-secondary)]">até</span>
<div class="w-32">
<DatePicker
:modelValue="hhmmToDate(jornadaPorDia[0]?.fim)"
@update:modelValue="v => { const h = dateToHHMM(v); if (h) jornadaPorDia[0] = { ...jornadaPorDia[0], fim: h } }"
showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false">
<template #inputicon="slotProps"><i class="pi pi-clock" @click="slotProps.clickCallback" /></template>
</DatePicker>
</div>
</div>
</div>
</div>
@@ -1306,6 +1395,8 @@ const jornadaEndDate = computed({
.day-chip:hover { border-color: var(--primary-color); }
.day-chip--active { background: var(--primary-color); border-color: var(--primary-color); color: #fff; }
.day-chip--sm { padding: .2rem .55rem; font-size: .75rem; }
.cfg-equal-group { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
.cfg-equal-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; min-width: 200px; }
/* ── Toggle opções ──────────────────────────────────────────── */
.toggle-opt {

View File

@@ -1,7 +1,18 @@
// src/router/routes.clinic.js
import AppLayout from '@/layout/AppLayout.vue'
export default {
export default [
// ======================================================
// 🚀 SETUP WIZARD — fora do AppLayout (fullscreen)
// ======================================================
{
path: '/admin/setup',
name: 'admin.setup',
component: () => import('@/features/setup/SetupWizardPage.vue'),
meta: { area: 'admin', requiresAuth: true, roles: ['clinic_admin'], fullscreen: true },
},
{
path: '/admin',
component: AppLayout,
@@ -15,6 +26,7 @@ export default {
roles: ['clinic_admin']
},
children: [
// ======================================================
// 📊 DASHBOARD
// ======================================================
@@ -194,4 +206,4 @@ export default {
}
}
]
}
}]

View File

@@ -1,13 +1,25 @@
// src/router/routes.therapist.js
import AppLayout from '@/layout/AppLayout.vue'
export default {
export default [
// ======================================================
// 🚀 SETUP WIZARD — fora do AppLayout (fullscreen)
// ======================================================
{
path: '/therapist/setup',
name: 'therapist.setup',
component: () => import('@/features/setup/SetupWizardPage.vue'),
meta: { area: 'therapist', requiresAuth: true, roles: ['therapist'], fullscreen: true },
},
{
path: '/therapist',
component: AppLayout,
meta: { area: 'therapist', requiresAuth: true, roles: ['therapist'] },
children: [
// ======================================================
// 📊 DASHBOARD
// ======================================================
@@ -142,4 +154,4 @@ export default {
component: () => import('@/views/pages/auth/SecurityPage.vue')
}
]
}
}]

View File

@@ -0,0 +1,216 @@
-- =========================================================
-- Agência PSI — ProfilePage — Novos campos (v9)
-- Referência: src/views/pages/account/ProfilePage.vue
--
-- Campos adicionados:
-- profiles.nickname — apelido / como a Agência PSI chama o usuário
-- profiles.work_description — categoria de trabalho (enum textual)
-- profiles.work_description_other — descrição livre quando work_description = 'outro'
-- profiles.bio — já existia; garantido aqui (idempotente)
-- profiles.phone — já existia; garantido aqui (idempotente)
-- profiles.site_url — endereço do site pessoal
-- profiles.social_instagram — perfil do Instagram
-- profiles.social_youtube — canal do YouTube
-- profiles.social_facebook — página do Facebook
-- profiles.social_x — usuário no X (Twitter)
-- profiles.social_custom — redes adicionais livres (array JSON)
-- profiles.language — já existia; garantido aqui (idempotente)
-- profiles.timezone — já existia; garantido aqui (idempotente)
-- profiles.notify_system_email — já existia; garantido aqui (idempotente)
-- profiles.notify_reminders — já existia; garantido aqui (idempotente)
-- profiles.notify_news — já existia; garantido aqui (idempotente)
-- =========================================================
-- ----------------------------------------------------------
-- 1. Campos de identidade / apresentação
-- ----------------------------------------------------------
-- Apelido preferido para comunicação interna
alter table public.profiles
add column if not exists nickname text;
-- Categoria de trabalho (valor do select no front)
-- Valores aceitos pelo front:
-- psicologo_clinico | psiquiatra | psicoterapeuta | neuropsicologo
-- psicologo_organizacional | psicologo_escolar | psicologo_hospitalar
-- psicologo_juridico | coach_mentor | terapeuta_holistico | outro
alter table public.profiles
add column if not exists work_description text;
-- Descrição livre — preenchido somente quando work_description = 'outro'
alter table public.profiles
add column if not exists work_description_other text;
-- Bio curta (front limita a 300 chars, mas a coluna aceita mais)
alter table public.profiles
add column if not exists bio text;
-- Telefone / WhatsApp (máscara: (99) 99999-9999)
alter table public.profiles
add column if not exists phone text;
-- ----------------------------------------------------------
-- 2. Sites e Redes Sociais
-- ----------------------------------------------------------
-- Endereço do site pessoal / profissional
alter table public.profiles
add column if not exists site_url text;
-- Instagram (ex: @handle ou URL completa)
alter table public.profiles
add column if not exists social_instagram text;
-- YouTube (ex: @canal ou URL completa)
alter table public.profiles
add column if not exists social_youtube text;
-- Facebook (ex: /pagina ou URL completa)
alter table public.profiles
add column if not exists social_facebook text;
-- X / Twitter (ex: @handle ou URL completa)
alter table public.profiles
add column if not exists social_x text;
-- Redes adicionais livres
-- Estrutura esperada: [{ "name": "TikTok", "url": "@handle" }, ...]
alter table public.profiles
add column if not exists social_custom jsonb not null default '[]'::jsonb;
-- ----------------------------------------------------------
-- 3. Preferências de idioma / fuso (já existiam, idempotente)
-- ----------------------------------------------------------
alter table public.profiles
add column if not exists language text not null default 'pt-BR';
alter table public.profiles
add column if not exists timezone text not null default 'America/Sao_Paulo';
-- ----------------------------------------------------------
-- 4. Preferências de notificação (já existiam, idempotente)
-- ----------------------------------------------------------
alter table public.profiles
add column if not exists notify_system_email boolean not null default true;
alter table public.profiles
add column if not exists notify_reminders boolean not null default true;
alter table public.profiles
add column if not exists notify_news boolean not null default false;
-- ----------------------------------------------------------
-- 5. Constraint opcional: work_description
-- Valida que o valor seja um dos aceitos pelo front
-- (remova se preferir flexibilidade total)
-- ----------------------------------------------------------
do $$
begin
if not exists (
select 1
from pg_constraint
where conname = 'profiles_work_description_check'
) then
alter table public.profiles
add constraint profiles_work_description_check
check (
work_description is null or
work_description in (
'psicologo_clinico',
'psiquiatra',
'psicoterapeuta',
'neuropsicologo',
'psicologo_organizacional',
'psicologo_escolar',
'psicologo_hospitalar',
'psicologo_juridico',
'coach_mentor',
'terapeuta_holistico',
'outro'
)
);
end if;
end $$;
-- ----------------------------------------------------------
-- 6. Índice em work_description (útil para filtros/relatórios)
-- ----------------------------------------------------------
create index if not exists profiles_work_description_idx
on public.profiles(work_description)
where work_description is not null;
-- ----------------------------------------------------------
-- 7. RLS — política de leitura/escrita do próprio usuário
-- (adiciona somente se a tabela já tiver RLS habilitado)
-- ----------------------------------------------------------
-- Habilita RLS caso ainda não esteja
alter table public.profiles enable row level security;
-- Leitura: cada usuário vê apenas seu próprio registro
do $$
begin
if not exists (
select 1 from pg_policies
where schemaname = 'public'
and tablename = 'profiles'
and policyname = 'profiles_select_own'
) then
create policy profiles_select_own
on public.profiles
for select
using (id = auth.uid());
end if;
end $$;
-- Atualização: cada usuário atualiza apenas seu próprio registro
do $$
begin
if not exists (
select 1 from pg_policies
where schemaname = 'public'
and tablename = 'profiles'
and policyname = 'profiles_update_own'
) then
create policy profiles_update_own
on public.profiles
for update
using (id = auth.uid());
end if;
end $$;
-- Inserção: apenas o próprio usuário pode criar seu perfil
do $$
begin
if not exists (
select 1 from pg_policies
where schemaname = 'public'
and tablename = 'profiles'
and policyname = 'profiles_insert_own'
) then
create policy profiles_insert_own
on public.profiles
for insert
with check (id = auth.uid());
end if;
end $$;
-- ----------------------------------------------------------
-- 8. Comentários nas colunas (documentação no banco)
-- ----------------------------------------------------------
comment on column public.profiles.nickname is 'Apelido preferido para comunicação interna (Agência PSI)';
comment on column public.profiles.work_description is 'Categoria de trabalho selecionada no perfil público';
comment on column public.profiles.work_description_other is 'Descrição livre quando work_description = ''outro''';
comment on column public.profiles.bio is 'Breve apresentação pública (máx 300 chars no front)';
comment on column public.profiles.phone is 'WhatsApp / telefone no formato (99) 99999-9999';
comment on column public.profiles.site_url is 'Endereço do site pessoal ou profissional';
comment on column public.profiles.social_instagram is 'Handle ou URL do Instagram';
comment on column public.profiles.social_youtube is 'Handle ou URL do canal no YouTube';
comment on column public.profiles.social_facebook is 'Handle ou URL da página no Facebook';
comment on column public.profiles.social_x is 'Handle ou URL do perfil no X (Twitter)';
comment on column public.profiles.social_custom is 'Array JSON com redes adicionais livres: [{name, url}]';

View File

@@ -0,0 +1,38 @@
-- =========================================================
-- Agência PSI — Hotfix constraint work_description (v9b)
--
-- Problema: a constraint criada em 09_profile_page_fields.sql
-- não aceitava string vazia ''. O front pode enviar '' quando
-- o campo não é preenchido, causando:
-- "new row for relation profiles violates check constraint
-- profiles_work_description_check"
--
-- Solução: recria a constraint aceitando NULL ou '' ou os
-- valores válidos do enum. O código Vue também foi corrigido
-- para converter '' → NULL antes de salvar.
-- =========================================================
-- Remove a constraint antiga
alter table public.profiles
drop constraint if exists profiles_work_description_check;
-- Recria aceitando NULL, '' e os valores do enum
alter table public.profiles
add constraint profiles_work_description_check
check (
work_description is null
or work_description = ''
or work_description in (
'psicologo_clinico',
'psiquiatra',
'psicoterapeuta',
'neuropsicologo',
'psicologo_organizacional',
'psicologo_escolar',
'psicologo_hospitalar',
'psicologo_juridico',
'coach_mentor',
'terapeuta_holistico',
'outro'
)
);

View File

@@ -0,0 +1,4 @@
-- Remove definitivamente a constraint de work_description.
-- A validação dos valores é feita no front-end (Select com opções fixas).
alter table public.profiles
drop constraint if exists profiles_work_description_check;

View File

@@ -0,0 +1,156 @@
-- =========================================================
-- Agência PSI — user_settings (v10)
-- Tabela de preferências de aparência por usuário.
--
-- Colunas usadas pelo ProfilePage.vue:
-- user_id — FK para auth.users (PK da tabela)
-- theme_mode — 'light' | 'dark'
-- preset — 'Aura' | 'Lara' | 'Nora'
-- primary_color — nome da cor primária (ex: 'blue', 'emerald')
-- surface_color — nome da surface (ex: 'slate', 'zinc')
-- menu_mode — 'static' | 'overlay'
-- layout_variant — 'classic' | 'rail'
-- updated_at — atualizado automaticamente via trigger
-- =========================================================
-- ----------------------------------------------------------
-- 1. Cria a tabela (idempotente)
-- ----------------------------------------------------------
create table if not exists public.user_settings (
user_id uuid primary key references auth.users(id) on delete cascade,
theme_mode text not null default 'light',
preset text not null default 'Aura',
primary_color text not null default 'noir',
surface_color text not null default 'slate',
menu_mode text not null default 'static',
layout_variant text not null default 'classic',
updated_at timestamptz not null default now()
);
-- ----------------------------------------------------------
-- 2. Adiciona colunas que possam estar faltando
-- (caso a tabela já exista sem elas)
-- ----------------------------------------------------------
alter table public.user_settings
add column if not exists theme_mode text not null default 'light';
alter table public.user_settings
add column if not exists preset text not null default 'Aura';
alter table public.user_settings
add column if not exists primary_color text not null default 'noir';
alter table public.user_settings
add column if not exists surface_color text not null default 'slate';
alter table public.user_settings
add column if not exists menu_mode text not null default 'static';
alter table public.user_settings
add column if not exists layout_variant text not null default 'classic';
alter table public.user_settings
add column if not exists updated_at timestamptz not null default now();
-- ----------------------------------------------------------
-- 3. Constraints de valor (idempotentes)
-- ----------------------------------------------------------
do $$
begin
if not exists (
select 1 from pg_constraint where conname = 'user_settings_theme_mode_check'
) then
alter table public.user_settings
add constraint user_settings_theme_mode_check
check (theme_mode in ('light', 'dark'));
end if;
end $$;
do $$
begin
if not exists (
select 1 from pg_constraint where conname = 'user_settings_menu_mode_check'
) then
alter table public.user_settings
add constraint user_settings_menu_mode_check
check (menu_mode in ('static', 'overlay'));
end if;
end $$;
do $$
begin
if not exists (
select 1 from pg_constraint where conname = 'user_settings_layout_variant_check'
) then
alter table public.user_settings
add constraint user_settings_layout_variant_check
check (layout_variant in ('classic', 'rail'));
end if;
end $$;
-- ----------------------------------------------------------
-- 4. Trigger updated_at
-- ----------------------------------------------------------
drop trigger if exists t_user_settings_set_updated_at on public.user_settings;
create trigger t_user_settings_set_updated_at
before update on public.user_settings
for each row execute function public.set_updated_at();
-- ----------------------------------------------------------
-- 5. RLS
-- ----------------------------------------------------------
alter table public.user_settings enable row level security;
do $$
begin
if not exists (
select 1 from pg_policies
where schemaname = 'public'
and tablename = 'user_settings'
and policyname = 'user_settings_select_own'
) then
create policy user_settings_select_own
on public.user_settings for select
using (user_id = auth.uid());
end if;
end $$;
do $$
begin
if not exists (
select 1 from pg_policies
where schemaname = 'public'
and tablename = 'user_settings'
and policyname = 'user_settings_insert_own'
) then
create policy user_settings_insert_own
on public.user_settings for insert
with check (user_id = auth.uid());
end if;
end $$;
do $$
begin
if not exists (
select 1 from pg_policies
where schemaname = 'public'
and tablename = 'user_settings'
and policyname = 'user_settings_update_own'
) then
create policy user_settings_update_own
on public.user_settings for update
using (user_id = auth.uid());
end if;
end $$;
-- ----------------------------------------------------------
-- 6. Comentários
-- ----------------------------------------------------------
comment on table public.user_settings is 'Preferências de aparência e layout por usuário';
comment on column public.user_settings.user_id is 'FK = auth.users.id — um registro por usuário';
comment on column public.user_settings.theme_mode is 'light | dark';
comment on column public.user_settings.preset is 'Preset PrimeVue: Aura | Lara | Nora';
comment on column public.user_settings.primary_color is 'Nome da cor primária (ex: blue, emerald, noir)';
comment on column public.user_settings.surface_color is 'Nome da surface (ex: slate, zinc, neutral)';
comment on column public.user_settings.menu_mode is 'static | overlay';
comment on column public.user_settings.layout_variant is 'classic (sidebar) | rail (mini rail + painel)';

View File

@@ -0,0 +1,41 @@
-- =========================================================
-- Agência PSI — agenda_configuracoes: timezone + is_conveniado (v11)
--
-- Mudanças:
-- 1. Garante que a coluna `timezone` existe com default 'America/Sao_Paulo'
-- (anteriormente configurada apenas no SetupWizard — agora gerenciada
-- em Configurações → Agenda).
-- 2. Normaliza linhas existentes com timezone NULL ou vazio.
-- 3. Adiciona coluna `is_conveniado` para indicar se o profissional
-- atende por convênio (usado no SetupWizard e no agendador).
-- =========================================================
-- ----------------------------------------------------------
-- 1. Coluna timezone — cria se não existir, define default
-- ----------------------------------------------------------
alter table public.agenda_configuracoes
add column if not exists timezone text not null default 'America/Sao_Paulo';
-- Garante que o default continua correto mesmo se a coluna já existia
alter table public.agenda_configuracoes
alter column timezone set default 'America/Sao_Paulo';
-- Normaliza linhas com timezone vazio ou NULL (seguro pois coluna é NOT NULL)
update public.agenda_configuracoes
set timezone = 'America/Sao_Paulo'
where timezone is null or trim(timezone) = '';
-- ----------------------------------------------------------
-- 2. Coluna is_conveniado — cria se não existir
-- ----------------------------------------------------------
alter table public.agenda_configuracoes
add column if not exists is_conveniado boolean not null default false;
-- ----------------------------------------------------------
-- 3. Comentários
-- ----------------------------------------------------------
comment on column public.agenda_configuracoes.timezone
is 'Fuso horário do profissional/clínica. Ex: America/Sao_Paulo (Brasília). Gerenciado em Configurações → Agenda.';
comment on column public.agenda_configuracoes.is_conveniado
is 'true = profissional atende por convênio (insurance_plans); false = particular';

View File

@@ -123,8 +123,9 @@
<div class="prof-card__sep" />
<div class="grid grid-cols-12 gap-4">
<!-- Nome -->
<div class="col-span-12 md:col-span-7">
<!-- Nome completo -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<InputText id="prof_name" v-model="form.full_name" class="w-full" autocomplete="name" @input="markDirty" />
<label for="prof_name">Nome completo</label>
@@ -132,8 +133,34 @@
<small class="prof-hint">Aparece no menu, cabeçalhos e registros.</small>
</div>
<!-- Como a Agência PSI deveria te chamar? -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<InputText id="prof_nickname" v-model="form.nickname" class="w-full" autocomplete="nickname" @input="markDirty" />
<label for="prof_nickname">Como a Agência PSI deveria te chamar?</label>
</FloatLabel>
<small class="prof-hint">Apelido ou nome preferido para comunicação.</small>
</div>
<!-- O que melhor descreve seu trabalho? -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<Select
id="prof_work_desc"
v-model="form.work_description"
:options="workDescriptionOptions"
optionLabel="label"
optionValue="value"
class="w-full"
@change="markDirty"
/>
<label for="prof_work_desc">O que melhor descreve seu trabalho?</label>
</FloatLabel>
<small class="prof-hint">Exibido no seu perfil público.</small>
</div>
<!-- E-mail -->
<div class="col-span-12 md:col-span-5">
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<InputText id="prof_email" :modelValue="userEmail" class="w-full" disabled />
<label for="prof_email">E-mail</label>
@@ -141,27 +168,44 @@
<small class="prof-hint">Gerenciado pelo Supabase Auth.</small>
</div>
<!-- Informe seu trabalho (somente quando 'outro') -->
<Transition name="prof-slide">
<div v-if="form.work_description === 'outro'" class="col-span-12">
<FloatLabel variant="on">
<InputText
id="prof_work_other"
v-model="form.work_description_other"
class="w-full"
autocomplete="off"
@input="markDirty"
/>
<label for="prof_work_other">Informe qual é o seu trabalho</label>
</FloatLabel>
<small class="prof-hint">Descreva brevemente sua atuação profissional.</small>
</div>
</Transition>
<!-- Bio -->
<div class="col-span-12 md:col-span-7">
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<Textarea
id="prof_bio"
v-model="form.bio"
class="w-full"
rows="5"
maxlength="2000"
maxlength="300"
@input="markDirty"
/>
<label for="prof_bio">Bio</label>
</FloatLabel>
<div class="prof-hint flex justify-between">
<span>Breve descrição sobre você.</span>
<span>{{ (form.bio || '').length }}/2000</span>
<span>{{ (form.bio || '').length }}/300</span>
</div>
</div>
<!-- Telefone -->
<div class="col-span-12 md:col-span-5">
<!-- Whatsapp -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<InputMask
id="prof_phone"
@@ -171,20 +215,195 @@
:autoClear="false"
@update:modelValue="markDirty"
/>
<label for="prof_phone">Telefone</label>
<label for="prof_phone">Whatsapp</label>
</FloatLabel>
<small class="prof-hint">Opcional.</small>
</div>
</div>
</div>
<!-- 02 AVATAR -->
<!-- 02 SITES E REDES SOCIAIS -->
<div
id="redes-sociais"
class="prof-card scroll-mt-20"
style="--c:#E879F9;--c-dim:rgba(232,121,249,0.08);--c-border:rgba(232,121,249,0.2)"
>
<div class="prof-card__num">02</div>
<div class="prof-card__shine" />
<div class="prof-card__head">
<div class="prof-card__icon"><i class="pi pi-share-alt" /></div>
<span class="prof-card__tag">Sites e Redes Sociais</span>
</div>
<div class="prof-card__title">Seus Sites e Redes Sociais</div>
<div class="prof-card__subtitle">Links exibidos no seu perfil público.</div>
<div class="prof-card__sep" />
<div class="grid grid-cols-12 gap-4">
<!-- Site -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-globe" />
<InputText
id="prof_site"
v-model="form.site_url"
class="w-full"
type="url"
@input="markDirty"
/>
</IconField>
<label for="prof_site">Endereço do site</label>
</FloatLabel>
<small class="prof-hint">Ex: https://seuperfil.com.br</small>
</div>
<!-- Instagram -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-instagram" />
<InputText
id="prof_instagram"
v-model="form.social_instagram"
class="w-full"
@input="markDirty"
/>
</IconField>
<label for="prof_instagram">Instagram</label>
</FloatLabel>
<small class="prof-hint">Ex: @seuperfil</small>
</div>
<!-- Youtube -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-youtube" />
<InputText
id="prof_youtube"
v-model="form.social_youtube"
class="w-full"
@input="markDirty"
/>
</IconField>
<label for="prof_youtube">YouTube</label>
</FloatLabel>
<small class="prof-hint">Ex: @seucanal</small>
</div>
<!-- Facebook -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-facebook" />
<InputText
id="prof_facebook"
v-model="form.social_facebook"
class="w-full"
@input="markDirty"
/>
</IconField>
<label for="prof_facebook">Facebook</label>
</FloatLabel>
<small class="prof-hint">Ex: /suapagina</small>
</div>
<!-- X -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-twitter" />
<InputText
id="prof_x"
v-model="form.social_x"
class="w-full"
@input="markDirty"
/>
</IconField>
<label for="prof_x">X (Twitter)</label>
</FloatLabel>
<small class="prof-hint">Ex: @seuuser</small>
</div>
</div>
<!-- Outras redes -->
<div class="mt-5">
<div class="flex items-center justify-between mb-3">
<div>
<div class="text-sm font-semibold text-[var(--text-color)]">Outras redes ou links</div>
<div class="text-xs text-[var(--text-color-secondary)]">Adicione qualquer outra rede social, podcast, link ou perfil.</div>
</div>
<Button
icon="pi pi-plus"
label="Adicionar"
severity="secondary"
size="small"
outlined
class="rounded-full"
@click="addCustomSocial"
/>
</div>
<div v-if="customSocials.length" class="flex flex-col gap-3">
<div
v-for="(item, idx) in customSocials"
:key="idx"
class="flex items-center gap-2"
>
<FloatLabel variant="on" class="flex-1">
<InputText
:id="`prof_cs_name_${idx}`"
v-model="item.name"
class="w-full"
@input="markDirty"
/>
<label :for="`prof_cs_name_${idx}`">Nome da rede</label>
</FloatLabel>
<FloatLabel variant="on" class="flex-[2]">
<IconField>
<InputIcon class="pi pi-link" />
<InputText
:id="`prof_cs_url_${idx}`"
v-model="item.url"
class="w-full"
@input="markDirty"
/>
</IconField>
<label :for="`prof_cs_url_${idx}`">URL / usuário</label>
</FloatLabel>
<Button
icon="pi pi-trash"
severity="danger"
text
rounded
size="small"
v-tooltip.top="'Remover'"
@click="removeCustomSocial(idx)"
/>
</div>
</div>
<p v-else class="text-xs text-[var(--text-color-secondary)] italic mt-1">
Nenhuma rede adicional cadastrada ainda.
</p>
</div>
</div>
<!-- 03 AVATAR -->
<div
id="avatar"
class="prof-card scroll-mt-20"
style="--c:#4ADE80;--c-dim:rgba(74,222,128,0.08);--c-border:rgba(74,222,128,0.2)"
>
<div class="prof-card__num">02</div>
<div class="prof-card__num">03</div>
<div class="prof-card__shine" />
<div class="prof-card__head">
@@ -272,7 +491,7 @@
class="prof-card scroll-mt-20"
style="--c:#A78BFA;--c-dim:rgba(167,139,250,0.08);--c-border:rgba(167,139,250,0.2)"
>
<div class="prof-card__num">03</div>
<div class="prof-card__num">04</div>
<div class="prof-card__shine" />
<div class="prof-card__head">
@@ -404,7 +623,7 @@
class="prof-card scroll-mt-20"
style="--c:#FB923C;--c-dim:rgba(251,146,60,0.08);--c-border:rgba(251,146,60,0.2)"
>
<div class="prof-card__num">04</div>
<div class="prof-card__num">05</div>
<div class="prof-card__shine" />
<div class="prof-card__head">
@@ -526,13 +745,13 @@
</div>
</div>
<!-- 05 SEGURANÇA -->
<!-- 07 SEGURANÇA -->
<div
id="seguranca"
class="prof-card scroll-mt-20"
style="--c:#F87171;--c-dim:rgba(248,113,113,0.08);--c-border:rgba(248,113,113,0.2)"
>
<div class="prof-card__num">05</div>
<div class="prof-card__num">07</div>
<div class="prof-card__shine" />
<div class="prof-card__head">
@@ -623,6 +842,7 @@ const { setVariant } = _useLayout()
import Textarea from 'primevue/textarea'
import InputMask from 'primevue/inputmask'
import Checkbox from 'primevue/checkbox'
import Select from 'primevue/select'
import { supabase } from '@/lib/supabase/client'
import { useLayout } from '@/layout/composables/layout'
@@ -661,10 +881,19 @@ const ui = reactive({
// Perfil
const form = reactive({
full_name: '',
nickname: '',
work_description: '',
work_description_other: '',
avatar_url: '',
bio: '',
phone: '',
site_url: '',
social_instagram: '',
social_youtube: '',
social_facebook: '',
social_x: '',
language: 'pt-BR',
timezone: 'America/Sao_Paulo',
@@ -673,13 +902,41 @@ const form = reactive({
notify_news: false
})
const customSocials = ref([])
function addCustomSocial () {
customSocials.value.push({ name: '', url: '' })
markDirty()
}
function removeCustomSocial (idx) {
customSocials.value.splice(idx, 1)
markDirty()
}
const workDescriptionOptions = [
{ label: 'Psicólogo(a) Clínico(a)', value: 'psicologo_clinico' },
{ label: 'Psicanalista', value: 'psicanalista' },
{ label: 'Psiquiatra', value: 'psiquiatra' },
{ label: 'Psicoterapeuta', value: 'psicoterapeuta' },
{ label: 'Neuropsicólogo(a)', value: 'neuropsicologo' },
{ label: 'Psicólogo(a) Organizacional', value: 'psicologo_organizacional' },
{ label: 'Psicólogo(a) Escolar / Educacional', value: 'psicologo_escolar' },
{ label: 'Psicólogo(a) Hospitalar', value: 'psicologo_hospitalar' },
{ label: 'Psicólogo(a) Jurídico(a)', value: 'psicologo_juridico' },
{ label: 'Coach / Mentor(a)', value: 'coach_mentor' },
{ label: 'Terapeuta Holístico(a)', value: 'terapeuta_holistico' },
{ label: 'Outro', value: 'outro' },
]
const sections = [
{ id: 'conta', label: 'Conta', icon: 'pi pi-user' },
{ id: 'avatar', label: 'Avatar', icon: 'pi pi-image' },
{ id: 'layout', label: 'Aparência', icon: 'pi pi-palette' },
{ id: 'preferencias', label: 'Preferências', icon: 'pi pi-sliders-h' },
{ id: 'seguranca', label: 'Segurança', icon: 'pi pi-shield' },
{ id: 'layout-variant', label: 'Layout', icon: 'pi pi-th-large' }
{ id: 'conta', label: 'Conta', icon: 'pi pi-user' },
{ id: 'redes-sociais', label: 'Sites e Redes', icon: 'pi pi-share-alt' },
{ id: 'avatar', label: 'Avatar', icon: 'pi pi-image' },
{ id: 'layout', label: 'Aparência', icon: 'pi pi-palette' },
{ id: 'preferencias', label: 'Preferências', icon: 'pi pi-sliders-h' },
{ id: 'layout-variant', label: 'Layout', icon: 'pi pi-th-large' },
{ id: 'seguranca', label: 'Segurança', icon: 'pi pi-shield' },
]
const activeSection = ref('conta')
@@ -968,13 +1225,15 @@ async function loadUserSettings (uid) {
if (settings.surface_color && !safeEq(settings.surface_color, layoutConfig.surface)) layoutConfig.surface = settings.surface_color
if (settings.menu_mode && !safeEq(settings.menu_mode, layoutConfig.menuMode)) {
layoutConfig.menuMode = settings.menu_mode
try { changeMenuMode?.(settings.menu_mode) } catch {
try { changeMenuMode?.({ value: settings.menu_mode }) } catch {}
}
// Não chama changeMenuMode() — ela reseta staticMenuInactive e outros estados,
// fazendo a sidebar desaparecer ao entrar na página.
}
// layout variant
if (settings.layout_variant === 'rail' || settings.layout_variant === 'classic') {
// layout variant — só aplica se mudou, para não resetar o estado do layout
if (
(settings.layout_variant === 'rail' || settings.layout_variant === 'classic') &&
settings.layout_variant !== layoutConfig.variant
) {
setVariant(settings.layout_variant)
}
@@ -1026,7 +1285,7 @@ async function loadProfile () {
const { data: prof, error: pErr } = await supabase
.from('profiles')
.select('full_name, avatar_url, phone, bio, language, timezone, notify_system_email, notify_reminders, notify_news')
.select('full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom, language, timezone, notify_system_email, notify_reminders, notify_news')
.eq('id', user.id)
.maybeSingle()
@@ -1035,6 +1294,18 @@ async function loadProfile () {
form.avatar_url = prof.avatar_url ?? form.avatar_url
form.phone = prof.phone ?? ''
form.bio = prof.bio ?? ''
form.nickname = prof.nickname ?? ''
form.work_description = prof.work_description ?? ''
form.work_description_other = prof.work_description_other ?? ''
form.site_url = prof.site_url ?? ''
form.social_instagram = prof.social_instagram ?? ''
form.social_youtube = prof.social_youtube ?? ''
form.social_facebook = prof.social_facebook ?? ''
form.social_x = prof.social_x ?? ''
if (Array.isArray(prof.social_custom)) {
customSocials.value = prof.social_custom
}
form.language = prof.language ?? form.language
form.timezone = prof.timezone ?? form.timezone
@@ -1087,6 +1358,15 @@ async function saveAll () {
avatar_url: metaPayload.avatar_url,
phone: String(form.phone || '').trim() || null,
bio: String(form.bio || '').trim() || null,
nickname: String(form.nickname || '').trim() || null,
work_description: String(form.work_description || '').trim() || null,
work_description_other: form.work_description === 'outro' ? (String(form.work_description_other || '').trim() || null) : null,
site_url: String(form.site_url || '').trim() || null,
social_instagram: String(form.social_instagram || '').trim() || null,
social_youtube: String(form.social_youtube || '').trim() || null,
social_facebook: String(form.social_facebook || '').trim() || null,
social_x: String(form.social_x || '').trim() || null,
social_custom: customSocials.value.filter(s => s.name || s.url),
language: form.language || 'pt-BR',
timezone: form.timezone || 'America/Sao_Paulo',
@@ -1100,7 +1380,7 @@ async function saveAll () {
.from('profiles')
.update(profilePayload)
.eq('id', userId.value)
.select('id, role, full_name, avatar_url, phone, bio, language, timezone, notify_system_email, notify_reminders, notify_news, updated_at')
.select('id, role, full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom, language, timezone, notify_system_email, notify_reminders, notify_news, updated_at')
.single()
if (pErr2) {
@@ -1644,4 +1924,39 @@ onBeforeUnmount(() => {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
/* ─── Social addons ─────────────────────────────────────── */
.social-addon {
display: flex; align-items: center; justify-content: center;
width: 2.75rem; flex-shrink: 0;
font-size: 0.95rem;
}
.social-addon--site { color: var(--text-color-secondary); }
.social-addon--instagram { color: #E1306C; }
.social-addon--youtube { color: #FF0000; }
.social-addon--facebook { color: #1877F2; }
.social-addon--x { color: var(--text-color); }
/* FloatLabel com inputgroup: label offset pelo addon */
.social-float-label {
left: 2.85rem !important;
}
/* Fix FloatLabel wrapping inputgroup */
.p-floatlabel:has(.p-inputgroup) { display: block; }
.p-floatlabel .p-inputgroup { width: 100%; }
.p-floatlabel .p-inputgroup .p-inputtext {
border-radius: 0 var(--p-inputtext-border-radius, 8px) var(--p-inputtext-border-radius, 8px) 0 !important;
}
/* ─── Transition "outro" ────────────────────────────────── */
.prof-slide-enter-active,
.prof-slide-leave-active {
transition: opacity 0.2s ease, max-height 0.25s ease, margin 0.2s ease;
max-height: 6rem; overflow: hidden;
}
.prof-slide-enter-from,
.prof-slide-leave-to {
opacity: 0; max-height: 0; margin: 0;
}
</style>