feat: documentos.vue integrado com API real (CRUD + status de vencimento)
This commit is contained in:
@@ -1,46 +1,330 @@
|
|||||||
<!-- front-end/app/pages/gestao/documentos.vue -->
|
<!-- front-end/app/pages/gestao/documentos.vue -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { documentos } from '~/data/mock/documentos'
|
const { apiFetch } = useApi()
|
||||||
|
|
||||||
const statusConfig = {
|
interface ApiDocument {
|
||||||
valida: { label: 'Válida', color: '#16a34a', bg: '#f0fdf4' },
|
ID: string
|
||||||
vencendo: { label: 'Vencendo', color: '#d97706', bg: '#fffbeb' },
|
Tipo: string
|
||||||
vencida: { label: 'Vencida', color: '#dc2626', bg: '#fef2f2' },
|
Nome: string
|
||||||
sem_vencimento: { label: 'OK', color: '#64748b', bg: '#f8fafc' },
|
DataVencimento: string | null
|
||||||
|
Observacoes: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const tipoLabel: Record<string, string> = {
|
const { data: documentos, refresh } = await useAsyncData('documentos', () =>
|
||||||
certidao: 'Certidão',
|
apiFetch<ApiDocument[]>('/documents')
|
||||||
contrato_social: 'Contrato Social',
|
)
|
||||||
balanco: 'Balanço',
|
|
||||||
atestado: 'Atestado',
|
// ---------- tipos ----------
|
||||||
procuracao: 'Procuração',
|
const TIPOS = [
|
||||||
|
{ value: 'cnpj', label: 'CNPJ' },
|
||||||
|
{ value: 'contrato_social', label: 'Contrato Social' },
|
||||||
|
{ value: 'balanco', label: 'Balanço' },
|
||||||
|
{ value: 'certidao', label: 'Certidão' },
|
||||||
|
{ value: 'atestado', label: 'Atestado de Capacidade Técnica' },
|
||||||
|
{ value: 'procuracao', label: 'Procuração' },
|
||||||
|
{ value: 'outro', label: 'Outro' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function tipoLabel(tipo: string): string {
|
||||||
|
return TIPOS.find(t => t.value === tipo)?.label ?? tipo
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- status ----------
|
||||||
|
function calcStatus(dataVenc: string | null): 'sem_vencimento' | 'vencida' | 'vencendo' | 'valida' {
|
||||||
|
if (!dataVenc) return 'sem_vencimento'
|
||||||
|
const hoje = new Date()
|
||||||
|
hoje.setHours(0, 0, 0, 0)
|
||||||
|
const venc = new Date(dataVenc + 'T00:00:00')
|
||||||
|
const diff = (venc.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
if (diff < 0) return 'vencida'
|
||||||
|
if (diff <= 30) return 'vencendo'
|
||||||
|
return 'valida'
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CFG = {
|
||||||
|
valida: { label: 'Válida', color: '#16a34a', bg: '#f0fdf4' },
|
||||||
|
vencendo: { label: 'Vencendo', color: '#d97706', bg: '#fffbeb' },
|
||||||
|
vencida: { label: 'Vencida', color: '#dc2626', bg: '#fef2f2' },
|
||||||
|
sem_vencimento: { label: 'OK', color: '#64748b', bg: '#f8fafc' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string | null): string {
|
||||||
|
if (!iso) return 'Sem vencimento'
|
||||||
|
const [y, m, d] = iso.split('T')[0].split('-')
|
||||||
|
return `${d}/${m}/${y}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- menu ⋯ ----------
|
||||||
|
const menuAberto = ref<string | null>(null)
|
||||||
|
const menuPos = ref({ top: 0, left: 0 })
|
||||||
|
|
||||||
|
function abrirMenu(id: string, event: MouseEvent) {
|
||||||
|
const btn = (event.currentTarget as HTMLElement)
|
||||||
|
const rect = btn.getBoundingClientRect()
|
||||||
|
menuPos.value = { top: rect.bottom + 4, left: rect.left - 100 }
|
||||||
|
menuAberto.value = menuAberto.value === id ? null : id
|
||||||
|
}
|
||||||
|
|
||||||
|
function fecharMenu() { menuAberto.value = null }
|
||||||
|
|
||||||
|
// ---------- modal criar ----------
|
||||||
|
const showCriar = ref(false)
|
||||||
|
const criando = ref(false)
|
||||||
|
const erroCreate = ref('')
|
||||||
|
|
||||||
|
const createForm = reactive({
|
||||||
|
tipo: '',
|
||||||
|
nome: '',
|
||||||
|
data_vencimento: '',
|
||||||
|
observacoes: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
function abrirCriar() {
|
||||||
|
createForm.tipo = ''
|
||||||
|
createForm.nome = ''
|
||||||
|
createForm.data_vencimento = ''
|
||||||
|
createForm.observacoes = ''
|
||||||
|
erroCreate.value = ''
|
||||||
|
showCriar.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function criarDocumento() {
|
||||||
|
criando.value = true
|
||||||
|
erroCreate.value = ''
|
||||||
|
try {
|
||||||
|
await apiFetch('/documents', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
tipo: createForm.tipo,
|
||||||
|
nome: createForm.nome,
|
||||||
|
data_vencimento: createForm.data_vencimento || '',
|
||||||
|
observacoes: createForm.observacoes,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
showCriar.value = false
|
||||||
|
await refresh()
|
||||||
|
} catch {
|
||||||
|
erroCreate.value = 'Erro ao criar documento.'
|
||||||
|
} finally {
|
||||||
|
criando.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- modal editar ----------
|
||||||
|
const showEditar = ref(false)
|
||||||
|
const editando = ref(false)
|
||||||
|
const erroEdit = ref('')
|
||||||
|
const editId = ref('')
|
||||||
|
|
||||||
|
const editForm = reactive({
|
||||||
|
tipo: '',
|
||||||
|
nome: '',
|
||||||
|
data_vencimento: '',
|
||||||
|
observacoes: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
function abrirEditar(doc: ApiDocument) {
|
||||||
|
fecharMenu()
|
||||||
|
editId.value = doc.ID
|
||||||
|
editForm.tipo = doc.Tipo
|
||||||
|
editForm.nome = doc.Nome
|
||||||
|
editForm.data_vencimento = doc.DataVencimento ? doc.DataVencimento.split('T')[0] : ''
|
||||||
|
editForm.observacoes = doc.Observacoes
|
||||||
|
erroEdit.value = ''
|
||||||
|
showEditar.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function salvarDocumento() {
|
||||||
|
editando.value = true
|
||||||
|
erroEdit.value = ''
|
||||||
|
try {
|
||||||
|
await apiFetch(`/documents/${editId.value}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
tipo: editForm.tipo,
|
||||||
|
nome: editForm.nome,
|
||||||
|
data_vencimento: editForm.data_vencimento || '',
|
||||||
|
observacoes: editForm.observacoes,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
showEditar.value = false
|
||||||
|
await refresh()
|
||||||
|
} catch {
|
||||||
|
erroEdit.value = 'Erro ao salvar documento.'
|
||||||
|
} finally {
|
||||||
|
editando.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- confirmação exclusão ----------
|
||||||
|
const showExcluir = ref(false)
|
||||||
|
const excluindo = ref(false)
|
||||||
|
const docParaExcluir = ref<ApiDocument | null>(null)
|
||||||
|
|
||||||
|
function abrirExcluir(doc: ApiDocument) {
|
||||||
|
fecharMenu()
|
||||||
|
docParaExcluir.value = doc
|
||||||
|
showExcluir.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmarExclusao() {
|
||||||
|
if (!docParaExcluir.value) return
|
||||||
|
excluindo.value = true
|
||||||
|
try {
|
||||||
|
await apiFetch(`/documents/${docParaExcluir.value.ID}`, { method: 'DELETE' })
|
||||||
|
showExcluir.value = false
|
||||||
|
await refresh()
|
||||||
|
} finally {
|
||||||
|
excluindo.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page" @click="fecharMenu">
|
||||||
<AppTopbar title="Documentos da Empresa" breadcrumb="Gestão · Documentos">
|
<AppTopbar title="Documentos da Empresa" breadcrumb="Gestão · Documentos">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<UButton size="sm" class="btn-primary">+ Adicionar Documento</UButton>
|
<button class="btn-primary" @click.stop="abrirCriar">+ Adicionar Documento</button>
|
||||||
</template>
|
</template>
|
||||||
</AppTopbar>
|
</AppTopbar>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div v-for="doc in documentos" :key="doc.id" class="doc-row">
|
<div v-if="!documentos || documentos.length === 0" class="empty">
|
||||||
|
Nenhum documento cadastrado.
|
||||||
|
</div>
|
||||||
|
<div v-for="doc in documentos" :key="doc.ID" class="doc-row">
|
||||||
<div class="doc-info">
|
<div class="doc-info">
|
||||||
<p class="doc-nome">{{ doc.nome }}</p>
|
<p class="doc-nome">{{ doc.Nome || tipoLabel(doc.Tipo) }}</p>
|
||||||
<p class="doc-meta">{{ tipoLabel[doc.tipo] }} · {{ doc.dataVencimento ? doc.dataVencimento.toLocaleDateString('pt-BR') : 'Sem vencimento' }}</p>
|
<p class="doc-meta">{{ tipoLabel(doc.Tipo) }} · {{ formatDate(doc.DataVencimento) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="doc-right">
|
||||||
|
<span
|
||||||
|
class="doc-status"
|
||||||
|
:style="{ color: STATUS_CFG[calcStatus(doc.DataVencimento)].color, background: STATUS_CFG[calcStatus(doc.DataVencimento)].bg }"
|
||||||
|
>
|
||||||
|
{{ STATUS_CFG[calcStatus(doc.DataVencimento)].label }}
|
||||||
|
</span>
|
||||||
|
<button class="btn-menu" @click.stop="abrirMenu(doc.ID, $event)">⋯</button>
|
||||||
</div>
|
</div>
|
||||||
<span
|
|
||||||
class="doc-status"
|
|
||||||
:style="{ color: statusConfig[doc.status].color, background: statusConfig[doc.status].bg }"
|
|
||||||
>
|
|
||||||
{{ statusConfig[doc.status].label }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Menu flutuante -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="menuAberto"
|
||||||
|
class="dropdown-fixed"
|
||||||
|
:style="{ top: menuPos.top + 'px', left: menuPos.left + 'px' }"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<button @click="abrirEditar(documentos!.find(d => d.ID === menuAberto)!)">Editar</button>
|
||||||
|
<button @click="abrirExcluir(documentos!.find(d => d.ID === menuAberto)!)">Excluir</button>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- Modal Criar -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showCriar" class="modal-overlay" @click.self="showCriar = false">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Novo Documento</h2>
|
||||||
|
<button @click="showCriar = false">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>Tipo *</label>
|
||||||
|
<select v-model="createForm.tipo" class="field-select">
|
||||||
|
<option value="" disabled>Selecione o tipo</option>
|
||||||
|
<option v-for="t in TIPOS" :key="t.value" :value="t.value">{{ t.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Nome / Identificação</label>
|
||||||
|
<UInput v-model="createForm.nome" placeholder="Ex: CND Federal, Certidão Estadual SP" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Data de Vencimento</label>
|
||||||
|
<UInput v-model="createForm.data_vencimento" type="date" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Observações</label>
|
||||||
|
<UTextarea v-model="createForm.observacoes" placeholder="Informações adicionais..." class="w-full" />
|
||||||
|
</div>
|
||||||
|
<p v-if="erroCreate" class="erro">{{ erroCreate }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-cancel" @click="showCriar = false">Cancelar</button>
|
||||||
|
<button class="btn-save" :disabled="criando || !createForm.tipo" @click="criarDocumento">
|
||||||
|
{{ criando ? 'Criando...' : 'Criar' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- Modal Editar -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showEditar" class="modal-overlay" @click.self="showEditar = false">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Editar Documento</h2>
|
||||||
|
<button @click="showEditar = false">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>Tipo *</label>
|
||||||
|
<select v-model="editForm.tipo" class="field-select">
|
||||||
|
<option value="" disabled>Selecione o tipo</option>
|
||||||
|
<option v-for="t in TIPOS" :key="t.value" :value="t.value">{{ t.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Nome / Identificação</label>
|
||||||
|
<UInput v-model="editForm.nome" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Data de Vencimento</label>
|
||||||
|
<UInput v-model="editForm.data_vencimento" type="date" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Observações</label>
|
||||||
|
<UTextarea v-model="editForm.observacoes" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<p v-if="erroEdit" class="erro">{{ erroEdit }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-cancel" @click="showEditar = false">Cancelar</button>
|
||||||
|
<button class="btn-save" :disabled="editando || !editForm.tipo" @click="salvarDocumento">
|
||||||
|
{{ editando ? 'Salvando...' : 'Salvar' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- Modal Excluir -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showExcluir" class="modal-overlay" @click.self="showExcluir = false">
|
||||||
|
<div class="modal modal-sm">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Excluir Documento</h2>
|
||||||
|
<button @click="showExcluir = false">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Tem certeza que deseja excluir <strong>{{ docParaExcluir?.Nome || tipoLabel(docParaExcluir?.Tipo ?? '') }}</strong>?</p>
|
||||||
|
<p style="color:#94a3b8;font-size:12px;margin-top:6px">Esta ação não pode ser desfeita.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-cancel" @click="showExcluir = false">Cancelar</button>
|
||||||
|
<button class="btn-danger" :disabled="excluindo" @click="confirmarExclusao">
|
||||||
|
{{ excluindo ? 'Excluindo...' : 'Excluir' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -48,10 +332,57 @@ const tipoLabel: Record<string, string> = {
|
|||||||
.page { display: flex; flex-direction: column; height: 100vh; }
|
.page { display: flex; flex-direction: column; height: 100vh; }
|
||||||
.content { padding: 20px 22px; flex: 1; overflow-y: auto; }
|
.content { padding: 20px 22px; flex: 1; overflow-y: auto; }
|
||||||
.card { background: white; border-radius: 11px; border: 1px solid #e2e8f0; overflow: hidden; }
|
.card { background: white; border-radius: 11px; border: 1px solid #e2e8f0; overflow: hidden; }
|
||||||
|
.empty { padding: 32px; text-align: center; color: #94a3b8; font-size: 13px; }
|
||||||
|
|
||||||
.doc-row { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid #f8fafc; }
|
.doc-row { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid #f8fafc; }
|
||||||
.doc-row:last-child { border-bottom: none; }
|
.doc-row:last-child { border-bottom: none; }
|
||||||
|
.doc-info { flex: 1; }
|
||||||
.doc-nome { font-size: 13px; font-weight: 600; color: #0f172a; }
|
.doc-nome { font-size: 13px; font-weight: 600; color: #0f172a; }
|
||||||
.doc-meta { font-size: 11px; color: #94a3b8; margin-top: 2px; }
|
.doc-meta { font-size: 11px; color: #94a3b8; margin-top: 2px; }
|
||||||
|
.doc-right { display: flex; align-items: center; gap: 10px; }
|
||||||
.doc-status { font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 20px; }
|
.doc-status { font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 20px; }
|
||||||
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2) !important; }
|
|
||||||
|
.btn-menu { background: none; border: none; font-size: 18px; cursor: pointer; color: #94a3b8; padding: 2px 6px; border-radius: 4px; }
|
||||||
|
.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 button { background: none; border: none; font-size: 18px; cursor: pointer; color: #94a3b8; }
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.field { display: flex; flex-direction: column; gap: 5px; }
|
||||||
|
.field label { font-size: 12px; font-weight: 600; color: #374151; }
|
||||||
|
.field-select { width: 100%; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px 11px; font-size: 13px; color: #0f172a; background: white; outline: none; }
|
||||||
|
.field-select:focus { border-color: #667eea; }
|
||||||
|
|
||||||
|
.btn-cancel { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 7px 16px; font-size: 13px; cursor: pointer; }
|
||||||
|
.btn-save { background: linear-gradient(135deg, #667eea, #764ba2); color: white; border: none; border-radius: 8px; padding: 7px 16px; font-size: 13px; font-weight: 600; cursor: pointer; }
|
||||||
|
.btn-save:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
.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; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.35);
|
||||||
|
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: white; border-radius: 14px; width: 100%; max-width: 480px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.15); max-height: 90vh; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.modal.modal-sm { max-width: 380px; }
|
||||||
|
.dropdown-fixed {
|
||||||
|
position: fixed; background: white; border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.12);
|
||||||
|
z-index: 9999; min-width: 130px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.dropdown-fixed button {
|
||||||
|
display: block; width: 100%; text-align: left; padding: 9px 14px;
|
||||||
|
font-size: 13px; background: none; border: none; cursor: pointer; color: #374151;
|
||||||
|
}
|
||||||
|
.dropdown-fixed button:hover { background: #f8fafc; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user