Initial SeConectai briefing system
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { getBriefingDoCliente } from "@/lib/briefing-service";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const respostaSchema = z.object({
|
||||
texto: z.string().optional(),
|
||||
complemento: z.string().optional(),
|
||||
selecionadas: z.array(z.string()).optional()
|
||||
});
|
||||
|
||||
const salvarRespostasSchema = z.object({
|
||||
perguntas: z.record(respostaSchema)
|
||||
});
|
||||
|
||||
export type SalvarRespostasResult = {
|
||||
ok: boolean;
|
||||
savedAt?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function hasValue(resposta: z.infer<typeof respostaSchema>) {
|
||||
return Boolean(
|
||||
resposta.texto?.trim() ||
|
||||
resposta.complemento?.trim() ||
|
||||
(resposta.selecionadas && resposta.selecionadas.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
export async function salvarRespostas(
|
||||
briefingId: string,
|
||||
secaoId: string,
|
||||
valores: unknown
|
||||
): Promise<SalvarRespostasResult> {
|
||||
const parsed = salvarRespostasSchema.safeParse(valores);
|
||||
|
||||
if (!parsed.success) {
|
||||
return { ok: false, message: "Dados invalidos para salvar." };
|
||||
}
|
||||
|
||||
const briefing = await getBriefingDoCliente(briefingId);
|
||||
|
||||
if (briefing.status !== "RASCUNHO") {
|
||||
return { ok: false, message: "Este briefing ja foi enviado e nao aceita novas alteracoes." };
|
||||
}
|
||||
|
||||
const perguntasDaSecao = await db.pergunta.findMany({
|
||||
where: {
|
||||
secaoId,
|
||||
ativo: true
|
||||
},
|
||||
select: { id: true }
|
||||
});
|
||||
const perguntaIds = new Set(perguntasDaSecao.map((pergunta) => pergunta.id));
|
||||
|
||||
await db.$transaction(
|
||||
Object.entries(parsed.data.perguntas)
|
||||
.filter(([perguntaId]) => perguntaIds.has(perguntaId))
|
||||
.map(([perguntaId, resposta]) => {
|
||||
if (!hasValue(resposta)) {
|
||||
return db.resposta.deleteMany({
|
||||
where: {
|
||||
briefingId,
|
||||
perguntaId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return db.resposta.upsert({
|
||||
where: {
|
||||
briefingId_perguntaId: {
|
||||
briefingId,
|
||||
perguntaId
|
||||
}
|
||||
},
|
||||
update: {
|
||||
texto: resposta.texto?.trim() || null,
|
||||
complemento: resposta.complemento?.trim() || null,
|
||||
selecionadas: resposta.selecionadas?.length ? resposta.selecionadas : null
|
||||
},
|
||||
create: {
|
||||
briefingId,
|
||||
perguntaId,
|
||||
texto: resposta.texto?.trim() || null,
|
||||
complemento: resposta.complemento?.trim() || null,
|
||||
selecionadas: resposta.selecionadas?.length ? resposta.selecionadas : null
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
savedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import Link from "next/link";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { getSecaoBriefing } from "@/lib/briefing-service";
|
||||
import { SecaoForm } from "./secao-form";
|
||||
|
||||
export default async function SecaoBriefingPage({
|
||||
params
|
||||
}: {
|
||||
params: { id: string; secao: string };
|
||||
}) {
|
||||
const ordemSecao = Number(params.secao);
|
||||
const { briefing, secao, secoes, progressoGeral, anterior, proxima } = await getSecaoBriefing(
|
||||
params.id,
|
||||
ordemSecao
|
||||
);
|
||||
const percentual =
|
||||
progressoGeral.total > 0 ? Math.round((progressoGeral.respondidas / progressoGeral.total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<main className="container py-8">
|
||||
<div className="mb-6">
|
||||
<Button asChild variant="ghost" className="mb-4 px-0">
|
||||
<Link href={`/briefings/${briefing.id}`}>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Voltar para abertura
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<Badge variant="muted">Secao {secao.ordem} de {secoes.length}</Badge>
|
||||
<h1 className="mt-3 text-3xl font-semibold">{secao.titulo}</h1>
|
||||
{secao.descricao ? <p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">{secao.descricao}</p> : null}
|
||||
</div>
|
||||
<div className="min-w-56 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">Progresso geral</span>
|
||||
<span className="text-muted-foreground">{progressoGeral.respondidas}/{progressoGeral.total}</span>
|
||||
</div>
|
||||
<Progress value={percentual} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[280px_1fr]">
|
||||
<aside className="lg:sticky lg:top-6 lg:self-start">
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
<nav aria-label="Secoes do briefing" className="grid gap-1">
|
||||
{secoes.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`/briefings/${briefing.id}/${item.ordem}`}
|
||||
className={`rounded-md px-3 py-2 text-sm transition ${
|
||||
item.ordem === secao.ordem
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span className="block font-medium">{item.ordem}. {item.titulo}</span>
|
||||
<span className={item.ordem === secao.ordem ? "text-primary-foreground/80" : "text-muted-foreground"}>
|
||||
{item.respondidas}/{item.total} respondidas
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
|
||||
<div className="space-y-6">
|
||||
<SecaoForm
|
||||
briefingId={briefing.id}
|
||||
secaoId={secao.id}
|
||||
perguntas={secao.perguntas}
|
||||
readOnly={briefing.status !== "RASCUNHO"}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:justify-between">
|
||||
{anterior ? (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/briefings/${briefing.id}/${anterior.ordem}`}>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Secao anterior
|
||||
</Link>
|
||||
</Button>
|
||||
) : <span />}
|
||||
{proxima ? (
|
||||
<Button asChild>
|
||||
<Link href={`/briefings/${briefing.id}/${proxima.ordem}`}>
|
||||
Proxima secao
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild>
|
||||
<Link href={`/briefings/${briefing.id}/revisao`}>Ir para revisao</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import type { Ajuda, Anexo, Pergunta, Resposta } from "@prisma/client";
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { PerguntaField } from "@/components/form/PerguntaField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { salvarRespostas } from "./actions";
|
||||
|
||||
type PerguntaComDados = Pergunta & {
|
||||
ajuda: Ajuda | null;
|
||||
anexos?: Anexo[];
|
||||
respostas: Resposta[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
briefingId: string;
|
||||
secaoId: string;
|
||||
perguntas: PerguntaComDados[];
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
perguntas: Record<
|
||||
string,
|
||||
{
|
||||
texto?: string;
|
||||
selecionadas?: string[];
|
||||
complemento?: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
function getDefaultValues(perguntas: PerguntaComDados[]): FormValues {
|
||||
return {
|
||||
perguntas: perguntas.reduce<FormValues["perguntas"]>((acc, pergunta) => {
|
||||
const resposta = pergunta.respostas[0];
|
||||
const selecionadas = Array.isArray(resposta?.selecionadas)
|
||||
? resposta.selecionadas.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
|
||||
acc[pergunta.id] = {
|
||||
texto: resposta?.texto ?? "",
|
||||
selecionadas,
|
||||
complemento: resposta?.complemento ?? ""
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {})
|
||||
};
|
||||
}
|
||||
|
||||
type SaveStatus = "idle" | "dirty" | "saving" | "saved" | "error";
|
||||
|
||||
function formatTime(value: string) {
|
||||
return new Intl.DateTimeFormat("pt-BR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function SecaoForm({ briefingId, secaoId, perguntas, readOnly = false }: Props) {
|
||||
const [status, setStatus] = useState<SaveStatus>("idle");
|
||||
const [savedAt, setSavedAt] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [, startTransition] = useTransition();
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const router = useRouter();
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: getDefaultValues(perguntas)
|
||||
});
|
||||
const watchedValues = form.watch();
|
||||
|
||||
async function saveNow(values: FormValues) {
|
||||
if (readOnly) {
|
||||
return true;
|
||||
}
|
||||
|
||||
setStatus("saving");
|
||||
setMessage(null);
|
||||
|
||||
const result = await salvarRespostas(briefingId, secaoId, values);
|
||||
|
||||
startTransition(() => {
|
||||
if (!result.ok) {
|
||||
setStatus("error");
|
||||
setMessage(result.message ?? "Erro ao salvar. Tente novamente.");
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset(values);
|
||||
setStatus("saved");
|
||||
setSavedAt(result.savedAt ?? new Date().toISOString());
|
||||
});
|
||||
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (readOnly || !form.formState.isDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("dirty");
|
||||
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(() => {
|
||||
void saveNow(form.getValues());
|
||||
}, 800);
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [watchedValues, form, readOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||
if (form.formState.isDirty && status !== "saved") {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [form.formState.isDirty, status]);
|
||||
|
||||
useEffect(() => {
|
||||
async function handleClick(event: MouseEvent) {
|
||||
const link = (event.target as HTMLElement).closest("a");
|
||||
|
||||
if (!link || readOnly || !form.formState.isDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = link.getAttribute("href");
|
||||
|
||||
if (!href || !href.startsWith(`/briefings/${briefingId}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
|
||||
const saved = await saveNow(form.getValues());
|
||||
|
||||
if (saved) {
|
||||
router.push(href);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", handleClick);
|
||||
return () => document.removeEventListener("click", handleClick);
|
||||
}, [briefingId, form, readOnly, router]);
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form className="space-y-4">
|
||||
<div className="sticky top-0 z-20 rounded-lg border bg-card p-3 text-sm shadow-sm">
|
||||
{readOnly ? (
|
||||
<span className="text-muted-foreground">Briefing enviado. Respostas em modo leitura.</span>
|
||||
) : status === "saving" ? (
|
||||
<span className="text-muted-foreground">Salvando...</span>
|
||||
) : status === "saved" && savedAt ? (
|
||||
<span className="text-muted-foreground">Salvo as {formatTime(savedAt)}</span>
|
||||
) : status === "error" ? (
|
||||
<span className="text-destructive">{message ?? "Erro ao salvar — tentar de novo"}</span>
|
||||
) : status === "dirty" ? (
|
||||
<span className="text-muted-foreground">Alteracoes pendentes...</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Rascunho pronto para edicao.</span>
|
||||
)}
|
||||
</div>
|
||||
{perguntas.map((pergunta, index) => (
|
||||
<fieldset key={pergunta.id} disabled={readOnly} className="disabled:opacity-75">
|
||||
<PerguntaField
|
||||
briefingId={briefingId}
|
||||
pergunta={pergunta}
|
||||
index={index}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</fieldset>
|
||||
))}
|
||||
{!readOnly ? (
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Button type="button" variant="outline" onClick={() => form.reset()}>
|
||||
Descartar alteracoes locais
|
||||
</Button>
|
||||
<Button type="button" onClick={() => saveNow(form.getValues())}>
|
||||
Salvar agora
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user