470 lines
19 KiB
TypeScript
470 lines
19 KiB
TypeScript
'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>
|
|
)
|
|
}
|