This commit is contained in:
Leonardo
2026-03-06 06:37:13 -03:00
parent d58dc21297
commit f733db8436
146 changed files with 43436 additions and 12779 deletions
@@ -0,0 +1,753 @@
<!-- src/features/agenda/pages/CompromissosDeterminados.vue -->
<template>
<Toast />
<!-- Sentinel para detecção de sticky -->
<div ref="headerSentinelRef" class="cmpr-sentinel" />
<!-- Hero Header sticky -->
<div ref="headerEl" class="cmpr-hero mx-3 md:mx-5 mb-4" :class="{ 'cmpr-hero--stuck': headerStuck }">
<!-- Blobs decorativos -->
<div class="cmpr-hero__blobs" aria-hidden="true">
<div class="cmpr-hero__blob cmpr-hero__blob--1" />
<div class="cmpr-hero__blob cmpr-hero__blob--2" />
</div>
<!-- Linha 1: brand + controles -->
<div class="cmpr-hero__row1">
<div class="cmpr-hero__brand">
<div class="cmpr-hero__icon">
<i class="pi pi-list text-lg" />
</div>
<div class="min-w-0">
<div class="cmpr-hero__title">Compromissos</div>
<div class="cmpr-hero__sub">Configure tipos de compromissos e campos adicionais</div>
</div>
</div>
<!-- Controles desktop (1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button label="Novo" icon="pi pi-plus" class="rounded-full" :disabled="loading" @click="openCreate()" />
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Recarregar" @click="fetchAll()" />
</div>
<!-- Menu mobile (<1200px) -->
<div class="flex xl:hidden items-center shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
</div>
</div>
<!-- Divisor -->
<Divider class="cmpr-hero__divider my-2" />
<!-- Linha 2: filtros + busca (oculta no mobile) -->
<div class="cmpr-hero__row2">
<SelectButton v-model="typeFilter" :options="typeOptions" optionLabel="label" optionValue="value" :disabled="loading" />
<InputGroup class="w-72">
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
<InputText v-model="filters.global.value" placeholder="Buscar compromisso" :disabled="loading" />
<Button
v-if="filters.global.value"
icon="pi pi-trash"
severity="danger"
title="Limpar busca"
@click="clearSearch"
/>
</InputGroup>
</div>
</div>
<!-- Dialog de busca (mobile) -->
<Dialog v-model:visible="searchDlgOpen" modal :draggable="false" header="Buscar compromisso" class="w-[94vw] max-w-sm">
<div class="pt-1">
<InputGroup>
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
<InputText v-model="filters.global.value" placeholder="Nome ou descrição..." autofocus />
<Button
v-if="filters.global.value"
icon="pi pi-trash"
severity="danger"
title="Limpar"
@click="filters.global.value = null"
/>
</InputGroup>
</div>
<template #footer>
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="searchDlgOpen = false" />
</template>
</Dialog>
<!-- Cards -->
<div class="mb-4 px-3 md:px-5 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
<Card
v-for="c in cardsCommitments"
:key="c.id"
class="rounded-3xl border border-[var(--surface-border)] shadow-sm"
>
<template #content>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span
v-if="c.bg_color"
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold"
:style="{ backgroundColor: `#${c.bg_color}`, color: c.text_color || '#ffffff' }"
>{{ c.name }}</span>
<span v-else class="truncate text-base font-semibold">{{ c.name }}</span>
<Tag v-if="c.is_native" value="Nativo" severity="info" />
</div>
<div class="mt-1 line-clamp-2 text-sm opacity-70">
{{ c.description || '—' }}
</div>
<div class="mt-3 text-sm">
<span class="opacity-70">Tempo total:</span>
<span class="ml-2 font-semibold">{{ formatMinutes(getTotalMinutes(c.id)) }}</span>
</div>
</div>
<div class="flex flex-col items-end gap-2">
<div class="flex items-center gap-2">
<span class="text-xs opacity-70">Ativo</span>
<InputSwitch
v-model="c.active"
:disabled="isActiveLocked(c) || saving"
@change="onToggleActive(c)"
/>
</div>
<div class="flex items-center gap-1">
<Button
icon="pi pi-pencil"
severity="secondary"
text
rounded
:disabled="isEditLocked(c) || saving"
@click="openEdit(c)"
/>
<Button
icon="pi pi-trash"
severity="danger"
text
rounded
:disabled="isDeleteLocked(c) || saving"
@click="confirmDelete(c)"
/>
</div>
</div>
</div>
</template>
</Card>
</div>
<!-- Tabela -->
<div class="mx-3 md:mx-5 mb-5 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 shadow-sm">
<div class="mb-2 flex items-center justify-between gap-3">
<div class="text-base font-semibold">Lista de compromissos</div>
<div class="text-sm opacity-60">
{{ visibleCommitments.length }} itens
</div>
</div>
<DataTable
:value="visibleCommitments"
dataKey="id"
:loading="loading"
:paginator="true"
:rows="10"
responsiveLayout="scroll"
class="p-datatable-sm"
:filters="filters"
filterDisplay="menu"
:globalFilterFields="['name','description']"
>
<Column field="name" header="Nome" sortable filter filterPlaceholder="Filtrar nome" style="min-width: 14rem">
<template #body="{ data }">
<div class="flex items-center gap-2">
<span class="font-semibold">{{ data.name }}</span>
<Tag v-if="data.is_native" value="Nativo" severity="info" />
</div>
</template>
</Column>
<Column field="description" header="Descrição" sortable filter filterPlaceholder="Filtrar descrição" style="min-width: 18rem">
<template #body="{ data }">
<span class="opacity-80">{{ data.description || '—' }}</span>
</template>
</Column>
<Column header="Tempo total" sortable style="min-width: 10rem">
<template #body="{ data }">
{{ formatMinutes(getTotalMinutes(data.id)) }}
</template>
</Column>
<Column field="active" header="Ativo" style="width: 8rem">
<template #body="{ data }">
<InputSwitch
v-model="data.active"
:disabled="isActiveLocked(data) || saving"
@change="onToggleActive(data)"
/>
</template>
</Column>
<Column header="Ação" style="width: 10rem">
<template #body="{ data }">
<div class="flex items-center gap-1">
<Button
icon="pi pi-pencil"
severity="secondary"
text
rounded
:disabled="isEditLocked(data) || saving"
@click="openEdit(data)"
/>
<Button
icon="pi pi-trash"
severity="danger"
text
rounded
:disabled="isDeleteLocked(data) || saving"
@click="confirmDelete(data)"
/>
</div>
</template>
</Column>
<template #empty>
<div class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-search text-xl" />
</div>
<div class="font-semibold">Nenhum compromisso determinístico encontrado</div>
<div class="mt-1 text-sm text-color-secondary">
Tente limpar filtros ou mudar o termo de busca.
</div>
<div class="mt-4 flex justify-center gap-2">
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="clearSearch" />
<Button icon="pi pi-plus" label="Cadastrar compromisso" @click="openCreate()" />
</div>
</div>
</template>
</DataTable>
</div>
<!-- Dialog -->
<DeterminedCommitmentDialog
v-model="dlgOpen"
:mode="dlgMode"
:saving="saving"
:commitment="editing"
@save="onSave"
@delete="onDelete"
/>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useToast } from 'primevue/usetoast'
import InputSwitch from 'primevue/inputswitch'
import Menu from 'primevue/menu'
import DeterminedCommitmentDialog from '@/features/agenda/components/DeterminedCommitmentDialog.vue'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
const toast = useToast()
const tenantStore = useTenantStore()
// ── Hero sticky ───────────────────────────────────────────
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
// ── Mobile menu ───────────────────────────────────────────
const mobileMenuRef = ref(null)
const searchDlgOpen = ref(false)
const mobileMenuItems = computed(() => [
{
label: 'Novo compromisso',
icon: 'pi pi-plus',
command: () => openCreate()
},
{
label: 'Buscar',
icon: 'pi pi-search',
command: () => { searchDlgOpen.value = true }
},
{ separator: true },
{
label: 'Recarregar',
icon: 'pi pi-refresh',
command: () => fetchAll()
}
])
onMounted(async () => {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
_observer = new IntersectionObserver(
([entry]) => { headerStuck.value = !entry.isIntersecting },
{ threshold: 0, rootMargin }
)
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
await tenantStore.loadSessionAndTenant()
await fetchAll()
})
onBeforeUnmount(() => { _observer?.disconnect() })
const loading = ref(false)
const saving = ref(false)
const filters = reactive({
global: { value: null, matchMode: 'contains' },
name: { value: null, matchMode: 'contains' },
description: { value: null, matchMode: 'contains' }
})
/**
* Filtro por tipo (Todos / Nativos / Meus)
* - aplica na tabela (via computed) e nos cards
*/
const typeFilter = ref('all')
const typeOptions = [
{ label: 'Todos', value: 'all' },
{ label: 'Nativos', value: 'native' },
{ label: 'Meus', value: 'custom' }
]
/**
* Modelo de compromisso (tipo determinístico):
* - is_native: template do sistema
* - is_locked: trava comportamento (ex: Sessão)
* - fields: campos adicionais (dinâmicos)
*/
const commitments = ref([])
// Totais reais (minutos) agregados de commitment_time_logs
const totalsByCommitmentId = ref({})
/**
* Lista base para tabela:
* - aplica filtro por tipo (Todos / Nativos / Meus)
* - (global search do DataTable continua via :filters)
*/
const visibleCommitments = computed(() => {
let list = commitments.value
if (typeFilter.value === 'native') list = list.filter(c => !!c.is_native)
if (typeFilter.value === 'custom') list = list.filter(c => !c.is_native)
return list
})
/**
* Lista para cards:
* - aplica o mesmo filtro de tipo
* - + aplica busca global (para cards acompanharem a barra de busca)
*/
const cardsCommitments = computed(() => {
let list = visibleCommitments.value
const q = String(filters.global?.value ?? '').trim().toLowerCase()
if (q) {
list = list.filter(c =>
String(c.name || '').toLowerCase().includes(q) ||
String(c.description || '').toLowerCase().includes(q)
)
}
return list
})
function clearSearch () {
filters.global.value = null
}
const dlgOpen = ref(false)
const dlgMode = ref('create') // 'create' | 'edit'
const editing = ref(null)
function getTenantId () {
// ✅ sem fallback (evita vazamento clinic↔therapist)
return tenantStore.activeTenantId || null
}
async function fetchAll () {
const tenantId = getTenantId()
if (!tenantId) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Tenant inválido.', life: 3000 })
return
}
loading.value = true
try {
// 1) commitments
const { data: cData, error: cErr } = await supabase
.from('determined_commitments')
.select('id, tenant_id, is_native, native_key, is_locked, active, name, description, bg_color, text_color, created_at, updated_at')
.eq('tenant_id', tenantId)
.order('is_native', { ascending: false })
.order('name', { ascending: true })
if (cErr) throw cErr
const ids = (cData || []).map(x => x.id)
// 2) fields
let fieldsByCommitmentId = {}
if (ids.length > 0) {
const { data: fData, error: fErr } = await supabase
.from('determined_commitment_fields')
.select('id, tenant_id, commitment_id, key, label, field_type, required, sort_order')
.eq('tenant_id', tenantId)
.in('commitment_id', ids)
.order('sort_order', { ascending: true })
if (fErr) throw fErr
fieldsByCommitmentId = (fData || []).reduce((acc, row) => {
const k = row.commitment_id
if (!acc[k]) acc[k] = []
acc[k].push({
id: row.id,
key: row.key,
label: row.label,
type: row.field_type,
required: !!row.required,
sort_order: row.sort_order
})
return acc
}, {})
}
// 3) totals (logs)
const { data: lData, error: lErr } = await supabase
.from('commitment_time_logs')
.select('commitment_id, minutes')
.eq('tenant_id', tenantId)
if (lErr) throw lErr
const totals = {}
for (const row of (lData || [])) {
const cid = row.commitment_id
const m = Number(row.minutes ?? 0) || 0
totals[cid] = (totals[cid] || 0) + m
}
totalsByCommitmentId.value = totals
// 4) merge
commitments.value = (cData || []).map(c => ({
...c,
fields: fieldsByCommitmentId[c.id] || []
}))
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar compromissos.', life: 4500 })
} finally {
loading.value = false
}
}
function getTotalMinutes (commitmentId) {
return Number(totalsByCommitmentId.value?.[commitmentId] ?? 0)
}
function formatMinutes (minutes) {
const m = Math.max(0, Number(minutes) || 0)
const h = Math.floor(m / 60)
const mm = m % 60
if (h <= 0) return `${mm}m`
return `${h}h ${String(mm).padStart(2, '0')}m`
}
function isActiveLocked (c) {
return !!c.is_locked
}
function isDeleteLocked (c) {
return !!c.is_native
}
function isEditLocked (_c) {
return false // edição sempre permitida; só o "ativo" fica travado
}
function openCreate () {
dlgMode.value = 'create'
editing.value = null
dlgOpen.value = true
}
function openEdit (c) {
dlgMode.value = 'edit'
editing.value = JSON.parse(JSON.stringify(c))
dlgOpen.value = true
}
async function onToggleActive (c) {
if (isActiveLocked(c)) return
const tenantId = getTenantId()
if (!tenantId) return
saving.value = true
try {
const { error } = await supabase
.from('determined_commitments')
.update({ active: !!c.active })
.eq('tenant_id', tenantId)
.eq('id', c.id)
if (error) throw error
toast.add({
severity: 'success',
summary: 'Atualizado',
detail: `${c.name}” agora está ${c.active ? 'ativo' : 'inativo'}.`,
life: 2500
})
} catch (e) {
console.error(e)
c.active = !c.active
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao atualizar.', life: 4500 })
} finally {
saving.value = false
}
}
async function onSave (payload) {
const tenantId = getTenantId()
if (!tenantId) return
saving.value = true
try {
// pega usuário atual (se quiser auditoria futura)
await supabase.auth.getUser()
if (dlgMode.value === 'create') {
const insertRow = {
tenant_id: tenantId,
is_native: false,
native_key: null,
is_locked: false,
active: !!payload.active,
name: payload.name,
description: payload.description,
bg_color: payload.bg_color || null,
text_color: payload.text_color || null
}
const { data: newC, error: cErr } = await supabase
.from('determined_commitments')
.insert(insertRow)
.select('id, tenant_id, is_native, native_key, is_locked, active, name, description, bg_color, text_color, created_at, updated_at')
.single()
if (cErr) throw cErr
const fields = Array.isArray(payload.fields) ? payload.fields : []
if (fields.length > 0) {
const rows = fields.map((f, idx) => ({
tenant_id: tenantId,
commitment_id: newC.id,
key: f.key,
label: f.label,
field_type: f.type,
required: !!f.required,
sort_order: Number(f.sort_order ?? ((idx + 1) * 10))
}))
const { error: fErr } = await supabase
.from('determined_commitment_fields')
.insert(rows)
if (fErr) throw fErr
}
toast.add({ severity: 'success', summary: 'Criado', detail: 'Compromisso criado com sucesso.', life: 2500 })
dlgOpen.value = false
await fetchAll()
} else {
const updateRow = {
name: payload.name,
description: payload.description,
active: !!payload.active,
bg_color: payload.bg_color || null,
text_color: payload.text_color || null
}
const { error: upErr } = await supabase
.from('determined_commitments')
.update(updateRow)
.eq('tenant_id', tenantId)
.eq('id', payload.id)
if (upErr) throw upErr
const fields = Array.isArray(payload.fields) ? payload.fields : []
const { error: delErr } = await supabase
.from('determined_commitment_fields')
.delete()
.eq('tenant_id', tenantId)
.eq('commitment_id', payload.id)
if (delErr) throw delErr
if (fields.length > 0) {
const rows = fields.map((f, idx) => ({
tenant_id: tenantId,
commitment_id: payload.id,
key: f.key,
label: f.label,
field_type: f.type,
required: !!f.required,
sort_order: Number(f.sort_order ?? ((idx + 1) * 10))
}))
const { error: insErr } = await supabase
.from('determined_commitment_fields')
.insert(rows)
if (insErr) throw insErr
}
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Alterações salvas.', life: 2500 })
dlgOpen.value = false
await fetchAll()
}
} catch (e) {
console.error(e)
const msg = e?.message || ''
const detail = (e?.code === '23505' || /duplicate key value/i.test(msg))
? 'Já existe um compromisso com esse nome neste tenant. Escolha outro nome.'
: (msg || 'Falha ao salvar compromisso.')
toast.add({ severity: 'error', summary: 'Erro', detail, life: 4500 })
} finally {
saving.value = false
}
}
function confirmDelete (c) {
if (isDeleteLocked(c)) return
const ok = window.confirm(`Excluir “${c.name}”? Essa ação não pode ser desfeita.`)
if (!ok) return
onDelete(c)
}
async function onDelete (c) {
if (isDeleteLocked(c)) return
const tenantId = getTenantId()
if (!tenantId) return
saving.value = true
try {
const { error: fErr } = await supabase
.from('determined_commitment_fields')
.delete()
.eq('tenant_id', tenantId)
.eq('commitment_id', c.id)
if (fErr) throw fErr
const { error: lErr } = await supabase
.from('commitment_time_logs')
.delete()
.eq('tenant_id', tenantId)
.eq('commitment_id', c.id)
if (lErr) throw lErr
const { data: delRows, error: dErr } = await supabase
.from('determined_commitments')
.delete()
.eq('tenant_id', tenantId)
.eq('id', c.id)
.eq('is_native', false)
.select('id')
if (dErr) throw dErr
if (!delRows || delRows.length === 0) {
throw new Error('DELETE bloqueado por RLS (0 linhas). Confirme policy dc_delete_custom_for_active_member.')
}
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Compromisso removido.', life: 2500 })
dlgOpen.value = false
await fetchAll()
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao excluir compromisso.', life: 4500 })
} finally {
saving.value = false
}
}
</script>
<style scoped>
/* ── Hero Header ─────────────────────────────────── */
.cmpr-sentinel { height: 1px; }
.cmpr-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.cmpr-hero--stuck {
margin-left: 0;
margin-right: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
/* Blobs */
.cmpr-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.cmpr-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.cmpr-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(52,211,153,0.10); }
.cmpr-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
/* Linha 1 */
.cmpr-hero__row1 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.cmpr-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex: 1; min-width: 0;
}
.cmpr-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem;
flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.cmpr-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.cmpr-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
/* Linha 2 (oculta no mobile) */
.cmpr-hero__row2 {
position: relative; z-index: 1;
display: flex; align-items: center;
gap: 0.75rem;
}
@media (max-width: 767px) {
.cmpr-hero__divider,
.cmpr-hero__row2 { display: none; }
}
</style>