Files
2026-04-21 18:05:15 -03:00

1098 lines
42 KiB
Vue

<!-- front-end/app/pages/oportunidades/[id].vue -->
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
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 ApiFile {
ID: string; Nome: string; Size: number; CreatedAt: string
}
const editalId = route.params.id as string
const { data: edital, refresh } = await useAsyncData(`edital-${editalId}`, () =>
apiFetch<ApiEdital>(`/editais/${editalId}`)
)
const { data: files, refresh: refreshFiles } = await useAsyncData(`edital-files-${editalId}`, () =>
apiFetch<ApiFile[]>(`/editais/${editalId}/files`),
{ 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; icon: string }> = {
em_analise: { label: 'Mapeamento', color: '#0284c7', bg: '#eff6ff', icon: '🔍' },
elaborando_proposta: { label: 'Termo de Referência', color: '#7c3aed', bg: '#faf5ff', icon: '✏️' },
edital_publicado: { label: 'Edital Publicado', color: '#ea580c', bg: '#fff7ed', icon: '📢' },
fase_lances: { label: 'Fase de Lances', color: '#3b82f6', bg: '#eff6ff', icon: '🏁' },
habilitacao: { label: 'Habilitação', color: '#d97706', bg: '#fef3c7', icon: '📋' },
recurso: { label: 'Recursos', color: '#d97706', bg: '#fffbeb', icon: '⚖️' },
adjudicado: { label: 'Adjudicado', color: '#059669', bg: '#ecfdf5', icon: '✔️' },
contrato: { label: 'Contrato', color: '#16a34a', bg: '#f0fdf4', icon: '📝' },
vencida: { label: 'Vencida', color: '#16a34a', bg: '#f0fdf4', icon: '🏆' },
perdida: { label: 'Perdida', color: '#dc2626', bg: '#fef2f2', icon: '✗' },
deserta: { label: 'Deserta/Fracassada', color: '#64748b', bg: '#f8fafc', icon: '—' },
}
const PIPELINE_STAGES = ['Mapeamento', 'Termo de Referência', 'Edital Publicado', 'Fase de Lances', 'Habilitação', 'Recursos', 'Adjudicado', 'Contrato']
const statusToStage: Record<string, number> = {
em_analise: 1, elaborando_proposta: 2, edital_publicado: 3,
fase_lances: 4, habilitacao: 5, recurso: 6,
adjudicado: 7, contrato: 8, vencida: 8, perdida: 8, deserta: 8,
}
const currentStage = computed(() => statusToStage[edital.value?.Status ?? ''] ?? 1)
function formatDate(iso: string): string {
if (!iso) return '—'
const [y, m, d] = iso.split('T')[0].split('-')
return `${d}/${m}/${y}`
}
function formatDateRelative(iso: string): string {
if (!iso) return ''
const date = new Date(iso.split('T')[0])
const now = new Date()
const diff = Math.floor((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
if (diff < 0) return `${Math.abs(diff)}d atrás`
if (diff === 0) return 'Hoje'
if (diff === 1) return 'Amanhã'
return `em ${diff}d`
}
function formatBRL(value: number): string {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(value)
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function fileExtension(name: string): string {
return name.split('.').pop()?.toUpperCase() ?? ''
}
// ---------- actions ----------
const uploadLoading = ref(false)
// ---------- toast notifications ----------
interface Toast { id: number; message: string; type: 'info' | 'success' | 'error'; icon: string }
const toasts = ref<Toast[]>([])
let toastCounter = 0
function showToast(message: string, type: Toast['type'] = 'info', icon = '', duration = 5000) {
const id = ++toastCounter
const icons: Record<string, string> = { info: '🔍', success: '✅', error: '❌' }
toasts.value.push({ id, message, type, icon: icon || icons[type] || '' })
setTimeout(() => { toasts.value = toasts.value.filter(t => t.id !== id) }, duration)
}
async function uploadArquivos(event: Event) {
const input = event.target as HTMLInputElement
if (!input.files?.length) return
uploadLoading.value = true
try {
const hasPDF = Array.from(input.files).some(f => f.name.toLowerCase().endsWith('.pdf'))
const fileCount = input.files.length
const formData = new FormData()
for (const file of Array.from(input.files)) formData.append('file', file)
await $fetch(`${apiBase}/editais/${editalId}/files`, {
method: 'POST',
headers: { Authorization: `Bearer ${token.value}` },
body: formData,
})
await refreshFiles()
showToast(`${fileCount} arquivo${fileCount > 1 ? 's' : ''} enviado${fileCount > 1 ? 's' : ''} com sucesso`, 'success', '📎')
if (hasPDF) {
showToast('Analisando documento com IA…', 'info', '🔍', 6000)
setTimeout(async () => {
await refresh()
if (edital.value?.Status === 'elaborando_proposta') {
showToast('Termo de Referência detectado! Pipeline atualizado para "Elaborando Proposta".', 'success', '🚀', 7000)
}
}, 4000)
}
} catch {
showToast('Erro ao enviar arquivo. Tente novamente.', 'error')
} finally {
uploadLoading.value = false
input.value = ''
}
}
async function downloadArquivo(fileId: string, nome: string) {
const blob = await $fetch<Blob>(`${apiBase}/editais/${editalId}/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)
}
// ---------- file viewer modal ----------
const viewerOpen = ref(false)
const viewerUrl = ref('')
const viewerName = ref('')
const viewerLoading = ref(false)
const viewerType = ref<'pdf' | 'image'>('image')
async function visualizarArquivo(fileId: string, nome: string) {
viewerName.value = nome
viewerLoading.value = true
viewerOpen.value = true
try {
const blob = await $fetch<Blob>(`${apiBase}/editais/${editalId}/files/${fileId}/download`, {
headers: { Authorization: `Bearer ${token.value}` },
responseType: 'blob',
})
const mimeType = nome.toLowerCase().endsWith('.pdf') ? 'application/pdf' : blob.type
const typedBlob = new Blob([blob], { type: mimeType })
viewerUrl.value = URL.createObjectURL(typedBlob)
viewerType.value = nome.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
} catch {
viewerOpen.value = false
} finally {
viewerLoading.value = false
}
}
function fecharViewer() {
viewerOpen.value = false
if (viewerUrl.value) {
URL.revokeObjectURL(viewerUrl.value)
viewerUrl.value = ''
}
}
// close viewer on Esc
if (import.meta.client) {
const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape' && viewerOpen.value) fecharViewer() }
onMounted(() => window.addEventListener('keydown', onEsc))
onUnmounted(() => window.removeEventListener('keydown', onEsc))
}
function isViewable(nome: string): boolean {
const ext = nome.toLowerCase().split('.').pop() ?? ''
return ['pdf', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)
}
async function excluirArquivo(fileId: string) {
await apiFetch(`/editais/${editalId}/files/${fileId}`, { method: 'DELETE' })
await refreshFiles()
}
const showDeleteConfirm = ref(false)
const deleting = ref(false)
function confirmarExclusao() {
showDeleteConfirm.value = true
}
async function executarExclusao() {
deleting.value = true
try {
await apiFetch(`/editais/${editalId}`, { method: 'DELETE' })
showDeleteConfirm.value = false
router.push('/oportunidades')
} finally {
deleting.value = false
}
}
// ---------- edit modal ----------
const showEdit = ref(false)
const saving = ref(false)
const erroModal = ref('')
const MODALIDADE_OPTIONS = Object.entries(MODALIDADE_LABEL).map(([value, label]) => ({ value, label }))
const STATUS_OPTIONS = Object.entries(STATUS_CFG).map(([value, cfg]) => ({ value, label: cfg.label }))
const form = ref({
Numero: '', Orgao: '', Modalidade: 'pregao_eletronico',
Objeto: '', Plataforma: '', ValorEstimado: 0,
DataPublicacao: '', DataAbertura: '', Status: 'em_analise',
})
function abrirEditar() {
if (!edital.value) return
const e = edital.value
form.value = {
Numero: e.Numero, Orgao: e.Orgao, Modalidade: e.Modalidade,
Objeto: e.Objeto, Plataforma: e.Plataforma, ValorEstimado: e.ValorEstimado,
DataPublicacao: e.DataPublicacao.split('T')[0], DataAbertura: e.DataAbertura.split('T')[0],
Status: e.Status,
}
erroModal.value = ''
showEdit.value = true
}
async function salvar() {
erroModal.value = ''
saving.value = true
try {
await apiFetch(`/editais/${editalId}`, { method: 'PUT', body: form.value })
showEdit.value = false
await refresh()
} catch { erroModal.value = 'Erro ao salvar edital.' }
finally { saving.value = false }
}
</script>
<template>
<div class="detail-page">
<!-- Top bar -->
<header class="detail-topbar">
<div class="topbar-left">
<button class="back-btn" @click="router.push('/oportunidades')" title="Voltar">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><polyline points="12 19 5 12 12 5"/></svg>
</button>
<div>
<p class="topbar-crumb">Oportunidades / Detalhe</p>
<h1 class="topbar-title">{{ edital?.Numero }}</h1>
</div>
</div>
<div class="topbar-actions">
<button class="btn-outline" @click="abrirEditar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Editar
</button>
<button class="btn-danger-outline" @click="confirmarExclusao">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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"/></svg>
Excluir
</button>
</div>
</header>
<div class="detail-content" v-if="edital">
<!-- Hero section -->
<section class="hero-card">
<div class="hero-gradient" />
<div class="hero-inner">
<div class="hero-top">
<div class="hero-badge-row">
<span
class="status-badge-lg"
:style="{
background: STATUS_CFG[edital.Status]?.bg ?? '#f8fafc',
color: STATUS_CFG[edital.Status]?.color ?? '#64748b',
borderColor: STATUS_CFG[edital.Status]?.color ?? '#64748b',
}"
>
<span class="status-icon">{{ STATUS_CFG[edital.Status]?.icon ?? '•' }}</span>
{{ STATUS_CFG[edital.Status]?.label ?? edital.Status }}
</span>
<span class="modalidade-tag">{{ MODALIDADE_LABEL[edital.Modalidade] ?? edital.Modalidade }}</span>
</div>
</div>
<h2 class="hero-orgao">{{ edital.Orgao }}</h2>
<p class="hero-objeto" v-if="edital.Objeto">{{ edital.Objeto }}</p>
<div class="hero-metrics">
<div class="metric">
<span class="metric-label">Valor Estimado</span>
<span class="metric-value money">{{ formatBRL(edital.ValorEstimado) }}</span>
</div>
<div class="metric-divider" />
<div class="metric">
<span class="metric-label">Publicação</span>
<span class="metric-value">{{ formatDate(edital.DataPublicacao) }}</span>
</div>
<div class="metric-divider" />
<div class="metric">
<span class="metric-label">Abertura</span>
<span class="metric-value">
{{ formatDate(edital.DataAbertura) }}
<span class="metric-relative">{{ formatDateRelative(edital.DataAbertura) }}</span>
</span>
</div>
<div class="metric-divider" />
<div class="metric">
<span class="metric-label">Plataforma</span>
<span class="metric-value">{{ edital.Plataforma || '—' }}</span>
</div>
</div>
</div>
</section>
<!-- Pipeline progress -->
<section class="pipeline-section">
<h3 class="section-title">Progresso no Pipeline</h3>
<div class="pipeline-track">
<div
v-for="(stage, i) in PIPELINE_STAGES"
:key="stage"
class="pipeline-step"
:class="{
completed: i + 1 < currentStage,
current: i + 1 === currentStage,
future: i + 1 > currentStage,
}"
>
<div class="step-dot">
<svg v-if="i + 1 < currentStage" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<span v-else-if="i + 1 === currentStage" class="dot-pulse" />
<span v-else class="dot-empty" />
</div>
<span class="step-label">{{ stage }}</span>
<div v-if="i < PIPELINE_STAGES.length - 1" class="step-connector" :class="{ filled: i + 1 < currentStage }" />
</div>
</div>
</section>
<!-- Two-column bottom -->
<div class="bottom-grid">
<!-- Info card -->
<section class="info-card">
<h3 class="section-title">Informações do Edital</h3>
<div class="info-rows">
<div class="info-row">
<span class="info-key">Número</span>
<span class="info-val mono">{{ edital.Numero }}</span>
</div>
<div class="info-row">
<span class="info-key">Órgão</span>
<span class="info-val">{{ edital.Orgao }}</span>
</div>
<div class="info-row">
<span class="info-key">Modalidade</span>
<span class="info-val">{{ MODALIDADE_LABEL[edital.Modalidade] ?? edital.Modalidade }}</span>
</div>
<div class="info-row">
<span class="info-key">Plataforma</span>
<span class="info-val">{{ edital.Plataforma || '—' }}</span>
</div>
<div class="info-row">
<span class="info-key">Valor Estimado</span>
<span class="info-val mono">{{ formatBRL(edital.ValorEstimado) }}</span>
</div>
<div class="info-row">
<span class="info-key">Data Publicação</span>
<span class="info-val">{{ formatDate(edital.DataPublicacao) }}</span>
</div>
<div class="info-row">
<span class="info-key">Data Abertura</span>
<span class="info-val">{{ formatDate(edital.DataAbertura) }}</span>
</div>
<div class="info-row">
<span class="info-key">Status</span>
<span
class="badge-sm"
:style="{ background: STATUS_CFG[edital.Status]?.bg, color: STATUS_CFG[edital.Status]?.color }"
>{{ STATUS_CFG[edital.Status]?.label ?? edital.Status }}</span>
</div>
<div class="info-row full" v-if="edital.Objeto">
<span class="info-key">Objeto</span>
<span class="info-val obj-text">{{ edital.Objeto }}</span>
</div>
</div>
</section>
<!-- Files card -->
<section class="files-card">
<div class="files-card-header">
<h3 class="section-title">Arquivos</h3>
<label class="upload-btn" :class="{ loading: uploadLoading }">
<input type="file" multiple style="display:none" @change="uploadArquivos" />
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
{{ uploadLoading ? 'Enviando…' : 'Upload' }}
</label>
</div>
<div v-if="!files || files.length === 0" class="files-empty-state">
<div class="empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
</div>
<p>Nenhum arquivo anexado</p>
<label class="empty-upload-btn">
<input type="file" multiple style="display:none" @change="uploadArquivos" />
Fazer upload
</label>
</div>
<div v-else class="files-list">
<div v-for="f in files" :key="f.ID" class="file-card">
<div class="file-ext-badge" :class="fileExtension(f.Nome).toLowerCase()">
{{ fileExtension(f.Nome) }}
</div>
<div class="file-info">
<span class="file-name">{{ f.Nome }}</span>
<span class="file-meta">{{ formatFileSize(f.Size) }} · {{ formatDate(f.CreatedAt) }}</span>
</div>
<div class="file-actions">
<button v-if="isViewable(f.Nome)" class="file-action-btn view" @click="visualizarArquivo(f.ID, f.Nome)" title="Visualizar">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="file-action-btn dl" @click="downloadArquivo(f.ID, f.Nome)" title="Baixar">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
<button class="file-action-btn rm" @click="excluirArquivo(f.ID)" title="Remover">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.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"/></svg>
</button>
</div>
</div>
</div>
</section>
</div>
</div>
<!-- File Viewer Lightbox -->
<Teleport to="body">
<div v-if="viewerOpen" class="viewer-overlay" @click.self="fecharViewer">
<!-- Loading -->
<div v-if="viewerLoading" class="viewer-loading">
<div class="viewer-spinner" />
<span>Carregando</span>
</div>
<!-- Content -->
<div v-else class="viewer-container" @click.stop>
<!-- Header -->
<div class="viewer-header">
<span class="viewer-filename">{{ viewerName }}</span>
<button class="viewer-close" @click="fecharViewer" title="Fechar (Esc)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<!-- PDF -->
<iframe
v-if="viewerType === 'pdf'"
:src="viewerUrl"
class="viewer-pdf"
/>
<!-- Image -->
<div v-else class="viewer-image-wrapper">
<img :src="viewerUrl" :alt="viewerName" class="viewer-image" />
</div>
</div>
</div>
</Teleport>
<!-- Toast notifications -->
<Teleport to="body">
<div class="toast-container">
<TransitionGroup name="toast">
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.type">
<span class="toast-icon">{{ t.icon }}</span>
<span class="toast-msg">{{ t.message }}</span>
</div>
</TransitionGroup>
</div>
</Teleport>
<!-- Delete Confirmation Modal -->
<Teleport to="body">
<div v-if="showDeleteConfirm" class="modal-overlay" @click.self="showDeleteConfirm = false">
<div class="modal-delete" @click.stop>
<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>{{ edital?.Numero }}</strong>?
<br/>Esta ação não pode ser desfeita.
</p>
<div class="modal-delete-actions">
<button class="btn-cancel-del" @click="showDeleteConfirm = false">Cancelar</button>
<button class="btn-delete" :disabled="deleting" @click="executarExclusao">
{{ deleting ? 'Excluindo' : 'Sim, excluir' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Edit Modal -->
<Teleport to="body">
<div v-if="showEdit" class="modal-overlay" @click.self="showEdit = false">
<div class="modal-edit" @click.stop>
<div class="modal-edit-header">
<h3>Editar Edital</h3>
<button class="close-btn" @click="showEdit = false">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-edit-body">
<div class="form-row">
<div class="field"><label> do Edital *</label><input v-model="form.Numero" class="inp" /></div>
<div class="field"><label>Órgão *</label><input v-model="form.Orgao" class="inp" /></div>
</div>
<div class="field"><label>Objeto</label><textarea v-model="form.Objeto" class="inp textarea" rows="3" /></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" /></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-edit-footer">
<button class="btn-cancel" @click="showEdit = false">Cancelar</button>
<button class="btn-save" :disabled="saving" @click="salvar">{{ saving ? 'Salvando' : 'Salvar Alterações' }}</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=JetBrains+Mono:wght@400;600&display=swap');
.detail-page {
display: flex; flex-direction: column; height: 100vh;
font-family: 'DM Sans', system-ui, sans-serif;
background: #f1f3f8;
}
/* ── Topbar ── */
.detail-topbar {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 12px 28px;
display: flex; align-items: center; justify-content: space-between;
flex-shrink: 0;
}
.topbar-left { display: flex; align-items: center; gap: 14px; }
.back-btn {
width: 36px; height: 36px; border-radius: 10px;
border: 1px solid #e2e8f0; background: white;
display: flex; align-items: center; justify-content: center;
cursor: pointer; color: #475569; transition: all .15s;
}
.back-btn:hover { background: #f8fafc; border-color: #cbd5e1; color: #1e293b; }
.topbar-crumb { font-size: 11px; color: #94a3b8; letter-spacing: .2px; }
.topbar-title { font-size: 17px; font-weight: 700; color: #0f172a; margin-top: 1px; }
.topbar-actions { display: flex; gap: 8px; }
.btn-outline {
display: flex; align-items: center; gap: 6px;
padding: 7px 16px; border-radius: 8px;
border: 1px solid #e2e8f0; background: white;
font-size: 13px; font-weight: 600; color: #334155; cursor: pointer;
transition: all .15s;
}
.btn-outline:hover { background: #f8fafc; border-color: #cbd5e1; }
.btn-danger-outline {
display: flex; align-items: center; gap: 6px;
padding: 7px 16px; border-radius: 8px;
border: 1px solid #fecaca; background: white;
font-size: 13px; font-weight: 600; color: #dc2626; cursor: pointer;
transition: all .15s;
}
.btn-danger-outline:hover { background: #fef2f2; }
/* ── Content ── */
.detail-content {
flex: 1; overflow-y: auto;
padding: 24px 28px 40px;
display: flex; flex-direction: column; gap: 20px;
}
/* ── Hero Card ── */
.hero-card {
background: white;
border-radius: 16px;
border: 1px solid #e2e8f0;
overflow: hidden;
position: relative;
box-shadow: 0 1px 3px rgba(0,0,0,.04);
flex-shrink: 0;
}
.hero-gradient {
height: 4px;
background: linear-gradient(90deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
}
.hero-inner { padding: 24px 28px 28px; }
.hero-top { margin-bottom: 16px; }
.hero-badge-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.status-badge-lg {
display: inline-flex; align-items: center; gap: 6px;
padding: 5px 14px; border-radius: 20px;
font-size: 13px; font-weight: 700;
border: 1.5px solid;
}
.status-icon { font-size: 14px; }
.modalidade-tag {
padding: 4px 12px; border-radius: 6px;
font-size: 12px; font-weight: 600; color: #475569;
background: #f1f5f9; border: 1px solid #e2e8f0;
}
.hero-orgao {
font-size: 24px; font-weight: 700; color: #0f172a;
margin: 0 0 6px; line-height: 1.25;
letter-spacing: -0.3px;
}
.hero-objeto {
font-size: 14px; color: #64748b; line-height: 1.55;
max-width: 700px; margin: 0 0 24px;
}
.hero-metrics {
display: flex; align-items: stretch; gap: 0;
background: #f8fafc; border-radius: 12px;
border: 1px solid #e9edf3;
overflow: hidden;
}
.metric {
flex: 1; padding: 16px 20px;
display: flex; flex-direction: column; gap: 4px;
}
.metric-divider { width: 1px; background: #e2e8f0; flex-shrink: 0; }
.metric-label {
font-size: 10.5px; font-weight: 700; color: #94a3b8;
text-transform: uppercase; letter-spacing: .8px;
}
.metric-value {
font-size: 15px; font-weight: 700; color: #1e293b;
display: flex; align-items: baseline; gap: 8px;
}
.metric-value.money {
font-family: 'JetBrains Mono', monospace;
color: #059669; font-size: 16px;
}
.metric-relative {
font-size: 11px; font-weight: 600; color: #94a3b8;
background: #f1f5f9; padding: 1px 7px; border-radius: 4px;
}
/* ── Pipeline ── */
.pipeline-section {
background: white; border-radius: 16px;
border: 1px solid #e2e8f0; padding: 24px 28px;
box-shadow: 0 1px 3px rgba(0,0,0,.04);
flex-shrink: 0;
}
.section-title {
font-size: 14px; font-weight: 700; color: #0f172a;
margin: 0 0 18px; letter-spacing: -0.2px;
}
.pipeline-track {
display: flex; align-items: flex-start;
position: relative;
}
.pipeline-step {
display: flex; flex-direction: column; align-items: center;
flex: 1; position: relative; min-width: 0;
}
.step-dot {
width: 28px; height: 28px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
position: relative; z-index: 2;
transition: all .3s;
}
.completed .step-dot {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
}
.current .step-dot {
background: white;
border: 3px solid #667eea;
box-shadow: 0 0 0 4px rgba(102,126,234,.2);
}
.future .step-dot {
background: #f1f5f9; border: 2px solid #e2e8f0;
}
.dot-pulse {
width: 8px; height: 8px; border-radius: 50%;
background: #667eea;
animation: pulse 1.5s ease infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.3); opacity: .7; }
}
.dot-empty { width: 6px; height: 6px; border-radius: 50%; background: #cbd5e1; }
.step-label {
margin-top: 8px; font-size: 11px; font-weight: 600;
color: #94a3b8; text-align: center;
line-height: 1.3;
}
.completed .step-label { color: #667eea; }
.current .step-label { color: #1e293b; font-weight: 700; }
.step-connector {
position: absolute; top: 13px;
left: calc(50% + 16px); right: calc(-50% + 16px);
height: 3px; background: #e9edf3; border-radius: 2px; z-index: 1;
}
.step-connector.filled {
background: linear-gradient(90deg, #667eea, #764ba2);
}
/* ── Bottom grid ── */
.bottom-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; flex-shrink: 0; }
/* ── Info card ── */
.info-card {
background: white; border-radius: 16px;
border: 1px solid #e2e8f0; padding: 24px 28px;
box-shadow: 0 1px 3px rgba(0,0,0,.04);
}
.info-rows { display: flex; flex-direction: column; }
.info-row {
display: flex; align-items: baseline; justify-content: space-between;
padding: 11px 0; border-bottom: 1px solid #f5f7fa;
}
.info-row:last-child { border-bottom: none; }
.info-row.full { flex-direction: column; gap: 6px; }
.info-key {
font-size: 12.5px; font-weight: 500; color: #94a3b8;
min-width: 120px; flex-shrink: 0;
}
.info-val { font-size: 13.5px; font-weight: 600; color: #1e293b; text-align: right; }
.info-val.mono { font-family: 'JetBrains Mono', monospace; font-size: 13px; }
.obj-text { text-align: left; line-height: 1.5; font-weight: 500; color: #475569; }
.badge-sm {
display: inline-block; padding: 3px 10px; border-radius: 20px;
font-size: 11.5px; font-weight: 700;
}
/* ── Toast notifications ── */
.toast-container {
position: fixed; bottom: 20px; right: 20px;
z-index: 3000;
display: flex; flex-direction: column-reverse; gap: 8px;
pointer-events: none;
max-width: 380px;
}
.toast {
display: flex; align-items: flex-start; gap: 10px;
padding: 14px 18px; border-radius: 12px;
background: white;
box-shadow: 0 8px 30px rgba(0,0,0,.15), 0 2px 8px rgba(0,0,0,.08);
border-left: 4px solid #3b82f6;
pointer-events: auto;
animation: toastIn .35s cubic-bezier(.21,1.02,.73,1);
}
.toast.success { border-left-color: #16a34a; }
.toast.error { border-left-color: #dc2626; }
.toast.info { border-left-color: #3b82f6; }
.toast-icon { font-size: 18px; flex-shrink: 0; line-height: 1.2; }
.toast-msg { font-size: 13px; font-weight: 500; color: #1e293b; line-height: 1.45; }
@keyframes toastIn {
from { opacity: 0; transform: translateX(40px) scale(.95); }
to { opacity: 1; transform: translateX(0) scale(1); }
}
.toast-enter-active { animation: toastIn .35s cubic-bezier(.21,1.02,.73,1); }
.toast-leave-active { transition: all .25s ease; }
.toast-leave-to { opacity: 0; transform: translateX(40px) scale(.95); }
/* ── Files card ── */
.files-card {
background: white; border-radius: 16px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0,0,0,.04);
display: flex; flex-direction: column;
overflow: hidden; min-width: 0;
}
.files-card-header {
display: flex; align-items: center; justify-content: space-between;
padding: 20px 24px 0;
}
.upload-btn {
display: flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 8px;
background: linear-gradient(135deg, #667eea, #764ba2);
color: white; font-size: 12.5px; font-weight: 600;
cursor: pointer; border: none; transition: all .15s;
}
.upload-btn:hover { opacity: .9; transform: translateY(-1px); }
.upload-btn.loading { opacity: .6; pointer-events: none; }
.files-empty-state {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 40px 20px; gap: 12px;
}
.empty-icon { color: #cbd5e1; }
.files-empty-state p { font-size: 13px; color: #94a3b8; margin: 0; }
.empty-upload-btn {
margin-top: 4px; padding: 8px 18px; border-radius: 8px;
background: #f1f5f9; color: #475569; font-size: 13px;
font-weight: 600; cursor: pointer; border: 1px solid #e2e8f0;
transition: all .15s;
}
.empty-upload-btn:hover { background: #e2e8f0; }
.files-list {
padding: 16px 20px 20px;
display: flex; flex-direction: column; gap: 8px;
}
.file-card {
display: flex; align-items: center; gap: 12px;
padding: 12px 14px; border-radius: 10px;
background: #f8fafc; border: 1px solid #f1f5f9;
transition: all .15s;
overflow: hidden;
}
.file-card:hover { background: #f1f5f9; border-color: #e2e8f0; }
.file-ext-badge {
width: 40px; height: 40px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 10px; font-weight: 800; color: white;
background: #94a3b8; flex-shrink: 0;
font-family: 'JetBrains Mono', monospace;
letter-spacing: .5px;
}
.file-ext-badge.pdf { background: linear-gradient(135deg, #ef4444, #dc2626); }
.file-ext-badge.doc, .file-ext-badge.docx { background: linear-gradient(135deg, #3b82f6, #2563eb); }
.file-ext-badge.xls, .file-ext-badge.xlsx { background: linear-gradient(135deg, #22c55e, #16a34a); }
.file-ext-badge.jpg, .file-ext-badge.png, .file-ext-badge.jpeg { background: linear-gradient(135deg, #f59e0b, #d97706); }
.file-ext-badge.zip, .file-ext-badge.rar { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
.file-info { flex: 1; min-width: 0; }
.file-name {
font-size: 13px; font-weight: 600; color: #1e293b;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
display: block;
}
.file-meta { font-size: 11.5px; color: #94a3b8; margin-top: 2px; display: block; }
.file-actions { display: flex; gap: 4px; flex-shrink: 0; }
.file-action-btn {
width: 32px; height: 32px; border-radius: 8px;
border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all .15s;
}
.file-action-btn.view { background: #f0fdf4; color: #16a34a; }
.file-action-btn.view:hover { background: #dcfce7; }
.file-action-btn.dl { background: #eff6ff; color: #2563eb; }
.file-action-btn.dl:hover { background: #dbeafe; }
.file-action-btn.rm { background: transparent; color: #cbd5e1; }
.file-action-btn.rm:hover { background: #fef2f2; color: #dc2626; }
/* ── File Viewer Lightbox ── */
.viewer-overlay {
position: fixed; inset: 0;
background: rgba(0, 0, 0, .75);
backdrop-filter: blur(8px);
z-index: 2000;
display: flex; align-items: center; justify-content: center;
animation: fadeIn .2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.viewer-loading {
display: flex; flex-direction: column; align-items: center; gap: 14px;
color: white; font-size: 14px; font-weight: 600;
}
.viewer-spinner {
width: 36px; height: 36px; border-radius: 50%;
border: 3px solid rgba(255,255,255,.2);
border-top-color: white;
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.viewer-container {
display: flex; flex-direction: column;
width: 90vw; max-width: 1100px;
height: 90vh;
border-radius: 14px;
overflow: hidden;
background: #1e1e1e;
box-shadow: 0 30px 80px rgba(0,0,0,.5);
animation: viewerIn .25s ease;
}
@keyframes viewerIn {
from { opacity: 0; transform: scale(.95); }
to { opacity: 1; transform: scale(1); }
}
.viewer-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 18px;
background: #111;
border-bottom: 1px solid rgba(255,255,255,.08);
flex-shrink: 0;
}
.viewer-filename {
font-size: 13px; font-weight: 600; color: #e2e8f0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.viewer-close {
width: 34px; height: 34px; border-radius: 8px;
border: none; background: rgba(255,255,255,.08);
color: #94a3b8; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all .15s;
}
.viewer-close:hover { background: rgba(255,255,255,.15); color: white; }
.viewer-pdf {
flex: 1; width: 100%; border: none;
background: #2a2a2a;
}
.viewer-image-wrapper {
flex: 1; display: flex; align-items: center; justify-content: center;
padding: 20px; overflow: auto;
background: #111;
}
.viewer-image {
max-width: 100%; max-height: 100%;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 4px 20px rgba(0,0,0,.4);
}
/* ── Modal ── */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(15,23,42,.5);
backdrop-filter: blur(4px);
z-index: 1000;
display: flex; align-items: center; justify-content: center;
}
.modal-edit {
background: white; border-radius: 16px;
width: 640px; max-width: 95vw; max-height: 90vh; overflow-y: auto;
box-shadow: 0 20px 60px rgba(0,0,0,.25);
animation: modalIn .2s ease;
}
@keyframes modalIn {
from { opacity: 0; transform: translateY(10px) scale(.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.modal-edit-header {
display: flex; align-items: center; justify-content: space-between;
padding: 20px 24px; border-bottom: 1px solid #f1f5f9;
}
.modal-edit-header h3 { font-size: 16px; font-weight: 700; color: #0f172a; margin: 0; }
.close-btn {
width: 32px; height: 32px; border-radius: 8px;
border: none; background: #f8fafc; cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: #94a3b8; transition: all .15s;
}
.close-btn:hover { background: #f1f5f9; color: #475569; }
.modal-edit-body { padding: 24px 24px; display: flex; flex-direction: column; gap: 16px; }
.modal-edit-footer {
display: flex; justify-content: flex-end; gap: 10px;
padding: 16px 24px; 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: 700; color: #475569; }
.inp {
border: 1.5px solid #e2e8f0; border-radius: 9px;
padding: 9px 12px; font-size: 13.5px; color: #1e293b;
width: 100%; box-sizing: border-box;
font-family: 'DM Sans', system-ui, sans-serif;
transition: all .15s;
}
.inp:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,.12); }
.textarea { resize: vertical; min-height: 60px; }
.btn-cancel {
background: #f8fafc; color: #475569; border: 1px solid #e2e8f0;
border-radius: 9px; padding: 9px 20px; font-size: 13px;
font-weight: 600; cursor: pointer; transition: all .15s;
}
.btn-cancel:hover { background: #f1f5f9; }
.btn-save {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white; border: none; border-radius: 9px;
padding: 9px 22px; font-size: 13px; font-weight: 700;
cursor: pointer; transition: all .15s;
}
.btn-save:hover { opacity: .9; transform: translateY(-1px); }
.btn-save:disabled { opacity: .5; cursor: not-allowed; transform: none; }
.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-cancel-del {
background: #f8fafc; color: #475569; border: 1px solid #e2e8f0;
border-radius: 9px; padding: 9px 20px; font-size: 13px;
font-weight: 600; cursor: pointer; transition: all .15s;
}
.btn-cancel-del:hover { background: #f1f5f9; }
.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; }
/* ── Responsive ── */
@media (max-width: 900px) {
.bottom-grid { grid-template-columns: 1fr; }
.hero-metrics { flex-direction: column; }
.metric-divider { width: 100%; height: 1px; }
.pipeline-track { overflow-x: auto; }
}
</style>