Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras
This commit is contained in:
@@ -15,474 +15,554 @@
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<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'
|
||||
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 toast = useToast();
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
const { discounts, loading, error: discountsError, load, save, remove } = usePatientDiscounts()
|
||||
const { discounts, loading, error: discountsError, load, save, remove } = usePatientDiscounts();
|
||||
|
||||
const ownerId = ref(null)
|
||||
const tenantId = ref(null)
|
||||
const pageLoading = ref(true)
|
||||
const patients = ref([])
|
||||
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,
|
||||
})
|
||||
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)
|
||||
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)
|
||||
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
|
||||
})
|
||||
const map = {};
|
||||
for (const p of patients.value) map[p.id] = p.nome_completo;
|
||||
return map;
|
||||
});
|
||||
|
||||
function patientName (pid) {
|
||||
return patientMap.value[pid] || pid || '—'
|
||||
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 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 = {}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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 })
|
||||
}
|
||||
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 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 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')
|
||||
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
|
||||
try {
|
||||
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id;
|
||||
if (!uid) return;
|
||||
|
||||
ownerId.value = uid
|
||||
tenantId.value = tenantStore.activeTenantId || null
|
||||
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 }),
|
||||
])
|
||||
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
|
||||
}
|
||||
})
|
||||
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">
|
||||
<!-- Subheader -->
|
||||
<div class="cfg-subheader">
|
||||
<div class="cfg-subheader__icon"><i class="pi pi-percentage" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="cfg-subheader__title">Descontos por Paciente</div>
|
||||
<div class="cfg-subheader__sub">Descontos recorrentes aplicados automaticamente por paciente</div>
|
||||
</div>
|
||||
<div class="cfg-subheader__actions">
|
||||
<Button
|
||||
label="Novo desconto"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
:disabled="pageLoading || addingNew"
|
||||
class="rounded-full"
|
||||
@click="
|
||||
addingNew = true;
|
||||
cancelEdit();
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Subheader -->
|
||||
<div class="cfg-subheader">
|
||||
<div class="cfg-subheader__icon"><i class="pi pi-percentage" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="cfg-subheader__title">Descontos por Paciente</div>
|
||||
<div class="cfg-subheader__sub">Descontos recorrentes aplicados automaticamente por paciente</div>
|
||||
</div>
|
||||
<div class="cfg-subheader__actions">
|
||||
<Button label="Novo desconto" icon="pi pi-plus" size="small" :disabled="pageLoading || addingNew" class="rounded-full" @click="addingNew = true; cancelEdit()" />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- ══ 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>
|
||||
/* ── 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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
.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;
|
||||
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);
|
||||
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;
|
||||
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__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;
|
||||
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; }
|
||||
.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;
|
||||
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;
|
||||
}
|
||||
.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;
|
||||
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);
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
.dsc-form-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.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);
|
||||
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;
|
||||
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);
|
||||
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>
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user