feat: orgaos page integrated with real API

This commit is contained in:
Junior
2026-03-15 00:30:57 -03:00
parent 4851f16d1d
commit 9ee07f5a28
2 changed files with 403 additions and 19 deletions

1
back-end Submodule

Submodule back-end added at 6480a285f5

View File

@@ -1,43 +1,426 @@
<!-- front-end/app/pages/gestao/orgaos.vue -->
<script setup lang="ts">
import { orgaos } from '~/data/mock/orgaos'
const { apiFetch } = useApi()
const esferaLabel = { federal: 'Federal', estadual: 'Estadual', municipal: 'Municipal' }
const columns = [
{ id: 'nome', accessorKey: 'nome', header: 'Órgão' },
{ id: 'esfera', accessorKey: 'esfera', header: 'Esfera' },
{ id: 'estado', accessorKey: 'estado', header: 'UF' },
{ id: 'totalParticipacoes', accessorKey: 'totalParticipacoes', header: 'Participações' },
{ id: 'totalVitorias', accessorKey: 'totalVitorias', header: 'Vitórias' },
{ id: 'taxaVitoria', accessorKey: 'taxaVitoria', header: 'Taxa' },
]
interface ApiOrgan {
ID: string
Nome: string
Esfera: string
Estado: string
Site: string
Telefone: string
Email: string
Observacoes: string
CreatedAt: string
}
const dados = computed(() => orgaos.map(o => ({
...o,
taxaVitoria: o.totalParticipacoes > 0 ? `${Math.round((o.totalVitorias / o.totalParticipacoes) * 100)}%` : '0%',
})))
const { data: orgaos, refresh } = await useAsyncData('orgaos', () =>
apiFetch<ApiOrgan[]>('/organs')
)
// --- Menu dropdown ---
const openMenuId = ref<string | null>(null)
const menuPos = ref({ top: 0, right: 0 })
function toggleMenu(id: string, event: MouseEvent) {
if (openMenuId.value === id) {
openMenuId.value = null
return
}
const btn = event.currentTarget as HTMLElement
const rect = btn.getBoundingClientRect()
menuPos.value = {
top: rect.bottom + window.scrollY + 4,
right: window.innerWidth - rect.right,
}
openMenuId.value = id
}
const closeMenu = () => { openMenuId.value = null }
onMounted(() => document.addEventListener('click', closeMenu))
onUnmounted(() => document.removeEventListener('click', closeMenu))
// --- Modal Criar ---
const showCreate = ref(false)
const createForm = reactive({
nome: '', esfera: 'federal', estado: '',
site: '', telefone: '', email: '', observacoes: '',
})
const createError = ref('')
const createLoading = ref(false)
async function criarOrgao() {
createError.value = ''
createLoading.value = true
try {
await apiFetch('/organs', { method: 'POST', body: { ...createForm } })
showCreate.value = false
Object.assign(createForm, { nome: '', esfera: 'federal', estado: '', site: '', telefone: '', email: '', observacoes: '' })
await refresh()
} catch (err: any) {
createError.value = err?.data?.error || 'Erro ao criar órgão.'
} finally {
createLoading.value = false
}
}
// --- Modal Editar ---
const showEdit = ref(false)
const editOrgao = ref<ApiOrgan | null>(null)
const editForm = reactive({
nome: '', esfera: 'federal', estado: '',
site: '', telefone: '', email: '', observacoes: '',
})
const editError = ref('')
const editLoading = ref(false)
function abrirEditar(o: ApiOrgan) {
editOrgao.value = o
Object.assign(editForm, {
nome: o.Nome, esfera: o.Esfera, estado: o.Estado,
site: o.Site, telefone: o.Telefone, email: o.Email, observacoes: o.Observacoes,
})
editError.value = ''
showEdit.value = true
}
async function salvarEdicao() {
if (!editOrgao.value) return
editError.value = ''
editLoading.value = true
try {
await apiFetch(`/organs/${editOrgao.value.ID}`, { method: 'PUT', body: { ...editForm } })
showEdit.value = false
await refresh()
} catch (err: any) {
editError.value = err?.data?.error || 'Erro ao salvar.'
} finally {
editLoading.value = false
}
}
// --- Modal Confirmação Exclusão ---
const showDeleteConfirm = ref(false)
const deleteTarget = ref<ApiOrgan | null>(null)
const deletingId = ref<string | null>(null)
function pedirExclusao(o: ApiOrgan) {
deleteTarget.value = o
showDeleteConfirm.value = true
}
async function confirmarExclusao() {
if (!deleteTarget.value) return
deletingId.value = deleteTarget.value.ID
try {
await apiFetch(`/organs/${deleteTarget.value.ID}`, { method: 'DELETE' })
showDeleteConfirm.value = false
deleteTarget.value = null
await refresh()
} catch {}
deletingId.value = null
}
// --- Helpers ---
const esferaLabel: Record<string, string> = {
federal: 'Federal',
estadual: 'Estadual',
municipal: 'Municipal',
}
const esferaBadgeClass: Record<string, string> = {
federal: 'badge-federal',
estadual: 'badge-estadual',
municipal: 'badge-municipal',
}
</script>
<template>
<div class="page">
<AppTopbar title="Órgãos Públicos" breadcrumb="Gestão · Órgãos">
<template #actions>
<UButton size="sm" class="btn-primary">+ Adicionar Órgão</UButton>
<UButton size="sm" class="btn-primary" @click="showCreate = true">+ Adicionar Órgão</UButton>
</template>
</AppTopbar>
<div class="content">
<div class="card">
<UTable :data="dados" :columns="columns">
<template #esfera-cell="{ row }">{{ esferaLabel[row.original.esfera] }}</template>
</UTable>
<table class="table">
<thead>
<tr>
<th>Órgão</th>
<th>Esfera</th>
<th>UF</th>
<th>Contato</th>
<th style="width:48px"></th>
</tr>
</thead>
<tbody>
<tr v-if="!orgaos?.length">
<td colspan="5" class="empty">Nenhum órgão cadastrado.</td>
</tr>
<tr v-for="o in orgaos" :key="o.ID">
<td class="td-nome">{{ o.Nome }}</td>
<td><span :class="['badge', esferaBadgeClass[o.Esfera]]">{{ esferaLabel[o.Esfera] }}</span></td>
<td>{{ o.Estado }}</td>
<td class="td-contato">
<span v-if="o.Email">{{ o.Email }}</span>
<span v-else-if="o.Telefone">{{ o.Telefone }}</span>
<span v-else class="muted"></span>
</td>
<td>
<button class="menu-btn" @click.stop="toggleMenu(o.ID, $event)"></button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Dropdown menu (Teleport para escapar do overflow da tabela) -->
<Teleport to="body">
<div
v-if="openMenuId"
class="dropdown-fixed"
:style="{ top: menuPos.top + 'px', right: menuPos.right + 'px' }"
@click.stop
>
<button @click="abrirEditar(orgaos!.find(o => o.ID === openMenuId)!); closeMenu()">Editar</button>
<button class="danger" @click="pedirExclusao(orgaos!.find(o => o.ID === openMenuId)!); closeMenu()">Excluir</button>
</div>
</Teleport>
<!-- Modal Criar -->
<Teleport to="body">
<div v-if="showCreate" class="modal-overlay" @click.self="showCreate = false">
<div class="modal">
<div class="modal-header">
<h2>Novo Órgão</h2>
<button class="close-btn" @click="showCreate = false"></button>
</div>
<div class="modal-body">
<div class="form-row">
<div class="form-group full">
<label>Nome *</label>
<input v-model="createForm.nome" placeholder="Ex: Ministério da Saúde" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Esfera *</label>
<select v-model="createForm.esfera">
<option value="federal">Federal</option>
<option value="estadual">Estadual</option>
<option value="municipal">Municipal</option>
</select>
</div>
<div class="form-group">
<label>UF *</label>
<input v-model="createForm.estado" placeholder="Ex: DF" maxlength="2" style="text-transform:uppercase" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Telefone</label>
<input v-model="createForm.telefone" placeholder="(61) 3000-0000" />
</div>
<div class="form-group">
<label>E-mail</label>
<input v-model="createForm.email" type="email" placeholder="contato@orgao.gov.br" />
</div>
</div>
<div class="form-row">
<div class="form-group full">
<label>Site</label>
<input v-model="createForm.site" placeholder="https://www.orgao.gov.br" />
</div>
</div>
<div class="form-row">
<div class="form-group full">
<label>Observações</label>
<textarea v-model="createForm.observacoes" rows="3" placeholder="Informações adicionais..."></textarea>
</div>
</div>
<p v-if="createError" class="error-msg">{{ createError }}</p>
</div>
<div class="modal-footer">
<button class="btn-cancel" @click="showCreate = false">Cancelar</button>
<button class="btn-save" :disabled="createLoading" @click="criarOrgao">
{{ createLoading ? 'Salvando...' : 'Criar Órgão' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Modal Editar -->
<Teleport to="body">
<div v-if="showEdit" class="modal-overlay" @click.self="showEdit = false">
<div class="modal">
<div class="modal-header">
<h2>Editar Órgão</h2>
<button class="close-btn" @click="showEdit = false"></button>
</div>
<div class="modal-body">
<div class="form-row">
<div class="form-group full">
<label>Nome *</label>
<input v-model="editForm.nome" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Esfera *</label>
<select v-model="editForm.esfera">
<option value="federal">Federal</option>
<option value="estadual">Estadual</option>
<option value="municipal">Municipal</option>
</select>
</div>
<div class="form-group">
<label>UF *</label>
<input v-model="editForm.estado" maxlength="2" style="text-transform:uppercase" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Telefone</label>
<input v-model="editForm.telefone" />
</div>
<div class="form-group">
<label>E-mail</label>
<input v-model="editForm.email" type="email" />
</div>
</div>
<div class="form-row">
<div class="form-group full">
<label>Site</label>
<input v-model="editForm.site" />
</div>
</div>
<div class="form-row">
<div class="form-group full">
<label>Observações</label>
<textarea v-model="editForm.observacoes" rows="3"></textarea>
</div>
</div>
<p v-if="editError" class="error-msg">{{ editError }}</p>
</div>
<div class="modal-footer">
<button class="btn-cancel" @click="showEdit = false">Cancelar</button>
<button class="btn-save" :disabled="editLoading" @click="salvarEdicao">
{{ editLoading ? 'Salvando...' : 'Salvar' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Modal Confirmação Exclusão -->
<Teleport to="body">
<div v-if="showDeleteConfirm" class="modal-overlay" @click.self="showDeleteConfirm = false">
<div class="modal modal-sm">
<div class="modal-header">
<h2>Excluir Órgão</h2>
<button class="close-btn" @click="showDeleteConfirm = false"></button>
</div>
<div class="modal-body">
<p>Tem certeza que deseja excluir <strong>{{ deleteTarget?.Nome }}</strong>?</p>
<p class="muted" style="margin-top:8px">Esta ação não pode ser desfeita.</p>
</div>
<div class="modal-footer">
<button class="btn-cancel" @click="showDeleteConfirm = false">Cancelar</button>
<button class="btn-danger" :disabled="!!deletingId" @click="confirmarExclusao">
{{ deletingId ? 'Excluindo...' : 'Excluir' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
.page { display: flex; flex-direction: column; height: 100vh; }
.content { padding: 20px 22px; flex: 1; overflow-y: auto; }
.card { background: white; border-radius: 11px; border: 1px solid #e2e8f0; overflow: hidden; }
.card { background: white; border-radius: 11px; border: 1px solid #e2e8f0; overflow: visible; }
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2) !important; }
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
.table th { padding: 12px 16px; text-align: left; font-size: 12px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: .05em; border-bottom: 1px solid #e2e8f0; background: #f8fafc; }
.table td { padding: 13px 16px; border-bottom: 1px solid #f1f5f9; color: #1e293b; vertical-align: middle; }
.table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: #f8fafc; }
.td-nome { font-weight: 500; }
.td-contato { color: #64748b; font-size: 13px; }
.muted { color: #94a3b8; }
.empty { text-align: center; color: #94a3b8; padding: 40px; }
.badge { display: inline-block; padding: 3px 10px; border-radius: 99px; font-size: 12px; font-weight: 500; }
.badge-federal { background: #dbeafe; color: #1d4ed8; }
.badge-estadual { background: #dcfce7; color: #16a34a; }
.badge-municipal{ background: #ffedd5; color: #c2410c; }
.menu-btn { background: none; border: none; cursor: pointer; font-size: 18px; color: #94a3b8; padding: 4px 8px; border-radius: 6px; line-height: 1; }
.menu-btn:hover { background: #f1f5f9; color: #475569; }
/* Modais */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal { background: white; border-radius: 12px; width: 560px; max-width: 95vw; box-shadow: 0 20px 60px rgba(0,0,0,.2); }
.modal-sm { width: 400px; }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 20px 24px 16px; border-bottom: 1px solid #e2e8f0; }
.modal-header h2 { font-size: 17px; font-weight: 600; margin: 0; color: #1e293b; }
.close-btn { background: none; border: none; font-size: 16px; cursor: pointer; color: #94a3b8; padding: 4px; }
.close-btn:hover { color: #475569; }
.modal-body { padding: 20px 24px; }
.modal-footer { padding: 16px 24px 20px; display: flex; justify-content: flex-end; gap: 10px; border-top: 1px solid #f1f5f9; }
.form-row { display: flex; gap: 14px; margin-bottom: 14px; }
.form-group { display: flex; flex-direction: column; gap: 5px; flex: 1; }
.form-group.full { flex: 1 1 100%; }
.form-group label { font-size: 13px; font-weight: 500; color: #374151; }
.form-group input, .form-group select, .form-group textarea {
border: 1px solid #d1d5db; border-radius: 7px; padding: 8px 11px;
font-size: 14px; color: #1e293b; outline: none; width: 100%; box-sizing: border-box;
font-family: inherit;
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,.15); }
.form-group textarea { resize: vertical; }
.error-msg { color: #ef4444; font-size: 13px; margin-top: 8px; }
.btn-cancel { padding: 8px 18px; border: 1px solid #e2e8f0; border-radius: 7px; background: white; cursor: pointer; font-size: 14px; color: #64748b; }
.btn-cancel:hover { background: #f8fafc; }
.btn-save { padding: 8px 20px; border: none; border-radius: 7px; background: linear-gradient(135deg,#667eea,#764ba2); color: white; cursor: pointer; font-size: 14px; font-weight: 500; }
.btn-save:hover:not(:disabled) { opacity: .9; }
.btn-save:disabled { opacity: .6; cursor: not-allowed; }
.btn-danger { padding: 8px 20px; border: none; border-radius: 7px; background: #ef4444; color: white; cursor: pointer; font-size: 14px; font-weight: 500; }
.btn-danger:hover:not(:disabled) { background: #dc2626; }
.btn-danger:disabled { opacity: .6; cursor: not-allowed; }
</style>
<!-- CSS global para o dropdown teleportado (não pode ser scoped) -->
<style>
.dropdown-fixed {
position: fixed;
z-index: 9999;
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,.12);
min-width: 140px;
overflow: hidden;
}
.dropdown-fixed button {
display: block;
width: 100%;
padding: 9px 16px;
text-align: left;
background: none;
border: none;
cursor: pointer;
font-size: 14px;
color: #1e293b;
}
.dropdown-fixed button:hover { background: #f8fafc; }
.dropdown-fixed button.danger { color: #ef4444; }
.dropdown-fixed button.danger:hover { background: #fef2f2; }
</style>