feat: orgaos page integrated with real API
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user