1098 lines
42 KiB
Vue
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>Nº 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>
|