Agenda, Agendador, Configurações

This commit is contained in:
Leonardo
2026-03-12 08:58:36 -03:00
parent f733db8436
commit f4b185ae17
197 changed files with 33405 additions and 6507 deletions
+24 -9
View File
@@ -1,14 +1,24 @@
<script setup>
import { useLayout } from '@/layout/composables/layout'
import { computed, onMounted, onBeforeUnmount, provide } from 'vue'
import { computed, onMounted, onBeforeUnmount, provide, watch } from 'vue'
import { useRoute } from 'vue-router'
import AppFooter from './AppFooter.vue'
import AppSidebar from './AppSidebar.vue'
import AppTopbar from './AppTopbar.vue'
import AppRail from './AppRail.vue'
import AppRailPanel from './AppRailPanel.vue'
import AppRailTopbar from './AppRailTopbar.vue'
import AppFooter from './AppFooter.vue'
import AppSidebar from './AppSidebar.vue'
import AppTopbar from './AppTopbar.vue'
import AppRail from './AppRail.vue'
import AppRailPanel from './AppRailPanel.vue'
import AppRailTopbar from './AppRailTopbar.vue'
import AjudaDrawer from '@/components/AjudaDrawer.vue'
import { fetchDocsForPath, useAjuda } from '@/composables/useAjuda'
const { drawerOpen } = useAjuda()
const ajudaPushStyle = computed(() => ({
transition: 'padding-right 0.3s ease',
paddingRight: drawerOpen.value ? '420px' : '0'
}))
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
@@ -77,6 +87,9 @@ function onSessionRefreshed () {
revalidateAfterSessionRefresh()
}
// Dispara busca de docs de ajuda sempre que a rota muda
watch(() => route.path, (path) => fetchDocsForPath(path), { immediate: true })
onMounted(() => {
window.addEventListener('app:session-refreshed', onSessionRefreshed)
})
@@ -95,12 +108,13 @@ onBeforeUnmount(() => {
<AppRailTopbar />
<div class="l2-content">
<AppRailPanel />
<div class="l2-main">
<div class="l2-main" :style="ajudaPushStyle">
<router-view />
</div>
</div>
</div>
</div>
<AjudaDrawer />
<Toast />
</template>
@@ -109,7 +123,7 @@ onBeforeUnmount(() => {
<div class="layout-wrapper" :class="containerClass">
<AppTopbar />
<AppSidebar />
<div class="layout-main-container">
<div class="layout-main-container" :style="ajudaPushStyle">
<div class="layout-main">
<router-view />
</div>
@@ -117,6 +131,7 @@ onBeforeUnmount(() => {
</div>
<div class="layout-mask animate-fadein" @click="hideMobileMenu" />
</div>
<AjudaDrawer />
<Toast />
</template>
</template>
+13 -1
View File
@@ -6,10 +6,12 @@ import AppConfigurator from './AppConfigurator.vue'
import { useLayout } from '@/layout/composables/layout'
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence'
import { useTenantStore } from '@/stores/tenantStore'
import { useAjuda } from '@/composables/useAjuda'
const { toggleDarkMode, isDarkTheme } = useLayout()
const { queuePatch } = useUserSettingsPersistence()
const tenantStore = useTenantStore()
const { openDrawer: openAjudaDrawer } = useAjuda()
const tenantName = computed(() => {
const t =
@@ -64,6 +66,16 @@ async function toggleDarkAndPersist () {
<!-- Ações -->
<div class="rail-topbar__actions">
<!-- Ajuda -->
<button
type="button"
class="rail-topbar__btn"
title="Ajuda"
@click="openAjudaDrawer"
>
<i class="pi pi-question-circle" />
</button>
<!-- Dark mode -->
<button
type="button"
@@ -156,4 +168,4 @@ async function toggleDarkAndPersist () {
.rail-topbar__btn--highlight {
color: var(--primary-color);
}
</style>
</style>
+19
View File
@@ -15,6 +15,9 @@ import { useTenantStore } from '@/stores/tenantStore'
import { useRoleGuard } from '@/composables/useRoleGuard'
const { canSee } = useRoleGuard()
import { useAjuda } from '@/composables/useAjuda'
const { openDrawer: openAjudaDrawer } = useAjuda()
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence'
import { applyThemeEngine } from '@/theme/theme.options'
@@ -623,6 +626,15 @@ onMounted(async () => {
:baseZIndex="3000"
/>
<Button
icon="pi pi-question-circle"
label="Ajuda"
severity="secondary"
outlined
class="ajuda-btn"
@click="openAjudaDrawer"
/>
<button type="button" class="layout-topbar-action">
<i class="pi pi-calendar"></i>
<span>Calendar</span>
@@ -680,6 +692,13 @@ onMounted(async () => {
white-space: nowrap;
}
.ajuda-btn {
border-radius: 999px;
font-size: 0.8rem;
padding: 0.3rem 0.8rem;
height: 2rem;
}
.topbar-ctx-v {
font-size: 0.75rem;
opacity: 0.95;
-172
View File
@@ -1,172 +0,0 @@
<!-- src/layout/ConfiguracoesPage.vue -->
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const secoes = [
{
key: 'agenda',
label: 'Agenda',
desc: 'Horários semanais, exceções, duração e intervalo padrão.',
icon: 'pi pi-calendar',
to: '/configuracoes/agenda',
tags: ['Horários', 'Exceções', 'Duração']
},
// Ative quando criar as rotas/páginas
// {
// key: 'clinica',
// label: 'Clínica',
// desc: 'Padrões clínicos, status e preferências de atendimento.',
// icon: 'pi pi-heart',
// to: '/configuracoes/clinica',
// tags: ['Status', 'Modelos', 'Preferências']
// },
// {
// key: 'intake',
// label: 'Cadastros & Intake',
// desc: 'Link externo, campos do formulário e mensagens padrão.',
// icon: 'pi pi-file-edit',
// to: '/configuracoes/intake',
// tags: ['Formulário', 'Campos', 'Textos']
// },
// {
// key: 'conta',
// label: 'Conta',
// desc: 'Perfil, segurança e preferências da conta.',
// icon: 'pi pi-user',
// to: '/configuracoes/conta',
// tags: ['Perfil', 'Segurança', 'Preferências']
// }
]
const activeTo = computed(() => {
const p = route.path || ''
const hit = secoes.find(s => p.startsWith(s.to))
return hit?.to || '/configuracoes/agenda'
})
function ir(to) {
if (!to) return
if (route.path !== to) router.push(to)
}
</script>
<template>
<div class="p-4">
<!-- HEADER CONCEITUAL -->
<div class="mb-4 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-5">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-16 -right-16 h-52 w-52 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute top-10 -left-24 h-60 w-60 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute bottom-0 right-24 h-44 w-44 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<div class="relative">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-900 text-2xl font-semibold leading-none">Configurações</div>
<div class="text-600 mt-2 max-w-2xl">
Defina como sua clínica funciona: agenda, cadastros e preferências. Tudo no mesmo lugar sem espalhar opções pelo sistema.
</div>
</div>
<Button
label="Voltar"
icon="pi pi-arrow-left"
severity="secondary"
outlined
class="hidden md:inline-flex"
@click="router.back()"
/>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-12 gap-4">
<!-- SIDEBAR (seções) -->
<div class="col-span-12 lg:col-span-4 xl:col-span-3">
<Card class="h-full">
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-cog" />
<span>Seções</span>
</div>
</template>
<template #content>
<div class="flex flex-col gap-2">
<button
v-for="s in secoes"
:key="s.key"
type="button"
class="w-full text-left p-3 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] hover:bg-[var(--surface-hover)] transition flex items-start justify-between gap-3"
:class="activeTo === s.to ? 'ring-1 ring-primary/40 border-primary/40' : ''"
@click="ir(s.to)"
>
<div class="flex gap-3">
<div class="mt-1">
<i :class="[s.icon, 'text-lg']" style="opacity:.85" />
</div>
<div>
<div class="text-900 font-medium leading-none">{{ s.label }}</div>
<div class="text-600 text-sm mt-2 leading-snug">{{ s.desc }}</div>
<div v-if="s.tags?.length" class="mt-3 flex flex-wrap gap-2">
<span
v-for="t in s.tags"
:key="t"
class="text-xs px-2 py-1 rounded-full border border-[var(--surface-border)] text-600"
>
{{ t }}
</span>
</div>
</div>
</div>
<i class="pi pi-angle-right mt-1" style="opacity:.55" />
</button>
<Divider class="my-2" />
<Button
label="Voltar"
icon="pi pi-arrow-left"
severity="secondary"
outlined
class="w-full md:hidden"
@click="router.back()"
/>
</div>
</template>
</Card>
<!-- Card pequeno atalhos opcional -->
<div class="mt-4 hidden lg:block">
<Card>
<template #content>
<div class="text-900 font-medium">Dica</div>
<div class="text-600 text-sm mt-2 leading-relaxed">
Comece pela <b>Agenda</b>. É ela que tempo ao prontuário: sessão marcada sessão realizada evolução.
</div>
</template>
</Card>
</div>
</div>
<!-- CONTEÚDO (seção selecionada) -->
<div class="col-span-12 lg:col-span-8 xl:col-span-9">
<!-- Aqui entra /configuracoes/agenda etc -->
<router-view />
</div>
</div>
</div>
</template>
+35 -1
View File
@@ -21,6 +21,38 @@ const secoes = [
to: '/configuracoes/agenda',
tags: ['Horários', 'Exceções', 'Duração']
},
{
key: 'bloqueios',
label: 'Bloqueios',
desc: 'Feriados nacionais, municipais e períodos bloqueados para pacientes.',
icon: 'pi pi-ban',
to: '/configuracoes/bloqueios',
tags: ['Feriados', 'Períodos', 'Recorrentes']
},
{
key: 'agendador',
label: 'Agendador Online',
desc: 'Link público para pacientes solicitarem horários. Aprovação, identidade visual e pagamento.',
icon: 'pi pi-calendar-clock',
to: '/configuracoes/agendador',
tags: ['PRO', 'Link', 'Pix', 'LGPD']
},
{
key: 'pagamento',
label: 'Pagamento',
desc: 'Formas de pagamento aceitas: Pix, depósito bancário, dinheiro, cartão e convênio.',
icon: 'pi pi-wallet',
to: '/configuracoes/pagamento',
tags: ['Pix', 'TED', 'Cartão', 'Convênio']
},
{
key: 'precificacao',
label: 'Precificação',
desc: 'Valor padrão da sessão e preços específicos por tipo de compromisso.',
icon: 'pi pi-tag',
to: '/configuracoes/precificacao',
tags: ['Valores', 'Sessão', 'Compromisso']
},
// Ative quando criar as rotas/páginas
// {
@@ -51,7 +83,9 @@ const secoes = [
const activeTo = computed(() => {
const p = route.path || ''
const hit = secoes.find(s => p.startsWith(s.to))
const hit = [...secoes]
.sort((a, b) => b.to.length - a.to.length)
.find(s => p === s.to || p.startsWith(s.to + '/'))
return hit?.to || '/configuracoes/agenda'
})
+617
View File
@@ -0,0 +1,617 @@
<!-- src/layout/configuracoes/BloqueiosPage.vue -->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useToast } from 'primevue/usetoast'
import { useFeriados } from '@/composables/useFeriados'
import DatePicker from 'primevue/datepicker'
const toast = useToast()
const tenantStore = useTenantStore()
// ── Feriados (nacionais + municipais) ───────────────────────
const { nacionais, municipais, todos, loading: loadingF, load: loadFeriados, criar: criarFeriado, remover: removerFeriado, isDuplicata } = useFeriados()
// ── Estado bloqueios ────────────────────────────────────────
const loadingB = ref(false)
const saving = ref(false)
const ownerId = ref(null)
const tenantId = ref(null)
const ano = ref(new Date().getFullYear())
const bloqueios = ref([])
// ── Dialog bloqueio ─────────────────────────────────────────
const dlgOpen = ref(false)
const dlgMode = ref('add')
const form = ref(emptyForm())
// ── Dialog feriado municipal ────────────────────────────────
const fdlgOpen = ref(false)
const fsaving = ref(false)
const fform = ref(emptyFForm())
function emptyForm () {
return {
id: null,
titulo: '',
data_inicio: null,
data_fim: null,
hora_inicio: null,
hora_fim: null,
recorrente: false,
dia_semana: null,
observacao: ''
}
}
function emptyFForm () {
return { nome: '', data: null, observacao: '', bloqueia_sessoes: false }
}
// ── Helpers ─────────────────────────────────────────────────
function dateToISO (d) {
if (!d) return null
const dt = d instanceof Date ? d : new Date(d)
return `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`
}
function isoToDate (s) {
if (!s) return null
const [y, m, d] = String(s).split('-').map(Number)
return new Date(y, m-1, d)
}
function dateToHHMM (d) {
if (!d || !(d instanceof Date)) return null
return String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0')
}
function hhmmToDate (hhmm) {
if (!hhmm) return null
const [h, m] = String(hhmm).split(':').map(Number)
const d = new Date(); d.setHours(h, m, 0, 0); return d
}
function fmtDate (iso) {
if (!iso) return '—'
const [y, m, d] = String(iso).split('-')
return `${d}/${m}/${y}`
}
function fmtDateShort (iso) {
if (!iso) return ''
const [, m, d] = String(iso).split('-')
return `${d}/${m}`
}
function fmtHora (t) {
if (!t) return null
return String(t).slice(0, 5)
}
// ── Auth + tenant ────────────────────────────────────────────
async function boot () {
const { data } = await supabase.auth.getUser()
ownerId.value = data?.user?.id || null
tenantId.value = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null
if (tenantId.value) await loadFeriados(tenantId.value)
await loadBloqueios()
}
onMounted(boot)
// ── Load bloqueios ────────────────────────────────────────────
async function loadBloqueios () {
if (!ownerId.value) return
loadingB.value = true
try {
const { data, error } = await supabase
.from('agenda_bloqueios')
.select('*')
.eq('owner_id', ownerId.value)
.gte('data_inicio', `${ano.value}-01-01`)
.lte('data_inicio', `${ano.value}-12-31`)
.order('data_inicio')
if (error) throw error
bloqueios.value = data || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
} finally {
loadingB.value = false
}
}
// ── Navegação de ano ─────────────────────────────────────────
async function anoAnterior () {
ano.value--
await loadFeriados(tenantId.value, ano.value)
await loadBloqueios()
}
async function anoProximo () {
ano.value++
await loadFeriados(tenantId.value, ano.value)
await loadBloqueios()
}
// ── Feriado municipal CRUD ────────────────────────────────────
function abrirFeriadoMunicipal () {
fform.value = emptyFForm()
fdlgOpen.value = true
}
const fformValid = computed(() => !!fform.value.nome.trim() && !!fform.value.data)
async function salvarFeriado () {
if (!fformValid.value) return
const iso = dateToISO(fform.value.data)
if (isDuplicata(iso, fform.value.nome)) {
toast.add({ severity: 'warn', summary: 'Duplicado', detail: 'Já existe um feriado com esse nome nessa data.', life: 3000 })
return
}
fsaving.value = true
try {
await criarFeriado({
tenant_id: tenantId.value,
owner_id: ownerId.value,
tipo: 'municipal',
nome: fform.value.nome.trim(),
data: iso,
observacao: fform.value.observacao || null,
bloqueia_sessoes: fform.value.bloqueia_sessoes
})
toast.add({ severity: 'success', summary: 'Feriado cadastrado', life: 1800 })
fdlgOpen.value = false
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
} finally {
fsaving.value = false
}
}
async function excluirFeriado (id) {
try {
await removerFeriado(id)
toast.add({ severity: 'success', summary: 'Removido', life: 1500 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
}
}
// ── Bloqueio CRUD ─────────────────────────────────────────────
function abrirAddBloqueio () {
form.value = emptyForm()
dlgMode.value = 'add'
dlgOpen.value = true
}
function abrirEditBloqueio (b) {
form.value = {
id: b.id,
titulo: b.titulo,
data_inicio: isoToDate(b.data_inicio),
data_fim: isoToDate(b.data_fim),
hora_inicio: hhmmToDate(fmtHora(b.hora_inicio)),
hora_fim: hhmmToDate(fmtHora(b.hora_fim)),
recorrente: !!b.recorrente,
dia_semana: b.dia_semana ?? null,
observacao: b.observacao || ''
}
dlgMode.value = 'edit'
dlgOpen.value = true
}
const formValid = computed(() => !!form.value.titulo.trim() && !!form.value.data_inicio)
async function salvarBloqueio () {
if (!formValid.value) return
saving.value = true
try {
const payload = {
owner_id: ownerId.value,
tenant_id: tenantId.value,
tipo: 'bloqueio',
titulo: form.value.titulo.trim(),
data_inicio: dateToISO(form.value.data_inicio),
data_fim: dateToISO(form.value.data_fim) || null,
hora_inicio: dateToHHMM(form.value.hora_inicio) || null,
hora_fim: dateToHHMM(form.value.hora_fim) || null,
recorrente: form.value.recorrente,
dia_semana: form.value.dia_semana ?? null,
observacao: form.value.observacao || null,
origem: 'manual'
}
if (dlgMode.value === 'edit') {
const { error } = await supabase.from('agenda_bloqueios').update(payload).eq('id', form.value.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Bloqueio atualizado.', life: 1800 })
} else {
const { error } = await supabase.from('agenda_bloqueios').insert(payload)
if (error) throw error
toast.add({ severity: 'success', summary: 'Criado', detail: 'Bloqueio adicionado.', life: 1800 })
}
dlgOpen.value = false
await loadBloqueios()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
} finally {
saving.value = false
}
}
async function excluirBloqueio (id) {
try {
const { error } = await supabase.from('agenda_bloqueios').delete().eq('id', id)
if (error) throw error
bloqueios.value = bloqueios.value.filter(b => b.id !== id)
toast.add({ severity: 'success', summary: 'Removido', life: 1500 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
}
}
// ── fmtPeriodo ────────────────────────────────────────────────
function fmtPeriodo (b) {
if (b.recorrente && b.dia_semana != null) {
const dias = ['Dom','Seg','Ter','Qua','Qui','Sex','Sáb']
return `Toda ${dias[b.dia_semana]}`
}
const ini = fmtDate(b.data_inicio)
if (!b.data_fim || b.data_fim === b.data_inicio) {
const hora = b.hora_inicio ? ` · ${fmtHora(b.hora_inicio)}${fmtHora(b.hora_fim) || '?'}` : ' · Dia inteiro'
return ini + hora
}
return `${ini} até ${fmtDate(b.data_fim)}`
}
const loading = computed(() => loadingF.value || loadingB.value)
</script>
<template>
<Toast />
<div class="flex flex-col gap-4">
<!-- Cabeçalho do ano -->
<div class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-5 py-4">
<div>
<div class="font-semibold text-base">Bloqueios da agenda</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">
Feriados e períodos em que não é possível agendar com pacientes.
</div>
</div>
<div class="flex items-center gap-2">
<Button icon="pi pi-chevron-left" text rounded severity="secondary" @click="anoAnterior" />
<span class="font-bold text-lg w-14 text-center">{{ ano }}</span>
<Button icon="pi pi-chevron-right" text rounded severity="secondary" @click="anoProximo" />
</div>
</div>
<!-- Stats rápidos -->
<div class="grid grid-cols-3 gap-3">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="text-2xl font-bold text-blue-500">{{ nacionais.length }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Feriados nacionais</div>
</div>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="text-2xl font-bold text-orange-500">{{ municipais.length }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Feriados municipais</div>
</div>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="text-2xl font-bold text-red-500">{{ bloqueios.length }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Bloqueios</div>
</div>
</div>
<!-- Ações -->
<div class="flex flex-wrap gap-2">
<Button
icon="pi pi-map-marker"
label="Adicionar feriado municipal"
severity="secondary"
outlined
class="rounded-full"
@click="abrirFeriadoMunicipal"
/>
<Button
icon="pi pi-ban"
label="Adicionar bloqueio"
class="rounded-full"
@click="abrirAddBloqueio"
/>
</div>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-16">
<i class="pi pi-spinner pi-spin text-2xl opacity-40" />
</div>
<template v-else>
<!-- Feriados Nacionais (somente leitura) -->
<div class="blk-group">
<div class="blk-group__head">
<i class="pi pi-flag text-blue-500" />
<span>Feriados Nacionais</span>
<span class="blk-group__count">{{ nacionais.length }}</span>
<span class="ml-auto mr-0 text-xs text-[var(--text-color-secondary)] font-normal">gerado automaticamente</span>
</div>
<div class="blk-list">
<div v-for="f in nacionais" :key="f.data + f.nome" class="blk-item">
<div class="blk-item__date">{{ fmtDateShort(f.data) }}</div>
<div class="blk-item__title">{{ f.nome }}</div>
<Tag v-if="f.movel" value="Móvel" severity="secondary" class="text-xs shrink-0" />
</div>
</div>
</div>
<!-- Feriados Municipais -->
<div class="blk-group">
<div class="blk-group__head">
<i class="pi pi-map-marker text-orange-500" />
<span>Feriados Municipais</span>
<span class="blk-group__count">{{ municipais.length }}</span>
</div>
<div v-if="!municipais.length" class="blk-empty">
Nenhum feriado municipal cadastrado para {{ ano }}.
</div>
<div v-else class="blk-list">
<div v-for="f in municipais" :key="f.id" class="blk-item">
<div class="blk-item__date">{{ fmtDate(f.data) }}</div>
<div class="blk-item__title">{{ f.nome }}</div>
<div v-if="f.observacao" class="blk-item__obs">{{ f.observacao }}</div>
<div class="blk-item__actions">
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="excluirFeriado(f.id)" />
</div>
</div>
</div>
</div>
<!-- Bloqueios -->
<div class="blk-group">
<div class="blk-group__head">
<i class="pi pi-ban text-red-500" />
<span>Bloqueios</span>
<span class="blk-group__count">{{ bloqueios.length }}</span>
</div>
<div v-if="!bloqueios.length" class="blk-empty">
Nenhum bloqueio cadastrado para {{ ano }}.
</div>
<div v-else class="blk-list">
<div v-for="b in bloqueios" :key="b.id" class="blk-item">
<div class="blk-item__date">{{ fmtPeriodo(b) }}</div>
<div class="blk-item__title">{{ b.titulo }}</div>
<Tag v-if="b.recorrente" value="Recorrente" severity="warn" class="text-xs" />
<div v-if="b.observacao" class="blk-item__obs">{{ b.observacao }}</div>
<div class="blk-item__actions">
<Button icon="pi pi-pencil" text rounded size="small" severity="secondary" @click="abrirEditBloqueio(b)" />
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="excluirBloqueio(b.id)" />
</div>
</div>
</div>
</div>
</template>
</div>
<!-- Dialog feriado municipal -->
<Dialog
v-model:visible="fdlgOpen"
modal
:draggable="false"
header="Cadastrar feriado municipal"
:style="{ width: '420px' }"
>
<div class="flex flex-col gap-4 pt-1">
<div>
<label class="blk-label">Nome do feriado *</label>
<InputText v-model="fform.nome" class="w-full mt-1" placeholder="Ex.: Aniversário da cidade, Padroeiro…" />
</div>
<div>
<label class="blk-label">Data *</label>
<DatePicker
v-model="fform.data"
showIcon fluid iconDisplay="input"
dateFormat="dd/mm/yy"
:manualInput="false"
class="mt-1"
>
<template #inputicon="sp"><i class="pi pi-calendar" @click="sp.clickCallback" /></template>
</DatePicker>
</div>
<div>
<label class="blk-label">Observação <span class="opacity-60">(opcional)</span></label>
<Textarea v-model="fform.observacao" class="w-full mt-1" rows="2" autoResize placeholder="Nota interna…" />
</div>
<div v-if="fform.data && fform.nome && isDuplicata(dateToISO(fform.data), fform.nome)"
class="text-sm text-red-500 flex items-center gap-2">
<i class="pi pi-exclamation-triangle" />
existe um feriado com esse nome nessa data.
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="fdlgOpen = false" />
<Button
label="Cadastrar"
icon="pi pi-check"
:disabled="!fformValid || (fform.data && fform.nome && isDuplicata(dateToISO(fform.data), fform.nome))"
:loading="fsaving"
@click="salvarFeriado"
/>
</template>
</Dialog>
<!-- Dialog bloqueio add/edit -->
<Dialog
v-model:visible="dlgOpen"
modal
:draggable="false"
:header="dlgMode === 'edit' ? 'Editar bloqueio' : 'Novo bloqueio'"
:style="{ width: '480px' }"
>
<div class="flex flex-col gap-4 pt-1">
<div>
<label class="blk-label">Título *</label>
<InputText v-model="form.titulo" class="w-full mt-1" placeholder="Ex.: Recesso, Férias, Licença…" />
</div>
<div class="flex gap-3">
<div class="flex-1">
<label class="blk-label">Data início *</label>
<DatePicker
v-model="form.data_inicio"
showIcon fluid iconDisplay="input"
dateFormat="dd/mm/yy"
:manualInput="false"
class="mt-1"
>
<template #inputicon="sp"><i class="pi pi-calendar" @click="sp.clickCallback" /></template>
</DatePicker>
</div>
<div class="flex-1">
<label class="blk-label">Data fim <span class="opacity-60">(opcional)</span></label>
<DatePicker
v-model="form.data_fim"
showIcon fluid iconDisplay="input"
dateFormat="dd/mm/yy"
:manualInput="false"
:minDate="form.data_inicio || undefined"
class="mt-1"
>
<template #inputicon="sp"><i class="pi pi-calendar" @click="sp.clickCallback" /></template>
</DatePicker>
</div>
</div>
<div class="flex gap-3">
<div class="flex-1">
<label class="blk-label">Hora início <span class="opacity-60">(opcional)</span></label>
<DatePicker
v-model="form.hora_inicio"
showIcon fluid iconDisplay="input"
timeOnly hourFormat="24" :stepMinute="15" :manualInput="false"
class="mt-1"
>
<template #inputicon="sp"><i class="pi pi-clock" @click="sp.clickCallback" /></template>
</DatePicker>
</div>
<div class="flex-1">
<label class="blk-label">Hora fim</label>
<DatePicker
v-model="form.hora_fim"
showIcon fluid iconDisplay="input"
timeOnly hourFormat="24" :stepMinute="15" :manualInput="false"
class="mt-1"
>
<template #inputicon="sp"><i class="pi pi-clock" @click="sp.clickCallback" /></template>
</DatePicker>
</div>
</div>
<div>
<label class="blk-label">Observação <span class="opacity-60">(opcional)</span></label>
<Textarea v-model="form.observacao" class="w-full mt-1" rows="2" autoResize placeholder="Nota interna…" />
</div>
</div>
<template #footer>
<Button label="Cancelar" icon="pi pi-times" severity="secondary" outlined @click="dlgOpen = false" />
<Button
:label="dlgMode === 'edit' ? 'Salvar' : 'Adicionar'"
icon="pi pi-check"
:disabled="!formValid"
:loading="saving"
@click="salvarBloqueio"
/>
</template>
</Dialog>
</template>
<style scoped>
/* ── Grupos ──────────────────────────────────────────────── */
.blk-group {
border-radius: 1.25rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
overflow: hidden;
}
.blk-group__head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.875rem 1.25rem;
border-bottom: 1px solid var(--surface-border);
font-weight: 600;
font-size: 0.9rem;
}
.blk-group__count {
font-size: 0.75rem;
background: var(--surface-ground);
border: 1px solid var(--surface-border);
border-radius: 999px;
padding: 1px 8px;
color: var(--text-color-secondary);
}
/* ── Itens ───────────────────────────────────────────────── */
.blk-empty {
padding: 1.25rem;
font-size: 0.875rem;
color: var(--text-color-secondary);
}
.blk-list {
display: flex;
flex-direction: column;
}
.blk-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--surface-border);
flex-wrap: wrap;
}
.blk-item:last-child { border-bottom: none; }
.blk-item:hover { background: var(--surface-hover); }
.blk-item__date {
font-size: 0.8rem;
color: var(--text-color-secondary);
white-space: nowrap;
min-width: 5.5rem;
font-variant-numeric: tabular-nums;
}
.blk-item__title {
flex: 1;
font-weight: 500;
font-size: 0.875rem;
min-width: 0;
}
.blk-item__obs {
font-size: 0.75rem;
color: var(--text-color-secondary);
width: 100%;
padding-left: 6.25rem;
margin-top: -0.25rem;
}
.blk-item__actions {
display: flex;
gap: 0.25rem;
margin-left: auto;
}
/* ── Dialog ──────────────────────────────────────────────── */
.blk-label {
font-size: 0.75rem;
color: var(--text-color-secondary);
font-weight: 500;
}
</style>
@@ -34,7 +34,6 @@ const cfg = ref({
agenda_custom_end: null,
session_duration_min: 40,
session_break_min: 10,
session_start_offset_min: 0,
pausas_semanais: [],
online_ativo: false,
setup_clinica_concluido: false,
@@ -136,6 +135,17 @@ function dateForDayOfWeek (dayValue) {
d.setDate(d.getDate() + delta)
return d
}
function floorTo30 (hhmm) {
const [h, m] = String(hhmm || '00:00').slice(0, 5).split(':').map(Number)
return String(h).padStart(2,'0') + ':' + (m < 30 ? '00' : '30')
}
function ceilTo30 (hhmm) {
const [h, m] = String(hhmm || '00:00').slice(0, 5).split(':').map(Number)
if (m === 0 || m === 30) return String(h).padStart(2,'0') + ':' + String(m).padStart(2,'0')
if (m < 30) return String(h).padStart(2,'0') + ':30'
return String(h + 1).padStart(2,'0') + ':00'
}
function toLocalIsoAt (dateBase, hhmm) {
const [h, m] = String(hhmm).split(':').map(Number)
const d = new Date(dateBase); d.setHours(h, m, 0, 0)
@@ -159,8 +169,7 @@ const jornadaOk = computed(() => selectedDays.value.length > 0 && isValidHHMM(jo
const resumoRitmo = computed(() => {
const d = cfg.value.session_duration_min || 50
const i = cfg.value.session_break_min || 0
const off = cfg.value.session_start_offset_min || 0
return `${d} min sessão · ${i > 0 ? `${i} min intervalo` : 'sem intervalo'} · início :${String(off).padStart(2,'0')}`
return `${d} min sessão · ${i > 0 ? `${i} min intervalo` : 'sem intervalo'}`
})
const resumoOnline = computed(() => {
@@ -175,6 +184,14 @@ const resumoOnline = computed(() => {
return `Ativo · ${total} slot${total !== 1 ? 's' : ''} configurado${total !== 1 ? 's' : ''} em ${dias} dia${dias !== 1 ? 's' : ''}`
})
// Dias que têm slots no banco mas não estão mais na jornada (órfãos)
const orphanSlotDays = computed(() => {
const active = new Set(selectedDays.value.map(d => d.value))
return diasSemana
.filter(d => !active.has(d.value) && (onlineSlotsByDay.value[d.value]?.size || 0) > 0)
.map(d => d.short)
})
// ══ SYNC / HYDRATE ════════════════════════════════════════════
watch([selectedDays, jornadaStart, jornadaEnd], () => {
if (!isValidHHMM(jornadaStart.value) || !isValidHHMM(jornadaEnd.value)) return
@@ -189,6 +206,32 @@ function getPausasForDay (dayValue) {
return pausasPorDia.value[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 }
})
}
// Copia pausas globais para todos os dias e usa apenas pausasGlobais
selectedDays.value.forEach(d => { pausasPorDia.value[d.value] = [] })
jornadaIgualTodos.value = true
}
function switchToDiferente () {
// Inicializa cada dia com o horário global atual e as pausas globais
if (isValidHHMM(jornadaStart.value) && isValidHHMM(jornadaEnd.value)) {
selectedDays.value.forEach(d => {
jornadaPorDia.value[d.value] = { inicio: jornadaStart.value, fim: jornadaEnd.value }
})
}
selectedDays.value.forEach(d => {
pausasPorDia.value[d.value] = pausasGlobais.value.map(p => ({ ...p, id: newId() }))
})
jornadaIgualTodos.value = false
}
function hydratePausasFromCfg () {
const byDay = { 1: [], 2: [], 3: [], 4: [], 5: [], 6: [], 0: [] }
for (const p of cfg.value.pausas_semanais || []) {
@@ -401,6 +444,19 @@ async function saveJornada () {
if (insErr) throw insErr
}
// Limpar slots online de dias removidos da jornada
const activeDays = new Set(selectedDays.value.map(d => d.value))
const orphanDays = [0,1,2,3,4,5,6].filter(d => !activeDays.has(d))
if (orphanDays.length) {
const { error: orphanErr } = await supabase
.from('agenda_online_slots')
.delete()
.eq('owner_id', uid)
.in('weekday', orphanDays)
if (orphanErr) console.warn('[CFG] limpeza órfãos:', orphanErr)
else for (const d of orphanDays) _setDay(d, new Set())
}
cfg.value.setup_clinica_concluido = true
cfg.value.jornada_igual_todos = igualTodos
toast.add({ severity: 'success', summary: 'Jornada salva', detail: 'Horários de trabalho atualizados.', life: 1800 })
@@ -414,11 +470,9 @@ async function saveJornada () {
async function saveRitmo () {
const dur = Number(cfg.value.session_duration_min || 0)
const gap = Number(cfg.value.session_break_min || 0)
const off = Number(cfg.value.session_start_offset_min ?? 0)
if (dur < 10 || dur > 240) { toast.add({ severity: 'warn', summary: 'Ritmo', detail: 'Duração deve ser entre 10 e 240 min.', life: 3500 }); return }
if (gap < 0 || gap > 60) { toast.add({ severity: 'warn', summary: 'Ritmo', detail: 'Intervalo deve ser entre 0 e 60 min.', life: 3500 }); return }
if (![0,15,30,45].includes(off)) { toast.add({ severity: 'warn', summary: 'Ritmo', detail: 'Início deve ser :00, :15, :30 ou :45.', life: 3500 }); return }
savingCard.value = 'ritmo'
try {
@@ -427,9 +481,8 @@ async function saveRitmo () {
const { error } = await supabase.from('agenda_configuracoes').upsert({
owner_id: uid,
session_duration_min: dur,
session_break_min: gap,
session_start_offset_min: off
session_duration_min: dur,
session_break_min: gap,
}, { onConflict: 'owner_id' })
if (error) throw error
@@ -498,7 +551,6 @@ function generateSlotsForDay (dayValue) {
const duration = Number(cfg.value.session_duration_min || 50)
const gap = Number(cfg.value.session_break_min || 10)
const offset = Number(cfg.value.session_start_offset_min || 0)
const cycle = Math.max(1, duration + gap)
const out = []
@@ -506,14 +558,12 @@ function generateSlotsForDay (dayValue) {
const wStart = hhmmToMin(w.start)
const wEnd = hhmmToMin(w.end)
let t = wStart
const rem = t % 60
if (rem !== offset) t = t + ((offset - rem + 60) % 60)
while (t < wEnd) {
const aEnd = t + duration
if (aEnd > wEnd) break
const conflict = breaks.find(b => { const bS = hhmmToMin(b.start), bE = hhmmToMin(b.end); return !(aEnd <= bS || t >= bE) })
if (conflict) { t = hhmmToMin(conflict.end); const r2 = t % 60; if (r2 !== offset) t += (offset - r2 + 60) % 60; continue }
if (conflict) { t = hhmmToMin(conflict.end); continue }
out.push({ hhmm: minToHHMM(t), endHHMM: minToHHMM(aEnd) })
t += cycle
}
@@ -624,15 +674,23 @@ const previewFcEvents = computed(() => {
})
const previewBounds = computed(() => {
const active = liveRegras.value.filter(r => r.ativo)
const day = previewDay.value
const active = liveRegras.value.filter(r => r.ativo && (day == null || r.dia_semana === day))
if (!active.length) return { start: '06:00', end: '22:00' }
const start = active.reduce((acc, r) => r.hora_inicio < acc ? r.hora_inicio : acc, active[0].hora_inicio)
const end = active.reduce((acc, r) => r.hora_fim > acc ? r.hora_fim : acc, active[0].hora_fim)
const padded_start = minToHHMM(Math.max(0, hhmmToMin(String(start).slice(0,5)) - 60))
const padded_end = minToHHMM(Math.min(24*60, hhmmToMin(String(end).slice(0,5)) + 60))
return { start: padded_start, end: padded_end }
return { start: floorTo30(String(start).slice(0,5)), end: ceilTo30(String(end).slice(0,5)) }
})
function previewSlotLabelContent (arg) {
const min = arg.date.getMinutes()
if (min === 0) {
const h = String(arg.date.getHours()).padStart(2, '0')
return { html: `<span class="fc-slot-label-hour">${h}:00</span>` }
}
return { html: '<span class="fc-slot-label-half">:30</span>' }
}
const previewFcOptions = computed(() => {
const day = previewDay.value
const base = day != null ? dateForDayOfWeek(day) : new Date()
@@ -646,8 +704,10 @@ const previewFcOptions = computed(() => {
allDaySlot: false,
slotMinTime: previewBounds.value.start + ':00',
slotMaxTime: previewBounds.value.end + ':00',
slotDuration: '00:30:00',
slotLabelInterval: '01:00',
slotDuration: '00:15:00',
snapDuration: '00:15:00',
slotLabelInterval: '00:30',
slotLabelContent: previewSlotLabelContent,
expandRows: true,
height: 'auto',
editable: false,
@@ -662,6 +722,7 @@ watch(previewFcEvents, async () => { await nextTick(); fcRef.value?.getApi?.()?.
// presets de duração
const durationPresets = [
{ label: '30 min', dur: 30, gap: 0, off: 0 },
{ label: '45 min', dur: 45, gap: 15, off: 0 },
{ label: '50 min', dur: 50, gap: 10, off: 0 },
{ label: '60 min', dur: 60, gap: 0, off: 0 },
@@ -736,6 +797,7 @@ 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) -->
<!-- Dias da semana -->
<div class="mb-5">
<div class="cfg-label mb-2">Quais dias você trabalha?</div>
@@ -761,14 +823,14 @@ const jornadaEndDate = computed({
<button
class="toggle-opt"
:class="jornadaIgualTodos !== false ? 'toggle-opt--active' : ''"
@click="jornadaIgualTodos = true"
@click="switchToIgual"
>
Igual para todos os dias
</button>
<button
class="toggle-opt"
:class="jornadaIgualTodos === false ? 'toggle-opt--active' : ''"
@click="jornadaIgualTodos = false"
@click="switchToDiferente"
>
Diferente por dia
</button>
@@ -779,7 +841,7 @@ const jornadaEndDate = computed({
<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">
<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>
@@ -787,7 +849,7 @@ const jornadaEndDate = computed({
</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">
<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>
@@ -808,7 +870,7 @@ const jornadaEndDate = computed({
<DatePicker
:modelValue="hhmmToDate(jornadaPorDia[d.value]?.inicio)"
@update:modelValue="v => { const h = dateToHHMM(v); if (h) { jornadaPorDia[d.value] = { ...jornadaPorDia[d.value], inicio: h }; previewDay = d.value } }"
showIcon fluid iconDisplay="input" timeOnly hourFormat="24"
showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false"
>
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
@@ -820,7 +882,7 @@ const jornadaEndDate = computed({
<DatePicker
:modelValue="hhmmToDate(jornadaPorDia[d.value]?.fim)"
@update:modelValue="v => { const h = dateToHHMM(v); if (h) { jornadaPorDia[d.value] = { ...jornadaPorDia[d.value], fim: h }; previewDay = d.value } }"
showIcon fluid iconDisplay="input" timeOnly hourFormat="24"
showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false"
>
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
@@ -895,7 +957,8 @@ const jornadaEndDate = computed({
@click="applyDurationPreset(p)"
>
{{ p.label }}
<span v-if="p.gap" class="text-xs opacity-70 ml-1">· {{ p.gap }}min intervalo</span>
<span v-if="p.gap > 0" class="text-xs opacity-70 ml-1">· {{ p.gap }}min intervalo</span>
<span v-else class="text-xs opacity-70 ml-1">· sem pausa</span>
</button>
<button
class="preset-chip"
@@ -913,7 +976,7 @@ const jornadaEndDate = computed({
<div class="flex flex-row gap-6">
<div class="flex flex-col gap-1">
<label class="text-xs text-[var(--text-color-secondary)]">Duração</label>
<DatePicker v-model="durationDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24">
<DatePicker v-model="durationDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="5" :manualInput="false">
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
</template>
@@ -921,7 +984,7 @@ const jornadaEndDate = computed({
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-[var(--text-color-secondary)]">Intervalo</label>
<DatePicker v-model="breakDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24">
<DatePicker v-model="breakDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="5" :manualInput="false">
<template #inputicon="slotProps">
<i class="pi pi-clock" @click="slotProps.clickCallback" />
</template>
@@ -930,25 +993,6 @@ const jornadaEndDate = computed({
</div>
</div>
<!-- Alinhamento de início (sempre visível) -->
<div class="mb-5">
<div class="cfg-label mb-2">Início das sessões na jornada de trabalho</div>
<div class="flex gap-2 flex-wrap">
<button
v-for="off in [0, 15, 30, 45]"
:key="off"
class="preset-chip"
:class="cfg.session_start_offset_min === off ? 'preset-chip--active' : ''"
@click="cfg.session_start_offset_min = off"
>
:{{ String(off).padStart(2,'0') }}
</button>
</div>
<p class="text-xs text-[var(--text-color-secondary)] mt-2">
Ex.: :00 = sessões em 08:00, 09:00 · :30 = em 08:30, 09:30
</p>
</div>
<div class="flex justify-end">
<Button
label="Salvar ritmo"
@@ -987,6 +1031,15 @@ const jornadaEndDate = computed({
<div v-show="expandedCard === 'online'" class="cfg-card__body">
<div class="border-t border-[var(--surface-border)] pt-4">
<!-- Aviso slots órfãos -->
<div v-if="orphanSlotDays.length" class="flex items-start gap-2 mb-4 p-3 rounded-xl bg-[var(--yellow-50)] border border-[var(--yellow-200)] text-sm text-[var(--yellow-800)]">
<i class="pi pi-exclamation-triangle mt-0.5 shrink-0" />
<span>
slots configurados para <b>{{ orphanSlotDays.join(', ') }}</b>, mas esses dias não estão mais na sua jornada.
Eles serão removidos automaticamente ao salvar a jornada.
</span>
</div>
<!-- Toggle ativo -->
<div class="flex items-center justify-between mb-5 p-4 rounded-2xl bg-[var(--surface-ground)]">
<div>
@@ -1131,7 +1184,7 @@ const jornadaEndDate = computed({
<!-- FullCalendar -->
<div v-if="previewDay != null && !loading" class="p-2">
<FullCalendar ref="fcRef" :key="`preview-${previewDay}`" :options="previewFcOptions" />
<FullCalendar ref="fcRef" :key="`preview-${previewDay}-${previewBounds.start}-${previewBounds.end}`" :options="previewFcOptions" />
</div>
<div v-else class="flex items-center justify-center py-16 text-[var(--text-color-secondary)] text-sm">
Selecione um dia de trabalho para ver o preview.
@@ -1142,6 +1195,26 @@ const jornadaEndDate = computed({
</div>
</template>
<style>
.fc-slot-label-hour {
display: inline-block;
font-size: 0.8rem;
font-weight: 700;
color: var(--text-color);
letter-spacing: -0.01em;
line-height: 1;
}
.fc-slot-label-half {
display: inline-block;
font-size: 0.68rem;
font-weight: 400;
color: var(--text-color-secondary);
opacity: 0.5;
line-height: 1;
padding-left: 2px;
}
</style>
<style scoped>
/* ── Cards ─────────────────────────────────────────────────── */
.cfg-card {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,553 @@
<!-- src/layout/configuracoes/ConfiguracoesPagamentoPage.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useToast } from 'primevue/usetoast'
const toast = useToast()
const tenantStore = useTenantStore()
const loading = ref(true)
const ownerId = ref(null)
const expandedCard = ref(null)
const savingCard = ref(null)
// ── Defaults ────────────────────────────────────────────────────
const DEFAULT = {
pix_ativo: false,
pix_tipo: 'cpf',
pix_chave: '',
pix_nome_titular: '',
deposito_ativo: false,
deposito_banco: '',
deposito_agencia: '',
deposito_conta: '',
deposito_tipo_conta: 'corrente',
deposito_titular: '',
deposito_cpf_cnpj: '',
dinheiro_ativo: false,
cartao_ativo: false,
cartao_instrucao: '',
convenio_ativo: false,
convenio_lista: '',
observacoes_pagamento: '',
}
const cfg = ref({ ...DEFAULT })
// ── Opções ───────────────────────────────────────────────────────
const pixTipoOptions = [
{ label: 'CPF', value: 'cpf' },
{ label: 'CNPJ', value: 'cnpj' },
{ label: 'E-mail', value: 'email' },
{ label: 'Celular', value: 'celular' },
{ label: 'Chave aleatória', value: 'aleatoria' },
]
const pixTipoLabel = {
cpf: 'CPF (somente números)',
cnpj: 'CNPJ (somente números)',
email: 'E-mail',
celular: 'Celular (+55DDD9XXXXXXXX)',
aleatoria: 'Chave aleatória (UUID)',
}
const tipoConta = [
{ label: 'Conta-corrente', value: 'corrente' },
{ label: 'Poupança', value: 'poupanca' },
]
// Principais bancos BR
const bancos = [
{ label: 'Banco do Brasil', value: '001' },
{ label: 'Bradesco', value: '237' },
{ label: 'Caixa Econômica', value: '104' },
{ label: 'Itaú', value: '341' },
{ label: 'Nubank', value: '260' },
{ label: 'Santander', value: '033' },
{ label: 'Sicoob', value: '756' },
{ label: 'Sicredi', value: '748' },
{ label: 'Inter', value: '077' },
{ label: 'C6 Bank', value: '336' },
{ label: 'PicPay', value: '380' },
{ label: 'Mercado Pago', value: '323' },
{ label: 'PagBank', value: '290' },
{ label: 'Neon', value: '655' },
{ label: 'Next', value: '237' },
{ label: 'Outro', value: 'outro' },
]
// ── Toggle cards ─────────────────────────────────────────────────
function toggleCard (key) {
expandedCard.value = expandedCard.value === key ? null : key
}
// ── Load ─────────────────────────────────────────────────────────
async function load () {
loading.value = true
try {
const uid = tenantStore.user?.id || null
if (!uid) return
ownerId.value = uid
const { data } = await supabase
.from('payment_settings')
.select('*')
.eq('owner_id', uid)
.maybeSingle()
if (data) {
cfg.value = { ...DEFAULT, ...data }
}
} finally {
loading.value = false
}
}
// ── Save card ────────────────────────────────────────────────────
async function saveCard (cardKey) {
if (!ownerId.value) return
savingCard.value = cardKey
const payload = {
owner_id: ownerId.value,
tenant_id: tenantStore.activeTenantId || null,
}
if (cardKey === 'pix') {
Object.assign(payload, {
pix_ativo: cfg.value.pix_ativo,
pix_tipo: cfg.value.pix_tipo,
pix_chave: cfg.value.pix_chave?.trim() ?? '',
pix_nome_titular: cfg.value.pix_nome_titular?.trim() ?? '',
})
} else if (cardKey === 'deposito') {
Object.assign(payload, {
deposito_ativo: cfg.value.deposito_ativo,
deposito_banco: cfg.value.deposito_banco,
deposito_agencia: cfg.value.deposito_agencia?.trim() ?? '',
deposito_conta: cfg.value.deposito_conta?.trim() ?? '',
deposito_tipo_conta: cfg.value.deposito_tipo_conta,
deposito_titular: cfg.value.deposito_titular?.trim() ?? '',
deposito_cpf_cnpj: cfg.value.deposito_cpf_cnpj?.trim() ?? '',
})
} else if (cardKey === 'dinheiro') {
payload.dinheiro_ativo = cfg.value.dinheiro_ativo
} else if (cardKey === 'cartao') {
Object.assign(payload, {
cartao_ativo: cfg.value.cartao_ativo,
cartao_instrucao: cfg.value.cartao_instrucao?.trim() ?? '',
})
} else if (cardKey === 'convenio') {
Object.assign(payload, {
convenio_ativo: cfg.value.convenio_ativo,
convenio_lista: cfg.value.convenio_lista?.trim() ?? '',
})
} else if (cardKey === 'observacoes') {
payload.observacoes_pagamento = cfg.value.observacoes_pagamento?.trim() ?? ''
}
try {
const { error } = await supabase
.from('payment_settings')
.upsert(payload, { onConflict: 'owner_id' })
if (error) throw error
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Configurações de pagamento atualizadas.', life: 2500 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e?.message, life: 4000 })
} finally {
savingCard.value = null
}
}
onMounted(load)
</script>
<template>
<Toast />
<div v-if="loading" class="flex items-center gap-2 p-6 text-slate-500">
<i class="pi pi-spin pi-spinner" /> Carregando
</div>
<div v-else class="flex flex-col gap-4">
<!-- Pix -->
<div class="rounded-2xl border bg-[var(--surface-card)] overflow-hidden"
:class="cfg.pix_ativo ? 'border-green-300' : 'border-[var(--surface-border)]'">
<!-- Header -->
<button
type="button"
class="w-full flex items-center justify-between gap-4 px-5 py-4 hover:bg-[var(--surface-hover)] transition text-left"
@click="toggleCard('pix')"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
:class="cfg.pix_ativo ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-400'">
<i class="pi pi-qrcode text-lg" />
</div>
<div>
<div class="font-semibold text-slate-800">Pix</div>
<div class="text-sm text-slate-500">
{{ cfg.pix_ativo && cfg.pix_chave ? `Chave: ${cfg.pix_chave}` : 'Pagamento instantâneo via chave Pix' }}
</div>
</div>
</div>
<div class="flex items-center gap-3 shrink-0">
<Tag v-if="cfg.pix_ativo" value="Ativo" severity="success" />
<Tag v-else value="Inativo" severity="secondary" />
<i class="pi text-slate-400" :class="expandedCard === 'pix' ? 'pi-angle-up' : 'pi-angle-down'" />
</div>
</button>
<!-- Body -->
<div v-if="expandedCard === 'pix'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
<div class="flex items-center justify-between">
<span class="font-medium text-slate-700">Habilitar Pix</span>
<ToggleSwitch v-model="cfg.pix_ativo" />
</div>
<template v-if="cfg.pix_ativo">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Tipo de chave</label>
<Select
v-model="cfg.pix_tipo"
:options="pixTipoOptions"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">
{{ pixTipoLabel[cfg.pix_tipo] || 'Chave' }}
</label>
<InputText v-model="cfg.pix_chave" class="w-full" :placeholder="pixTipoLabel[cfg.pix_tipo]" />
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-600 mb-1">Nome do titular</label>
<InputText v-model="cfg.pix_nome_titular" class="w-full" placeholder="Nome que aparece na chave" />
</div>
</div>
</template>
<div class="flex justify-end">
<Button
label="Salvar"
icon="pi pi-check"
:loading="savingCard === 'pix'"
@click="saveCard('pix')"
/>
</div>
</div>
</div>
<!-- Depósito bancário -->
<div class="rounded-2xl border bg-[var(--surface-card)] overflow-hidden"
:class="cfg.deposito_ativo ? 'border-blue-300' : 'border-[var(--surface-border)]'">
<button
type="button"
class="w-full flex items-center justify-between gap-4 px-5 py-4 hover:bg-[var(--surface-hover)] transition text-left"
@click="toggleCard('deposito')"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
:class="cfg.deposito_ativo ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-400'">
<i class="pi pi-building-columns text-lg" />
</div>
<div>
<div class="font-semibold text-slate-800">Depósito / TED</div>
<div class="text-sm text-slate-500">
{{ cfg.deposito_ativo && cfg.deposito_banco ? `${cfg.deposito_banco} · Ag. ${cfg.deposito_agencia || '—'} · Conta ${cfg.deposito_conta || '—'}` : 'Transferência bancária ou depósito' }}
</div>
</div>
</div>
<div class="flex items-center gap-3 shrink-0">
<Tag v-if="cfg.deposito_ativo" value="Ativo" severity="success" />
<Tag v-else value="Inativo" severity="secondary" />
<i class="pi text-slate-400" :class="expandedCard === 'deposito' ? 'pi-angle-up' : 'pi-angle-down'" />
</div>
</button>
<div v-if="expandedCard === 'deposito'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
<div class="flex items-center justify-between">
<span class="font-medium text-slate-700">Habilitar Depósito / TED</span>
<ToggleSwitch v-model="cfg.deposito_ativo" />
</div>
<template v-if="cfg.deposito_ativo">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Banco</label>
<Select
v-model="cfg.deposito_banco"
:options="bancos"
option-label="label"
option-value="label"
class="w-full"
placeholder="Selecione o banco"
filter
/>
</div>
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Tipo de conta</label>
<Select
v-model="cfg.deposito_tipo_conta"
:options="tipoConta"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Agência</label>
<InputText v-model="cfg.deposito_agencia" class="w-full" placeholder="0000" />
</div>
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Conta</label>
<InputText v-model="cfg.deposito_conta" class="w-full" placeholder="00000-0" />
</div>
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Titular</label>
<InputText v-model="cfg.deposito_titular" class="w-full" placeholder="Nome completo ou razão social" />
</div>
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">CPF / CNPJ do titular</label>
<InputText v-model="cfg.deposito_cpf_cnpj" class="w-full" placeholder="000.000.000-00" />
</div>
</div>
</template>
<div class="flex justify-end">
<Button
label="Salvar"
icon="pi pi-check"
:loading="savingCard === 'deposito'"
@click="saveCard('deposito')"
/>
</div>
</div>
</div>
<!-- Dinheiro -->
<div class="rounded-2xl border bg-[var(--surface-card)] overflow-hidden"
:class="cfg.dinheiro_ativo ? 'border-yellow-300' : 'border-[var(--surface-border)]'">
<button
type="button"
class="w-full flex items-center justify-between gap-4 px-5 py-4 hover:bg-[var(--surface-hover)] transition text-left"
@click="toggleCard('dinheiro')"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
:class="cfg.dinheiro_ativo ? 'bg-yellow-100 text-yellow-700' : 'bg-slate-100 text-slate-400'">
<i class="pi pi-wallet text-lg" />
</div>
<div>
<div class="font-semibold text-slate-800">Dinheiro (espécie)</div>
<div class="text-sm text-slate-500">Pagamento presencial em dinheiro</div>
</div>
</div>
<div class="flex items-center gap-3 shrink-0">
<Tag v-if="cfg.dinheiro_ativo" value="Ativo" severity="success" />
<Tag v-else value="Inativo" severity="secondary" />
<i class="pi text-slate-400" :class="expandedCard === 'dinheiro' ? 'pi-angle-up' : 'pi-angle-down'" />
</div>
</button>
<div v-if="expandedCard === 'dinheiro'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
<div class="flex items-center justify-between">
<div>
<span class="font-medium text-slate-700">Habilitar pagamento em dinheiro</span>
<p class="text-sm text-slate-500 mt-1">
Aceitar pagamento em espécie nas sessões presenciais.
</p>
</div>
<ToggleSwitch v-model="cfg.dinheiro_ativo" />
</div>
<div class="flex justify-end">
<Button
label="Salvar"
icon="pi pi-check"
:loading="savingCard === 'dinheiro'"
@click="saveCard('dinheiro')"
/>
</div>
</div>
</div>
<!-- Cartão -->
<div class="rounded-2xl border bg-[var(--surface-card)] overflow-hidden"
:class="cfg.cartao_ativo ? 'border-purple-300' : 'border-[var(--surface-border)]'">
<button
type="button"
class="w-full flex items-center justify-between gap-4 px-5 py-4 hover:bg-[var(--surface-hover)] transition text-left"
@click="toggleCard('cartao')"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
:class="cfg.cartao_ativo ? 'bg-purple-100 text-purple-700' : 'bg-slate-100 text-slate-400'">
<i class="pi pi-credit-card text-lg" />
</div>
<div>
<div class="font-semibold text-slate-800">Cartão (maquininha)</div>
<div class="text-sm text-slate-500">Crédito e débito presencial</div>
</div>
</div>
<div class="flex items-center gap-3 shrink-0">
<Tag v-if="cfg.cartao_ativo" value="Ativo" severity="success" />
<Tag v-else value="Inativo" severity="secondary" />
<i class="pi text-slate-400" :class="expandedCard === 'cartao' ? 'pi-angle-up' : 'pi-angle-down'" />
</div>
</button>
<div v-if="expandedCard === 'cartao'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
<div class="flex items-center justify-between">
<div>
<span class="font-medium text-slate-700">Habilitar pagamento por cartão</span>
<p class="text-sm text-slate-500 mt-1">
Aceitar cartão de crédito e débito via maquininha nas sessões presenciais.
</p>
</div>
<ToggleSwitch v-model="cfg.cartao_ativo" />
</div>
<template v-if="cfg.cartao_ativo">
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Instrução ao paciente <span class="text-slate-400">(opcional)</span></label>
<InputText
v-model="cfg.cartao_instrucao"
class="w-full"
placeholder="Ex: Aceito todas as bandeiras. Parcelo em até 3x."
/>
</div>
</template>
<div class="flex justify-end">
<Button
label="Salvar"
icon="pi pi-check"
:loading="savingCard === 'cartao'"
@click="saveCard('cartao')"
/>
</div>
</div>
</div>
<!-- Plano de Saúde / Convênio -->
<div class="rounded-2xl border bg-[var(--surface-card)] overflow-hidden"
:class="cfg.convenio_ativo ? 'border-teal-300' : 'border-[var(--surface-border)]'">
<button
type="button"
class="w-full flex items-center justify-between gap-4 px-5 py-4 hover:bg-[var(--surface-hover)] transition text-left"
@click="toggleCard('convenio')"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
:class="cfg.convenio_ativo ? 'bg-teal-100 text-teal-700' : 'bg-slate-100 text-slate-400'">
<i class="pi pi-heart text-lg" />
</div>
<div>
<div class="font-semibold text-slate-800">Plano de saúde / Convênio</div>
<div class="text-sm text-slate-500">
{{ cfg.convenio_ativo && cfg.convenio_lista ? cfg.convenio_lista.slice(0, 60) + (cfg.convenio_lista.length > 60 ? '…' : '') : 'Atendimento por convênio' }}
</div>
</div>
</div>
<div class="flex items-center gap-3 shrink-0">
<Tag v-if="cfg.convenio_ativo" value="Ativo" severity="success" />
<Tag v-else value="Inativo" severity="secondary" />
<i class="pi text-slate-400" :class="expandedCard === 'convenio' ? 'pi-angle-up' : 'pi-angle-down'" />
</div>
</button>
<div v-if="expandedCard === 'convenio'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
<div class="flex items-center justify-between">
<div>
<span class="font-medium text-slate-700">Aceitar plano de saúde / convênio</span>
<p class="text-sm text-slate-500 mt-1">
Habilite para informar quais convênios são aceitos.
</p>
</div>
<ToggleSwitch v-model="cfg.convenio_ativo" />
</div>
<template v-if="cfg.convenio_ativo">
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Convênios aceitos</label>
<Textarea
v-model="cfg.convenio_lista"
rows="3"
class="w-full"
placeholder="Ex: Unimed, Bradesco Saúde, Amil, SulAmérica..."
autoResize
/>
<small class="text-slate-400">Liste os convênios separados por vírgula ou um por linha.</small>
</div>
</template>
<div class="flex justify-end">
<Button
label="Salvar"
icon="pi pi-check"
:loading="savingCard === 'convenio'"
@click="saveCard('convenio')"
/>
</div>
</div>
</div>
<!-- Observações gerais -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<button
type="button"
class="w-full flex items-center justify-between gap-4 px-5 py-4 hover:bg-[var(--surface-hover)] transition text-left"
@click="toggleCard('observacoes')"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-slate-100 text-slate-400 flex items-center justify-center shrink-0">
<i class="pi pi-comment text-lg" />
</div>
<div>
<div class="font-semibold text-slate-800">Observações ao paciente</div>
<div class="text-sm text-slate-500">Texto exibido junto às formas de pagamento</div>
</div>
</div>
<i class="pi text-slate-400" :class="expandedCard === 'observacoes' ? 'pi-angle-up' : 'pi-angle-down'" />
</button>
<div v-if="expandedCard === 'observacoes'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
<Textarea
v-model="cfg.observacoes_pagamento"
rows="4"
class="w-full"
placeholder="Ex: O pagamento deve ser realizado no dia da sessão. Em caso de cancelamento com menos de 24h, a sessão será cobrada."
autoResize
/>
<div class="flex justify-end">
<Button
label="Salvar"
icon="pi pi-check"
:loading="savingCard === 'observacoes'"
@click="saveCard('observacoes')"
/>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,332 @@
<!-- src/layout/configuracoes/ConfiguracoesPrecificacaoPage.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useToast } from 'primevue/usetoast'
const toast = useToast()
const tenantStore = useTenantStore()
const loading = ref(true)
const saving = ref(false)
const ownerId = ref(null)
const tenantId = ref(null)
// ── Tipos de compromisso do tenant ─────────────────────────────────
const commitments = ref([]) // [{ id, label, native_key }]
// ── Preços: Map<commitmentId | '__default__', { price, notes }> ────
// '__default__' = linha com determined_commitment_id IS NULL
const prices = ref({}) // { [key]: { price: number|null, notes: '' } }
// ── Carregar commitments do tenant ─────────────────────────────────
async function loadCommitments () {
if (!tenantId.value) return
const { data, error } = await supabase
.from('determined_commitments')
.select('id, name, native_key, active')
.eq('tenant_id', tenantId.value)
.eq('active', true)
.order('name')
if (error) throw error
commitments.value = data || []
}
// ── Carregar preços existentes ──────────────────────────────────────
async function loadPrices (uid) {
const { data, error } = await supabase
.from('professional_pricing')
.select('id, determined_commitment_id, price, notes')
.eq('owner_id', uid)
if (error) throw error
const map = {}
for (const row of (data || [])) {
const key = row.determined_commitment_id ?? '__default__'
map[key] = { price: row.price != null ? Number(row.price) : null, notes: row.notes ?? '' }
}
prices.value = map
}
// ── Garantir que todos os commitments + default têm entrada no mapa
function ensureDefaults () {
if (!prices.value['__default__']) {
prices.value['__default__'] = { price: null, notes: '' }
}
for (const c of commitments.value) {
if (!prices.value[c.id]) {
prices.value[c.id] = { price: null, notes: '' }
}
}
}
// ── Mount ───────────────────────────────────────────────────────────
onMounted(async () => {
try {
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id
if (!uid) return
ownerId.value = uid
tenantId.value = tenantStore.activeTenantId || null
await Promise.all([
loadCommitments(),
loadPrices(uid),
])
ensureDefaults()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 })
} finally {
loading.value = false
}
})
// ── Salvar todos os preços configurados ────────────────────────────
async function save () {
if (!ownerId.value) return
saving.value = true
try {
const uid = ownerId.value
const tid = tenantId.value
const rows = []
// Linha padrão (NULL commitment)
const def = prices.value['__default__']
if (def?.price != null && def.price !== '') {
rows.push({
owner_id: uid,
tenant_id: tid,
determined_commitment_id: null,
price: Number(def.price),
notes: def.notes?.trim() || null,
})
}
// Linhas por tipo de compromisso
for (const c of commitments.value) {
const entry = prices.value[c.id]
if (entry?.price != null && entry.price !== '') {
rows.push({
owner_id: uid,
tenant_id: tid,
determined_commitment_id: c.id,
price: Number(entry.price),
notes: entry.notes?.trim() || null,
})
}
}
if (!rows.length) {
toast.add({ severity: 'warn', summary: 'Aviso', detail: 'Nenhum preço configurado para salvar.', life: 3000 })
return
}
const { error } = await supabase
.from('professional_pricing')
.upsert(rows, { onConflict: 'owner_id,determined_commitment_id' })
if (error) throw error
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Precificação atualizada!', life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar.', life: 4000 })
} finally {
saving.value = false
}
}
function fmtBRL (v) {
if (v == null || v === '') return '—'
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
</script>
<template>
<Toast />
<div class="flex flex-col gap-4">
<!-- Header card -->
<Card>
<template #content>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3">
<div class="cfg-icon-box">
<i class="pi pi-tag text-lg" />
</div>
<div>
<div class="text-900 font-semibold text-lg">Precificação</div>
<div class="text-600 text-sm">Defina o valor padrão da sessão e valores específicos por tipo de compromisso.</div>
</div>
</div>
<Button
label="Salvar preços"
icon="pi pi-check"
:loading="saving"
:disabled="loading"
@click="save"
/>
</div>
</template>
</Card>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-10">
<ProgressSpinner style="width:40px;height:40px" />
</div>
<template v-else>
<!-- Preço padrão -->
<Card>
<template #content>
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<i class="pi pi-star text-primary-500" />
<span class="font-semibold text-900">Preço padrão (fallback)</span>
</div>
<div class="text-600 text-sm">
Aplicado quando o tipo de compromisso da sessão não tem um preço específico cadastrado.
</div>
<div class="grid grid-cols-12 gap-3 mt-1">
<div class="col-span-12 sm:col-span-5">
<FloatLabel variant="on">
<InputNumber
v-model="prices['__default__'].price"
inputId="price-default"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
:max="99999"
:minFractionDigits="2"
fluid
placeholder="R$ 0,00"
/>
<label for="price-default">Valor da sessão (R$)</label>
</FloatLabel>
</div>
<div class="col-span-12 sm:col-span-7">
<FloatLabel variant="on">
<InputText
v-model="prices['__default__'].notes"
inputId="notes-default"
class="w-full"
placeholder="Ex: Particular, valor padrão"
/>
<label for="notes-default">Observação (opcional)</label>
</FloatLabel>
</div>
</div>
</div>
</template>
</Card>
<!-- Preços por tipo de compromisso -->
<Card v-if="commitments.length">
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-list" />
<span>Por tipo de compromisso</span>
</div>
</template>
<template #content>
<div class="text-600 text-sm mb-4">
Valores específicos sobrepõem o preço padrão quando o tipo de compromisso coincide.
</div>
<div class="flex flex-col gap-4">
<div
v-for="c in commitments"
:key="c.id"
class="commitment-row"
>
<div class="commitment-label">
<div class="font-medium text-900">{{ c.name }}</div>
<div v-if="prices[c.id]?.price != null" class="text-xs text-color-secondary mt-0.5">
{{ fmtBRL(prices[c.id]?.price) }}
</div>
</div>
<div class="grid grid-cols-12 gap-3 flex-1">
<div class="col-span-12 sm:col-span-5">
<FloatLabel variant="on">
<InputNumber
v-model="prices[c.id].price"
:inputId="`price-${c.id}`"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
:max="99999"
:minFractionDigits="2"
fluid
placeholder="R$ 0,00"
/>
<label :for="`price-${c.id}`">Valor (R$)</label>
</FloatLabel>
</div>
<div class="col-span-12 sm:col-span-7">
<FloatLabel variant="on">
<InputText
v-model="prices[c.id].notes"
:id="`notes-${c.id}`"
class="w-full"
placeholder="Ex: Convênio, valor reduzido..."
/>
<label :for="`notes-${c.id}`">Observação (opcional)</label>
</FloatLabel>
</div>
</div>
</div>
</div>
</template>
</Card>
<!-- Dica -->
<Message severity="info" :closable="false">
<span class="text-sm">
O preço configurado aqui é preenchido automaticamente ao criar uma sessão na agenda.
Você ainda pode ajustá-lo manualmente no diálogo de cada evento.
</span>
</Message>
</template>
</div>
</template>
<style scoped>
.cfg-icon-box {
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.875rem;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
flex-shrink: 0;
}
.commitment-row {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 0.75rem;
border-radius: 0.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-ground);
}
.commitment-label {
min-width: 9rem;
flex-shrink: 0;
padding-top: 0.5rem;
}
</style>