Files
lic/front-end/app/pages/gestao/contratos.vue

620 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- front-end/app/pages/gestao/contratos.vue -->
<script setup lang="ts">
const { apiFetch } = useApi()
const { public: { apiBase } } = useRuntimeConfig()
const token = useCookie<string | null>('auth_token')
interface ApiContract {
ID: string
Numero: string
Orgao: string
Objeto: string
Valor: number
DataInicio: string
DataFim: string
FiscalContrato: string
Reajuste: string
ProrrogacaoMaxima: string | null
Sla: string
Status: string
}
interface ApiFile {
ID: string
Nome: string
Size: number
CreatedAt: string
}
const { data: contratos, refresh } = await useAsyncData('contratos', () =>
apiFetch<ApiContract[]>('/contracts')
)
interface ApiOrgan { ID: string; Nome: string }
const { data: orgaos } = await useAsyncData('orgaos-select', () =>
apiFetch<ApiOrgan[]>('/organs')
)
function formatDate(d: string) {
if (!d) return ''
return new Date(d).toLocaleDateString('pt-BR')
}
function formatValor(v: number) {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(v)
}
function formatFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
// --- 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({
numero: '', orgao: '', objeto: '', valor: 0,
data_inicio: '', data_fim: '', fiscal_contrato: '',
reajuste: '', prorrogacao_maxima: '', sla: '', status: 'ativo',
})
const createError = ref('')
const createLoading = ref(false)
async function criarContrato() {
createError.value = ''
createLoading.value = true
try {
await apiFetch('/contracts', { method: 'POST', body: { ...createForm } })
showCreate.value = false
Object.assign(createForm, { numero: '', orgao: '', objeto: '', valor: 0, data_inicio: '', data_fim: '', fiscal_contrato: '', reajuste: '', prorrogacao_maxima: '', sla: '', status: 'ativo' })
await refresh()
} catch (err: any) {
createError.value = err?.data?.error || 'Erro ao criar contrato.'
} finally {
createLoading.value = false
}
}
// --- Modal Editar ---
const showEdit = ref(false)
const editContract = ref<ApiContract | null>(null)
const editForm = reactive({
numero: '', orgao: '', objeto: '', valor: 0,
data_inicio: '', data_fim: '', fiscal_contrato: '',
reajuste: '', prorrogacao_maxima: '', sla: '', status: 'ativo',
})
const editError = ref('')
const editLoading = ref(false)
function abrirEditar(c: ApiContract) {
editContract.value = c
Object.assign(editForm, {
numero: c.Numero, orgao: c.Orgao, objeto: c.Objeto, valor: c.Valor,
data_inicio: c.DataInicio ? c.DataInicio.slice(0, 10) : '',
data_fim: c.DataFim ? c.DataFim.slice(0, 10) : '',
fiscal_contrato: c.FiscalContrato, reajuste: c.Reajuste,
prorrogacao_maxima: c.ProrrogacaoMaxima ? c.ProrrogacaoMaxima.slice(0, 10) : '',
sla: c.Sla, status: c.Status,
})
editError.value = ''
showEdit.value = true
}
async function salvarEdicao() {
if (!editContract.value) return
editError.value = ''
editLoading.value = true
try {
await apiFetch(`/contracts/${editContract.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<ApiContract | null>(null)
const deletingId = ref<string | null>(null)
function pedirExclusao(c: ApiContract) {
deleteTarget.value = c
showDeleteConfirm.value = true
}
async function confirmarExclusao() {
if (!deleteTarget.value) return
deletingId.value = deleteTarget.value.ID
try {
await apiFetch(`/contracts/${deleteTarget.value.ID}`, { method: 'DELETE' })
showDeleteConfirm.value = false
deleteTarget.value = null
await refresh()
} catch {}
deletingId.value = null
}
// --- Modal Visualizar ---
const showView = ref(false)
const viewContract = ref<ApiContract | null>(null)
const viewFiles = ref<ApiFile[]>([])
const viewFilesLoading = ref(false)
const uploadLoading = ref(false)
async function abrirVisualizar(c: ApiContract) {
viewContract.value = c
showView.value = true
viewFilesLoading.value = true
try {
viewFiles.value = await apiFetch<ApiFile[]>(`/contracts/${c.ID}/files`)
} catch { viewFiles.value = [] }
viewFilesLoading.value = false
}
async function uploadArquivos(event: Event) {
const input = event.target as HTMLInputElement
if (!input.files?.length || !viewContract.value) return
uploadLoading.value = true
const formData = new FormData()
for (const file of Array.from(input.files)) formData.append('file', file)
try {
await $fetch(`${apiBase}/contracts/${viewContract.value.ID}/files`, {
method: 'POST',
headers: { Authorization: `Bearer ${token.value}` },
body: formData,
})
viewFiles.value = await apiFetch<ApiFile[]>(`/contracts/${viewContract.value.ID}/files`)
} catch {}
uploadLoading.value = false
input.value = ''
}
async function excluirArquivo(fileId: string) {
if (!viewContract.value) return
try {
await apiFetch(`/contracts/${viewContract.value.ID}/files/${fileId}`, { method: 'DELETE' })
viewFiles.value = viewFiles.value.filter(f => f.ID !== fileId)
} catch {}
}
async function downloadArquivo(contractId: string, fileId: string, nome: string) {
try {
const blob = await $fetch<Blob>(`${apiBase}/contracts/${contractId}/files/${fileId}/download`, {
headers: { Authorization: `Bearer ${token.value}` },
responseType: 'blob',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = nome
a.click()
URL.revokeObjectURL(url)
} catch {}
}
</script>
<template>
<div class="page">
<AppTopbar title="Contratos" breadcrumb="Gestão · Contratos">
<template #actions>
<UButton size="sm" class="btn-primary" @click="showCreate = true">+ Novo Contrato</UButton>
</template>
</AppTopbar>
<div class="content">
<div class="card">
<table class="tbl">
<thead>
<tr>
<th></th>
<th>Órgão</th>
<th>Objeto</th>
<th>Valor</th>
<th>Vigência</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="c in (contratos ?? [])" :key="c.ID">
<td class="mono">{{ c.Numero }}</td>
<td>{{ c.Orgao }}</td>
<td class="objeto">{{ c.Objeto }}</td>
<td class="valor">{{ formatValor(c.Valor) }}</td>
<td class="datas">{{ formatDate(c.DataInicio) }} {{ formatDate(c.DataFim) }}</td>
<td>
<span :class="['badge', `badge-${c.Status}`]">{{ c.Status }}</span>
</td>
<td class="menu-cell" @click.stop>
<button class="menu-btn" @click.stop="toggleMenu(c.ID, $event)"></button>
</td>
</tr>
<tr v-if="!contratos?.length">
<td colspan="7" class="empty">Nenhum contrato encontrado.</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Dropdown menu (teleportado para o body para escapar do overflow) -->
<Teleport to="body">
<div
v-if="openMenuId"
class="dropdown-fixed"
:style="{ top: menuPos.top + 'px', right: menuPos.right + 'px' }"
@click.stop
>
<button @click="abrirVisualizar((contratos ?? []).find(c => c.ID === openMenuId)!); openMenuId = null">Visualizar</button>
<button @click="abrirEditar((contratos ?? []).find(c => c.ID === openMenuId)!); openMenuId = null">Editar</button>
<button class="drop-danger" @click="pedirExclusao((contratos ?? []).find(c => c.ID === openMenuId)!); openMenuId = null">Excluir</button>
</div>
</Teleport>
<!-- Modal Criar -->
<div v-if="showCreate" class="overlay">
<div class="modal">
<h2>Novo Contrato</h2>
<div class="fields-grid">
<div class="field">
<label>Número</label>
<UInput v-model="createForm.numero" placeholder="Ex: 001/2024" class="w-full" />
</div>
<div class="field">
<label>Órgão</label>
<select v-model="createForm.orgao" class="field-select">
<option value="" disabled>Selecione o órgão</option>
<option v-for="o in orgaos" :key="o.ID" :value="o.Nome">{{ o.Nome }}</option>
</select>
</div>
<div class="field field-full">
<label>Objeto</label>
<UInput v-model="createForm.objeto" placeholder="Descrição do objeto" class="w-full" />
</div>
<div class="field">
<label>Valor (R$)</label>
<UInput v-model.number="createForm.valor" type="number" placeholder="0" class="w-full" />
</div>
<div class="field">
<label>Status</label>
<select v-model="createForm.status" class="sel">
<option value="ativo">Ativo</option>
<option value="encerrado">Encerrado</option>
<option value="suspenso">Suspenso</option>
</select>
</div>
<div class="field">
<label>Data Início</label>
<UInput v-model="createForm.data_inicio" type="date" class="w-full" />
</div>
<div class="field">
<label>Data Fim</label>
<UInput v-model="createForm.data_fim" type="date" class="w-full" />
</div>
<div class="field">
<label>Fiscal</label>
<UInput v-model="createForm.fiscal_contrato" placeholder="Nome do fiscal" class="w-full" />
</div>
<div class="field">
<label>Reajuste</label>
<UInput v-model="createForm.reajuste" placeholder="Ex: IPCA" class="w-full" />
</div>
<div class="field">
<label>Prorrogação Máxima</label>
<UInput v-model="createForm.prorrogacao_maxima" type="date" class="w-full" />
</div>
<div class="field field-full">
<label>SLA</label>
<UInput v-model="createForm.sla" placeholder="Acordo de nível de serviço" class="w-full" />
</div>
</div>
<p v-if="createError" class="err">{{ createError }}</p>
<div class="modal-actions">
<button class="btn-cancel" @click="showCreate = false">Cancelar</button>
<UButton class="btn-primary" size="sm" :loading="createLoading" @click="criarContrato">Criar</UButton>
</div>
</div>
</div>
<!-- Modal Editar -->
<div v-if="showEdit" class="overlay">
<div class="modal">
<h2>Editar Contrato</h2>
<div class="fields-grid">
<div class="field">
<label>Número</label>
<UInput v-model="editForm.numero" class="w-full" />
</div>
<div class="field">
<label>Órgão</label>
<select v-model="editForm.orgao" class="field-select">
<option value="" disabled>Selecione o órgão</option>
<option v-for="o in orgaos" :key="o.ID" :value="o.Nome">{{ o.Nome }}</option>
</select>
</div>
<div class="field field-full">
<label>Objeto</label>
<UInput v-model="editForm.objeto" class="w-full" />
</div>
<div class="field">
<label>Valor (R$)</label>
<UInput v-model.number="editForm.valor" type="number" class="w-full" />
</div>
<div class="field">
<label>Status</label>
<select v-model="editForm.status" class="sel">
<option value="ativo">Ativo</option>
<option value="encerrado">Encerrado</option>
<option value="suspenso">Suspenso</option>
</select>
</div>
<div class="field">
<label>Data Início</label>
<UInput v-model="editForm.data_inicio" type="date" class="w-full" />
</div>
<div class="field">
<label>Data Fim</label>
<UInput v-model="editForm.data_fim" type="date" class="w-full" />
</div>
<div class="field">
<label>Fiscal</label>
<UInput v-model="editForm.fiscal_contrato" class="w-full" />
</div>
<div class="field">
<label>Reajuste</label>
<UInput v-model="editForm.reajuste" class="w-full" />
</div>
<div class="field">
<label>Prorrogação Máxima</label>
<UInput v-model="editForm.prorrogacao_maxima" type="date" class="w-full" />
</div>
<div class="field field-full">
<label>SLA</label>
<UInput v-model="editForm.sla" class="w-full" />
</div>
</div>
<p v-if="editError" class="err">{{ editError }}</p>
<div class="modal-actions">
<button class="btn-cancel" @click="showEdit = false">Cancelar</button>
<UButton class="btn-primary" size="sm" :loading="editLoading" @click="salvarEdicao">Salvar</UButton>
</div>
</div>
</div>
<!-- Modal Confirmação Exclusão -->
<div v-if="showDeleteConfirm" class="overlay">
<div class="modal modal-sm">
<h2>Excluir Contrato</h2>
<p class="confirm-msg">
Deseja realmente excluir o contrato <strong> {{ deleteTarget?.Numero }}</strong>
{{ deleteTarget?.Orgao }}?<br>
<span class="confirm-warn">Esta ação não pode ser desfeita.</span>
</p>
<div class="modal-actions">
<button class="btn-cancel" @click="showDeleteConfirm = false">Cancelar</button>
<button class="btn-danger" :disabled="deletingId !== null" @click="confirmarExclusao">
{{ deletingId ? 'Excluindo...' : 'Excluir' }}
</button>
</div>
</div>
</div>
<!-- Modal Visualizar -->
<div v-if="showView" class="overlay" @click.self="showView = false">
<div class="modal modal-view">
<div class="modal-header">
<div>
<h2>Contrato {{ viewContract?.Numero }}</h2>
<span :class="['badge', `badge-${viewContract?.Status}`]">{{ viewContract?.Status }}</span>
</div>
<button class="close-btn" @click="showView = false"></button>
</div>
<div class="view-grid">
<div class="view-field">
<label>Órgão</label>
<span>{{ viewContract?.Orgao }}</span>
</div>
<div class="view-field">
<label>Fiscal</label>
<span>{{ viewContract?.FiscalContrato || '—' }}</span>
</div>
<div class="view-field view-full">
<label>Objeto</label>
<span>{{ viewContract?.Objeto || '—' }}</span>
</div>
<div class="view-field">
<label>Valor</label>
<span>{{ formatValor(viewContract?.Valor ?? 0) }}</span>
</div>
<div class="view-field">
<label>Reajuste</label>
<span>{{ viewContract?.Reajuste || '—' }}</span>
</div>
<div class="view-field">
<label>Início da Vigência</label>
<span>{{ formatDate(viewContract?.DataInicio ?? '') }}</span>
</div>
<div class="view-field">
<label>Término da Vigência</label>
<span>{{ formatDate(viewContract?.DataFim ?? '') }}</span>
</div>
<div class="view-field">
<label>Prorrogação Máxima</label>
<span>{{ viewContract?.ProrrogacaoMaxima ? formatDate(viewContract.ProrrogacaoMaxima) : '—' }}</span>
</div>
<div class="view-field view-full">
<label>SLA</label>
<span>{{ viewContract?.Sla || '—' }}</span>
</div>
</div>
<div class="files-section">
<div class="files-header">
<h3>Arquivos</h3>
<label class="upload-btn" :class="{ loading: uploadLoading }">
<input type="file" multiple @change="uploadArquivos" style="display:none" :disabled="uploadLoading" />
{{ uploadLoading ? 'Enviando...' : '+ Anexar' }}
</label>
</div>
<div v-if="viewFilesLoading" class="files-empty">Carregando arquivos...</div>
<div v-else-if="viewFiles.length === 0" class="files-empty">Nenhum arquivo anexado.</div>
<div v-else class="files-list">
<div v-for="f in viewFiles" :key="f.ID" class="file-item">
<span class="file-icon">📄</span>
<span class="file-name">{{ f.Nome }}</span>
<span class="file-size">{{ formatFileSize(f.Size) }}</span>
<button class="file-act" title="Download" @click="downloadArquivo(viewContract!.ID, f.ID, f.Nome)"></button>
<button class="file-act file-del" title="Remover" @click="excluirArquivo(f.ID)"></button>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn-cancel" @click="showView = false">Fechar</button>
<UButton class="btn-primary" size="sm" @click="showView = false; abrirEditar(viewContract!)">Editar</UButton>
</div>
</div>
</div>
</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: visible; }
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2) !important; }
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.tbl thead tr { background: #f8fafc; border-bottom: 1px solid #e2e8f0; }
.tbl th { padding: 10px 14px; text-align: left; font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.5px; }
.tbl td { padding: 12px 14px; border-bottom: 1px solid #f1f5f9; color: #1e293b; }
.tbl tbody tr:last-child td { border-bottom: none; }
.tbl tbody tr:hover { background: #fafafa; }
.mono { font-family: monospace; font-size: 12px; }
.objeto { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #64748b; }
.valor { font-variant-numeric: tabular-nums; }
.datas { font-size: 12px; color: #64748b; white-space: nowrap; }
.empty { text-align: center; color: #94a3b8; padding: 32px !important; }
.badge { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; text-transform: capitalize; }
.badge-ativo { background: #dcfce7; color: #16a34a; }
.badge-encerrado { background: #f1f5f9; color: #94a3b8; }
.badge-suspenso { background: #fff7ed; color: #ea580c; }
/* Menu dropdown */
.menu-cell { width: 40px; }
.menu-btn { background: none; border: none; font-size: 20px; cursor: pointer; padding: 2px 8px; border-radius: 6px; color: #94a3b8; line-height: 1; letter-spacing: 2px; }
.menu-btn:hover { background: #f1f5f9; color: #475569; }
.drop-danger { color: #dc2626 !important; }
.drop-danger:hover { background: #fff1f1 !important; }
/* Modal base */
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; z-index: 100; }
.modal { background: white; border-radius: 14px; padding: 28px; width: 100%; max-width: 560px; max-height: 90vh; overflow-y: auto; box-shadow: 0 20px 50px rgba(0,0,0,0.2); }
.modal h2 { font-size: 16px; font-weight: 700; color: #0f172a; margin-bottom: 18px; }
.fields-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.field { display: flex; flex-direction: column; }
.field-full { grid-column: 1 / -1; }
.field label { font-size: 12px; font-weight: 600; color: #475569; margin-bottom: 5px; }
.sel { width: 100%; padding: 8px 10px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 13px; color: #0f172a; background: white; }
.err { color: #dc2626; font-size: 12px; margin-top: 8px; margin-bottom: 4px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.btn-cancel { font-size: 13px; padding: 6px 14px; border-radius: 8px; border: 1px solid #e2e8f0; background: white; cursor: pointer; color: #64748b; }
.btn-cancel:hover { background: #f8fafc; }
/* Modal confirmação */
.modal-sm { max-width: 420px; }
.confirm-msg { font-size: 14px; color: #334155; line-height: 1.6; margin-bottom: 20px; }
.confirm-warn { font-size: 12px; color: #dc2626; }
.btn-danger { font-size: 13px; padding: 6px 14px; border-radius: 8px; background: #dc2626; color: white; border: none; cursor: pointer; font-weight: 600; }
.btn-danger:hover { background: #b91c1c; }
.btn-danger:disabled { opacity: 0.6; cursor: not-allowed; }
/* Modal visualizar */
.modal-view { max-width: 680px; }
.modal-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; }
.modal-header h2 { font-size: 16px; font-weight: 700; color: #0f172a; margin: 0 0 6px; }
.close-btn { background: none; border: none; font-size: 16px; color: #94a3b8; cursor: pointer; padding: 4px 8px; border-radius: 6px; }
.close-btn:hover { background: #f1f5f9; color: #475569; }
.view-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 24px; }
.view-full { grid-column: 1 / -1; }
.view-field { display: flex; flex-direction: column; gap: 3px; }
.view-field label { font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.5px; }
.view-field span { font-size: 13px; color: #1e293b; }
/* Arquivos */
.files-section { border-top: 1px solid #f1f5f9; padding-top: 18px; margin-bottom: 20px; }
.files-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.files-header h3 { font-size: 11px; font-weight: 700; color: #94a3b8; margin: 0; text-transform: uppercase; letter-spacing: 0.5px; }
.upload-btn { font-size: 12px; font-weight: 600; padding: 5px 12px; border-radius: 8px; border: 1px dashed #667eea; color: #667eea; cursor: pointer; transition: all 0.15s; }
.upload-btn:hover { background: #f0f3ff; }
.upload-btn.loading { opacity: 0.6; cursor: not-allowed; }
.files-empty { font-size: 13px; color: #94a3b8; text-align: center; padding: 16px 0; }
.files-list { display: flex; flex-direction: column; gap: 6px; }
.file-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; }
.file-icon { font-size: 16px; flex-shrink: 0; }
.file-name { flex: 1; font-size: 13px; color: #1e293b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-size { font-size: 11px; color: #94a3b8; white-space: nowrap; flex-shrink: 0; }
.file-act { font-size: 13px; padding: 3px 7px; background: none; border: none; cursor: pointer; color: #667eea; border-radius: 4px; text-decoration: none; flex-shrink: 0; }
.file-act:hover { background: #f0f3ff; }
.file-del { color: #dc2626; }
.file-del:hover { background: #fff1f1 !important; }
.field-select {
width: 100%; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px 11px;
font-size: 13px; color: #1e293b; background: white; outline: none; font-family: inherit;
}
.field-select:focus { border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,.15); }
</style>
<style>
.dropdown-fixed {
position: fixed;
background: white;
border: 1px solid #e2e8f0;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
min-width: 140px;
z-index: 9999;
overflow: hidden;
}
.dropdown-fixed button {
display: block;
width: 100%;
padding: 9px 14px;
text-align: left;
font-size: 13px;
background: none;
border: none;
cursor: pointer;
color: #1e293b;
}
.dropdown-fixed button:hover { background: #f8fafc; }
.dropdown-fixed .drop-danger { color: #dc2626; }
.dropdown-fixed .drop-danger:hover { background: #fff1f1; }
</style>