Initial commit: LexMind - Plataforma Jurídica Inteligente
This commit is contained in:
469
src/app/dashboard/publicacoes/page.tsx
Normal file
469
src/app/dashboard/publicacoes/page.tsx
Normal file
@@ -0,0 +1,469 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
Newspaper,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Loader2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Calendar,
|
||||
Building2,
|
||||
FileText,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Bell,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface Processo {
|
||||
id: string
|
||||
numeroProcesso: string
|
||||
tribunal: string
|
||||
vara: string | null
|
||||
comarca: string | null
|
||||
parteAutora: string | null
|
||||
parteRe: string | null
|
||||
status: 'ATIVO' | 'ARQUIVADO' | 'SUSPENSO'
|
||||
publicacoesNaoLidas: number
|
||||
ultimaPublicacao: Publicacao | null
|
||||
}
|
||||
|
||||
interface Publicacao {
|
||||
id: string
|
||||
processoId: string
|
||||
dataPublicacao: string
|
||||
diario: string
|
||||
conteudo: string
|
||||
tipo: 'INTIMACAO' | 'CITACAO' | 'SENTENCA' | 'DESPACHO' | 'ACORDAO' | 'OUTROS'
|
||||
prazoCalculado: string | null
|
||||
prazoTipo: string | null
|
||||
visualizado: boolean
|
||||
processo?: {
|
||||
numeroProcesso: string
|
||||
tribunal: string
|
||||
parteAutora: string | null
|
||||
parteRe: string | null
|
||||
}
|
||||
}
|
||||
|
||||
const tipoConfig = {
|
||||
INTIMACAO: { label: 'Intimação', bg: 'bg-blue-500/15 border-blue-500/30', color: 'text-blue-400' },
|
||||
CITACAO: { label: 'Citação', bg: 'bg-purple-500/15 border-purple-500/30', color: 'text-purple-400' },
|
||||
SENTENCA: { label: 'Sentença', bg: 'bg-red-500/15 border-red-500/30', color: 'text-red-400' },
|
||||
DESPACHO: { label: 'Despacho', bg: 'bg-yellow-500/15 border-yellow-500/30', color: 'text-yellow-400' },
|
||||
ACORDAO: { label: 'Acórdão', bg: 'bg-orange-500/15 border-orange-500/30', color: 'text-orange-400' },
|
||||
OUTROS: { label: 'Outros', bg: 'bg-zinc-500/15 border-zinc-500/30', color: 'text-zinc-400' },
|
||||
}
|
||||
|
||||
export default function PublicacoesPage() {
|
||||
const router = useRouter()
|
||||
const [processos, setProcessos] = useState<Processo[]>([])
|
||||
const [publicacoes, setPublicacoes] = useState<Publicacao[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [buscando, setBuscando] = useState(false)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
// Filtros
|
||||
const [filterTipo, setFilterTipo] = useState<string>('')
|
||||
const [filterVisualizado, setFilterVisualizado] = useState<string>('')
|
||||
const [filterProcesso, setFilterProcesso] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [filterTipo, filterVisualizado, filterProcesso])
|
||||
|
||||
async function fetchData() {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Busca processos e publicações em paralelo
|
||||
const [processosRes, publicacoesRes] = await Promise.all([
|
||||
fetch('/api/processos'),
|
||||
fetchPublicacoes(),
|
||||
])
|
||||
|
||||
if (processosRes.ok) {
|
||||
const data = await processosRes.json()
|
||||
setProcessos(data.processos || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load data:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPublicacoes() {
|
||||
const params = new URLSearchParams()
|
||||
if (filterTipo) params.set('tipo', filterTipo)
|
||||
if (filterVisualizado) params.set('visualizado', filterVisualizado)
|
||||
if (filterProcesso) params.set('processoId', filterProcesso)
|
||||
|
||||
const res = await fetch(`/api/publicacoes?${params}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setPublicacoes(data.publicacoes || [])
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
async function buscarNovasPublicacoes() {
|
||||
setBuscando(true)
|
||||
try {
|
||||
const res = await fetch('/api/publicacoes/buscar', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchData()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to search:', e)
|
||||
} finally {
|
||||
setBuscando(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function marcarComoLida(id: string) {
|
||||
try {
|
||||
await fetch(`/api/publicacoes/${id}/visualizar`, { method: 'PATCH' })
|
||||
setPublicacoes(pubs => pubs.map(p => p.id === id ? { ...p, visualizado: true } : p))
|
||||
} catch (e) {
|
||||
console.error('Failed to mark as read:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProcesso(id: string) {
|
||||
if (!confirm('Tem certeza que deseja remover este processo do monitoramento?')) return
|
||||
try {
|
||||
await fetch(`/api/processos/${id}`, { method: 'DELETE' })
|
||||
fetchData()
|
||||
} catch (e) {
|
||||
console.error('Failed to delete:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
function getDaysUntilPrazo(prazoStr: string | null) {
|
||||
if (!prazoStr) return null
|
||||
const now = new Date()
|
||||
now.setHours(0, 0, 0, 0)
|
||||
const prazo = new Date(prazoStr)
|
||||
prazo.setHours(0, 0, 0, 0)
|
||||
return Math.ceil((prazo.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
// Stats
|
||||
const totalProcessos = processos.length
|
||||
const totalNaoLidas = publicacoes.filter(p => !p.visualizado).length
|
||||
const prazosProximos = publicacoes.filter(p => {
|
||||
const days = getDaysUntilPrazo(p.prazoCalculado)
|
||||
return days !== null && days >= 0 && days <= 5
|
||||
}).length
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-indigo-600/20">
|
||||
<Newspaper className="h-5 w-5 text-indigo-400" />
|
||||
</div>
|
||||
Monitoramento de Publicações
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">Acompanhe publicações dos Diários Oficiais</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={buscarNovasPublicacoes}
|
||||
disabled={buscando}
|
||||
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-zinc-300 transition-all hover:bg-white/10 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${buscando ? 'animate-spin' : ''}`} />
|
||||
{buscando ? 'Buscando...' : 'Buscar Publicações'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard/publicacoes/novo')}
|
||||
className="flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition-all hover:bg-indigo-500 hover:shadow-lg hover:shadow-indigo-500/20"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Adicionar Processo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/10">
|
||||
<FileText className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-zinc-500">Processos Monitorados</p>
|
||||
<p className="text-2xl font-bold text-white">{totalProcessos}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-yellow-500/10">
|
||||
<Bell className="h-5 w-5 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-zinc-500">Publicações Não Lidas</p>
|
||||
<p className="text-2xl font-bold text-yellow-400">{totalNaoLidas}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/5 bg-white/[0.02] p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-500/10">
|
||||
<AlertTriangle className="h-5 w-5 text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-zinc-500">Prazos Próximos (5 dias)</p>
|
||||
<p className="text-2xl font-bold text-red-400">{prazosProximos}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 flex flex-wrap items-center gap-3">
|
||||
<Filter className="h-4 w-4 text-zinc-500" />
|
||||
<select
|
||||
value={filterTipo}
|
||||
onChange={e => setFilterTipo(e.target.value)}
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-zinc-300 outline-none focus:border-indigo-500/40"
|
||||
>
|
||||
<option value="">Todos os tipos</option>
|
||||
<option value="INTIMACAO">Intimação</option>
|
||||
<option value="CITACAO">Citação</option>
|
||||
<option value="SENTENCA">Sentença</option>
|
||||
<option value="DESPACHO">Despacho</option>
|
||||
<option value="ACORDAO">Acórdão</option>
|
||||
<option value="OUTROS">Outros</option>
|
||||
</select>
|
||||
<select
|
||||
value={filterVisualizado}
|
||||
onChange={e => setFilterVisualizado(e.target.value)}
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-zinc-300 outline-none focus:border-indigo-500/40"
|
||||
>
|
||||
<option value="">Todas</option>
|
||||
<option value="false">Não lidas</option>
|
||||
<option value="true">Lidas</option>
|
||||
</select>
|
||||
{processos.length > 0 && (
|
||||
<select
|
||||
value={filterProcesso}
|
||||
onChange={e => setFilterProcesso(e.target.value)}
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-zinc-300 outline-none focus:border-indigo-500/40"
|
||||
>
|
||||
<option value="">Todos os processos</option>
|
||||
{processos.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.numeroProcesso} ({p.tribunal})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-indigo-400" />
|
||||
</div>
|
||||
) : processos.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-white/5 bg-white/[0.02] py-16">
|
||||
<Newspaper className="h-12 w-12 text-zinc-600" />
|
||||
<p className="mt-3 text-sm text-zinc-500">Nenhum processo sendo monitorado</p>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard/publicacoes/novo')}
|
||||
className="mt-4 text-sm text-indigo-400 hover:text-indigo-300"
|
||||
>
|
||||
Adicionar primeiro processo
|
||||
</button>
|
||||
</div>
|
||||
) : publicacoes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-white/5 bg-white/[0.02] py-16">
|
||||
<Search className="h-12 w-12 text-zinc-600" />
|
||||
<p className="mt-3 text-sm text-zinc-500">Nenhuma publicação encontrada</p>
|
||||
<button
|
||||
onClick={buscarNovasPublicacoes}
|
||||
className="mt-4 text-sm text-indigo-400 hover:text-indigo-300"
|
||||
>
|
||||
Buscar novas publicações
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{publicacoes.map(pub => {
|
||||
const config = tipoConfig[pub.tipo]
|
||||
const daysUntilPrazo = getDaysUntilPrazo(pub.prazoCalculado)
|
||||
const isUrgent = daysUntilPrazo !== null && daysUntilPrazo >= 0 && daysUntilPrazo <= 3
|
||||
const isOverdue = daysUntilPrazo !== null && daysUntilPrazo < 0
|
||||
const isExpanded = expandedId === pub.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={pub.id}
|
||||
className={`group rounded-xl border bg-white/[0.02] transition-all hover:bg-white/[0.04] ${
|
||||
!pub.visualizado
|
||||
? 'border-indigo-500/30 shadow-[0_0_15px_rgba(99,102,241,0.1)]'
|
||||
: isUrgent || isOverdue
|
||||
? 'border-red-500/30'
|
||||
: 'border-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Indicator */}
|
||||
{!pub.visualizado && (
|
||||
<div className="mt-1.5 h-2 w-2 rounded-full bg-indigo-500 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`rounded-md border px-2 py-0.5 text-[10px] font-bold uppercase ${config.bg} ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500">{pub.diario}</span>
|
||||
<span className="text-xs text-zinc-600">•</span>
|
||||
<span className="text-xs text-zinc-500">{formatDate(pub.dataPublicacao)}</span>
|
||||
</div>
|
||||
{pub.processo && (
|
||||
<p className="mt-1 text-sm font-medium text-white">
|
||||
{pub.processo.numeroProcesso}
|
||||
<span className="ml-2 text-zinc-500 font-normal">({pub.processo.tribunal})</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!pub.visualizado && (
|
||||
<button
|
||||
onClick={() => marcarComoLida(pub.id)}
|
||||
className="rounded-lg p-1.5 text-zinc-500 hover:bg-indigo-500/10 hover:text-indigo-400"
|
||||
title="Marcar como lida"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setExpandedId(isExpanded ? null : pub.id)}
|
||||
className="rounded-lg p-1.5 text-zinc-500 hover:bg-white/10 hover:text-zinc-300"
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview / Full content */}
|
||||
<p className={`mt-2 text-sm text-zinc-400 ${isExpanded ? '' : 'line-clamp-2'}`}>
|
||||
{pub.conteudo}
|
||||
</p>
|
||||
|
||||
{/* Prazo */}
|
||||
{pub.prazoCalculado && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Clock className="h-3.5 w-3.5 text-zinc-500" />
|
||||
<span className={`text-xs font-medium ${
|
||||
isOverdue
|
||||
? 'text-red-400'
|
||||
: isUrgent
|
||||
? 'text-red-400 animate-pulse'
|
||||
: daysUntilPrazo !== null && daysUntilPrazo <= 7
|
||||
? 'text-yellow-400'
|
||||
: 'text-zinc-400'
|
||||
}`}>
|
||||
Prazo: {formatDate(pub.prazoCalculado)}
|
||||
{pub.prazoTipo && ` (${pub.prazoTipo})`}
|
||||
{daysUntilPrazo !== null && (
|
||||
<span className="ml-2">
|
||||
{isOverdue
|
||||
? `• Vencido há ${Math.abs(daysUntilPrazo)} dia(s)`
|
||||
: daysUntilPrazo === 0
|
||||
? '• Vence HOJE!'
|
||||
: daysUntilPrazo === 1
|
||||
? '• Vence AMANHÃ'
|
||||
: `• ${daysUntilPrazo} dias restantes`
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Processos monitorados section */}
|
||||
{processos.length > 0 && (
|
||||
<div className="mt-10">
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-zinc-400" />
|
||||
Processos Monitorados
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{processos.map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="group rounded-xl border border-white/5 bg-white/[0.02] p-4 transition-all hover:bg-white/[0.04]"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-mono text-sm font-medium text-white truncate">
|
||||
{p.numeroProcesso}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">{p.tribunal}</p>
|
||||
{(p.parteAutora || p.parteRe) && (
|
||||
<p className="mt-1 text-xs text-zinc-600 truncate">
|
||||
{p.parteAutora && `Autor: ${p.parteAutora}`}
|
||||
{p.parteAutora && p.parteRe && ' • '}
|
||||
{p.parteRe && `Réu: ${p.parteRe}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{p.publicacoesNaoLidas > 0 && (
|
||||
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-indigo-500 px-1.5 text-[10px] font-bold text-white">
|
||||
{p.publicacoesNaoLidas}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteProcesso(p.id)}
|
||||
className="rounded-lg p-1.5 text-zinc-600 opacity-0 group-hover:opacity-100 hover:bg-red-500/10 hover:text-red-400 transition-all"
|
||||
title="Remover"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user