feat: orgao field in contract modal uses organ dropdown
This commit is contained in:
@@ -1,34 +1,503 @@
|
|||||||
<!-- front-end/app/pages/gestao/contratos.vue -->
|
<!-- front-end/app/pages/gestao/contratos.vue -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { contratos } from '~/data/mock/contratos'
|
const { apiFetch } = useApi()
|
||||||
|
const { public: { apiBase } } = useRuntimeConfig()
|
||||||
|
const token = useCookie<string | null>('auth_token')
|
||||||
|
|
||||||
const columns = [
|
interface ApiContract {
|
||||||
{ id: 'numero', accessorKey: 'numero', header: 'Nº Contrato' },
|
ID: string
|
||||||
{ id: 'orgao', accessorKey: 'orgao', header: 'Órgão' },
|
Numero: string
|
||||||
{ id: 'valor', accessorKey: 'valor', header: 'Valor' },
|
Orgao: string
|
||||||
{ id: 'dataInicio', accessorKey: 'dataInicio', header: 'Início' },
|
Objeto: string
|
||||||
{ id: 'dataFim', accessorKey: 'dataFim', header: 'Término' },
|
Valor: number
|
||||||
{ id: 'fiscalContrato', accessorKey: 'fiscalContrato', header: 'Fiscal' },
|
DataInicio: string
|
||||||
{ id: 'sla', accessorKey: 'sla', header: 'SLA' },
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<AppTopbar title="Contratos" breadcrumb="Gestão · Contratos">
|
<AppTopbar title="Contratos" breadcrumb="Gestão · Contratos">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<UButton size="sm" class="btn-primary">+ Novo Contrato</UButton>
|
<UButton size="sm" class="btn-primary" @click="showCreate = true">+ Novo Contrato</UButton>
|
||||||
</template>
|
</template>
|
||||||
</AppTopbar>
|
</AppTopbar>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<UTable :data="contratos" :columns="columns">
|
<table class="tbl">
|
||||||
<template #valor-cell="{ row }">
|
<thead>
|
||||||
{{ new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(row.original.valor) }}
|
<tr>
|
||||||
</template>
|
<th>Nº</th>
|
||||||
<template #dataInicio-cell="{ row }">{{ row.original.dataInicio.toLocaleDateString('pt-BR') }}</template>
|
<th>Órgão</th>
|
||||||
<template #dataFim-cell="{ row }">{{ row.original.dataFim.toLocaleDateString('pt-BR') }}</template>
|
<th>Objeto</th>
|
||||||
</UTable>
|
<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>Nº {{ 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 Nº {{ 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,6 +506,114 @@ const columns = [
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.page { display: flex; flex-direction: column; height: 100vh; }
|
.page { display: flex; flex-direction: column; height: 100vh; }
|
||||||
.content { padding: 20px 22px; flex: 1; overflow-y: auto; }
|
.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; }
|
.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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user