Files
agenciapsilmno/src/layout/configuracoes/ConfiguracoesDescontosPage.vue
T

491 lines
22 KiB
Vue

<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/layout/configuracoes/ConfiguracoesDescontosPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<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 { usePatientDiscounts } from '@/features/agenda/composables/usePatientDiscounts';
const toast = useToast();
const tenantStore = useTenantStore();
const { discounts, loading, error: discountsError, load, save, remove } = usePatientDiscounts();
const ownerId = ref(null);
const tenantId = ref(null);
const pageLoading = ref(true);
const patients = ref([]);
// ── Formulário ────────────────────────────────────────────────────────
const emptyForm = () => ({
patient_id: null,
discount_pct: 0,
discount_flat: 0,
reason: '',
active_from: null,
active_to: 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);
// ── Lookup de nome do paciente ────────────────────────────────────────
const patientMap = computed(() => {
const map = {};
for (const p of patients.value) map[p.id] = p.nome_completo;
return map;
});
function patientName(pid) {
return patientMap.value[pid] || pid || '—';
}
// ── Editar ────────────────────────────────────────────────────────────
function startEdit(disc) {
editingId.value = disc.id;
editForm.value = {
id: disc.id,
owner_id: ownerId.value,
tenant_id: tenantId.value,
patient_id: disc.patient_id,
discount_pct: disc.discount_pct != null ? Number(disc.discount_pct) : 0,
discount_flat: disc.discount_flat != null ? Number(disc.discount_flat) : 0,
reason: disc.reason ?? '',
active_from: disc.active_from ? new Date(disc.active_from) : null,
active_to: disc.active_to ? new Date(disc.active_to) : null
};
}
function cancelEdit() {
editingId.value = null;
editForm.value = {};
}
async function saveEdit() {
if (!editForm.value.discount_pct && !editForm.value.discount_flat) {
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Informe ao menos um desconto (% ou R$).', life: 3000 });
return;
}
savingEdit.value = true;
try {
await save({
...editForm.value,
discount_pct: editForm.value.discount_pct ?? 0,
discount_flat: editForm.value.discount_flat ?? 0,
reason: editForm.value.reason?.trim() || null,
active_from: editForm.value.active_from ? editForm.value.active_from.toISOString().slice(0, 10) : null,
active_to: editForm.value.active_to ? editForm.value.active_to.toISOString().slice(0, 10) : null
});
await load(ownerId.value);
cancelEdit();
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Desconto atualizado.', life: 3000 });
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: discountsError.value || 'Falha ao salvar.', life: 4000 });
} finally {
savingEdit.value = false;
}
}
// ── Novo desconto ─────────────────────────────────────────────────────
async function saveNew() {
if (!newForm.value.patient_id) {
toast.add({ severity: 'warn', summary: 'Campo obrigatório', detail: 'Selecione um paciente.', life: 3000 });
return;
}
if (!newForm.value.discount_pct && !newForm.value.discount_flat) {
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Informe ao menos um desconto (% ou R$).', life: 3000 });
return;
}
savingNew.value = true;
try {
await save({
owner_id: ownerId.value,
tenant_id: tenantId.value,
patient_id: newForm.value.patient_id,
discount_pct: newForm.value.discount_pct ?? 0,
discount_flat: newForm.value.discount_flat ?? 0,
reason: newForm.value.reason?.trim() || null,
active_from: newForm.value.active_from ? newForm.value.active_from.toISOString().slice(0, 10) : null,
active_to: newForm.value.active_to ? newForm.value.active_to.toISOString().slice(0, 10) : null,
active: true
});
await load(ownerId.value);
newForm.value = emptyForm();
addingNew.value = false;
toast.add({ severity: 'success', summary: 'Adicionado', detail: 'Desconto criado com sucesso.', life: 3000 });
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: discountsError.value || 'Falha ao criar.', life: 4000 });
} finally {
savingNew.value = false;
}
}
// ── Desativar (soft-delete) ───────────────────────────────────────────
async function confirmRemove(id) {
try {
await remove(id);
toast.add({ severity: 'success', summary: 'Desativado', detail: 'Desconto desativado.', life: 3000 });
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: discountsError.value || 'Falha ao desativar.', life: 4000 });
}
}
// ── Helpers de exibição ───────────────────────────────────────────────
function fmtBRL(v) {
if (v == null || v === '' || Number(v) === 0) return null;
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function fmtPct(v) {
if (v == null || Number(v) === 0) return null;
return `${Number(v).toLocaleString('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}%`;
}
function fmtDate(v) {
if (!v) return null;
const d = new Date(v);
return d.toLocaleDateString('pt-BR');
}
// ── 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;
const [, { data: pData }] = await Promise.all([load(uid), supabase.from('patients').select('id, nome_completo').eq('owner_id', uid).eq('status', 'Ativo').order('nome_completo', { ascending: true })]);
patients.value = pData || [];
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 });
} finally {
pageLoading.value = false;
}
});
</script>
<template>
<div class="flex flex-col gap-3">
<!-- SKELETON -->
<template v-if="pageLoading || loading">
<div class="cfg-wrap">
<div class="cfg-wrap__head">
<Skeleton width="1.75rem" height="1.75rem" border-radius="6px" />
<Skeleton width="11rem" height="12px" />
</div>
<div v-for="n in 3" :key="n" class="flex items-center gap-3 px-4 py-3 border-b border-[var(--surface-border)] last:border-b-0">
<div class="flex flex-col gap-2 flex-1">
<Skeleton :width="n % 2 === 0 ? '14rem' : '10rem'" height="11px" />
<Skeleton width="8rem" height="10px" />
</div>
<Skeleton width="4rem" height="1.4rem" border-radius="999px" />
</div>
</div>
<AppLoadingPhrases action="Carregando descontos por paciente..." containerClass="py-6" />
</template>
<template v-else>
<!-- Lista + form -->
<div v-if="discounts.length || addingNew" class="cfg-wrap">
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-percentage" /></div>
<span class="cfg-wrap__title">Descontos cadastrados</span>
<span class="cfg-wrap__count">{{ discounts.length }}</span>
</div>
<div class="dsc-list">
<template v-for="disc in discounts" :key="disc.id">
<!-- Edição inline -->
<div v-if="editingId === disc.id" class="dsc-form-row dsc-form-row--editing">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<Select v-model="editForm.patient_id" inputId="edit-patient" :options="patients" optionLabel="nome_completo" optionValue="id" disabled class="w-full" />
<label for="edit-patient">Paciente</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="editForm.discount_pct" inputId="edit-pct" :min="0" :max="100" :minFractionDigits="0" :maxFractionDigits="2" suffix="%" fluid />
<label for="edit-pct">Desconto %</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="editForm.discount_flat" inputId="edit-flat" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
<label for="edit-flat">Desconto R$</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="editForm.active_from" inputId="edit-from" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="edit-from">Vigência: de</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="editForm.active_to" inputId="edit-to" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="edit-to">Vigência: até</label>
</FloatLabel>
</div>
<div class="col-span-12">
<FloatLabel variant="on">
<InputText v-model="editForm.reason" inputId="edit-reason" class="w-full" />
<label for="edit-reason">Motivo (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" icon="pi pi-times" size="small" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="savingEdit" class="rounded-full" @click="saveEdit" />
</div>
</div>
<!-- Leitura -->
<div v-else class="dsc-row">
<div class="dsc-row__info">
<div class="font-semibold text-sm">{{ patientName(disc.patient_id) }}</div>
<div class="flex flex-wrap gap-1.5 mt-1">
<span v-if="fmtPct(disc.discount_pct)" class="dsc-badge">{{ fmtPct(disc.discount_pct) }}</span>
<span v-if="fmtBRL(disc.discount_flat)" class="dsc-badge">{{ fmtBRL(disc.discount_flat) }}</span>
</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">
<span v-if="disc.active_from || disc.active_to"> {{ fmtDate(disc.active_from) || 'Indefinido' }} {{ fmtDate(disc.active_to) || 'Indefinido' }} </span>
<span v-else>Vigência indefinida</span>
</div>
<div v-if="disc.reason" class="text-xs text-[var(--text-color-secondary)] italic mt-0.5">{{ disc.reason }}</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Tag :value="disc.active ? 'Ativo' : 'Inativo'" :severity="disc.active ? 'success' : 'secondary'" />
<Button
icon="pi pi-pencil"
size="small"
severity="secondary"
text
v-tooltip.top="'Editar'"
@click="
startEdit(disc);
addingNew = false;
"
/>
<Button v-if="disc.active" icon="pi pi-ban" size="small" severity="danger" text v-tooltip.top="'Desativar'" @click="confirmRemove(disc.id)" />
</div>
</div>
</template>
<!-- Form novo desconto -->
<div v-if="addingNew" class="dsc-form-row dsc-form-row--new">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<Select v-model="newForm.patient_id" inputId="new-patient" :options="patients" optionLabel="nome_completo" optionValue="id" filter class="w-full" />
<label for="new-patient">Paciente *</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="newForm.discount_pct" inputId="new-pct" :min="0" :max="100" :minFractionDigits="0" :maxFractionDigits="2" suffix="%" fluid />
<label for="new-pct">Desconto %</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="newForm.discount_flat" inputId="new-flat" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
<label for="new-flat">Desconto R$</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="newForm.active_from" inputId="new-from" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="new-from">Vigência: de</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="newForm.active_to" inputId="new-to" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="new-to">Vigência: até</label>
</FloatLabel>
</div>
<div class="col-span-12">
<FloatLabel variant="on">
<InputText v-model="newForm.reason" inputId="new-reason" class="w-full" />
<label for="new-reason">Motivo (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<Button
label="Cancelar"
icon="pi pi-times"
size="small"
severity="secondary"
outlined
class="rounded-full"
@click="
addingNew = false;
newForm = emptyForm();
"
/>
<Button label="Adicionar" icon="pi pi-check" size="small" :loading="savingNew" class="rounded-full" @click="saveNew" />
</div>
</div>
</div>
</div>
<!-- Estado vazio -->
<div v-else class="cfg-empty">
<i class="pi pi-percentage text-3xl opacity-25" />
<div class="text-sm font-medium">Nenhum desconto cadastrado ainda.</div>
<Button label="Adicionar primeiro desconto" icon="pi pi-plus" outlined size="small" class="rounded-full" @click="addingNew = true" />
</div>
<!-- Dica -->
<Message severity="info" :closable="false">
<span class="text-sm"> Descontos ativos são aplicados automaticamente ao adicionar serviços em sessões do paciente correspondente. Você ainda pode ajustá-los manualmente no diálogo de cada evento. </span>
</Message>
<LoadedPhraseBlock />
</template>
</div>
</template>
<style scoped>
/* ── 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);
flex: 1;
}
.cfg-wrap__count {
font-size: 0.7rem;
font-weight: 700;
background: var(--primary-color, #6366f1);
color: #fff;
padding: 1px 8px;
border-radius: 999px;
flex-shrink: 0;
}
/* ── Lista de descontos ───────────────────────────── */
.dsc-list {
display: flex;
flex-direction: column;
}
/* Linha de leitura */
.dsc-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
transition: background 0.1s;
flex-wrap: wrap;
}
.dsc-row:last-child {
border-bottom: none;
}
.dsc-row:hover {
background: var(--surface-hover);
}
.dsc-row__info {
flex: 1;
min-width: 0;
}
/* Badge de valor */
.dsc-badge {
font-size: 0.75rem;
font-weight: 600;
color: var(--primary-color, #6366f1);
background: color-mix(in srgb, var(--primary-color, #6366f1) 10%, transparent);
padding: 0.15rem 0.5rem;
border-radius: 6px;
white-space: nowrap;
}
/* Form de adição/edição */
.dsc-form-row {
padding: 1rem;
border-bottom: 1px solid var(--surface-border);
}
.dsc-form-row:last-child {
border-bottom: none;
}
.dsc-form-row--editing {
background: color-mix(in srgb, var(--primary-color, #6366f1) 3%, var(--surface-card));
border-left: 3px solid color-mix(in srgb, var(--primary-color, #6366f1) 50%, transparent);
}
.dsc-form-row--new {
background: var(--surface-ground);
border-top: 1px dashed var(--surface-border);
border-bottom: none;
}
/* ── 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>