ajustes
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
<!-- front-end/app/pages/gestao/documentos.vue -->
|
||||
<script setup lang="ts">
|
||||
const { apiFetch } = useApi()
|
||||
const { public: { apiBase } } = useRuntimeConfig()
|
||||
const token = useCookie<string | null>('auth_token')
|
||||
|
||||
interface ApiDocument {
|
||||
ID: string
|
||||
@@ -10,6 +12,13 @@ interface ApiDocument {
|
||||
Observacoes: string
|
||||
}
|
||||
|
||||
interface ApiFile {
|
||||
ID: string
|
||||
Nome: string
|
||||
Size: number
|
||||
CreatedAt: string
|
||||
}
|
||||
|
||||
const { data: documentos, refresh } = await useAsyncData('documentos', () =>
|
||||
apiFetch<ApiDocument[]>('/documents')
|
||||
)
|
||||
@@ -56,6 +65,12 @@ function formatDate(iso: string | null): string {
|
||||
return `${d}/${m}/${y}`
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
// ---------- menu ⋯ ----------
|
||||
const menuAberto = ref<string | null>(null)
|
||||
const menuPos = ref({ top: 0, left: 0 })
|
||||
@@ -69,6 +84,65 @@ function abrirMenu(id: string, event: MouseEvent) {
|
||||
|
||||
function fecharMenu() { menuAberto.value = null }
|
||||
|
||||
// ---------- modal visualizar ----------
|
||||
const showVisualizar = ref(false)
|
||||
const viewDoc = ref<ApiDocument | null>(null)
|
||||
const viewFiles = ref<ApiFile[]>([])
|
||||
const viewFilesLoading = ref(false)
|
||||
const uploadLoading = ref(false)
|
||||
|
||||
async function abrirVisualizar(doc: ApiDocument) {
|
||||
fecharMenu()
|
||||
viewDoc.value = doc
|
||||
showVisualizar.value = true
|
||||
viewFilesLoading.value = true
|
||||
try {
|
||||
viewFiles.value = await apiFetch<ApiFile[]>(`/documents/${doc.ID}/files`)
|
||||
} catch { viewFiles.value = [] }
|
||||
viewFilesLoading.value = false
|
||||
}
|
||||
|
||||
async function uploadArquivos(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (!input.files?.length || !viewDoc.value) return
|
||||
uploadLoading.value = true
|
||||
const formData = new FormData()
|
||||
for (const file of Array.from(input.files)) formData.append('file', file)
|
||||
try {
|
||||
await $fetch(`${apiBase}/documents/${viewDoc.value.ID}/files`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token.value}` },
|
||||
body: formData,
|
||||
})
|
||||
viewFiles.value = await apiFetch<ApiFile[]>(`/documents/${viewDoc.value.ID}/files`)
|
||||
} catch {}
|
||||
uploadLoading.value = false
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
async function excluirArquivo(fileId: string) {
|
||||
if (!viewDoc.value) return
|
||||
try {
|
||||
await apiFetch(`/documents/${viewDoc.value.ID}/files/${fileId}`, { method: 'DELETE' })
|
||||
viewFiles.value = viewFiles.value.filter(f => f.ID !== fileId)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function downloadArquivo(docId: string, fileId: string, nome: string) {
|
||||
try {
|
||||
const blob = await $fetch<Blob>(`${apiBase}/documents/${docId}/files/${fileId}/download`, {
|
||||
headers: { Authorization: `Bearer ${token.value}` },
|
||||
responseType: 'blob',
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = nome
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ---------- modal criar ----------
|
||||
const showCriar = ref(false)
|
||||
const criando = ref(false)
|
||||
@@ -221,8 +295,69 @@ async function confirmarExclusao() {
|
||||
:style="{ top: menuPos.top + 'px', left: menuPos.left + 'px' }"
|
||||
@click.stop
|
||||
>
|
||||
<button @click="abrirVisualizar(documentos!.find(d => d.ID === menuAberto)!); menuAberto = null">Visualizar</button>
|
||||
<button @click="abrirEditar(documentos!.find(d => d.ID === menuAberto)!)">Editar</button>
|
||||
<button @click="abrirExcluir(documentos!.find(d => d.ID === menuAberto)!)">Excluir</button>
|
||||
<button class="drop-danger" @click="abrirExcluir(documentos!.find(d => d.ID === menuAberto)!)">Excluir</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Modal Visualizar -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showVisualizar" class="modal-overlay" @click.self="showVisualizar = false">
|
||||
<div class="modal modal-view">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h2>{{ viewDoc?.Nome || tipoLabel(viewDoc?.Tipo ?? '') }}</h2>
|
||||
<span
|
||||
class="doc-status"
|
||||
:style="{ color: STATUS_CFG[calcStatus(viewDoc?.DataVencimento ?? null)].color, background: STATUS_CFG[calcStatus(viewDoc?.DataVencimento ?? null)].bg }"
|
||||
>
|
||||
{{ STATUS_CFG[calcStatus(viewDoc?.DataVencimento ?? null)].label }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="close-btn" @click="showVisualizar = false">✕</button>
|
||||
</div>
|
||||
<div class="view-grid">
|
||||
<div class="view-field">
|
||||
<label>Tipo</label>
|
||||
<span>{{ tipoLabel(viewDoc?.Tipo ?? '') }}</span>
|
||||
</div>
|
||||
<div class="view-field">
|
||||
<label>Vencimento</label>
|
||||
<span>{{ formatDate(viewDoc?.DataVencimento ?? null) }}</span>
|
||||
</div>
|
||||
<div class="view-field view-full">
|
||||
<label>Observações</label>
|
||||
<span>{{ viewDoc?.Observacoes || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="files-section">
|
||||
<div class="files-header">
|
||||
<h3>Arquivos</h3>
|
||||
<label class="upload-btn" :class="{ loading: uploadLoading }">
|
||||
<input type="file" multiple @change="uploadArquivos" style="display:none" :disabled="uploadLoading" />
|
||||
{{ uploadLoading ? 'Enviando...' : '+ Anexar' }}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="viewFilesLoading" class="files-empty">Carregando arquivos...</div>
|
||||
<div v-else-if="viewFiles.length === 0" class="files-empty">Nenhum arquivo anexado.</div>
|
||||
<div v-else class="files-list">
|
||||
<div v-for="f in viewFiles" :key="f.ID" class="file-item">
|
||||
<span class="file-icon">📄</span>
|
||||
<span class="file-name">{{ f.Nome }}</span>
|
||||
<span class="file-size">{{ formatFileSize(f.Size) }}</span>
|
||||
<button class="file-act" title="Download" @click="downloadArquivo(viewDoc!.ID, f.ID, f.Nome)">⬇</button>
|
||||
<button class="file-act file-del" title="Remover" @click="excluirArquivo(f.ID)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn-cancel" @click="showVisualizar = false">Fechar</button>
|
||||
<button class="btn-save" @click="showVisualizar = false; abrirEditar(viewDoc!)">Editar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
@@ -348,9 +483,11 @@ async function confirmarExclusao() {
|
||||
.btn-menu:hover { background: #f1f5f9; color: #334155; }
|
||||
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2); color: white; border: none; border-radius: 8px; padding: 7px 14px; font-size: 13px; font-weight: 600; cursor: pointer; }
|
||||
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px 12px; border-bottom: 1px solid #f1f5f9; }
|
||||
.modal-header h2 { font-size: 16px; font-weight: 700; color: #0f172a; margin: 0; }
|
||||
.modal-header { display: flex; align-items: flex-start; justify-content: space-between; padding: 18px 20px 12px; border-bottom: 1px solid #f1f5f9; }
|
||||
.modal-header h2 { font-size: 16px; font-weight: 700; color: #0f172a; margin: 0 0 6px; }
|
||||
.modal-header button { background: none; border: none; font-size: 18px; cursor: pointer; color: #94a3b8; }
|
||||
.close-btn { background: none; border: none; font-size: 16px; color: #94a3b8; cursor: pointer; padding: 4px 8px; border-radius: 6px; }
|
||||
.close-btn:hover { background: #f1f5f9; color: #475569; }
|
||||
.modal-body { padding: 16px 20px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 12px 20px 18px; border-top: 1px solid #f1f5f9; }
|
||||
|
||||
@@ -365,6 +502,32 @@ async function confirmarExclusao() {
|
||||
.btn-danger { background: #dc2626; color: white; border: none; border-radius: 8px; padding: 7px 16px; font-size: 13px; font-weight: 600; cursor: pointer; }
|
||||
.btn-danger:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.erro { color: #dc2626; font-size: 12px; }
|
||||
|
||||
/* Modal visualizar */
|
||||
.modal-view { max-width: 620px !important; }
|
||||
.view-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; padding: 16px 20px 4px; }
|
||||
.view-full { grid-column: 1 / -1; }
|
||||
.view-field { display: flex; flex-direction: column; gap: 3px; }
|
||||
.view-field label { font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.view-field span { font-size: 13px; color: #1e293b; }
|
||||
|
||||
/* Arquivos */
|
||||
.files-section { border-top: 1px solid #f1f5f9; padding: 16px 20px 4px; margin-top: 8px; }
|
||||
.files-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.files-header h3 { font-size: 11px; font-weight: 700; color: #94a3b8; margin: 0; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.upload-btn { font-size: 12px; font-weight: 600; padding: 5px 12px; border-radius: 8px; border: 1px dashed #667eea; color: #667eea; cursor: pointer; transition: all 0.15s; }
|
||||
.upload-btn:hover { background: #f0f3ff; }
|
||||
.upload-btn.loading { opacity: 0.6; cursor: not-allowed; }
|
||||
.files-empty { font-size: 13px; color: #94a3b8; text-align: center; padding: 16px 0; }
|
||||
.files-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
||||
.file-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; }
|
||||
.file-icon { font-size: 16px; flex-shrink: 0; }
|
||||
.file-name { flex: 1; font-size: 13px; color: #1e293b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-size { font-size: 11px; color: #94a3b8; white-space: nowrap; flex-shrink: 0; }
|
||||
.file-act { font-size: 13px; padding: 3px 7px; background: none; border: none; cursor: pointer; color: #667eea; border-radius: 4px; flex-shrink: 0; }
|
||||
.file-act:hover { background: #f0f3ff; }
|
||||
.file-del { color: #dc2626; }
|
||||
.file-del:hover { background: #fff1f1 !important; }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
@@ -387,4 +550,6 @@ async function confirmarExclusao() {
|
||||
font-size: 13px; background: none; border: none; cursor: pointer; color: #374151;
|
||||
}
|
||||
.dropdown-fixed button:hover { background: #f8fafc; }
|
||||
.dropdown-fixed .drop-danger { color: #dc2626; }
|
||||
.dropdown-fixed .drop-danger:hover { background: #fff1f1; }
|
||||
</style>
|
||||
|
||||
@@ -1,57 +1,416 @@
|
||||
<!-- front-end/app/pages/gestao/prazos.vue -->
|
||||
<script setup lang="ts">
|
||||
import { prazos } from '~/data/mock/prazos'
|
||||
const { apiFetch } = useApi()
|
||||
|
||||
const hoje = new Date()
|
||||
|
||||
const urgenciaConfig = {
|
||||
critico: { label: 'Crítico — Hoje', color: '#dc2626', bg: '#fef2f2' },
|
||||
urgente: { label: 'Urgente', color: '#d97706', bg: '#fffbeb' },
|
||||
normal: { label: 'Normal', color: '#667eea', bg: '#eff6ff' },
|
||||
interface ApiEdital {
|
||||
ID: string; Numero: string; Orgao: string; Modalidade: string
|
||||
Objeto: string; ValorEstimado: number; DataPublicacao: string
|
||||
DataAbertura: string; Status: string
|
||||
}
|
||||
interface ApiDocument {
|
||||
ID: string; Tipo: string; Nome: string
|
||||
DataVencimento: string | null; Observacoes: string
|
||||
}
|
||||
interface ApiContract {
|
||||
ID: string; Numero: string; Orgao: string; Objeto: string
|
||||
DataInicio: string; DataFim: string; Status: string
|
||||
}
|
||||
|
||||
function diasRestantes(data: Date) {
|
||||
const diff = Math.ceil((data.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (diff <= 0) return 'Hoje'
|
||||
const { data: editais } = await useAsyncData('prazos-editais', () =>
|
||||
apiFetch<ApiEdital[]>('/editais'), { server: false }
|
||||
)
|
||||
const { data: documentos } = await useAsyncData('prazos-docs', () =>
|
||||
apiFetch<ApiDocument[]>('/documents'), { server: false }
|
||||
)
|
||||
const { data: contratos } = await useAsyncData('prazos-contratos', () =>
|
||||
apiFetch<ApiContract[]>('/contracts'), { server: false, default: () => [] }
|
||||
)
|
||||
|
||||
// ---------- helpers ----------
|
||||
const TIPO_DOC: Record<string, string> = {
|
||||
cnpj: 'CNPJ', contrato_social: 'Contrato Social', balanco: 'Balanço',
|
||||
certidao: 'Certidão', atestado: 'Atestado Técnico', procuracao: 'Procuração', outro: 'Outro',
|
||||
}
|
||||
const MODALIDADE: Record<string, string> = {
|
||||
pregao_eletronico: 'Pregão Eletrônico', pregao_presencial: 'Pregão Presencial',
|
||||
concorrencia: 'Concorrência', dispensa: 'Dispensa', inexigibilidade: 'Inexigibilidade',
|
||||
}
|
||||
|
||||
function toDate(iso: string): Date {
|
||||
const [y, m, d] = iso.split('T')[0].split('-').map(Number)
|
||||
return new Date(y, m - 1, d)
|
||||
}
|
||||
function formatDate(iso: string): string {
|
||||
const [y, m, d] = iso.split('T')[0].split('-')
|
||||
return `${d}/${m}/${y}`
|
||||
}
|
||||
function diffDays(iso: string): number {
|
||||
const hoje = new Date()
|
||||
const hojeMs = Date.UTC(hoje.getFullYear(), hoje.getMonth(), hoje.getDate())
|
||||
const [y, m, d] = iso.split('T')[0].split('-').map(Number)
|
||||
const tMs = Date.UTC(y, m - 1, d)
|
||||
return Math.ceil((tMs - hojeMs) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
function diasLabel(diff: number): string {
|
||||
if (diff < 0) return `${Math.abs(diff)}d atrás`
|
||||
if (diff === 0) return 'Hoje'
|
||||
if (diff === 1) return 'Amanhã'
|
||||
return `${diff} dias`
|
||||
return `em ${diff}d`
|
||||
}
|
||||
|
||||
// ---------- prazo items ----------
|
||||
interface PrazoItem {
|
||||
id: string
|
||||
titulo: string
|
||||
subtitulo: string
|
||||
dataIso: string
|
||||
diff: number
|
||||
tipo: 'edital' | 'documento' | 'contrato'
|
||||
link?: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const tipoIcone: Record<string, string> = {
|
||||
edital: '📋', documento: '📄', contrato: '📝',
|
||||
}
|
||||
const tipoCor: Record<string, { color: string; bg: string }> = {
|
||||
edital: { color: '#3b82f6', bg: '#eff6ff' },
|
||||
documento: { color: '#8b5cf6', bg: '#f5f3ff' },
|
||||
contrato: { color: '#059669', bg: '#ecfdf5' },
|
||||
}
|
||||
|
||||
const prazos = computed<PrazoItem[]>(() => {
|
||||
const items: PrazoItem[] = []
|
||||
|
||||
// Editais — data de abertura (só status ativos)
|
||||
const statusAtivos = new Set(['em_analise', 'elaborando_proposta', 'edital_publicado', 'fase_lances', 'habilitacao', 'recurso'])
|
||||
for (const e of editais.value ?? []) {
|
||||
if (!statusAtivos.has(e.Status)) continue
|
||||
const d = diffDays(e.DataAbertura)
|
||||
items.push({
|
||||
id: `e-${e.ID}`,
|
||||
titulo: `Abertura — ${e.Numero}`,
|
||||
subtitulo: `${MODALIDADE[e.Modalidade] ?? e.Modalidade} · ${e.Orgao}`,
|
||||
dataIso: e.DataAbertura,
|
||||
diff: d,
|
||||
tipo: 'edital',
|
||||
link: `/oportunidades/${e.ID}`,
|
||||
icon: tipoIcone.edital,
|
||||
})
|
||||
}
|
||||
|
||||
// Documentos — data de vencimento
|
||||
for (const doc of documentos.value ?? []) {
|
||||
if (!doc.DataVencimento) continue
|
||||
const d = diffDays(doc.DataVencimento)
|
||||
items.push({
|
||||
id: `d-${doc.ID}`,
|
||||
titulo: `Vencimento — ${doc.Nome || TIPO_DOC[doc.Tipo] || doc.Tipo}`,
|
||||
subtitulo: `${TIPO_DOC[doc.Tipo] || doc.Tipo}${doc.Observacoes ? ' · ' + doc.Observacoes : ''}`,
|
||||
dataIso: doc.DataVencimento,
|
||||
diff: d,
|
||||
tipo: 'documento',
|
||||
link: '/gestao/documentos',
|
||||
icon: tipoIcone.documento,
|
||||
})
|
||||
}
|
||||
|
||||
// Contratos — data fim
|
||||
for (const c of contratos.value ?? []) {
|
||||
if (!c.DataFim || c.Status === 'encerrado') continue
|
||||
const d = diffDays(c.DataFim)
|
||||
items.push({
|
||||
id: `c-${c.ID}`,
|
||||
titulo: `Fim vigência — ${c.Numero}`,
|
||||
subtitulo: `Contrato · ${c.Orgao}`,
|
||||
dataIso: c.DataFim,
|
||||
diff: d,
|
||||
tipo: 'contrato',
|
||||
link: '/gestao/contratos',
|
||||
icon: tipoIcone.contrato,
|
||||
})
|
||||
}
|
||||
|
||||
// Ordenar por data (mais próximo primeiro)
|
||||
items.sort((a, b) => a.diff - b.diff)
|
||||
return items
|
||||
})
|
||||
|
||||
// ---------- filtros ----------
|
||||
type FiltroTipo = 'todos' | 'edital' | 'documento' | 'contrato'
|
||||
type FiltroPeriodo = 'todos' | 'vencido' | '7dias' | '30dias' | '90dias'
|
||||
|
||||
const filtroTipo = ref<FiltroTipo>('todos')
|
||||
const filtroPeriodo = ref<FiltroPeriodo>('todos')
|
||||
|
||||
const filtroTipoOptions: { value: FiltroTipo; label: string }[] = [
|
||||
{ value: 'todos', label: 'Todos' },
|
||||
{ value: 'edital', label: 'Editais' },
|
||||
{ value: 'documento', label: 'Documentos' },
|
||||
{ value: 'contrato', label: 'Contratos' },
|
||||
]
|
||||
const filtroPeriodoOptions: { value: FiltroPeriodo; label: string }[] = [
|
||||
{ value: 'todos', label: 'Todos' },
|
||||
{ value: 'vencido', label: 'Vencidos' },
|
||||
{ value: '7dias', label: 'Próx. 7 dias' },
|
||||
{ value: '30dias', label: 'Próx. 30 dias' },
|
||||
{ value: '90dias', label: 'Próx. 90 dias' },
|
||||
]
|
||||
|
||||
const prazosFiltrados = computed(() => {
|
||||
return prazos.value.filter(p => {
|
||||
if (filtroTipo.value !== 'todos' && p.tipo !== filtroTipo.value) return false
|
||||
if (filtroPeriodo.value === 'vencido' && p.diff >= 0) return false
|
||||
if (filtroPeriodo.value === '7dias' && (p.diff < 0 || p.diff > 7)) return false
|
||||
if (filtroPeriodo.value === '30dias' && (p.diff < 0 || p.diff > 30)) return false
|
||||
if (filtroPeriodo.value === '90dias' && (p.diff < 0 || p.diff > 90)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- stats ----------
|
||||
const stats = computed(() => {
|
||||
const all = prazos.value
|
||||
return {
|
||||
total: all.length,
|
||||
vencidos: all.filter(p => p.diff < 0).length,
|
||||
hoje: all.filter(p => p.diff === 0).length,
|
||||
semana: all.filter(p => p.diff > 0 && p.diff <= 7).length,
|
||||
mes: all.filter(p => p.diff > 0 && p.diff <= 30).length,
|
||||
}
|
||||
})
|
||||
|
||||
function urgencia(diff: number): 'vencido' | 'critico' | 'urgente' | 'normal' | 'folgado' {
|
||||
if (diff < 0) return 'vencido'
|
||||
if (diff === 0) return 'critico'
|
||||
if (diff <= 7) return 'urgente'
|
||||
if (diff <= 30) return 'normal'
|
||||
return 'folgado'
|
||||
}
|
||||
const urgenciaCfg = {
|
||||
vencido: { label: 'Vencido', color: '#dc2626', bg: '#fef2f2', border: '#fecaca' },
|
||||
critico: { label: 'Hoje', color: '#dc2626', bg: '#fef2f2', border: '#fecaca' },
|
||||
urgente: { label: 'Urgente', color: '#ea580c', bg: '#fff7ed', border: '#fed7aa' },
|
||||
normal: { label: 'Normal', color: '#d97706', bg: '#fffbeb', border: '#fde68a' },
|
||||
folgado: { label: 'Folgado', color: '#16a34a', bg: '#f0fdf4', border: '#bbf7d0' },
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppTopbar title="Prazos" breadcrumb="Gestão · Prazos" />
|
||||
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<div v-for="prazo in prazos" :key="prazo.id" class="prazo-row">
|
||||
<div class="prazo-dot" :style="{ background: urgenciaConfig[prazo.urgencia].color }" />
|
||||
<div class="prazo-info">
|
||||
<p class="prazo-titulo">{{ prazo.titulo }}</p>
|
||||
<p class="prazo-desc">{{ prazo.descricao }}</p>
|
||||
</div>
|
||||
<div class="prazo-right">
|
||||
<span class="prazo-badge" :style="{ color: urgenciaConfig[prazo.urgencia].color, background: urgenciaConfig[prazo.urgencia].bg }">
|
||||
{{ urgenciaConfig[prazo.urgencia].label }}
|
||||
</span>
|
||||
<p class="prazo-data">{{ prazo.dataLimite.toLocaleDateString('pt-BR') }} · {{ diasRestantes(prazo.dataLimite) }}</p>
|
||||
</div>
|
||||
<!-- Stats cards -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card stat-danger" @click="filtroPeriodo = 'vencido'; filtroTipo = 'todos'">
|
||||
<span class="stat-num">{{ stats.vencidos }}</span>
|
||||
<span class="stat-label">Vencidos</span>
|
||||
</div>
|
||||
<div class="stat-card stat-critical" @click="filtroPeriodo = 'todos'; filtroTipo = 'todos'">
|
||||
<span class="stat-num">{{ stats.hoje }}</span>
|
||||
<span class="stat-label">Hoje</span>
|
||||
</div>
|
||||
<div class="stat-card stat-warning" @click="filtroPeriodo = '7dias'; filtroTipo = 'todos'">
|
||||
<span class="stat-num">{{ stats.semana }}</span>
|
||||
<span class="stat-label">Próx. 7 dias</span>
|
||||
</div>
|
||||
<div class="stat-card stat-info" @click="filtroPeriodo = '30dias'; filtroTipo = 'todos'">
|
||||
<span class="stat-num">{{ stats.mes }}</span>
|
||||
<span class="stat-label">Próx. 30 dias</span>
|
||||
</div>
|
||||
<div class="stat-card stat-total">
|
||||
<span class="stat-num">{{ stats.total }}</span>
|
||||
<span class="stat-label">Total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters-row">
|
||||
<div class="filter-group">
|
||||
<button
|
||||
v-for="opt in filtroTipoOptions" :key="opt.value"
|
||||
class="filter-btn"
|
||||
:class="{ active: filtroTipo === opt.value }"
|
||||
@click="filtroTipo = opt.value"
|
||||
>{{ opt.label }}</button>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<button
|
||||
v-for="opt in filtroPeriodoOptions" :key="opt.value"
|
||||
class="filter-btn"
|
||||
:class="{ active: filtroPeriodo === opt.value }"
|
||||
@click="filtroPeriodo = opt.value"
|
||||
>{{ opt.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div class="timeline-card">
|
||||
<div v-if="prazosFiltrados.length === 0" class="timeline-empty">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
<p>Nenhum prazo encontrado para os filtros selecionados.</p>
|
||||
</div>
|
||||
|
||||
<NuxtLink
|
||||
v-for="(prazo, idx) in prazosFiltrados"
|
||||
:key="prazo.id"
|
||||
:to="prazo.link ?? '#'"
|
||||
class="timeline-item"
|
||||
:class="urgencia(prazo.diff)"
|
||||
>
|
||||
<div class="tl-left">
|
||||
<div class="tl-dot" :style="{ background: urgenciaCfg[urgencia(prazo.diff)].color }" />
|
||||
<div v-if="idx < prazosFiltrados.length - 1" class="tl-line" />
|
||||
</div>
|
||||
|
||||
<div class="tl-content">
|
||||
<div class="tl-header">
|
||||
<div class="tl-icon-badge" :style="{ background: tipoCor[prazo.tipo].bg, color: tipoCor[prazo.tipo].color }">
|
||||
{{ prazo.icon }}
|
||||
</div>
|
||||
<div class="tl-title-area">
|
||||
<span class="tl-title">{{ prazo.titulo }}</span>
|
||||
<span class="tl-sub">{{ prazo.subtitulo }}</span>
|
||||
</div>
|
||||
<div class="tl-right-area">
|
||||
<span
|
||||
class="tl-urgencia"
|
||||
:style="{
|
||||
color: urgenciaCfg[urgencia(prazo.diff)].color,
|
||||
background: urgenciaCfg[urgencia(prazo.diff)].bg,
|
||||
borderColor: urgenciaCfg[urgencia(prazo.diff)].border,
|
||||
}"
|
||||
>{{ urgenciaCfg[urgencia(prazo.diff)].label }}</span>
|
||||
<span class="tl-date">{{ formatDate(prazo.dataIso) }}</span>
|
||||
<span class="tl-diff" :style="{ color: urgenciaCfg[urgencia(prazo.diff)].color }">
|
||||
{{ diasLabel(prazo.diff) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</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; }
|
||||
.prazo-row { display: flex; align-items: center; gap: 12px; padding: 14px 18px; border-bottom: 1px solid #f8fafc; }
|
||||
.prazo-row:last-child { border-bottom: none; }
|
||||
.prazo-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.prazo-info { flex: 1; }
|
||||
.prazo-titulo { font-size: 13px; font-weight: 600; color: #0f172a; }
|
||||
.prazo-desc { font-size: 11px; color: #94a3b8; margin-top: 2px; }
|
||||
.prazo-right { text-align: right; flex-shrink: 0; }
|
||||
.prazo-badge { font-size: 10.5px; font-weight: 600; padding: 2px 8px; border-radius: 20px; display: inline-block; margin-bottom: 4px; }
|
||||
.prazo-data { font-size: 11px; color: #64748b; }
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300..700&family=JetBrains+Mono:wght@400;600&display=swap');
|
||||
|
||||
.page { display: flex; flex-direction: column; height: 100vh; font-family: 'DM Sans', system-ui, sans-serif; }
|
||||
.content { padding: 24px 28px 40px; flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 18px; }
|
||||
|
||||
/* ── Stats ── */
|
||||
.stats-row { display: flex; gap: 12px; }
|
||||
.stat-card {
|
||||
flex: 1; background: white; border-radius: 12px;
|
||||
border: 1px solid #e2e8f0; padding: 16px 18px;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
cursor: pointer; transition: all .15s;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.04);
|
||||
}
|
||||
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,.08); }
|
||||
.stat-num { font-size: 28px; font-weight: 800; font-family: 'JetBrains Mono', monospace; line-height: 1; }
|
||||
.stat-label { font-size: 11.5px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.stat-danger .stat-num { color: #dc2626; }
|
||||
.stat-danger { border-left: 3px solid #dc2626; }
|
||||
.stat-critical .stat-num { color: #ea580c; }
|
||||
.stat-critical { border-left: 3px solid #ea580c; }
|
||||
.stat-warning .stat-num { color: #d97706; }
|
||||
.stat-warning { border-left: 3px solid #d97706; }
|
||||
.stat-info .stat-num { color: #3b82f6; }
|
||||
.stat-info { border-left: 3px solid #3b82f6; }
|
||||
.stat-total .stat-num { color: #0f172a; }
|
||||
.stat-total { border-left: 3px solid #667eea; }
|
||||
|
||||
/* ── Filters ── */
|
||||
.filters-row { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
|
||||
.filter-group { display: flex; gap: 4px; background: #f1f5f9; border-radius: 9px; padding: 3px; }
|
||||
.filter-btn {
|
||||
padding: 6px 14px; border-radius: 7px; border: none;
|
||||
font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
color: #64748b; background: transparent; transition: all .15s;
|
||||
}
|
||||
.filter-btn:hover { color: #334155; }
|
||||
.filter-btn.active { background: white; color: #0f172a; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
||||
|
||||
/* ── Timeline ── */
|
||||
.timeline-card {
|
||||
background: white; border-radius: 14px;
|
||||
border: 1px solid #e2e8f0; overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.04);
|
||||
}
|
||||
.timeline-empty {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: 10px; padding: 48px 20px; color: #94a3b8;
|
||||
}
|
||||
.timeline-empty p { font-size: 13px; margin: 0; }
|
||||
|
||||
.timeline-item {
|
||||
display: flex; gap: 0; text-decoration: none;
|
||||
transition: background .12s;
|
||||
}
|
||||
.timeline-item:hover { background: #fafbfd; }
|
||||
.timeline-item:not(:last-child) { border-bottom: 1px solid #f5f7fa; }
|
||||
|
||||
/* Urgency left accent */
|
||||
.timeline-item.vencido { border-left: 3px solid #dc2626; }
|
||||
.timeline-item.critico { border-left: 3px solid #dc2626; }
|
||||
.timeline-item.urgente { border-left: 3px solid #ea580c; }
|
||||
.timeline-item.normal { border-left: 3px solid #d97706; }
|
||||
.timeline-item.folgado { border-left: 3px solid #16a34a; }
|
||||
|
||||
/* Timeline dots + line */
|
||||
.tl-left {
|
||||
width: 32px; display: flex; flex-direction: column;
|
||||
align-items: center; padding-top: 22px; flex-shrink: 0;
|
||||
}
|
||||
.tl-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
flex-shrink: 0; position: relative; z-index: 1;
|
||||
}
|
||||
.tl-line {
|
||||
width: 2px; flex: 1; background: #e9edf3; margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.tl-content { flex: 1; padding: 14px 18px 14px 6px; min-width: 0; }
|
||||
.tl-header { display: flex; align-items: center; gap: 12px; }
|
||||
.tl-icon-badge {
|
||||
width: 36px; height: 36px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 16px; flex-shrink: 0;
|
||||
}
|
||||
.tl-title-area { flex: 1; min-width: 0; }
|
||||
.tl-title {
|
||||
display: block; font-size: 13.5px; font-weight: 700; color: #0f172a;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.tl-sub {
|
||||
display: block; font-size: 12px; color: #94a3b8; margin-top: 2px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.tl-right-area {
|
||||
display: flex; flex-direction: column; align-items: flex-end;
|
||||
gap: 3px; flex-shrink: 0;
|
||||
}
|
||||
.tl-urgencia {
|
||||
font-size: 10.5px; font-weight: 700; padding: 2px 9px;
|
||||
border-radius: 20px; border: 1px solid;
|
||||
}
|
||||
.tl-date {
|
||||
font-size: 12px; font-weight: 600; color: #475569;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.tl-diff { font-size: 11px; font-weight: 700; }
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 768px) {
|
||||
.stats-row { flex-wrap: wrap; }
|
||||
.stat-card { min-width: calc(50% - 8px); }
|
||||
.filters-row { flex-direction: column; }
|
||||
.tl-header { flex-wrap: wrap; }
|
||||
.tl-right-area { flex-direction: row; gap: 8px; align-items: center; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user