654 lines
30 KiB
Vue
654 lines
30 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI
|
|
|--------------------------------------------------------------------------
|
|
| Criado e desenvolvido por Leonardo Nohama
|
|
|
|
|
| Tecnologia aplicada à escuta.
|
|
| Estrutura para o cuidado.
|
|
|
|
|
| Arquivo: src/features/agenda/pages/CompromissosDeterminados.vue
|
|
| Data: 2026
|
|
| Local: São Carlos/SP — Brasil
|
|
|--------------------------------------------------------------------------
|
|
| © 2026 — Todos os direitos reservados
|
|
|--------------------------------------------------------------------------
|
|
-->
|
|
<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 ───────────────────────────────────────────────
|
|
const mobileMenuRef = ref(null);
|
|
const searchDlgOpen = ref(false);
|
|
|
|
const mobileMenuItems = computed(() => [
|
|
{ label: 'Novo compromisso', icon: 'pi pi-plus', command: () => openCreate() },
|
|
{ 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 hasLoaded = ref(false);
|
|
const saving = ref(false);
|
|
|
|
const filters = reactive({
|
|
global: { value: null, matchMode: 'contains' },
|
|
name: { value: null, matchMode: 'contains' },
|
|
description: { value: null, matchMode: 'contains' }
|
|
});
|
|
|
|
const typeFilter = ref('all');
|
|
const typeOptions = [
|
|
{ label: 'Todos', value: 'all' },
|
|
{ label: 'Nativos', value: 'native' },
|
|
{ label: 'Meus', value: 'custom' }
|
|
];
|
|
|
|
const commitments = ref([]);
|
|
const totalsByCommitmentId = ref({});
|
|
|
|
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;
|
|
});
|
|
|
|
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;
|
|
});
|
|
|
|
// ── Stats ─────────────────────────────────────────────────
|
|
const stats = computed(() => {
|
|
const all = commitments.value;
|
|
const ativos = all.filter((c) => c.active).length;
|
|
const inativos = all.filter((c) => !c.active).length;
|
|
const nativos = all.filter((c) => c.is_native).length;
|
|
const meus = all.filter((c) => !c.is_native).length;
|
|
const totalMin = Object.values(totalsByCommitmentId.value).reduce((a, b) => a + b, 0);
|
|
return [
|
|
{ label: 'Total', value: all.length, cls: '' },
|
|
{ label: 'Ativos', value: ativos, cls: ativos > 0 ? 'stat-ok' : '' },
|
|
{ label: 'Inativos', value: inativos, cls: inativos > 0 ? 'stat-warn' : '' },
|
|
{ label: 'Nativos', value: nativos, cls: '' },
|
|
{ label: 'Meus', value: meus, cls: '' },
|
|
{ label: 'Tempo total', value: formatMinutes(totalMin), cls: '' }
|
|
];
|
|
});
|
|
|
|
function clearSearch() {
|
|
filters.global.value = null;
|
|
}
|
|
|
|
const dlgOpen = ref(false);
|
|
const dlgMode = ref('create');
|
|
const editing = ref(null);
|
|
|
|
const statsInfoDlg = reactive({ open: false, item: null });
|
|
function openStatsInfo(c) {
|
|
statsInfoDlg.item = c;
|
|
statsInfoDlg.open = true;
|
|
}
|
|
|
|
function getTenantId() {
|
|
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 {
|
|
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('created_at', { ascending: false });
|
|
if (cErr) throw cErr;
|
|
|
|
const ids = (cData || []).map((x) => x.id);
|
|
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) => {
|
|
if (!acc[row.commitment_id]) acc[row.commitment_id] = [];
|
|
acc[row.commitment_id].push({
|
|
id: row.id,
|
|
key: row.key,
|
|
label: row.label,
|
|
type: row.field_type,
|
|
required: !!row.required,
|
|
sort_order: row.sort_order
|
|
});
|
|
return acc;
|
|
}, {});
|
|
}
|
|
|
|
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;
|
|
totals[cid] = (totals[cid] || 0) + (Number(row.minutes ?? 0) || 0);
|
|
}
|
|
totalsByCommitmentId.value = totals;
|
|
|
|
commitments.value = (cData || []).map((c) => ({
|
|
...c,
|
|
fields: fieldsByCommitmentId[c.id] || []
|
|
}));
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4500 });
|
|
} finally {
|
|
loading.value = false;
|
|
hasLoaded.value = true;
|
|
}
|
|
}
|
|
|
|
function getTotalMinutes(id) {
|
|
return Number(totalsByCommitmentId.value?.[id] ?? 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;
|
|
}
|
|
|
|
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}" ${c.active ? 'ativo' : 'inativo'}.`, life: 2500 });
|
|
} catch (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 {
|
|
await supabase.auth.getUser();
|
|
if (dlgMode.value === 'create') {
|
|
const { data: newC, error: cErr } = await supabase
|
|
.from('determined_commitments')
|
|
.insert({
|
|
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
|
|
})
|
|
.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 { error: fErr } = await supabase.from('determined_commitment_fields').insert(
|
|
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)
|
|
}))
|
|
);
|
|
if (fErr) throw fErr;
|
|
}
|
|
toast.add({ severity: 'success', summary: 'Criado', detail: 'Compromisso criado.', life: 2500 });
|
|
} else {
|
|
const { error: upErr } = await supabase
|
|
.from('determined_commitments')
|
|
.update({
|
|
name: payload.name,
|
|
description: payload.description,
|
|
active: !!payload.active,
|
|
bg_color: payload.bg_color || null,
|
|
text_color: payload.text_color || null
|
|
})
|
|
.eq('tenant_id', tenantId)
|
|
.eq('id', payload.id);
|
|
if (upErr) throw upErr;
|
|
const { error: delErr } = await supabase.from('determined_commitment_fields').delete().eq('tenant_id', tenantId).eq('commitment_id', payload.id);
|
|
if (delErr) throw delErr;
|
|
const fields = Array.isArray(payload.fields) ? payload.fields : [];
|
|
if (fields.length > 0) {
|
|
const { error: insErr } = await supabase.from('determined_commitment_fields').insert(
|
|
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)
|
|
}))
|
|
);
|
|
if (insErr) throw insErr;
|
|
}
|
|
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Alterações salvas.', life: 2500 });
|
|
}
|
|
dlgOpen.value = false;
|
|
await fetchAll();
|
|
} catch (e) {
|
|
const msg = e?.message || '';
|
|
const detail = e?.code === '23505' || /duplicate key value/i.test(msg) ? 'Já existe um compromisso com esse nome. Escolha outro.' : msg || 'Falha ao salvar.';
|
|
toast.add({ severity: 'error', summary: 'Erro', detail, life: 4500 });
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
function confirmDelete(c) {
|
|
if (isDeleteLocked(c)) return;
|
|
if (!window.confirm(`Excluir "${c.name}"? Essa ação não pode ser desfeita.`)) 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?.length) throw new Error('DELETE bloqueado por RLS.');
|
|
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Compromisso removido.', life: 2500 });
|
|
dlgOpen.value = false;
|
|
await fetchAll();
|
|
} catch (e) {
|
|
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao excluir.', life: 4500 });
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
const HIGHLIGHT_MS = 24 * 60 * 60 * 1000;
|
|
function isRecent(row) {
|
|
if (!row?.created_at) return false;
|
|
return Date.now() - new Date(row.created_at).getTime() < HIGHLIGHT_MS;
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<!-- Sentinel -->
|
|
<div ref="headerSentinelRef" class="h-px" />
|
|
|
|
<!-- ══════════════════════════════════════
|
|
Hero sticky
|
|
═══════════════════════════════════════ -->
|
|
<div
|
|
ref="headerEl"
|
|
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
|
|
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
|
|
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
|
>
|
|
<!-- Blobs decorativos -->
|
|
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
|
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-emerald-400/10" />
|
|
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-indigo-500/[0.09]" />
|
|
</div>
|
|
|
|
<div class="relative z-[1] flex items-center gap-3">
|
|
<!-- Brand -->
|
|
<div class="flex items-center gap-2 flex-shrink-0">
|
|
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
|
<i class="pi pi-list text-base" />
|
|
</div>
|
|
<div class="min-w-0 hidden lg:block">
|
|
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Compromissos</div>
|
|
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">Configure tipos de compromissos e campos adicionais</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filtros + busca (desktop) -->
|
|
<div class="hidden xl:flex items-center gap-2 flex-1 min-w-0 mx-2">
|
|
<SelectButton v-model="typeFilter" :options="typeOptions" optionLabel="label" optionValue="value" :disabled="loading" size="small" />
|
|
<div class="w-56">
|
|
<FloatLabel variant="on">
|
|
<IconField class="w-full">
|
|
<InputIcon class="pi pi-search" />
|
|
<InputText id="cmprSearch" v-model="filters.global.value" class="w-full" :disabled="loading" />
|
|
</IconField>
|
|
<label for="cmprSearch">Buscar compromisso...</label>
|
|
</FloatLabel>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Ações desktop -->
|
|
<div class="hidden xl:flex items-center gap-1 flex-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" @click="fetchAll()" />
|
|
</div>
|
|
|
|
<!-- Mobile -->
|
|
<div class="flex xl:hidden items-center gap-1 flex-shrink-0 ml-auto">
|
|
<Button icon="pi pi-search" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="searchDlgOpen = true" />
|
|
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" @click="openCreate()" />
|
|
<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>
|
|
</div>
|
|
|
|
<!-- Dialog busca mobile -->
|
|
<Dialog v-model:visible="searchDlgOpen" modal :draggable="false" pt:mask:class="backdrop-blur-xs" 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-times" severity="secondary" @click="filters.global.value = null" />
|
|
</InputGroup>
|
|
</div>
|
|
<template #footer>
|
|
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="searchDlgOpen = false" />
|
|
</template>
|
|
</Dialog>
|
|
|
|
<!-- ══════════════════════════════════════
|
|
Conteúdo principal
|
|
═══════════════════════════════════════ -->
|
|
<div class="px-3 md:px-4 pb-5 flex flex-col xl:flex-row gap-3 xl:gap-4 items-start">
|
|
<!-- ── Coluna principal ── -->
|
|
<div class="w-full xl:flex-1 xl:min-w-0">
|
|
<!-- Stats row -->
|
|
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-2">
|
|
<template v-if="loading">
|
|
<Skeleton v-for="n in 6" :key="n" height="3.5rem" class="rounded-md" />
|
|
</template>
|
|
<template v-else>
|
|
<div v-for="s in stats" :key="s.label" class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
|
|
<div
|
|
class="text-[1.35rem] font-bold leading-none"
|
|
:class="{
|
|
'text-green-500': s.cls === 'stat-ok',
|
|
'text-red-500': s.cls === 'stat-warn',
|
|
'text-[var(--text-color)]': !s.cls
|
|
}"
|
|
>
|
|
{{ s.value }}
|
|
</div>
|
|
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Tabela -->
|
|
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
|
<!-- Cabeçalho da tabela -->
|
|
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<i class="pi pi-table text-[var(--text-color-secondary)] opacity-60" />
|
|
<span class="font-semibold text-sm">Lista completa</span>
|
|
</div>
|
|
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[1rem] font-bold">
|
|
{{ visibleCommitments.length }}
|
|
</span>
|
|
</div>
|
|
|
|
<DataTable
|
|
:value="visibleCommitments"
|
|
dataKey="id"
|
|
:loading="loading"
|
|
:paginator="visibleCommitments.length > 10"
|
|
:rows="10"
|
|
scrollable
|
|
scrollHeight="400px"
|
|
class="p-datatable-sm cmpr-datatable"
|
|
:rowClass="(r) => (isRecent(r) ? 'row-new-highlight' : '')"
|
|
:filters="filters"
|
|
filterDisplay="menu"
|
|
:globalFilterFields="['name', 'description']"
|
|
>
|
|
<Column field="name" header="Nome" sortable style="min-width: 14rem">
|
|
<template #body="{ data }">
|
|
<div class="flex items-center gap-2">
|
|
<div v-if="data.bg_color" class="w-3 h-3 rounded-full flex-shrink-0" :style="{ background: `#${data.bg_color}` }" />
|
|
<span class="font-semibold text-sm">{{ data.name }}</span>
|
|
<Tag v-if="data.is_native" value="Nativo" severity="info" class="text-xs" />
|
|
</div>
|
|
</template>
|
|
</Column>
|
|
|
|
<Column field="description" header="Descrição" style="min-width: 16rem">
|
|
<template #body="{ data }">
|
|
<span class="text-sm opacity-75">{{ data.description || '—' }}</span>
|
|
</template>
|
|
</Column>
|
|
|
|
<Column header="Tempo total" sortable style="min-width: 9rem">
|
|
<template #body="{ data }">
|
|
<span class="text-sm font-medium">{{ formatMinutes(getTotalMinutes(data.id)) }}</span>
|
|
</template>
|
|
</Column>
|
|
|
|
<Column header="Campos" style="width: 7rem">
|
|
<template #body="{ data }">
|
|
<span class="text-sm opacity-70">{{ data.fields?.length || 0 }}</span>
|
|
</template>
|
|
</Column>
|
|
|
|
<Column field="active" header="Ativo" style="width: 7rem">
|
|
<template #body="{ data }">
|
|
<InputSwitch v-model="data.active" :disabled="isActiveLocked(data) || saving" @change="onToggleActive(data)" />
|
|
</template>
|
|
</Column>
|
|
|
|
<Column header="Ação" style="width: 9rem">
|
|
<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" v-tooltip.top="isDeleteLocked(data) ? 'Nativo — não excluível' : 'Excluir'" @click="confirmDelete(data)" />
|
|
</div>
|
|
</template>
|
|
</Column>
|
|
|
|
<template #empty>
|
|
<div class="py-8 text-center">
|
|
<i class="pi pi-search text-2xl opacity-20 mb-2 block" />
|
|
<div class="font-semibold text-sm">Nenhum compromisso encontrado</div>
|
|
<div class="text-xs opacity-60 mt-1">Limpe os filtros ou cadastre um novo</div>
|
|
</div>
|
|
</template>
|
|
</DataTable>
|
|
</div>
|
|
|
|
<LoadedPhraseBlock v-if="hasLoaded" />
|
|
</div>
|
|
<!-- fim coluna principal -->
|
|
|
|
<!-- ── PAINEL LATERAL: tipos de compromisso ─────────── -->
|
|
<div class="w-full xl:w-[272px] xl:flex-shrink-0">
|
|
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
|
<!-- Header do painel -->
|
|
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5 border-b border-[var(--surface-border,#f1f5f9)]">
|
|
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
|
<i class="pi pi-chart-bar text-[0.9rem]" />
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Estatísticas</span>
|
|
<span class="block text-[0.72rem] text-[var(--text-color-secondary)]">Tempo por tipo de compromisso</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Skeleton -->
|
|
<div v-if="loading" class="flex flex-col gap-2 p-3">
|
|
<Skeleton v-for="n in 4" :key="n" height="2.75rem" class="rounded-md" />
|
|
</div>
|
|
|
|
<!-- Empty -->
|
|
<div v-else-if="cardsCommitments.length === 0" class="flex flex-col items-center justify-center gap-2 px-4 py-8 text-center text-[var(--text-color-secondary)]">
|
|
<i class="pi pi-list text-2xl opacity-20" />
|
|
<div class="font-semibold text-[0.8rem]">Nenhum compromisso</div>
|
|
<div class="text-[0.72rem] opacity-70 leading-relaxed">Cadastre tipos de compromissos para vê-los aqui.</div>
|
|
</div>
|
|
|
|
<!-- Lista de compromissos -->
|
|
<div v-else class="flex flex-col max-h-[480px] overflow-y-auto">
|
|
<button
|
|
v-for="(c, idx) in cardsCommitments"
|
|
:key="c.id"
|
|
class="flex items-center gap-2.5 px-3.5 py-2.5 text-left w-full bg-transparent border-none hover:bg-[var(--surface-ground,#f8fafc)] transition-colors duration-100 cursor-pointer group"
|
|
:class="{ 'border-b border-[var(--surface-border,#f1f5f9)]': idx < cardsCommitments.length - 1 }"
|
|
@click="openStatsInfo(c)"
|
|
>
|
|
<!-- Dot cor -->
|
|
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0" :style="c.bg_color ? { background: `#${c.bg_color}` } : { background: 'var(--surface-border)' }" />
|
|
<div class="flex-1 min-w-0">
|
|
<div class="font-semibold text-[0.8rem] truncate text-[var(--text-color)]">{{ c.name }}</div>
|
|
<div class="text-[0.72rem] text-[var(--text-color-secondary)]">
|
|
{{ formatMinutes(getTotalMinutes(c.id)) }}
|
|
</div>
|
|
</div>
|
|
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-30 group-hover:opacity-100 group-hover:text-[var(--primary-color,#6366f1)] transition-all duration-150 flex-shrink-0" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Footer hint -->
|
|
<div v-if="cardsCommitments.length" class="px-3.5 py-2 text-[1rem] text-[var(--text-color-secondary)] opacity-50 border-t border-[var(--surface-border,#f1f5f9)] text-center"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Dialog -->
|
|
<DeterminedCommitmentDialog v-model="dlgOpen" :mode="dlgMode" :saving="saving" :commitment="editing" @save="onSave" @delete="onDelete" />
|
|
|
|
<!-- Dialog: Stats info -->
|
|
<Dialog v-model:visible="statsInfoDlg.open" modal :draggable="false" class="w-[96vw] max-w-sm" pt:mask:class="backdrop-blur-xs">
|
|
<template #header>
|
|
<div class="flex items-center gap-3">
|
|
<div class="w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-base" :style="{ background: statsInfoDlg.item?.bg_color ? `#${statsInfoDlg.item.bg_color}` : 'var(--primary-color,#6366f1)' }">
|
|
{{ (statsInfoDlg.item?.name || '?')[0].toUpperCase() }}
|
|
</div>
|
|
<div>
|
|
<div class="text-[1rem] font-bold" :style="{ color: statsInfoDlg.item?.bg_color ? `#${statsInfoDlg.item.bg_color}` : 'var(--primary-color,#6366f1)' }">
|
|
{{ statsInfoDlg.item?.name }}
|
|
</div>
|
|
<div class="text-[0.72rem] text-[var(--text-color-secondary)]">Estatísticas</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="flex flex-col items-center justify-center gap-4 py-6 text-center">
|
|
<div class="w-16 h-16 rounded-full bg-indigo-500/10 flex items-center justify-center">
|
|
<i class="pi pi-chart-bar text-3xl text-indigo-500" />
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Em breve</div>
|
|
<div class="text-[0.85rem] text-[var(--text-color-secondary)] leading-relaxed max-w-[220px]">O tempo total investido neste tipo de compromisso será exibido aqui após você ter eventos concluídos.</div>
|
|
</div>
|
|
<div class="flex items-center gap-2 px-3 py-2 rounded-md bg-[var(--surface-ground,#f8fafc)]">
|
|
<i class="pi pi-clock text-[0.75rem] text-[var(--text-color-secondary)]" />
|
|
<span class="text-[0.78rem] text-[var(--text-color-secondary)]"> Atual: {{ statsInfoDlg.item ? formatMinutes(getTotalMinutes(statsInfoDlg.item.id)) : '0m' }} </span>
|
|
</div>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="statsInfoDlg.open = false" />
|
|
</template>
|
|
</Dialog>
|
|
</template>
|