Agenda, Agendador, Configurações
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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 dá “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>
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
|
||||
@@ -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" />
|
||||
Já 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>
|
||||
Há 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>
|
||||
Reference in New Issue
Block a user