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:
+8
-1
@@ -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 0–4 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 1–5 (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 há 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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)]"
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user