Files
agenciapsilmno/src/layout/configuracoes/ConfiguracoesPrecificacaoPage.vue
2026-03-17 21:08:14 -03:00

386 lines
16 KiB
Vue

<!-- src/layout/configuracoes/ConfiguracoesPrecificacaoPage.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 { useServices } from '@/features/agenda/composables/useServices'
const toast = useToast()
const tenantStore = useTenantStore()
const { services, loading, error: servicesError, load, save, toggle, remove } = useServices()
const ownerId = ref(null)
const tenantId = ref(null)
const slotMode = ref('fixed')
const pageLoading = ref(true)
const isDynamic = computed(() => slotMode.value === 'dynamic')
// ── Formulário novo serviço ──────────────────────────────────────────
const emptyForm = () => ({ name: '', description: '', price: null, duration_min: null })
const newForm = ref(emptyForm())
const addingNew = ref(false)
const savingNew = ref(false)
// ── Edição inline ────────────────────────────────────────────────────
const editingId = ref(null)
const editForm = ref({})
const savingEdit = ref(false)
function startEdit (svc) {
editingId.value = svc.id
editForm.value = {
id: svc.id,
owner_id: ownerId.value,
tenant_id: tenantId.value,
name: svc.name,
description: svc.description ?? '',
price: svc.price != null ? Number(svc.price) : null,
duration_min: svc.duration_min ?? null,
}
}
function cancelEdit () {
editingId.value = null
editForm.value = {}
}
async function saveEdit () {
if (!editForm.value.name?.trim() || editForm.value.price == null) {
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Nome e preço são obrigatórios.', life: 3000 })
return
}
savingEdit.value = true
try {
await save({
...editForm.value,
name: editForm.value.name.trim(),
description: editForm.value.description?.trim() || null,
duration_min: isDynamic.value ? (editForm.value.duration_min ?? null) : null,
})
await load(ownerId.value)
cancelEdit()
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Serviço atualizado.', life: 3000 })
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao salvar.', life: 4000 })
} finally {
savingEdit.value = false
}
}
async function saveNew () {
if (!newForm.value.name?.trim() || newForm.value.price == null) {
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Nome e preço são obrigatórios.', life: 3000 })
return
}
savingNew.value = true
try {
await save({
owner_id: ownerId.value,
tenant_id: tenantId.value,
name: newForm.value.name.trim(),
description: newForm.value.description?.trim() || null,
price: newForm.value.price,
duration_min: isDynamic.value ? (newForm.value.duration_min ?? null) : null,
})
await load(ownerId.value)
newForm.value = emptyForm()
addingNew.value = false
toast.add({ severity: 'success', summary: 'Adicionado', detail: 'Serviço criado com sucesso.', life: 3000 })
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao criar.', life: 4000 })
} finally {
savingNew.value = false
}
}
async function toggleService (svc) {
try {
await toggle(svc.id, !svc.active)
toast.add({ severity: 'success', summary: svc.active ? 'Desativado' : 'Ativado', detail: `Serviço ${svc.active ? 'desativado' : 'ativado'}.`, life: 3000 })
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao atualizar.', life: 4000 })
}
}
async function confirmRemove (id) {
try {
await remove(id)
toast.add({ severity: 'success', summary: 'Removido', detail: 'Serviço removido permanentemente.', life: 3000 })
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao remover.', life: 4000 })
}
}
function fmtBRL (v) {
if (v == null || v === '') return '—'
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
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
const { data: cfg } = await supabase
.from('agenda_configuracoes')
.select('slot_mode')
.eq('owner_id', uid)
.maybeSingle()
slotMode.value = cfg?.slot_mode ?? 'fixed'
await load(uid)
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 })
} finally {
pageLoading.value = false
}
})
</script>
<template>
<Toast />
<div class="flex flex-col gap-3">
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-tag" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Precificação</div>
<div class="cfg-subheader__sub">Valor padrão da sessão e preços por tipo de compromisso</div>
</div>
<div class="cfg-subheader__actions">
<Button label="Novo serviço" icon="pi pi-plus" size="small" :disabled="pageLoading || addingNew" class="rounded-full" @click="addingNew = true; cancelEdit()" />
</div>
</div>
<!-- Loading -->
<div v-if="pageLoading || loading" class="flex justify-center py-10">
<ProgressSpinner style="width:40px;height:40px" />
</div>
<template v-else>
<Message v-if="isDynamic" severity="info" :closable="false">
<span class="text-sm">Modo <b>dinâmico</b> ativo a duração da sessão é definida pelo serviço selecionado.</span>
</Message>
<!-- Form novo serviço -->
<div v-if="addingNew" class="cfg-wrap">
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-plus" /></div>
<span class="cfg-wrap__title">Novo serviço</span>
</div>
<div class="svc-form">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputText v-model="newForm.name" inputId="new-name" class="w-full" />
<label for="new-name">Nome *</label>
</FloatLabel>
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputNumber v-model="newForm.price" inputId="new-price" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" fluid />
<label for="new-price">Preço (R$) *</label>
</FloatLabel>
</div>
<div v-if="isDynamic" class="col-span-12 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="newForm.duration_min" inputId="new-duration" :min="1" :max="480" fluid />
<label for="new-duration">Duração (min)</label>
</FloatLabel>
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputText v-model="newForm.description" inputId="new-desc" class="w-full" />
<label for="new-desc">Descrição (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="addingNew = false; newForm = emptyForm()" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingNew" @click="saveNew" />
</div>
</div>
</div>
<!-- Lista vazia -->
<div v-if="!services.length && !addingNew" class="cfg-empty">
<i class="pi pi-tag text-3xl opacity-25" />
<div class="text-sm font-medium">Nenhum serviço cadastrado</div>
<div class="text-xs opacity-70">Clique em "Novo serviço" para começar.</div>
</div>
<!-- Lista de serviços -->
<div v-for="svc in services" :key="svc.id" class="cfg-wrap" :class="{ 'opacity-60': !svc.active }">
<!-- Modo leitura: head clicável -->
<template v-if="editingId !== svc.id">
<div class="svc-row">
<div class="svc-row__icon">
<i class="pi pi-tag" />
</div>
<div class="svc-row__info">
<div class="font-semibold text-sm">{{ svc.name }}</div>
<div class="flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5 text-xs text-[var(--text-color-secondary)]">
<span class="font-semibold text-[var(--primary-color)]">{{ fmtBRL(svc.price) }}</span>
<span v-if="svc.duration_min">{{ svc.duration_min }} min</span>
<span v-if="svc.description" class="italic">{{ svc.description }}</span>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<Tag :value="svc.active ? 'Ativo' : 'Inativo'" :severity="svc.active ? 'success' : 'secondary'" />
<Button
:icon="svc.active ? 'pi pi-eye-slash' : 'pi pi-eye'"
:severity="svc.active ? 'secondary' : 'success'"
outlined size="small"
v-tooltip.top="svc.active ? 'Desativar' : 'Ativar'"
@click="toggleService(svc)"
/>
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="startEdit(svc)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Remover'" @click="confirmRemove(svc.id)" />
</div>
</div>
</template>
<!-- Modo edição -->
<template v-else>
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-pencil" /></div>
<span class="cfg-wrap__title">Editar {{ svc.name }}</span>
</div>
<div class="svc-form svc-form--editing">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputText v-model="editForm.name" :inputId="`edit-name-${svc.id}`" class="w-full" />
<label :for="`edit-name-${svc.id}`">Nome *</label>
</FloatLabel>
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputNumber v-model="editForm.price" :inputId="`edit-price-${svc.id}`" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" fluid />
<label :for="`edit-price-${svc.id}`">Preço (R$) *</label>
</FloatLabel>
</div>
<div v-if="isDynamic" class="col-span-12 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="editForm.duration_min" :inputId="`edit-dur-${svc.id}`" :min="1" :max="480" fluid />
<label :for="`edit-dur-${svc.id}`">Duração (min)</label>
</FloatLabel>
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputText v-model="editForm.description" :inputId="`edit-desc-${svc.id}`" class="w-full" />
<label :for="`edit-desc-${svc.id}`">Descrição (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingEdit" @click="saveEdit" />
</div>
</div>
</template>
</div>
<Message severity="info" :closable="false">
<span class="text-sm">Os serviços cadastrados aqui aparecem para seleção ao criar ou editar um evento na agenda.</span>
</Message>
</template>
</div>
</template>
<style scoped>
/* ── Subheader degradê ────────────────────────────── */
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute; top: -20px; right: -20px;
width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.85rem;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color,#6366f1); }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
/* ── Card wrap ────────────────────────────────────── */
.cfg-wrap {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card);
overflow: hidden;
}
.cfg-wrap__head {
display: flex; align-items: center; gap: 0.625rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
}
.cfg-wrap__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 12%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.8rem;
}
.cfg-wrap__title { font-size: 0.88rem; font-weight: 700; color: var(--text-color); }
/* ── Linha de leitura do serviço ──────────────────── */
.svc-row {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.75rem 1rem; flex-wrap: wrap;
transition: background 0.1s;
}
.svc-row:hover { background: var(--surface-hover); }
.svc-row__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 10%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.78rem;
}
.svc-row__info { flex: 1; min-width: 0; }
/* ── Form (novo + edição) ─────────────────────────── */
.svc-form {
padding: 1rem;
display: flex; flex-direction: column; gap: 0.75rem;
}
.svc-form--editing {
background: color-mix(in srgb, var(--primary-color,#6366f1) 3%, var(--surface-card));
border-top: 2px solid color-mix(in srgb, var(--primary-color,#6366f1) 40%, transparent);
}
/* ── Empty state ──────────────────────────────────── */
.cfg-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 0.75rem; padding: 2.5rem 1rem; text-align: center;
color: var(--text-color-secondary);
border: 1px dashed var(--surface-border);
border-radius: 6px; background: var(--surface-ground);
}
</style>