This commit is contained in:
Junior
2026-04-21 18:05:15 -03:00
parent 8c3c56de09
commit d29137be9d
41 changed files with 3945 additions and 318 deletions

File diff suppressed because it is too large Load Diff

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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> 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> 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>