491 lines
22 KiB
Vue
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>
|