Setup Wizard
This commit is contained in:
@@ -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
21773
DBS/2026-03-14/schema.sql
Normal file
File diff suppressed because it is too large
Load Diff
40
src/App.vue
40
src/App.vue
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
2511
src/features/setup/SetupWizardPage.vue
Normal file
2511
src/features/setup/SetupWizardPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||
|
||||
@@ -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 Seg–Sex; 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 Seg–Sex; 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">
|
||||
<!-- Seg–Sex -->
|
||||
<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 {
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}]
|
||||
@@ -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')
|
||||
}
|
||||
]
|
||||
}
|
||||
}]
|
||||
216
src/sql-arquivos/09_profile_page_fields.sql
Normal file
216
src/sql-arquivos/09_profile_page_fields.sql
Normal 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}]';
|
||||
38
src/sql-arquivos/09b_profile_work_description_fix.sql
Normal file
38
src/sql-arquivos/09b_profile_work_description_fix.sql
Normal 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'
|
||||
)
|
||||
);
|
||||
@@ -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;
|
||||
156
src/sql-arquivos/10_user_settings.sql
Normal file
156
src/sql-arquivos/10_user_settings.sql
Normal 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)';
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user