Atualizacoes: Novo modo leitura de Parecer, campo instrucao agora é opcional, campo do agente do parecer pode ser recolhido, ajuste na cor do botão de agente de prompt

This commit is contained in:
Vitex Tecnologia
2026-04-14 14:42:33 -03:00
parent 8f2e4f1d21
commit 7a01c6f97b
8 changed files with 608 additions and 39 deletions
+8 -1
View File
@@ -15,7 +15,14 @@ const queryClient = new QueryClient();
const App = () => (
<QueryClientProvider client={queryClient}>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
<ThemeProvider
attribute="class"
defaultTheme="light"
themes={["light", "dark"]}
enableSystem={false}
storageKey="hgtx-codex-theme"
enableColorScheme
>
<TooltipProvider>
<Toaster />
<Sonner />
@@ -1,7 +1,11 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { useParams, useLocation, useNavigate } from "react-router-dom";
import {
AlignJustify,
ArrowLeft,
BookOpen,
ChevronsLeft,
ChevronsRight,
Copy,
Download,
FileDown,
@@ -9,8 +13,15 @@ import {
History,
Info,
Loader2,
Maximize2,
MessageSquare,
Minimize2,
Minus,
Pause,
Pencil,
Play,
Plus,
RotateCcw,
Send,
Trash2,
} from "lucide-react";
@@ -46,6 +57,7 @@ import { parecerService, type ParecerChatHistoricoItem, type ParecerDetalhe, typ
import { ParecerJuridicoGeneratingScreen } from "@/components/parecer-juridico/ParecerJuridicoGeneratingScreen";
import { playSuccessSound } from "@/utils/sound";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
function formatarDataDetalhe(iso: string): string {
if (!iso) return "—";
@@ -84,6 +96,60 @@ interface ChatMessage {
tipo_resposta?: "chat" | "agente" | null;
}
const LS_READER_FONT = "codex-parecer-reader-font-step";
const LS_READER_WIDTH = "codex-parecer-reader-comfortable-width";
const LS_MAIN_COMFORTABLE = "codex-parecer-main-comfortable-width";
/** Velocidade do auto-scroll (px/s): nível 1 mais lento … 5 mais rápido (progressão mais perceptível). */
const READER_AUTO_SCROLL_SPEEDS = [42, 68, 98, 138, 195] as const;
const READER_AUTO_SCROLL_LEVEL_LABELS = ["Lento", "Médio-lento", "Médio", "Médio-rápido", "Rápido"] as const;
const READER_FONT_DEFAULT = 1;
function readStoredReaderFontStep(): number {
if (typeof window === "undefined") return READER_FONT_DEFAULT;
try {
const raw = window.localStorage.getItem(LS_READER_FONT);
if (raw == null) return READER_FONT_DEFAULT;
const n = Number.parseInt(raw, 10);
if (Number.isNaN(n)) return READER_FONT_DEFAULT;
return Math.min(4, Math.max(0, n));
} catch {
return READER_FONT_DEFAULT;
}
}
function readStoredReaderComfortableWidth(): boolean {
if (typeof window === "undefined") return true;
try {
const raw = window.localStorage.getItem(LS_READER_WIDTH);
if (raw == null) return true;
return raw === "1" || raw === "true";
} catch {
return true;
}
}
function readStoredMainComfortableWidth(): boolean {
if (typeof window === "undefined") return false;
try {
const raw = window.localStorage.getItem(LS_MAIN_COMFORTABLE);
if (raw == null) return false;
return raw === "1" || raw === "true";
} catch {
return false;
}
}
/** Índice 04 mapeado para classes `prose-*` do plugin Typography. */
const READER_PROSE_SCALE = ["prose-sm", "prose-base", "prose-lg", "prose-xl", "prose-2xl"] as const;
const READER_PROSE_COMMON =
"prose-neutral dark:prose-invert w-full text-foreground prose-headings:font-semibold prose-p:leading-relaxed prose-ul:my-3 prose-ol:my-3 prose-li:my-0.5";
const READER_PANEL_FULLSCREEN_ID = "parecer-reader-panel";
export function ParecerJuridicoDetailView() {
const { id } = useParams<{ id: string }>();
const location = useLocation();
@@ -114,6 +180,24 @@ export function ParecerJuridicoDetailView() {
const [historicoLoading, setHistoricoLoading] = useState(false);
const [historicoItem, setHistoricoItem] = useState<ParecerChatHistoricoItem | null>(null);
const [chatInput, setChatInput] = useState("");
/** Painel lateral de chat: recolhido dá mais área à leitura do parecer. */
const [chatPanelExpanded, setChatPanelExpanded] = useState(true);
const [readerModeOpen, setReaderModeOpen] = useState(false);
const [readerFontStep, setReaderFontStep] = useState(readStoredReaderFontStep);
const [readerComfortableWidth, setReaderComfortableWidth] = useState(readStoredReaderComfortableWidth);
const [mainComfortableWidth, setMainComfortableWidth] = useState(readStoredMainComfortableWidth);
const [readerAutoScrollPlaying, setReaderAutoScrollPlaying] = useState(false);
/** Nível 15 (x1…x5) para velocidade do auto-scroll. */
const [readerAutoScrollSpeedLevel, setReaderAutoScrollSpeedLevel] = useState(3);
const readerPanelRef = useRef<HTMLDivElement>(null);
const [readerIsFullscreen, setReaderIsFullscreen] = useState(false);
const readerScrollRef = useRef<HTMLDivElement>(null);
const readerAutoScrollRafRef = useRef<number>(0);
const readerAutoScrollLastTsRef = useRef(0);
const readerAutoScrollProgrammaticRef = useRef(false);
const readerAutoScrollPlayingRef = useRef(false);
/** Acumula frações de px para velocidades baixas não ficarem presas em scrollTop inteiro. */
const readerAutoScrollCarryRef = useRef(0);
const chatInputRef = useRef<HTMLTextAreaElement>(null);
const [textareaHeight, setTextareaHeight] = useState(60);
const [isDragging, setIsDragging] = useState(false);
@@ -140,6 +224,24 @@ export function ParecerJuridicoDetailView() {
setEditOpen(true);
}, [aplicarDadosAtuaisNoFormularioEdicao]);
const syncReaderFullscreen = useCallback(() => {
setReaderIsFullscreen(document.fullscreenElement?.id === READER_PANEL_FULLSCREEN_ID);
}, []);
const toggleReaderFullscreen = useCallback(async () => {
const el = readerPanelRef.current;
if (!el) return;
try {
if (document.fullscreenElement?.id === READER_PANEL_FULLSCREEN_ID) {
await document.exitFullscreen();
} else {
await el.requestFullscreen();
}
} catch {
toast.error("Tela cheia não disponível neste navegador ou foi bloqueada.");
}
}, []);
const MIN_INPUT_HEIGHT = 60;
const MAX_INPUT_HEIGHT = 400;
@@ -256,6 +358,139 @@ export function ParecerJuridicoDetailView() {
return () => cancelAnimationFrame(t);
}, [messages]);
useEffect(() => {
try {
window.localStorage.setItem(LS_READER_FONT, String(readerFontStep));
} catch {
/* ignore */
}
}, [readerFontStep]);
useEffect(() => {
try {
window.localStorage.setItem(LS_READER_WIDTH, readerComfortableWidth ? "1" : "0");
} catch {
/* ignore */
}
}, [readerComfortableWidth]);
useEffect(() => {
try {
window.localStorage.setItem(LS_MAIN_COMFORTABLE, mainComfortableWidth ? "1" : "0");
} catch {
/* ignore */
}
}, [mainComfortableWidth]);
useEffect(() => {
readerAutoScrollPlayingRef.current = readerAutoScrollPlaying;
}, [readerAutoScrollPlaying]);
useEffect(() => {
document.addEventListener("fullscreenchange", syncReaderFullscreen);
return () => document.removeEventListener("fullscreenchange", syncReaderFullscreen);
}, [syncReaderFullscreen]);
useEffect(() => {
readerAutoScrollCarryRef.current = 0;
}, [readerAutoScrollSpeedLevel]);
useEffect(() => {
if (!readerModeOpen) {
if (document.fullscreenElement?.id === READER_PANEL_FULLSCREEN_ID) {
void document.exitFullscreen().catch(() => {});
}
setReaderIsFullscreen(false);
setReaderAutoScrollPlaying(false);
readerAutoScrollPlayingRef.current = false;
readerAutoScrollLastTsRef.current = 0;
readerAutoScrollCarryRef.current = 0;
if (readerAutoScrollRafRef.current) {
cancelAnimationFrame(readerAutoScrollRafRef.current);
readerAutoScrollRafRef.current = 0;
}
}
}, [readerModeOpen]);
useEffect(() => {
if (!readerAutoScrollPlaying) readerAutoScrollCarryRef.current = 0;
}, [readerAutoScrollPlaying]);
useEffect(() => {
const el = readerScrollRef.current;
if (!el || !readerModeOpen) return;
const pause = () => setReaderAutoScrollPlaying(false);
const onScroll = () => {
if (readerAutoScrollProgrammaticRef.current) return;
pause();
};
const onWheel = () => pause();
const onTouchMove = () => pause();
el.addEventListener("scroll", onScroll, { passive: true });
el.addEventListener("wheel", onWheel, { passive: true });
el.addEventListener("touchmove", onTouchMove, { passive: true });
return () => {
el.removeEventListener("scroll", onScroll);
el.removeEventListener("wheel", onWheel);
el.removeEventListener("touchmove", onTouchMove);
};
}, [readerModeOpen]);
useEffect(() => {
if (!readerModeOpen || !readerAutoScrollPlaying) {
if (readerAutoScrollRafRef.current) {
cancelAnimationFrame(readerAutoScrollRafRef.current);
readerAutoScrollRafRef.current = 0;
}
readerAutoScrollLastTsRef.current = 0;
return;
}
const el = readerScrollRef.current;
if (!el) return;
const levelIdx = Math.min(4, Math.max(0, readerAutoScrollSpeedLevel - 1));
const speedPxPerSec = READER_AUTO_SCROLL_SPEEDS[levelIdx] ?? READER_AUTO_SCROLL_SPEEDS[2];
const tick = (ts: number) => {
const scrollEl = readerScrollRef.current;
if (!scrollEl || !readerAutoScrollPlayingRef.current) return;
if (!readerAutoScrollLastTsRef.current) readerAutoScrollLastTsRef.current = ts;
const dt = Math.min(80, ts - readerAutoScrollLastTsRef.current) / 1000;
readerAutoScrollLastTsRef.current = ts;
readerAutoScrollCarryRef.current += speedPxPerSec * dt;
const pixels = Math.floor(readerAutoScrollCarryRef.current);
readerAutoScrollCarryRef.current -= pixels;
if (pixels > 0) {
readerAutoScrollProgrammaticRef.current = true;
scrollEl.scrollTop += pixels;
requestAnimationFrame(() => {
readerAutoScrollProgrammaticRef.current = false;
});
}
const maxScroll = scrollEl.scrollHeight - scrollEl.clientHeight;
if (maxScroll <= 0 || scrollEl.scrollTop >= maxScroll - 1) {
setReaderAutoScrollPlaying(false);
return;
}
readerAutoScrollRafRef.current = requestAnimationFrame(tick);
};
readerAutoScrollLastTsRef.current = 0;
readerAutoScrollRafRef.current = requestAnimationFrame(tick);
return () => {
if (readerAutoScrollRafRef.current) {
cancelAnimationFrame(readerAutoScrollRafRef.current);
readerAutoScrollRafRef.current = 0;
}
readerAutoScrollLastTsRef.current = 0;
};
}, [readerModeOpen, readerAutoScrollPlaying, readerAutoScrollSpeedLevel]);
const titulo = parecer?.titulo ?? preview?.titulo ?? "Parecer";
const conteudoMarkdown =
parecer?.conteudo_gerado ?? conteudoAtualizadoChat ?? preview?.conteudoMarkdown ?? "";
@@ -506,6 +741,33 @@ export function ParecerJuridicoDetailView() {
)}
</div>
<div className="flex flex-wrap items-center gap-2 justify-end">
{!chatPanelExpanded && (
<Button
type="button"
variant="outline"
size="sm"
className="gap-2 shrink-0"
onClick={() => setChatPanelExpanded(true)}
aria-expanded={false}
aria-controls="parecer-chat-panel"
>
<MessageSquare className="w-4 h-4" />
<span className="hidden sm:inline">Mostrar chat</span>
<span className="sm:hidden">Chat</span>
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
className="gap-2 shrink-0"
disabled={!conteudoMarkdown.trim()}
onClick={() => setReaderModeOpen(true)}
>
<BookOpen className="w-4 h-4" />
<span className="hidden sm:inline">Modo leitura</span>
<span className="sm:hidden">Ler</span>
</Button>
<Button
type="button"
variant="outline"
@@ -541,6 +803,209 @@ export function ParecerJuridicoDetailView() {
</div>
</header>
<Dialog open={readerModeOpen} onOpenChange={setReaderModeOpen}>
<DialogContent
ref={readerPanelRef}
id={READER_PANEL_FULLSCREEN_ID}
className={cn(
"max-w-[min(96vw,1200px)] w-[calc(100vw-1rem)] max-h-[92dvh] h-[min(90dvh,92vh)] flex flex-col gap-0 p-0 sm:rounded-lg overflow-x-hidden",
"left-[50%] top-[4dvh] translate-x-[-50%] translate-y-0",
"data-[state=closed]:slide-out-to-top-[4dvh] data-[state=open]:slide-in-from-top-[4dvh]",
readerIsFullscreen &&
"!inset-0 !left-0 !top-0 !translate-x-0 !translate-y-0 !w-screen !max-w-none !h-[100dvh] !max-h-[100dvh] rounded-none sm:!rounded-none z-[100]"
)}
>
<DialogHeader className="shrink-0 border-b px-4 py-3 pr-12 space-y-3 sm:space-y-0 overflow-visible">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4 min-w-0">
<div className="min-w-0 flex-1 space-y-1">
<DialogTitle className="text-left text-lg sm:text-xl leading-tight line-clamp-3">{titulo}</DialogTitle>
<DialogDescription className="text-left text-muted-foreground text-sm">
Ajuste o tamanho do texto e a largura da coluna. Use tela cheia para ocupar o monitor. Esc fecha este modo (em tela cheia, Esc pode sair do fullscreen primeiro).
</DialogDescription>
</div>
<div className="flex flex-wrap items-center gap-2 shrink-0 sm:pt-0.5">
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={readerFontStep <= 0}
onClick={() => setReaderFontStep((s) => Math.max(0, s - 1))}
aria-label="Diminuir tamanho da letra"
>
<Minus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="z-[100]">
Letra menor
</TooltipContent>
</Tooltip>
<span className="text-xs text-muted-foreground tabular-nums px-1 min-w-[2.5rem] text-center" aria-live="polite">
{readerFontStep + 1}/5
</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={readerFontStep >= 4}
onClick={() => setReaderFontStep((s) => Math.min(4, s + 1))}
aria-label="Aumentar tamanho da letra"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="z-[100]">
Letra maior
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setReaderFontStep(READER_FONT_DEFAULT)}
aria-label="Redefinir tamanho da letra ao padrão"
>
<RotateCcw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="z-[100]">
Tamanho padrão
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-2 py-1.5">
<Switch
id="reader-comfortable-width"
checked={readerComfortableWidth}
onCheckedChange={setReaderComfortableWidth}
aria-label="Largura confortável da coluna de texto"
/>
<Label htmlFor="reader-comfortable-width" className="text-xs font-normal cursor-pointer whitespace-nowrap">
Largura confortável
</Label>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => void toggleReaderFullscreen()}
aria-label={readerIsFullscreen ? "Sair da tela cheia" : "Tela cheia"}
>
{readerIsFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="z-[100]">
{readerIsFullscreen ? "Sair da tela cheia" : "Tela cheia"}
</TooltipContent>
</Tooltip>
</div>
</div>
</DialogHeader>
<div
ref={readerScrollRef}
className="flex-1 min-h-0 overflow-y-auto overscroll-contain px-4 md:px-8 py-6"
>
{conteudoMarkdown.trim() ? (
<article
className={cn(
"prose",
READER_PROSE_SCALE[readerFontStep],
READER_PROSE_COMMON,
readerComfortableWidth ? "max-w-[65ch] mx-auto" : "max-w-none"
)}
>
<ReactMarkdown>{conteudoMarkdown}</ReactMarkdown>
</article>
) : (
<p className="text-muted-foreground text-sm">Ainda não conteúdo do parecer.</p>
)}
</div>
<DialogFooter className="shrink-0 border-t px-4 py-3 flex-col sm:flex-row items-stretch sm:items-center gap-3 sm:justify-between">
<div className="flex flex-col gap-2 flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-medium text-muted-foreground shrink-0">Auto-scroll</span>
<Button
type="button"
variant={readerAutoScrollPlaying ? "secondary" : "outline"}
size="sm"
className="gap-2 shrink-0"
disabled={!conteudoMarkdown.trim()}
onClick={() => setReaderAutoScrollPlaying((p) => !p)}
aria-label={readerAutoScrollPlaying ? "Pausar auto-scroll" : "Iniciar auto-scroll"}
>
{readerAutoScrollPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
{readerAutoScrollPlaying ? "Pausar" : "Iniciar"}
</Button>
<div className="flex items-center gap-1 rounded-md border border-border bg-muted/40 p-0.5 shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={!conteudoMarkdown.trim() || readerAutoScrollSpeedLevel <= 1}
onClick={() => setReaderAutoScrollSpeedLevel((s) => Math.max(1, s - 1))}
aria-label="Diminuir velocidade do auto-scroll"
>
<Minus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="z-[100]">
Velocidade menor (x1 = mais lento)
</TooltipContent>
</Tooltip>
<div className="flex flex-col items-center justify-center min-w-[4.25rem] px-0.5 py-0.5">
<span className="text-xs text-muted-foreground tabular-nums leading-none" aria-live="polite">
x{readerAutoScrollSpeedLevel}
</span>
<span className="text-[10px] text-muted-foreground leading-tight text-center max-w-[5.5rem] truncate">
{READER_AUTO_SCROLL_LEVEL_LABELS[readerAutoScrollSpeedLevel - 1]}
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={!conteudoMarkdown.trim() || readerAutoScrollSpeedLevel >= 5}
onClick={() => setReaderAutoScrollSpeedLevel((s) => Math.min(5, s + 1))}
aria-label="Aumentar velocidade do auto-scroll"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="z-[100]">
Velocidade maior (x5 = mais rápido)
</TooltipContent>
</Tooltip>
</div>
{readerAutoScrollPlaying && (
<span className="text-xs text-muted-foreground">Ligado</span>
)}
</div>
</div>
<Button type="button" variant="outline" className="shrink-0" onClick={() => setReaderModeOpen(false)}>
Fechar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
<DialogContent className="max-w-4xl w-[calc(100vw-2rem)] max-h-[90vh] flex flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl">
<DialogHeader className="shrink-0 px-6 pt-6 pb-2 pr-12">
@@ -831,10 +1296,31 @@ export function ParecerJuridicoDetailView() {
</Dialog>
<div className="flex-1 flex flex-col md:flex-row min-h-0">
<section className="flex-1 min-w-0 flex flex-col border-b md:border-b-0 md:border-r border-border bg-background">
<section className="flex-1 min-w-0 min-h-0 flex flex-col border-b md:border-b-0 md:border-r border-border bg-background">
<ScrollArea className="flex-1">
<div className="p-4 md:p-6 bg-background min-h-full">
<article className="prose prose-sm prose-neutral dark:prose-invert max-w-none text-foreground prose-headings:font-semibold prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-p:leading-relaxed prose-ul:my-3 prose-ol:my-3 prose-li:my-0.5">
{conteudoMarkdown.trim() ? (
<div className="flex flex-wrap items-center justify-end gap-2 mb-3">
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-2 py-1.5">
<AlignJustify className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden />
<Switch
id="main-comfortable-width"
checked={mainComfortableWidth}
onCheckedChange={setMainComfortableWidth}
aria-label="Leitura confortável: limitar largura da coluna de texto"
/>
<Label htmlFor="main-comfortable-width" className="text-xs font-normal cursor-pointer whitespace-nowrap">
Leitura confortável
</Label>
</div>
</div>
) : null}
<article
className={cn(
"prose prose-sm prose-neutral dark:prose-invert w-full text-foreground prose-headings:font-semibold prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-p:leading-relaxed prose-ul:my-3 prose-ol:my-3 prose-li:my-0.5",
mainComfortableWidth ? "max-w-[65ch] mx-auto" : "max-w-none"
)}
>
{conteudoMarkdown.trim() ? (
<ReactMarkdown>{conteudoMarkdown}</ReactMarkdown>
) : (
@@ -845,10 +1331,34 @@ export function ParecerJuridicoDetailView() {
</ScrollArea>
</section>
<aside className="w-full md:w-[min(100%,360px)] shrink-0 flex flex-col border-t md:border-t-0 md:border-l border-border bg-muted/30 max-h-[45vh] md:max-h-none">
<div className="p-3 border-b border-border flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-muted-foreground" />
<h2 className="font-semibold text-sm">Conversar sobre o parecer</h2>
{chatPanelExpanded ? (
<aside
id="parecer-chat-panel"
className="w-full md:w-[min(100%,360px)] shrink-0 flex flex-col border-t md:border-t-0 md:border-l border-border bg-muted/30 max-h-[45vh] md:max-h-none min-h-0"
aria-label="Conversa sobre o parecer"
>
<div className="p-3 border-b border-border flex items-center gap-2 min-w-0">
<MessageSquare className="w-5 h-5 text-muted-foreground shrink-0" />
<h2 className="font-semibold text-sm flex-1 min-w-0 truncate">Conversar sobre o parecer</h2>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setChatPanelExpanded(false)}
aria-expanded={true}
aria-controls="parecer-chat-panel"
aria-label="Recolher painel de chat para ampliar a leitura do parecer"
>
<ChevronsRight className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[220px]">
Recolher chat e usar toda a largura para o parecer
</TooltipContent>
</Tooltip>
</div>
<ScrollArea className="flex-1 p-3 min-h-0">
<div className="space-y-4">
@@ -1004,6 +1514,47 @@ export function ParecerJuridicoDetailView() {
</div>
</div>
</aside>
) : (
<>
<div className="md:hidden shrink-0 border-t border-border bg-muted/30 px-3 py-2">
<Button
type="button"
variant="outline"
size="sm"
className="gap-2 w-full"
onClick={() => setChatPanelExpanded(true)}
aria-expanded={false}
aria-controls="parecer-chat-panel"
>
<MessageSquare className="w-4 h-4 shrink-0" />
Mostrar conversa sobre o parecer
</Button>
</div>
<div
className="hidden md:flex w-11 shrink-0 flex-col items-center border-l border-border bg-muted/30 pt-3 px-1 gap-2"
aria-label="Chat recolhido"
>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={() => setChatPanelExpanded(true)}
aria-expanded={false}
aria-controls="parecer-chat-panel"
aria-label="Abrir painel de chat sobre o parecer"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Abrir chat</TooltipContent>
</Tooltip>
<MessageSquare className="w-4 h-4 text-muted-foreground opacity-70" aria-hidden />
</div>
</>
)}
</div>
</div>
</>
@@ -93,8 +93,7 @@ export function ParecerJuridicoFormView() {
!isGenerating &&
tituloParecer.trim().length > 0 &&
areaId.trim().length > 0 &&
conteudoPrompt.trim().length > 0 &&
instrucao.trim().length > 0;
conteudoPrompt.trim().length > 0;
const handleGerarParecer = () => {
const titulo = tituloParecer.trim();
@@ -102,7 +101,7 @@ export function ParecerJuridicoFormView() {
const prompt = conteudoPrompt.trim();
const instr = instrucao.trim();
if (!titulo || !area || !prompt || !instr) {
if (!titulo || !area || !prompt) {
toast.error("Preencha os campos obrigatórios antes de gerar.");
return;
}
@@ -114,7 +113,7 @@ export function ParecerJuridicoFormView() {
area_id: area,
prompt_id: promptId.trim() || undefined,
prompt,
instrucao: instr,
instrucao: instr || undefined,
anexo: anexo ?? undefined,
})
.then((data) => {
@@ -254,21 +253,13 @@ export function ParecerJuridicoFormView() {
</div>
<div className="space-y-2">
<Label htmlFor="instrucao" className="flex flex-wrap items-baseline gap-1">
<span>Instrução</span>
<span className="text-destructive font-semibold" aria-hidden>
*
</span>
<span className="sr-only">obrigatório</span>
</Label>
<Label htmlFor="instrucao">Instrução</Label>
<Textarea
id="instrucao"
value={instrucao}
onChange={(e) => setInstrucao(e.target.value)}
placeholder="Instruções adicionais para a geração do parecer..."
placeholder="Instruções adicionais para a geração do parecer (opcional)..."
className="min-h-[100px] resize-y w-full"
required
aria-required="true"
/>
</div>
+5 -5
View File
@@ -281,19 +281,19 @@ export function PromptsFormView() {
type="button"
onClick={() => setIsAssistantOpen(true)}
disabled={isAssistantWorkingInBackground}
background="rgb(255,255,255)"
shimmerColor="rgba(0,0,0,0.06)"
background="hsl(var(--card))"
shimmerColor="hsl(var(--foreground) / 0.14)"
className={cn(
"gap-2 px-5 py-2.5 text-sm font-medium text-neutral-900 border border-neutral-200 shadow-md hover:shadow-lg transition-shadow dark:border-neutral-300",
"gap-2 px-5 py-2.5 text-sm font-medium text-card-foreground border-border shadow-md hover:shadow-lg transition-shadow",
isAssistantWorkingInBackground && "opacity-70 pointer-events-none"
)}
>
{isAssistantWorkingInBackground ? (
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
<span className="text-sm font-medium text-card-foreground">
Gerando em segundo plano...
</span>
) : (
<SparklesText sparklesCount={5} className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
<SparklesText sparklesCount={5} className="text-sm font-medium text-card-foreground">
Assistente de Criação de Prompt
</SparklesText>
)}
+26 -4
View File
@@ -11,10 +11,19 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { usePrompts } from "@/contexts/PromptsContext";
import type { Prompt } from "@/contexts/PromptsContext";
import { promptsService } from "@/services/promptsApi";
import { promptsService, type PromptItem } from "@/services/promptsApi";
import { toast } from "sonner";
function apiItemToPrompt(item: { id: string; titulo: string; area_nome: string; area_id: string; descricao?: string; conteudo?: string }): Prompt {
function formatarDataCriacao(iso: string | undefined): string {
if (!iso?.trim()) return "—";
try {
return new Date(iso.replace(" ", "T")).toLocaleDateString("pt-BR");
} catch {
return iso;
}
}
function apiItemToPrompt(item: PromptItem): Prompt {
return {
id: item.id,
titulo: item.titulo,
@@ -22,6 +31,7 @@ function apiItemToPrompt(item: { id: string; titulo: string; area_nome: string;
area_id: item.area_id,
descricao: item.descricao ?? undefined,
conteudo: item.conteudo ?? "",
created_at: item.created_at,
};
}
@@ -234,6 +244,9 @@ export const PromptsView = () => {
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[140px] whitespace-nowrap font-semibold text-xs md:text-sm">
Data de criação
</TableHead>
<TableHead className="min-w-[200px] font-semibold text-xs md:text-sm">Título</TableHead>
<TableHead className="min-w-[120px] font-semibold text-xs md:text-sm">Área</TableHead>
<TableHead className="text-center text-xs md:text-sm">Ações</TableHead>
@@ -242,19 +255,22 @@ export const PromptsView = () => {
<TableBody>
{loadingList ? (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground py-8">
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
Carregando...
</TableCell>
</TableRow>
) : totalRegistros === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground py-10 text-base">
<TableCell colSpan={4} className="text-center text-muted-foreground py-10 text-base">
{hasActiveFilters ? "Nenhum prompt encontrado para os filtros selecionados." : "Não há resultados."}
</TableCell>
</TableRow>
) : (
promptsList.map((prompt) => (
<TableRow key={prompt.id}>
<TableCell className="text-xs md:text-sm whitespace-nowrap tabular-nums">
{formatarDataCriacao(prompt.created_at)}
</TableCell>
<TableCell className="font-medium text-xs md:text-sm">{prompt.titulo}</TableCell>
<TableCell className="text-xs md:text-sm">{prompt.area}</TableCell>
<TableCell>
@@ -364,6 +380,12 @@ export const PromptsView = () => {
</DialogHeader>
{selectedPrompt && (
<div className="space-y-4 overflow-y-auto flex-1 min-h-0 p-3">
{selectedPrompt.created_at?.trim() ? (
<div>
<Label className="text-muted-foreground">Data de criação</Label>
<p className="mt-1 text-sm font-medium tabular-nums">{formatarDataCriacao(selectedPrompt.created_at)}</p>
</div>
) : null}
<div>
<Label className="text-muted-foreground">Título</Label>
<p className="font-medium mt-1">{selectedPrompt.titulo}</p>
+4 -4
View File
@@ -42,7 +42,7 @@ export const ShimmerButton = React.forwardRef<
} as CSSProperties
}
className={cn(
"group relative z-0 flex cursor-pointer items-center justify-center overflow-hidden [border-radius:var(--radius)] border border-white/10 px-6 py-3 whitespace-nowrap text-white [background:var(--bg)]",
"group relative z-0 flex cursor-pointer items-center justify-center overflow-hidden [border-radius:var(--radius)] border border-border px-6 py-3 whitespace-nowrap text-card-foreground [background:var(--bg)]",
"transform-gpu transition-transform duration-300 ease-in-out active:translate-y-px",
className
)}
@@ -69,16 +69,16 @@ export const ShimmerButton = React.forwardRef<
className={cn(
"absolute inset-0 size-full",
"rounded-2xl px-4 py-1.5 text-sm font-medium shadow-[inset_0_-8px_10px_#ffffff1f]",
"rounded-2xl px-4 py-1.5 text-sm font-medium shadow-[inset_0_-8px_10px_hsl(var(--foreground)_/_0.08)]",
// transition
"transform-gpu transition-all duration-300 ease-in-out",
// on hover
"group-hover:shadow-[inset_0_-6px_10px_#ffffff3f]",
"group-hover:shadow-[inset_0_-6px_10px_hsl(var(--foreground)_/_0.14)]",
// on click
"group-active:shadow-[inset_0_-10px_10px_#ffffff3f]"
"group-active:shadow-[inset_0_-10px_10px_hsl(var(--foreground)_/_0.14)]"
)}
/>
+2
View File
@@ -18,6 +18,8 @@ export interface Prompt {
area_id?: string;
descricao?: string;
conteudo?: string;
/** ISO ou string da API (ex.: "2026-04-14 13:58:44"). */
created_at?: string;
}
export type AreaDescriptions = Record<string, string>;
+1 -5
View File
@@ -6,7 +6,7 @@ export interface GerarParecerParams {
area_id: string;
prompt_id?: string;
prompt: string;
instrucao: string;
instrucao?: string;
anexo?: File | null;
}
@@ -204,10 +204,6 @@ class ParecerService {
throw new Error("Prompt é obrigatório");
}
if (!params.instrucao?.trim()) {
throw new Error("Instrução é obrigatória");
}
const userEmail = GlobalFunctions.getTransferProperty(
TransferAreaProperties.UsuarioEmail
);