ajustes
This commit is contained in:
1097
front-end/app/pages/oportunidades/[id].vue
Normal file
1097
front-end/app/pages/oportunidades/[id].vue
Normal file
File diff suppressed because it is too large
Load Diff
17
front-end/app/pages/oportunidades/edital-publicado.vue
Normal file
17
front-end/app/pages/oportunidades/edital-publicado.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
const { apiFetch } = useApi()
|
||||
interface ApiEdital { ID: string; Numero: string; Orgao: string; Modalidade: string; Objeto: string; Plataforma: string; ValorEstimado: number; DataPublicacao: string; DataAbertura: string; Status: string }
|
||||
const { data: todos } = await useAsyncData('editais-edital-publicado', () => apiFetch<ApiEdital[]>('/editais'))
|
||||
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'edital_publicado'))
|
||||
</script>
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppTopbar title="Edital Publicado" breadcrumb="Oportunidades · Edital Publicado" />
|
||||
<div class="content"><div class="card"><EditaisTable :editais="editais" /></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: hidden; }
|
||||
</style>
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { editais } from '~/data/mock/editais'
|
||||
const filtrados = computed(() => editais.filter(e => e.status === 'elaborando_proposta'))
|
||||
const { apiFetch } = useApi()
|
||||
interface ApiEdital { ID: string; Numero: string; Orgao: string; Modalidade: string; Objeto: string; Plataforma: string; ValorEstimado: number; DataPublicacao: string; DataAbertura: string; Status: string }
|
||||
const { data: todos } = await useAsyncData('editais-elaborando', () => apiFetch<ApiEdital[]>('/editais'))
|
||||
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'elaborando_proposta'))
|
||||
</script>
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppTopbar title="Elaborando Proposta" breadcrumb="Oportunidades · Elaborando Proposta" />
|
||||
<div class="content"><div class="card"><EditaisTable :editais="filtrados" /></div></div>
|
||||
<div class="content"><div class="card"><EditaisTable :editais="editais" /></div></div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { editais } from '~/data/mock/editais'
|
||||
const filtrados = computed(() => editais.filter(e => e.status === 'em_analise'))
|
||||
const { apiFetch } = useApi()
|
||||
interface ApiEdital { ID: string; Numero: string; Orgao: string; Modalidade: string; Objeto: string; Plataforma: string; ValorEstimado: number; DataPublicacao: string; DataAbertura: string; Status: string }
|
||||
const { data: todos } = await useAsyncData('editais-em-analise', () => apiFetch<ApiEdital[]>('/editais'))
|
||||
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'em_analise'))
|
||||
</script>
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppTopbar title="Em Análise" breadcrumb="Oportunidades · Em Análise" />
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<EditaisTable :editais="filtrados" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="content"><div class="card"><EditaisTable :editais="editais" /></div></div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
17
front-end/app/pages/oportunidades/fase-lances.vue
Normal file
17
front-end/app/pages/oportunidades/fase-lances.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
const { apiFetch } = useApi()
|
||||
interface ApiEdital { ID: string; Numero: string; Orgao: string; Modalidade: string; Objeto: string; Plataforma: string; ValorEstimado: number; DataPublicacao: string; DataAbertura: string; Status: string }
|
||||
const { data: todos } = await useAsyncData('editais-fase-lances', () => apiFetch<ApiEdital[]>('/editais'))
|
||||
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'fase_lances'))
|
||||
</script>
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppTopbar title="Fase de Lances" breadcrumb="Oportunidades · Fase de Lances" />
|
||||
<div class="content"><div class="card"><EditaisTable :editais="editais" /></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: hidden; }
|
||||
</style>
|
||||
@@ -1,19 +1,451 @@
|
||||
<!-- front-end/app/pages/oportunidades/index.vue -->
|
||||
<script setup lang="ts">
|
||||
import { editais } from '~/data/mock/editais'
|
||||
const { apiFetch } = useApi()
|
||||
const { public: { apiBase } } = useRuntimeConfig()
|
||||
const token = useCookie<string | null>('auth_token')
|
||||
|
||||
interface ApiEdital {
|
||||
ID: string
|
||||
Numero: string
|
||||
Orgao: string
|
||||
Modalidade: string
|
||||
Objeto: string
|
||||
Plataforma: string
|
||||
ValorEstimado: number
|
||||
DataPublicacao: string
|
||||
DataAbertura: string
|
||||
Status: string
|
||||
}
|
||||
|
||||
interface ApiOrgao {
|
||||
ID: string
|
||||
Nome: string
|
||||
Esfera: string
|
||||
Estado: string
|
||||
}
|
||||
|
||||
const { data: editais, refresh } = await useAsyncData('editais', () =>
|
||||
apiFetch<ApiEdital[]>('/editais')
|
||||
)
|
||||
|
||||
const { data: orgaos } = await useAsyncData('orgaos-oportunidades', () =>
|
||||
apiFetch<ApiOrgao[]>('/organs'), { server: false }
|
||||
)
|
||||
|
||||
// ---------- labels ----------
|
||||
const MODALIDADE_LABEL: Record<string, string> = {
|
||||
pregao_eletronico: 'Pregão Eletrônico',
|
||||
pregao_presencial: 'Pregão Presencial',
|
||||
concorrencia: 'Concorrência',
|
||||
dispensa: 'Dispensa',
|
||||
inexigibilidade: 'Inexigibilidade',
|
||||
}
|
||||
|
||||
const STATUS_CFG: Record<string, { label: string; color: string; bg: string }> = {
|
||||
em_analise: { label: 'Mapeamento', color: '#0284c7', bg: '#eff6ff' },
|
||||
elaborando_proposta: { label: 'Termo de Referência', color: '#7c3aed', bg: '#faf5ff' },
|
||||
edital_publicado: { label: 'Edital Publicado', color: '#ea580c', bg: '#fff7ed' },
|
||||
fase_lances: { label: 'Fase de Lances', color: '#3b82f6', bg: '#eff6ff' },
|
||||
habilitacao: { label: 'Habilitação', color: '#d97706', bg: '#fef3c7' },
|
||||
recurso: { label: 'Recursos', color: '#d97706', bg: '#fffbeb' },
|
||||
adjudicado: { label: 'Adjudicado', color: '#059669', bg: '#ecfdf5' },
|
||||
contrato: { label: 'Contrato', color: '#16a34a', bg: '#f0fdf4' },
|
||||
vencida: { label: 'Vencida', color: '#16a34a', bg: '#f0fdf4' },
|
||||
perdida: { label: 'Perdida', color: '#dc2626', bg: '#fef2f2' },
|
||||
deserta: { label: 'Deserta/Fracassada', color: '#64748b', bg: '#f8fafc' },
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const [y, m, d] = iso.split('T')[0].split('-')
|
||||
return `${d}/${m}/${y}`
|
||||
}
|
||||
|
||||
function formatBRL(value: number): string {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(value)
|
||||
}
|
||||
|
||||
|
||||
// ---------- menu ⋯ ----------
|
||||
const menuAberto = ref<string | null>(null)
|
||||
const menuPos = ref({ top: 0, left: 0 })
|
||||
|
||||
function abrirMenu(id: string, event: MouseEvent) {
|
||||
const btn = event.currentTarget as HTMLElement
|
||||
const rect = btn.getBoundingClientRect()
|
||||
menuPos.value = { top: rect.bottom + 4, left: rect.left - 130 }
|
||||
menuAberto.value = menuAberto.value === id ? null : id
|
||||
}
|
||||
function fecharMenu() { menuAberto.value = null }
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function abrirVisualizar(edital: ApiEdital) {
|
||||
fecharMenu()
|
||||
router.push(`/oportunidades/${edital.ID}`)
|
||||
}
|
||||
|
||||
|
||||
// ---------- modal criar / editar ----------
|
||||
const showModal = ref(false)
|
||||
const editando = ref<ApiEdital | null>(null)
|
||||
const saving = ref(false)
|
||||
const erroModal = ref('')
|
||||
|
||||
const STATUS_OPTIONS = Object.entries(STATUS_CFG).map(([value, cfg]) => ({ value, label: cfg.label }))
|
||||
const MODALIDADE_OPTIONS = Object.entries(MODALIDADE_LABEL).map(([value, label]) => ({ value, label }))
|
||||
|
||||
const form = ref({
|
||||
Numero: '',
|
||||
Orgao: '',
|
||||
Modalidade: 'pregao_eletronico',
|
||||
Objeto: '',
|
||||
Plataforma: '',
|
||||
ValorEstimado: 0,
|
||||
DataPublicacao: '',
|
||||
DataAbertura: '',
|
||||
Status: 'em_analise',
|
||||
})
|
||||
|
||||
function abrirCriar() {
|
||||
editando.value = null
|
||||
erroModal.value = ''
|
||||
importedFile.value = null
|
||||
importedFileName.value = ''
|
||||
importError.value = ''
|
||||
form.value = {
|
||||
Numero: '', Orgao: '', Modalidade: 'pregao_eletronico',
|
||||
Objeto: '', Plataforma: '', ValorEstimado: 0,
|
||||
DataPublicacao: '', DataAbertura: '', Status: 'em_analise',
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function abrirEditar(edital: ApiEdital) {
|
||||
fecharMenu()
|
||||
editando.value = edital
|
||||
erroModal.value = ''
|
||||
form.value = {
|
||||
Numero: edital.Numero,
|
||||
Orgao: edital.Orgao,
|
||||
Modalidade: edital.Modalidade,
|
||||
Objeto: edital.Objeto,
|
||||
Plataforma: edital.Plataforma,
|
||||
ValorEstimado: edital.ValorEstimado,
|
||||
DataPublicacao: edital.DataPublicacao.split('T')[0],
|
||||
DataAbertura: edital.DataAbertura.split('T')[0],
|
||||
Status: edital.Status,
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function salvar() {
|
||||
erroModal.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
let editalId: string | null = null
|
||||
|
||||
if (editando.value) {
|
||||
await apiFetch(`/editais/${editando.value.ID}`, { method: 'PUT', body: form.value })
|
||||
editalId = editando.value.ID
|
||||
} else {
|
||||
const created = await apiFetch<ApiEdital>('/editais', { method: 'POST', body: form.value })
|
||||
editalId = created?.ID ?? null
|
||||
}
|
||||
|
||||
// If this was a partial import, attach the PDF file to the created edital
|
||||
if (importedFile.value && editalId) {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', importedFile.value)
|
||||
await $fetch(`${apiBase}/editais/${editalId}/files`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token.value}` },
|
||||
body: formData,
|
||||
})
|
||||
} catch {
|
||||
// Non-fatal: edital was created, file attach failed silently
|
||||
}
|
||||
importedFile.value = null
|
||||
importedFileName.value = ''
|
||||
}
|
||||
|
||||
showModal.value = false
|
||||
importError.value = ''
|
||||
await refresh()
|
||||
} catch {
|
||||
erroModal.value = 'Erro ao salvar edital.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- importar PDF ----------
|
||||
const importLoading = ref(false)
|
||||
const importError = ref('')
|
||||
const importedFile = ref<File | null>(null)
|
||||
const importedFileName = ref('')
|
||||
|
||||
async function importarEdital(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (!input.files?.length) return
|
||||
|
||||
const file = input.files[0]
|
||||
if (file.type !== 'application/pdf') {
|
||||
importError.value = 'Selecione um arquivo PDF.'
|
||||
return
|
||||
}
|
||||
|
||||
importLoading.value = true
|
||||
importError.value = ''
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await apiFetch<any>('/editais/import', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
// If backend returned partial data, open modal for manual completion
|
||||
if (res?.partial) {
|
||||
importedFile.value = file
|
||||
importedFileName.value = res.file_name || file.name
|
||||
editando.value = null
|
||||
erroModal.value = ''
|
||||
form.value = {
|
||||
Numero: res.numero || '',
|
||||
Orgao: res.orgao || '',
|
||||
Modalidade: res.modalidade || 'pregao_eletronico',
|
||||
Objeto: res.objeto || '',
|
||||
Plataforma: res.plataforma || '',
|
||||
ValorEstimado: res.valor_estimado || 0,
|
||||
DataPublicacao: res.data_publicacao || '',
|
||||
DataAbertura: res.data_abertura || '',
|
||||
Status: res.status || 'em_analise',
|
||||
}
|
||||
showModal.value = true
|
||||
const tipoLabel = res.tipo_documento === 'edital' ? 'Edital' : res.tipo_documento === 'termo_referencia' ? 'Termo de Referência' : 'Documento'
|
||||
importError.value = `⚠️ ${tipoLabel} detectado. Alguns campos precisam de revisão. Confira e complete manualmente.`
|
||||
return
|
||||
}
|
||||
|
||||
await refresh()
|
||||
} catch (err: any) {
|
||||
importError.value = err?.data?.error || 'Erro ao importar edital do PDF.'
|
||||
} finally {
|
||||
importLoading.value = false
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- excluir ----------
|
||||
const showDeleteConfirm = ref(false)
|
||||
const deleteTarget = ref<ApiEdital | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
function confirmarExclusao(edital: ApiEdital) {
|
||||
fecharMenu()
|
||||
deleteTarget.value = edital
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
function cancelarExclusao() {
|
||||
showDeleteConfirm.value = false
|
||||
deleteTarget.value = null
|
||||
}
|
||||
|
||||
async function executarExclusao() {
|
||||
if (!deleteTarget.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await apiFetch(`/editais/${deleteTarget.value.ID}`, { method: 'DELETE' })
|
||||
showDeleteConfirm.value = false
|
||||
deleteTarget.value = null
|
||||
await refresh()
|
||||
} catch {
|
||||
// keep modal open on error
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppTopbar title="Todos os Editais" breadcrumb="Oportunidades · Todos">
|
||||
<template #actions>
|
||||
<UButton size="sm" class="btn-primary">+ Nova Oportunidade</UButton>
|
||||
<label class="btn-import" :class="{ disabled: importLoading }">
|
||||
<input type="file" accept=".pdf" style="display:none" @change="importarEdital" />
|
||||
{{ importLoading ? '⏳ Importando…' : '📄 Importar Edital' }}
|
||||
</label>
|
||||
<button class="btn-primary" @click="abrirCriar">+ Nova Oportunidade</button>
|
||||
</template>
|
||||
</AppTopbar>
|
||||
|
||||
<div class="content">
|
||||
<p v-if="importError" class="import-error">{{ importError }}</p>
|
||||
<div class="card">
|
||||
<EditaisTable :editais="editais" />
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nº Edital</th>
|
||||
<th>Órgão</th>
|
||||
<th>Objeto</th>
|
||||
<th>Modalidade</th>
|
||||
<th>Valor Est.</th>
|
||||
<th>Abertura</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="!editais || editais.length === 0">
|
||||
<td colspan="8" class="empty">Nenhum edital cadastrado.</td>
|
||||
</tr>
|
||||
<tr v-for="e in editais" :key="e.ID">
|
||||
<td class="numero link" @click="router.push(`/oportunidades/${e.ID}`)">{{ e.Numero }}</td>
|
||||
<td>{{ e.Orgao }}</td>
|
||||
<td class="objeto">{{ e.Objeto }}</td>
|
||||
<td>{{ MODALIDADE_LABEL[e.Modalidade] ?? e.Modalidade }}</td>
|
||||
<td>{{ formatBRL(e.ValorEstimado) }}</td>
|
||||
<td>{{ formatDate(e.DataAbertura) }}</td>
|
||||
<td>
|
||||
<span
|
||||
v-if="STATUS_CFG[e.Status]"
|
||||
class="badge"
|
||||
:style="{ background: STATUS_CFG[e.Status].bg, color: STATUS_CFG[e.Status].color }"
|
||||
>{{ STATUS_CFG[e.Status].label }}</span>
|
||||
<span v-else class="badge">{{ e.Status }}</span>
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<button class="btn-menu" @click.stop="abrirMenu(e.ID, $event)">⋯</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
<Teleport to="body">
|
||||
<div v-if="menuAberto" class="overlay-menu" @click="fecharMenu" />
|
||||
<div
|
||||
v-if="menuAberto"
|
||||
class="dropdown"
|
||||
:style="{ top: menuPos.top + 'px', left: menuPos.left + 'px' }"
|
||||
>
|
||||
<button @click="abrirVisualizar(editais!.find(e => e.ID === menuAberto)!)">Visualizar</button>
|
||||
<button @click="abrirEditar(editais!.find(e => e.ID === menuAberto)!)">Editar</button>
|
||||
<button class="drop-danger" @click="confirmarExclusao(editais!.find(e => e.ID === menuAberto)!)">Excluir</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Modal criar / editar -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ editando ? 'Editar Edital' : importedFile ? 'Completar Importação' : 'Nova Oportunidade' }}</h3>
|
||||
<button class="close-btn" @click="showModal = false; importedFile = null; importedFileName = ''; importError = ''">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Import partial notice -->
|
||||
<div v-if="importedFile" class="import-notice">
|
||||
<span class="import-notice-icon">📄</span>
|
||||
<div>
|
||||
<strong>Importado do PDF:</strong> {{ importedFileName }}
|
||||
<p>A IA extraiu os dados abaixo. Complete os campos que ficaram vazios e confira os preenchidos.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label>Nº do Edital / Processo *</label>
|
||||
<input v-model="form.Numero" class="inp" placeholder="PE 001/2026" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Órgão Público *</label>
|
||||
<select v-model="form.Orgao" class="inp">
|
||||
<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>
|
||||
<div class="field">
|
||||
<label>Objeto</label>
|
||||
<input v-model="form.Objeto" class="inp" placeholder="Descrição do objeto licitado" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label>Modalidade *</label>
|
||||
<select v-model="form.Modalidade" class="inp">
|
||||
<option v-for="opt in MODALIDADE_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Plataforma</label>
|
||||
<input v-model="form.Plataforma" class="inp" placeholder="Compras.gov.br" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label>Valor Estimado (R$)</label>
|
||||
<input v-model.number="form.ValorEstimado" type="number" min="0" step="0.01" class="inp" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Status *</label>
|
||||
<select v-model="form.Status" class="inp">
|
||||
<option v-for="opt in STATUS_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label>Data Publicação *</label>
|
||||
<input v-model="form.DataPublicacao" type="date" class="inp" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Data Abertura *</label>
|
||||
<input v-model="form.DataAbertura" type="date" class="inp" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="erroModal" class="erro-msg">{{ erroModal }}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-cancel" @click="showModal = false">Cancelar</button>
|
||||
<button class="btn-save" :disabled="saving" @click="salvar">
|
||||
{{ saving ? 'Salvando…' : 'Salvar' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Delete confirmation modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showDeleteConfirm" class="modal-overlay" @click.self="cancelarExclusao">
|
||||
<div class="modal-delete">
|
||||
<div class="modal-delete-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
<line x1="10" y1="11" x2="10" y2="17"/>
|
||||
<line x1="14" y1="11" x2="14" y2="17"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="modal-delete-title">Excluir Oportunidade</h3>
|
||||
<p class="modal-delete-text">
|
||||
Tem certeza que deseja excluir o edital
|
||||
<strong>{{ deleteTarget?.Numero }}</strong>?
|
||||
<br/>Esta ação não pode ser desfeita.
|
||||
</p>
|
||||
<div class="modal-delete-actions">
|
||||
<button class="btn-cancel" @click="cancelarExclusao">Cancelar</button>
|
||||
<button class="btn-delete" :disabled="deleting" @click="executarExclusao">
|
||||
{{ deleting ? 'Excluindo…' : 'Sim, excluir' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -21,5 +453,119 @@ import { editais } from '~/data/mock/editais'
|
||||
.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; }
|
||||
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2) !important; }
|
||||
|
||||
.btn-import {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: white; border: none; border-radius: 7px;
|
||||
padding: 7px 16px; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.btn-import:hover { opacity: 0.9; }
|
||||
.btn-import.disabled { opacity: 0.6; pointer-events: none; }
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: white; border: none; border-radius: 7px;
|
||||
padding: 7px 16px; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
|
||||
.import-error {
|
||||
background: #fef2f2; color: #dc2626; border: 1px solid #fecaca;
|
||||
border-radius: 8px; padding: 10px 16px; font-size: 13px; margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* table */
|
||||
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.tbl thead tr { border-bottom: 1px solid #e2e8f0; background: #f8fafc; }
|
||||
.tbl th { padding: 10px 14px; text-align: left; font-weight: 600; color: #64748b; font-size: 12px; text-transform: uppercase; letter-spacing: .4px; white-space: nowrap; }
|
||||
.tbl td { padding: 11px 14px; color: #1e293b; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
|
||||
.tbl tbody tr:last-child td { border-bottom: none; }
|
||||
.tbl tbody tr:hover td { background: #f8fafc; }
|
||||
.numero { font-weight: 600; white-space: nowrap; }
|
||||
.numero.link { color: #667eea; cursor: pointer; }
|
||||
.numero.link:hover { color: #764ba2; text-decoration: underline; }
|
||||
.objeto { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.empty { text-align: center; color: #94a3b8; padding: 40px; }
|
||||
.actions-cell { text-align: right; white-space: nowrap; }
|
||||
.btn-menu { background: none; border: none; cursor: pointer; font-size: 18px; color: #94a3b8; padding: 2px 6px; border-radius: 4px; }
|
||||
.btn-menu:hover { background: #f1f5f9; color: #475569; }
|
||||
.badge { display: inline-block; padding: 3px 9px; border-radius: 20px; font-size: 11.5px; font-weight: 600; }
|
||||
|
||||
/* dropdown */
|
||||
.overlay-menu { position: fixed; inset: 0; z-index: 999; }
|
||||
.dropdown { position: fixed; z-index: 1000; background: white; border: 1px solid #e2e8f0; border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,.12); min-width: 140px; overflow: hidden; }
|
||||
.dropdown button { display: block; width: 100%; padding: 9px 14px; text-align: left; background: none; border: none; cursor: pointer; font-size: 13px; color: #334155; }
|
||||
.dropdown button:hover { background: #f8fafc; }
|
||||
.drop-danger { color: #dc2626 !important; }
|
||||
|
||||
/* modal */
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.35); z-index: 1000; display: flex; align-items: center; justify-content: center; }
|
||||
.modal { background: white; border-radius: 12px; width: 600px; max-width: 95vw; max-height: 90vh; overflow-y: auto; box-shadow: 0 8px 40px rgba(0,0,0,.2); }
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid #e2e8f0; }
|
||||
.modal-header h3 { margin: 0; font-size: 15px; font-weight: 700; color: #1e293b; }
|
||||
.close-btn { background: none; border: none; cursor: pointer; font-size: 16px; color: #64748b; }
|
||||
.modal-body { padding: 20px 22px; display: flex; flex-direction: column; gap: 18px; }
|
||||
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 22px; border-top: 1px solid #f1f5f9; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.field label { font-size: 12px; font-weight: 600; color: #475569; }
|
||||
.inp { border: 1px solid #e2e8f0; border-radius: 7px; padding: 8px 10px; font-size: 13px; color: #1e293b; width: 100%; box-sizing: border-box; }
|
||||
.inp:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,.1); }
|
||||
.btn-cancel { background: #f1f5f9; color: #475569; border: none; border-radius: 7px; padding: 8px 18px; font-size: 13px; font-weight: 600; cursor: pointer; }
|
||||
.btn-save { background: linear-gradient(135deg, #667eea, #764ba2); color: white; border: none; border-radius: 7px; padding: 8px 18px; font-size: 13px; font-weight: 600; cursor: pointer; }
|
||||
.btn-save:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.erro-msg { color: #dc2626; font-size: 12px; margin: 0; }
|
||||
|
||||
/* delete confirm modal */
|
||||
.modal-delete {
|
||||
background: white; border-radius: 16px;
|
||||
width: 400px; max-width: 90vw;
|
||||
padding: 32px 28px 24px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.25);
|
||||
text-align: center;
|
||||
animation: modalPopIn .2s ease;
|
||||
}
|
||||
@keyframes modalPopIn {
|
||||
from { opacity: 0; transform: scale(.92); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.modal-delete-icon {
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: #fef2f2; color: #dc2626;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
.modal-delete-title {
|
||||
font-size: 16px; font-weight: 700; color: #0f172a;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.modal-delete-text {
|
||||
font-size: 13.5px; color: #64748b; line-height: 1.6;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
.modal-delete-text strong { color: #1e293b; }
|
||||
.modal-delete-actions {
|
||||
display: flex; gap: 10px; justify-content: center;
|
||||
}
|
||||
.btn-delete {
|
||||
background: #dc2626; color: white;
|
||||
border: none; border-radius: 9px;
|
||||
padding: 9px 22px; font-size: 13px; font-weight: 700;
|
||||
cursor: pointer; transition: all .15s;
|
||||
}
|
||||
.btn-delete:hover { background: #b91c1c; }
|
||||
.btn-delete:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
/* import notice */
|
||||
.import-notice {
|
||||
display: flex; align-items: flex-start; gap: 12px;
|
||||
padding: 14px 16px; border-radius: 10px;
|
||||
background: #eff6ff; border: 1px solid #bfdbfe;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.import-notice-icon { font-size: 24px; flex-shrink: 0; margin-top: 2px; }
|
||||
.import-notice strong { font-size: 13px; color: #1e40af; }
|
||||
.import-notice p { font-size: 12px; color: #3b82f6; margin: 4px 0 0; line-height: 1.4; }
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { editais } from '~/data/mock/editais'
|
||||
const filtrados = computed(() => editais.filter(e => e.status === 'participando'))
|
||||
const { apiFetch } = useApi()
|
||||
interface ApiEdital { ID: string; Numero: string; Orgao: string; Modalidade: string; Objeto: string; Plataforma: string; ValorEstimado: number; DataPublicacao: string; DataAbertura: string; Status: string }
|
||||
const { data: todos } = await useAsyncData('editais-participando', () => apiFetch<ApiEdital[]>('/editais'))
|
||||
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'participando'))
|
||||
</script>
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppTopbar title="Participando" breadcrumb="Oportunidades · Participando" />
|
||||
<div class="content"><div class="card"><EditaisTable :editais="filtrados" /></div></div>
|
||||
<div class="content"><div class="card"><EditaisTable :editais="editais" /></div></div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { editais } from '~/data/mock/editais'
|
||||
const filtrados = computed(() => editais.filter(e => e.status === 'perdida'))
|
||||
const { apiFetch } = useApi()
|
||||
interface ApiEdital { ID: string; Numero: string; Orgao: string; Modalidade: string; Objeto: string; Plataforma: string; ValorEstimado: number; DataPublicacao: string; DataAbertura: string; Status: string }
|
||||
const { data: todos } = await useAsyncData('editais-perdidas', () => apiFetch<ApiEdital[]>('/editais'))
|
||||
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'perdida'))
|
||||
</script>
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppTopbar title="Perdidas" breadcrumb="Oportunidades · Perdidas" />
|
||||
<div class="content"><div class="card"><EditaisTable :editais="filtrados" /></div></div>
|
||||
<div class="content"><div class="card"><EditaisTable :editais="editais" /></div></div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { editais } from '~/data/mock/editais'
|
||||
const filtrados = computed(() => editais.filter(e => e.status === 'recurso'))
|
||||
const { apiFetch } = useApi()
|
||||
interface ApiEdital { ID: string; Numero: string; Orgao: string; Modalidade: string; Objeto: string; Plataforma: string; ValorEstimado: number; DataPublicacao: string; DataAbertura: string; Status: string }
|
||||
const { data: todos } = await useAsyncData('editais-recurso', () => apiFetch<ApiEdital[]>('/editais'))
|
||||
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'recurso'))
|
||||
</script>
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppTopbar title="Recurso" breadcrumb="Oportunidades · Recurso" />
|
||||
<div class="content"><div class="card"><EditaisTable :editais="filtrados" /></div></div>
|
||||
<div class="content"><div class="card"><EditaisTable :editais="editais" /></div></div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { editais } from '~/data/mock/editais'
|
||||
const filtrados = computed(() => editais.filter(e => e.status === 'vencida'))
|
||||
const { apiFetch } = useApi()
|
||||
interface ApiEdital { ID: string; Numero: string; Orgao: string; Modalidade: string; Objeto: string; Plataforma: string; ValorEstimado: number; DataPublicacao: string; DataAbertura: string; Status: string }
|
||||
const { data: todos } = await useAsyncData('editais-vencidas', () => apiFetch<ApiEdital[]>('/editais'))
|
||||
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'vencida'))
|
||||
</script>
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppTopbar title="Vencidas" breadcrumb="Oportunidades · Vencidas" />
|
||||
<div class="content"><div class="card"><EditaisTable :editais="filtrados" /></div></div>
|
||||
<div class="content"><div class="card"><EditaisTable :editais="editais" /></div></div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user