386 lines
16 KiB
Vue
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> |