Initial SeConectai briefing system
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import type { StatusBriefing } from "@prisma/client";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
requireAdmin,
|
||||
revelarCpfCliente,
|
||||
atualizarStatusBriefing
|
||||
} from "@/lib/admin-service";
|
||||
import { reabrirBriefing } from "@/lib/briefing-service";
|
||||
|
||||
export type AdminActionState = {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export async function revelarCpfAction(
|
||||
clienteId: string,
|
||||
_state: AdminActionState
|
||||
): Promise<AdminActionState> {
|
||||
const cpf = await revelarCpfCliente(clienteId);
|
||||
return { ok: true, value: cpf };
|
||||
}
|
||||
|
||||
export async function mudarStatusAction(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const briefingId = String(formData.get("briefingId") ?? "");
|
||||
const status = String(formData.get("status") ?? "") as StatusBriefing;
|
||||
|
||||
if (!["ENVIADO", "EM_ANALISE", "CONCLUIDO"].includes(status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await atualizarStatusBriefing(briefingId, status);
|
||||
revalidatePath(`/admin/briefings/${briefingId}`);
|
||||
}
|
||||
|
||||
export async function reabrirBriefingAction(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const briefingId = String(formData.get("briefingId") ?? "");
|
||||
await reabrirBriefing(briefingId);
|
||||
revalidatePath(`/admin/briefings/${briefingId}`);
|
||||
}
|
||||
|
||||
export async function adicionarNotaInternaAction(formData: FormData) {
|
||||
const admin = await requireAdmin();
|
||||
const briefingId = String(formData.get("briefingId") ?? "");
|
||||
const texto = String(formData.get("texto") ?? "").trim();
|
||||
|
||||
if (!texto) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db.notaInterna.create({
|
||||
data: {
|
||||
briefingId,
|
||||
adminId: admin.id,
|
||||
texto
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePath(`/admin/briefings/${briefingId}`);
|
||||
}
|
||||
|
||||
const perguntaSchema = z.object({
|
||||
perguntaId: z.string().min(1),
|
||||
rotulo: z.string().trim().min(1),
|
||||
dica: z.string().trim().optional(),
|
||||
ordem: z.coerce.number().int().positive(),
|
||||
ativo: z.boolean(),
|
||||
obrigatoria: z.boolean(),
|
||||
oQueResponder: z.string().trim().min(1),
|
||||
porQuePerguntamos: z.string().trim().min(1),
|
||||
miniExemplo: z.string().trim().min(1)
|
||||
});
|
||||
|
||||
export async function salvarPerguntaAction(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const parsed = perguntaSchema.safeParse({
|
||||
perguntaId: formData.get("perguntaId"),
|
||||
rotulo: formData.get("rotulo"),
|
||||
dica: formData.get("dica") || undefined,
|
||||
ordem: formData.get("ordem"),
|
||||
ativo: formData.get("ativo") === "on",
|
||||
obrigatoria: formData.get("obrigatoria") === "on",
|
||||
oQueResponder: formData.get("oQueResponder"),
|
||||
porQuePerguntamos: formData.get("porQuePerguntamos"),
|
||||
miniExemplo: formData.get("miniExemplo")
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db.pergunta.update({
|
||||
where: { id: parsed.data.perguntaId },
|
||||
data: {
|
||||
rotulo: parsed.data.rotulo,
|
||||
dica: parsed.data.dica ?? null,
|
||||
ordem: parsed.data.ordem,
|
||||
ativo: parsed.data.ativo,
|
||||
obrigatoria: parsed.data.obrigatoria,
|
||||
ajuda: {
|
||||
upsert: {
|
||||
update: {
|
||||
oQueResponder: parsed.data.oQueResponder,
|
||||
porQuePerguntamos: parsed.data.porQuePerguntamos,
|
||||
miniExemplo: parsed.data.miniExemplo
|
||||
},
|
||||
create: {
|
||||
oQueResponder: parsed.data.oQueResponder,
|
||||
porQuePerguntamos: parsed.data.porQuePerguntamos,
|
||||
miniExemplo: parsed.data.miniExemplo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePath("/admin/perguntas");
|
||||
}
|
||||
|
||||
export async function irParaClientes(formData: FormData) {
|
||||
const busca = String(formData.get("busca") ?? "").trim();
|
||||
const tipo = String(formData.get("tipo") ?? "").trim();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (busca) params.set("busca", busca);
|
||||
if (tipo) params.set("tipo", tipo);
|
||||
|
||||
redirect(`/admin/clientes${params.size ? `?${params.toString()}` : ""}`);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import Link from "next/link";
|
||||
import { adicionarNotaInternaAction, mudarStatusAction, reabrirBriefingAction } from "@/app/(admin)/admin/actions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getBriefingAdmin } from "@/lib/admin-service";
|
||||
import { formatFileSize } from "@/lib/format";
|
||||
|
||||
function respostaTexto(resposta: {
|
||||
texto: string | null;
|
||||
complemento: string | null;
|
||||
selecionadas: unknown;
|
||||
}) {
|
||||
const partes: string[] = [];
|
||||
const selecionadas = Array.isArray(resposta.selecionadas)
|
||||
? resposta.selecionadas.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
|
||||
if (resposta.texto?.trim()) partes.push(resposta.texto.trim());
|
||||
if (selecionadas.length > 0) partes.push(selecionadas.join(", "));
|
||||
if (resposta.complemento?.trim()) partes.push(`Complemento: ${resposta.complemento.trim()}`);
|
||||
|
||||
return partes.join(" | ");
|
||||
}
|
||||
|
||||
export default async function BriefingAdminPage({ params }: { params: { id: string } }) {
|
||||
const { briefing, secoes } = await getBriefingAdmin(params.id);
|
||||
|
||||
return (
|
||||
<main className="container py-8">
|
||||
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<Badge variant="muted">{briefing.status}</Badge>
|
||||
<h1 className="mt-3 text-3xl font-semibold">{briefing.titulo}</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{briefing.cliente.nomeCompleto} | {briefing.cliente.user.email}
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin">Voltar</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<a href={`/api/briefings/${briefing.id}/pdf`}>Baixar PDF</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||
<div className="space-y-4">
|
||||
{secoes.map((secao) => (
|
||||
<Card key={secao.id}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{secao.ordem}. {secao.titulo}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{secao.perguntas.map((pergunta) => {
|
||||
const resposta = pergunta.respostas[0];
|
||||
const texto = resposta ? respostaTexto(resposta) : "";
|
||||
|
||||
return (
|
||||
<div key={pergunta.id} className="rounded-md border p-3">
|
||||
<p className="text-sm font-medium">{pergunta.rotulo}</p>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">
|
||||
{texto || "Sem resposta informada."}
|
||||
</p>
|
||||
{pergunta.anexos.length > 0 ? (
|
||||
<ul className="mt-3 space-y-1 text-sm">
|
||||
{pergunta.anexos.map((anexo) => (
|
||||
<li key={anexo.id}>
|
||||
<a className="text-primary hover:underline" href={`/api/anexos/${anexo.id}`}>
|
||||
{anexo.nomeOriginal} ({formatFileSize(anexo.tamanho)})
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4 lg:sticky lg:top-6 lg:self-start">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Acoes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<form action={mudarStatusAction} className="grid gap-3">
|
||||
<input type="hidden" name="briefingId" value={briefing.id} />
|
||||
<select name="status" defaultValue={briefing.status} className="h-10 rounded-md border bg-background px-3 text-sm">
|
||||
<option value="ENVIADO">ENVIADO</option>
|
||||
<option value="EM_ANALISE">EM_ANALISE</option>
|
||||
<option value="CONCLUIDO">CONCLUIDO</option>
|
||||
</select>
|
||||
<Button type="submit">Atualizar status</Button>
|
||||
</form>
|
||||
<form action={reabrirBriefingAction}>
|
||||
<input type="hidden" name="briefingId" value={briefing.id} />
|
||||
<Button type="submit" variant="outline" className="w-full">Reabrir para edicao</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Nota interna</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form action={adicionarNotaInternaAction} className="space-y-3">
|
||||
<input type="hidden" name="briefingId" value={briefing.id} />
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="texto">Texto</Label>
|
||||
<Textarea id="texto" name="texto" required />
|
||||
</div>
|
||||
<Button type="submit">Adicionar nota</Button>
|
||||
</form>
|
||||
<div className="space-y-3">
|
||||
{briefing.notasInternas.map((nota) => (
|
||||
<div key={nota.id} className="rounded-md border p-3">
|
||||
<p className="text-sm leading-6">{nota.texto}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{nota.admin.email} | {nota.criadoEm.toLocaleString("pt-BR")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useFormState, useFormStatus } from "react-dom";
|
||||
import { revelarCpfAction, type AdminActionState } from "@/app/(admin)/admin/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const initialState: AdminActionState = { ok: false };
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
return (
|
||||
<Button type="submit" variant="outline" disabled={pending}>
|
||||
{pending ? "Registrando..." : "Mostrar CPF"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MostrarCpf({ clienteId }: { clienteId: string }) {
|
||||
const action = revelarCpfAction.bind(null, clienteId);
|
||||
const [state, formAction] = useFormState(action, initialState);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-2">
|
||||
<SubmitButton />
|
||||
{state.value ? <p className="text-sm font-medium">{state.value}</p> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { decryptCpf } from "@/lib/crypto";
|
||||
import { getClienteAdmin, maskCpf, whatsappLink } from "@/lib/admin-service";
|
||||
import { MostrarCpf } from "./mostrar-cpf";
|
||||
|
||||
export default async function ClienteAdminDetalhePage({ params }: { params: { id: string } }) {
|
||||
const cliente = await getClienteAdmin(params.id);
|
||||
const cpfMascarado = maskCpf(decryptCpf(cliente.cpfCifrado));
|
||||
|
||||
return (
|
||||
<main className="container py-8">
|
||||
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<Badge variant="muted">{cliente.tipo}</Badge>
|
||||
<h1 className="mt-3 text-3xl font-semibold">{cliente.nomeCompleto}</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{cliente.user.email}</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin/clientes">Voltar</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Dados cadastrais</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<div>
|
||||
<p className="font-medium">CPF</p>
|
||||
<p className="text-muted-foreground">{cpfMascarado}</p>
|
||||
<div className="mt-2">
|
||||
<MostrarCpf clienteId={cliente.id} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">WhatsApp</p>
|
||||
<a className="text-primary hover:underline" href={whatsappLink(cliente.whatsapp)} target="_blank" rel="noreferrer">
|
||||
{cliente.whatsapp}
|
||||
</a>
|
||||
</div>
|
||||
{cliente.razaoSocial ? (
|
||||
<div>
|
||||
<p className="font-medium">Razao social</p>
|
||||
<p className="text-muted-foreground">{cliente.razaoSocial}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{cliente.cnpj ? (
|
||||
<div>
|
||||
<p className="font-medium">CNPJ</p>
|
||||
<p className="text-muted-foreground">{cliente.cnpj}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<p className="font-medium">Endereco</p>
|
||||
<p className="text-muted-foreground">
|
||||
{[cliente.logradouro, cliente.numero, cliente.bairro, cliente.cidade, cliente.uf]
|
||||
.filter(Boolean)
|
||||
.join(", ") || "Nao informado"}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Briefings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Titulo</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Atualizado</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cliente.briefings.map((briefing) => (
|
||||
<TableRow key={briefing.id}>
|
||||
<TableCell>
|
||||
<Link className="font-medium text-primary hover:underline" href={`/admin/briefings/${briefing.id}`}>
|
||||
{briefing.titulo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell><Badge variant="muted">{briefing.status}</Badge></TableCell>
|
||||
<TableCell>{briefing.atualizadoEm.toLocaleString("pt-BR")}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Link from "next/link";
|
||||
import { irParaClientes } from "@/app/(admin)/admin/actions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { listarClientesAdmin, maskCpf } from "@/lib/admin-service";
|
||||
import { decryptCpf } from "@/lib/crypto";
|
||||
|
||||
export default async function ClientesAdminPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams: { busca?: string; tipo?: "PF" | "PJ" };
|
||||
}) {
|
||||
const clientes = await listarClientesAdmin({
|
||||
busca: searchParams.busca,
|
||||
tipo: searchParams.tipo
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="container py-8">
|
||||
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold">Clientes</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Busque clientes e acompanhe seus briefings.</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin">Voltar</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4">
|
||||
<form action={irParaClientes} className="grid gap-4 sm:grid-cols-[1fr_160px_auto] sm:items-end">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="busca">Busca</Label>
|
||||
<Input id="busca" name="busca" defaultValue={searchParams.busca ?? ""} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tipo">Tipo</Label>
|
||||
<select id="tipo" name="tipo" defaultValue={searchParams.tipo ?? ""} className="h-10 w-full rounded-md border bg-background px-3 text-sm">
|
||||
<option value="">Todos</option>
|
||||
<option value="PF">PF</option>
|
||||
<option value="PJ">PJ</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button type="submit">Filtrar</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{clientes.length} cliente(s)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nome</TableHead>
|
||||
<TableHead>E-mail</TableHead>
|
||||
<TableHead>Tipo</TableHead>
|
||||
<TableHead>CPF</TableHead>
|
||||
<TableHead>Briefings</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{clientes.map((cliente) => (
|
||||
<TableRow key={cliente.id}>
|
||||
<TableCell>
|
||||
<Link href={`/admin/clientes/${cliente.id}`} className="font-medium text-primary hover:underline">
|
||||
{cliente.nomeCompleto}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{cliente.user.email}</TableCell>
|
||||
<TableCell><Badge variant="muted">{cliente.tipo}</Badge></TableCell>
|
||||
<TableCell>{maskCpf(decryptCpf(cliente.cpfCifrado))}</TableCell>
|
||||
<TableCell>{cliente.briefings.length}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { auth } from "@/auth";
|
||||
import { sair } from "@/app/(auth)/actions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { getAdminDashboard } from "@/lib/admin-service";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function AdminPage() {
|
||||
const session = await auth();
|
||||
const dashboard = await getAdminDashboard();
|
||||
const countByStatus = Object.fromEntries(
|
||||
dashboard.porStatus.map((item) => [item.status, item._count._all])
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="container py-8">
|
||||
<header className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold">Painel administrativo</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Area restrita para {session?.user.email}.
|
||||
</p>
|
||||
</div>
|
||||
<form action={sair}>
|
||||
<Button variant="outline" type="submit">
|
||||
Sair
|
||||
</Button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-3">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin/clientes">Clientes</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin/perguntas">Perguntas</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Briefings enviados</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-semibold">{countByStatus.ENVIADO ?? 0}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Em analise</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-semibold">{countByStatus.EM_ANALISE ?? 0}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Concluidos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-semibold">{countByStatus.CONCLUIDO ?? 0}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Novos em 7 dias</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-semibold">{dashboard.novosUltimos7Dias}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Ultimos envios</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Briefing</TableHead>
|
||||
<TableHead>Cliente</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Enviado em</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dashboard.ultimosEnvios.map((briefing) => (
|
||||
<TableRow key={briefing.id}>
|
||||
<TableCell>
|
||||
<Link className="font-medium text-primary hover:underline" href={`/admin/briefings/${briefing.id}`}>
|
||||
{briefing.titulo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{briefing.cliente.nomeCompleto}</TableCell>
|
||||
<TableCell><Badge variant="muted">{briefing.status}</Badge></TableCell>
|
||||
<TableCell>{briefing.enviadoEm?.toLocaleString("pt-BR")}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Link from "next/link";
|
||||
import { salvarPerguntaAction } from "@/app/(admin)/admin/actions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { listarPerguntasAdmin } from "@/lib/admin-service";
|
||||
|
||||
export default async function PerguntasAdminPage() {
|
||||
const secoes = await listarPerguntasAdmin();
|
||||
|
||||
return (
|
||||
<main className="container py-8">
|
||||
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold">Perguntas e ajudas</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Edite textos do briefing sem alterar codigo.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin">Voltar</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{secoes.map((secao) => (
|
||||
<Card key={secao.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{secao.ordem}. {secao.titulo}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{secao.perguntas.map((pergunta) => (
|
||||
<form key={pergunta.id} action={salvarPerguntaAction} className="space-y-4 rounded-lg border p-4">
|
||||
<input type="hidden" name="perguntaId" value={pergunta.id} />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="muted">{pergunta.tipo}</Badge>
|
||||
{!pergunta.ativo ? <Badge variant="outline">Inativa</Badge> : null}
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-[100px_1fr]">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`ordem-${pergunta.id}`}>Ordem</Label>
|
||||
<Input id={`ordem-${pergunta.id}`} name="ordem" type="number" defaultValue={pergunta.ordem} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`rotulo-${pergunta.id}`}>Rotulo</Label>
|
||||
<Input id={`rotulo-${pergunta.id}`} name="rotulo" defaultValue={pergunta.rotulo} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`dica-${pergunta.id}`}>Dica</Label>
|
||||
<Input id={`dica-${pergunta.id}`} name="dica" defaultValue={pergunta.dica ?? ""} />
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="flex items-center gap-3 rounded-md border p-3 text-sm">
|
||||
<Checkbox name="ativo" defaultChecked={pergunta.ativo} />
|
||||
Ativa
|
||||
</label>
|
||||
<label className="flex items-center gap-3 rounded-md border p-3 text-sm">
|
||||
<Checkbox name="obrigatoria" defaultChecked={pergunta.obrigatoria} />
|
||||
Obrigatoria
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`oq-${pergunta.id}`}>O que responder</Label>
|
||||
<Textarea id={`oq-${pergunta.id}`} name="oQueResponder" defaultValue={pergunta.ajuda?.oQueResponder ?? ""} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`pq-${pergunta.id}`}>Por que perguntamos</Label>
|
||||
<Textarea id={`pq-${pergunta.id}`} name="porQuePerguntamos" defaultValue={pergunta.ajuda?.porQuePerguntamos ?? ""} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`ex-${pergunta.id}`}>Mini exemplo</Label>
|
||||
<Textarea id={`ex-${pergunta.id}`} name="miniExemplo" defaultValue={pergunta.ajuda?.miniExemplo ?? ""} />
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit">Salvar pergunta</Button>
|
||||
</form>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { signOut } from "@/auth";
|
||||
|
||||
export async function sair() {
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type Props = {
|
||||
empresaMarca: string;
|
||||
responsavel: string;
|
||||
contato: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export function CabecalhoBriefingForm({ empresaMarca, responsavel, contato, data }: Props) {
|
||||
return (
|
||||
<form className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="empresaMarca">Empresa / marca</Label>
|
||||
<Input id="empresaMarca" name="empresaMarca" defaultValue={empresaMarca} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="responsavel">Responsavel</Label>
|
||||
<Input id="responsavel" name="responsavel" defaultValue={responsavel} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data">Data</Label>
|
||||
<Input id="data" name="data" type="date" defaultValue={data} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contato">Contato</Label>
|
||||
<Input id="contato" name="contato" defaultValue={contato} />
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Estes campos serao salvos junto com o rascunho na etapa de autosave.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Link from "next/link";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { getAberturaBriefing } from "@/lib/briefing-service";
|
||||
import { CabecalhoBriefingForm } from "./cabecalho-briefing-form";
|
||||
|
||||
function asStringArray(value: unknown) {
|
||||
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
export default async function AberturaBriefingPage({ params }: { params: { id: string } }) {
|
||||
const { briefing, progresso, comoPreencher, avisoSenhas } = await getAberturaBriefing(params.id);
|
||||
const totalPerguntas = progresso.reduce((sum, secao) => sum + secao.total, 0);
|
||||
const totalRespondidas = progresso.reduce((sum, secao) => sum + secao.respondidas, 0);
|
||||
const percentual = totalPerguntas > 0 ? Math.round((totalRespondidas / totalPerguntas) * 100) : 0;
|
||||
const primeiraPendente = progresso.find((secao) => secao.respondidas < secao.total) ?? progresso[0];
|
||||
|
||||
return (
|
||||
<main className="container py-8">
|
||||
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<Badge variant="muted">{briefing.status.replace("_", " ")}</Badge>
|
||||
<h1 className="mt-3 text-3xl font-semibold">{briefing.titulo}</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Revise as orientacoes e continue pela secao indicada.
|
||||
</p>
|
||||
</div>
|
||||
{primeiraPendente ? (
|
||||
<Button asChild>
|
||||
<Link href={`/briefings/${briefing.id}/${primeiraPendente.ordem}`}>Continuar de onde parei</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mb-6 space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">Progresso geral</span>
|
||||
<span className="text-muted-foreground">{totalRespondidas}/{totalPerguntas}</span>
|
||||
</div>
|
||||
<Progress value={percentual} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Cabecalho do briefing</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CabecalhoBriefingForm
|
||||
empresaMarca={briefing.cliente.razaoSocial ?? briefing.cliente.nomeCompleto}
|
||||
responsavel={briefing.cliente.nomeCompleto}
|
||||
contato={`${briefing.cliente.user.email} | ${briefing.cliente.whatsapp}`}
|
||||
data={new Date().toISOString().slice(0, 10)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{avisoSenhas ? (
|
||||
<Alert>
|
||||
<AlertTitle>Aviso importante</AlertTitle>
|
||||
<AlertDescription>{String(avisoSenhas)}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Como preencher</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-3 text-sm leading-6 text-muted-foreground">
|
||||
{asStringArray(comoPreencher).map((item) => (
|
||||
<li key={item} className="rounded-md bg-muted p-3">{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{progresso.map((secao) => {
|
||||
const secaoPercentual = secao.total > 0 ? Math.round((secao.respondidas / secao.total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={secao.id}
|
||||
href={`/briefings/${briefing.id}/${secao.ordem}`}
|
||||
className="rounded-lg border bg-card p-4 transition hover:border-primary"
|
||||
>
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium">{secao.ordem}. {secao.titulo}</p>
|
||||
{secao.descricao ? <p className="mt-1 text-sm text-muted-foreground">{secao.descricao}</p> : null}
|
||||
</div>
|
||||
<Badge variant="muted">{secao.respondidas}/{secao.total}</Badge>
|
||||
</div>
|
||||
<Progress value={secaoPercentual} />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { getBriefingDoCliente } from "@/lib/briefing-service";
|
||||
import { db } from "@/lib/db";
|
||||
import { notificarBriefingEnviado } from "@/lib/notifications";
|
||||
|
||||
export type EnvioBriefingState = {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function enviarBriefing(
|
||||
briefingId: string,
|
||||
_state: EnvioBriefingState,
|
||||
formData: FormData
|
||||
): Promise<EnvioBriefingState> {
|
||||
const briefing = await getBriefingDoCliente(briefingId);
|
||||
|
||||
if (briefing.status !== "RASCUNHO") {
|
||||
return { ok: false, message: "Este briefing ja foi enviado." };
|
||||
}
|
||||
|
||||
if (formData.get("confirmacaoCliente") !== "on") {
|
||||
return { ok: false, message: "Confirme a declaracao do cliente para enviar." };
|
||||
}
|
||||
|
||||
const totalPerguntas = await db.pergunta.count({
|
||||
where: { ativo: true }
|
||||
});
|
||||
const respostas = await db.resposta.findMany({
|
||||
where: { briefingId: briefing.id },
|
||||
select: {
|
||||
texto: true,
|
||||
complemento: true,
|
||||
selecionadas: true
|
||||
}
|
||||
});
|
||||
const respondidas = respostas.filter((resposta) => {
|
||||
const selecionadas = Array.isArray(resposta.selecionadas) ? resposta.selecionadas : [];
|
||||
return Boolean(resposta.texto?.trim() || resposta.complemento?.trim() || selecionadas.length > 0);
|
||||
}).length;
|
||||
|
||||
if (respondidas < totalPerguntas && formData.get("confirmarBrancos") !== "on") {
|
||||
return {
|
||||
ok: false,
|
||||
message: "Confirme que deseja enviar mesmo com perguntas em branco."
|
||||
};
|
||||
}
|
||||
|
||||
await db.briefing.update({
|
||||
where: { id: briefing.id },
|
||||
data: {
|
||||
status: "ENVIADO",
|
||||
confirmadoEm: new Date(),
|
||||
enviadoEm: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await notificarBriefingEnviado(briefing.id);
|
||||
} catch {
|
||||
// Falha de notificacao nao pode impedir o envio do briefing.
|
||||
}
|
||||
|
||||
redirect(`/briefings/${briefing.id}/revisao`);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useFormState, useFormStatus } from "react-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { enviarBriefing, type EnvioBriefingState } from "./actions";
|
||||
|
||||
const initialState: EnvioBriefingState = { ok: false };
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Enviando..." : "Enviar briefing"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnvioBriefingForm({
|
||||
briefingId,
|
||||
confirmacaoCliente,
|
||||
perguntasEmBranco
|
||||
}: {
|
||||
briefingId: string;
|
||||
confirmacaoCliente: string;
|
||||
perguntasEmBranco: number;
|
||||
}) {
|
||||
const enviarComId = enviarBriefing.bind(null, briefingId);
|
||||
const [state, action] = useFormState(enviarComId, initialState);
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-4 rounded-lg border bg-card p-4">
|
||||
{state.message ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{perguntasEmBranco > 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Existem {perguntasEmBranco} pergunta(s) em branco. Voce pode enviar mesmo assim se estiver tudo certo.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<p className="text-sm leading-6 text-muted-foreground">{confirmacaoCliente}</p>
|
||||
|
||||
<label className="flex items-start gap-3 rounded-md border p-3 text-sm leading-6">
|
||||
<Checkbox name="confirmacaoCliente" required />
|
||||
<span>Confirmo a declaracao acima e desejo enviar o briefing para analise.</span>
|
||||
</label>
|
||||
|
||||
{perguntasEmBranco > 0 ? (
|
||||
<label className="flex items-start gap-3 rounded-md border p-3 text-sm leading-6">
|
||||
<Checkbox name="confirmarBrancos" required />
|
||||
<span>Estou ciente de que ha perguntas em branco e quero enviar mesmo assim.</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<SubmitButton />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { getRevisaoBriefing } from "@/lib/briefing-service";
|
||||
import { EnvioBriefingForm } from "./envio-briefing-form";
|
||||
|
||||
function respostaTexto(resposta: {
|
||||
texto: string | null;
|
||||
complemento: string | null;
|
||||
selecionadas: unknown;
|
||||
}) {
|
||||
const partes: string[] = [];
|
||||
const selecionadas = Array.isArray(resposta.selecionadas)
|
||||
? resposta.selecionadas.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
|
||||
if (resposta.texto?.trim()) {
|
||||
partes.push(resposta.texto.trim());
|
||||
}
|
||||
|
||||
if (selecionadas.length > 0) {
|
||||
partes.push(selecionadas.join(", "));
|
||||
}
|
||||
|
||||
if (resposta.complemento?.trim()) {
|
||||
partes.push(`Complemento: ${resposta.complemento.trim()}`);
|
||||
}
|
||||
|
||||
return partes.join(" | ");
|
||||
}
|
||||
|
||||
export default async function RevisaoBriefingPage({ params }: { params: { id: string } }) {
|
||||
const { briefing, secoes, confirmacaoCliente } = await getRevisaoBriefing(params.id);
|
||||
const perguntas = secoes.flatMap((secao) => secao.perguntas);
|
||||
const perguntasEmBranco = perguntas.filter((pergunta) => {
|
||||
const resposta = pergunta.respostas[0];
|
||||
const anexos = "anexos" in pergunta && Array.isArray(pergunta.anexos) ? pergunta.anexos : [];
|
||||
return (!resposta || !respostaTexto(resposta)) && anexos.length === 0;
|
||||
}).length;
|
||||
|
||||
return (
|
||||
<main className="container py-8">
|
||||
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<Badge variant="muted">{briefing.status.replace("_", " ")}</Badge>
|
||||
<h1 className="mt-3 text-3xl font-semibold">Revisao do briefing</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Confira as respostas antes de enviar para analise.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/briefings/${briefing.id}`}>Voltar para abertura</Link>
|
||||
</Button>
|
||||
{briefing.status !== "RASCUNHO" ? (
|
||||
<Button asChild>
|
||||
<a href={`/api/briefings/${briefing.id}/pdf`}>Baixar PDF</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||
<div className="grid gap-4">
|
||||
{secoes.map((secao) => (
|
||||
<Card key={secao.id}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{secao.ordem}. {secao.titulo}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{secao.perguntas.map((pergunta) => {
|
||||
const resposta = pergunta.respostas[0];
|
||||
const texto = resposta ? respostaTexto(resposta) : "";
|
||||
const anexos = "anexos" in pergunta && Array.isArray(pergunta.anexos) ? pergunta.anexos : [];
|
||||
|
||||
return (
|
||||
<div key={pergunta.id} className="rounded-md border p-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<p className="text-sm font-medium">{pergunta.rotulo}</p>
|
||||
{!texto && anexos.length === 0 ? <Badge variant="outline">Em branco</Badge> : null}
|
||||
</div>
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">
|
||||
{texto || "Sem resposta informada."}
|
||||
</p>
|
||||
{anexos.length > 0 ? (
|
||||
<ul className="mt-3 space-y-1 text-sm text-muted-foreground">
|
||||
{anexos.map((anexo) => (
|
||||
<li key={anexo.id}>
|
||||
<a className="text-primary underline-offset-4 hover:underline" href={`/api/anexos/${anexo.id}`}>
|
||||
{anexo.nomeOriginal}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{briefing.status === "RASCUNHO" ? (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/briefings/${briefing.id}/${secao.ordem}`}>Editar secao</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<aside className="lg:sticky lg:top-6 lg:self-start">
|
||||
{briefing.status === "RASCUNHO" ? (
|
||||
<EnvioBriefingForm
|
||||
briefingId={briefing.id}
|
||||
confirmacaoCliente={String(confirmacaoCliente ?? "")}
|
||||
perguntasEmBranco={perguntasEmBranco}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Briefing enviado</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
Este briefing foi enviado e esta bloqueado para edicao.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { criarBriefingParaCliente } from "@/lib/briefing-service";
|
||||
|
||||
export async function iniciarBriefing() {
|
||||
const briefing = await criarBriefingParaCliente();
|
||||
redirect(`/briefings/${briefing.id}`);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { auth } from "@/auth";
|
||||
import { sair } from "@/app/(auth)/actions";
|
||||
import { iniciarBriefing } from "@/app/(cliente)/briefings/actions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { listarBriefingsDoCliente } from "@/lib/briefing-service";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function BriefingsPage() {
|
||||
const session = await auth();
|
||||
const briefings = await listarBriefingsDoCliente();
|
||||
|
||||
return (
|
||||
<main className="container py-8">
|
||||
<header className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold">Meus briefings</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{session?.user.email} pode iniciar um briefing e continuar depois.
|
||||
</p>
|
||||
</div>
|
||||
<form action={sair}>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/minha-conta">Minha conta</Link>
|
||||
</Button>
|
||||
<Button variant="outline" type="submit">
|
||||
Sair
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<form action={iniciarBriefing} className="mb-6">
|
||||
<Button type="submit">Iniciar meu briefing</Button>
|
||||
</form>
|
||||
|
||||
{briefings.length === 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Nenhum briefing iniciado</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
Inicie seu primeiro briefing para organizar as informacoes do projeto.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{briefings.map((briefing) => (
|
||||
<Card key={briefing.id}>
|
||||
<CardHeader className="gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{briefing.titulo}</CardTitle>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Atualizado em {briefing.atualizadoEm.toLocaleDateString("pt-BR")}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="muted">{briefing.status.replace("_", " ")}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/briefings/${briefing.id}`}>Abrir briefing</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { getClienteConta } from "@/lib/account-service";
|
||||
import { db } from "@/lib/db";
|
||||
import { normalizeWhatsapp } from "@/lib/validators";
|
||||
|
||||
export type ContaState = {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const atualizarContaSchema = z.object({
|
||||
nomeCompleto: z.string().trim().min(3),
|
||||
whatsapp: z.string().trim().min(10),
|
||||
razaoSocial: z.string().trim().optional(),
|
||||
cep: z.string().trim().optional(),
|
||||
logradouro: z.string().trim().optional(),
|
||||
numero: z.string().trim().optional(),
|
||||
complemento: z.string().trim().optional(),
|
||||
bairro: z.string().trim().optional(),
|
||||
cidade: z.string().trim().optional(),
|
||||
uf: z.string().trim().max(2).optional()
|
||||
});
|
||||
|
||||
export async function atualizarConta(
|
||||
_state: ContaState,
|
||||
formData: FormData
|
||||
): Promise<ContaState> {
|
||||
const cliente = await getClienteConta();
|
||||
const parsed = atualizarContaSchema.safeParse({
|
||||
nomeCompleto: formData.get("nomeCompleto"),
|
||||
whatsapp: formData.get("whatsapp"),
|
||||
razaoSocial: formData.get("razaoSocial") || undefined,
|
||||
cep: formData.get("cep") || undefined,
|
||||
logradouro: formData.get("logradouro") || undefined,
|
||||
numero: formData.get("numero") || undefined,
|
||||
complemento: formData.get("complemento") || undefined,
|
||||
bairro: formData.get("bairro") || undefined,
|
||||
cidade: formData.get("cidade") || undefined,
|
||||
uf: formData.get("uf") || undefined
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { ok: false, message: "Revise os dados informados." };
|
||||
}
|
||||
|
||||
const whatsapp = normalizeWhatsapp(parsed.data.whatsapp);
|
||||
|
||||
if (!whatsapp) {
|
||||
return { ok: false, message: "Informe um WhatsApp com DDD." };
|
||||
}
|
||||
|
||||
await db.cliente.update({
|
||||
where: { id: cliente.id },
|
||||
data: {
|
||||
nomeCompleto: parsed.data.nomeCompleto,
|
||||
whatsapp,
|
||||
razaoSocial: parsed.data.razaoSocial ?? null,
|
||||
cep: parsed.data.cep ?? null,
|
||||
logradouro: parsed.data.logradouro ?? null,
|
||||
numero: parsed.data.numero ?? null,
|
||||
complemento: parsed.data.complemento ?? null,
|
||||
bairro: parsed.data.bairro ?? null,
|
||||
cidade: parsed.data.cidade ?? null,
|
||||
uf: parsed.data.uf?.toUpperCase() ?? null
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePath("/minha-conta");
|
||||
return { ok: true, message: "Dados atualizados." };
|
||||
}
|
||||
|
||||
export async function solicitarExclusao(formData: FormData) {
|
||||
const cliente = await getClienteConta();
|
||||
const motivo = String(formData.get("motivo") ?? "").trim();
|
||||
|
||||
await db.solicitacaoExclusao.create({
|
||||
data: {
|
||||
clienteId: cliente.id,
|
||||
motivo: motivo || null
|
||||
}
|
||||
});
|
||||
|
||||
revalidatePath("/minha-conta");
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useFormState, useFormStatus } from "react-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { atualizarConta, type ContaState } from "./actions";
|
||||
|
||||
type Props = {
|
||||
cliente: {
|
||||
nomeCompleto: string;
|
||||
whatsapp: string;
|
||||
razaoSocial: string | null;
|
||||
cep: string | null;
|
||||
logradouro: string | null;
|
||||
numero: string | null;
|
||||
complemento: string | null;
|
||||
bairro: string | null;
|
||||
cidade: string | null;
|
||||
uf: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
const initialState: ContaState = { ok: false };
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
return <Button type="submit" disabled={pending}>{pending ? "Salvando..." : "Salvar dados"}</Button>;
|
||||
}
|
||||
|
||||
export function ContaForm({ cliente }: Props) {
|
||||
const [state, action] = useFormState(atualizarConta, initialState);
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-4">
|
||||
{state.message ? (
|
||||
<Alert variant={state.ok ? "default" : "destructive"}>
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="nomeCompleto">Nome completo</Label>
|
||||
<Input id="nomeCompleto" name="nomeCompleto" defaultValue={cliente.nomeCompleto} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="whatsapp">WhatsApp</Label>
|
||||
<Input id="whatsapp" name="whatsapp" defaultValue={cliente.whatsapp} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="razaoSocial">Razao social</Label>
|
||||
<Input id="razaoSocial" name="razaoSocial" defaultValue={cliente.razaoSocial ?? ""} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cep">CEP</Label>
|
||||
<Input id="cep" name="cep" defaultValue={cliente.cep ?? ""} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="logradouro">Logradouro</Label>
|
||||
<Input id="logradouro" name="logradouro" defaultValue={cliente.logradouro ?? ""} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="numero">Numero</Label>
|
||||
<Input id="numero" name="numero" defaultValue={cliente.numero ?? ""} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="complemento">Complemento</Label>
|
||||
<Input id="complemento" name="complemento" defaultValue={cliente.complemento ?? ""} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bairro">Bairro</Label>
|
||||
<Input id="bairro" name="bairro" defaultValue={cliente.bairro ?? ""} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cidade">Cidade</Label>
|
||||
<Input id="cidade" name="cidade" defaultValue={cliente.cidade ?? ""} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="uf">UF</Label>
|
||||
<Input id="uf" name="uf" defaultValue={cliente.uf ?? ""} maxLength={2} />
|
||||
</div>
|
||||
</div>
|
||||
<SubmitButton />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Link from "next/link";
|
||||
import { solicitarExclusao } from "@/app/(cliente)/minha-conta/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getClienteConta } from "@/lib/account-service";
|
||||
import { ContaForm } from "./conta-form";
|
||||
|
||||
export default async function MinhaContaPage() {
|
||||
const cliente = await getClienteConta();
|
||||
|
||||
return (
|
||||
<main className="container py-8">
|
||||
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold">Minha conta</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Gerencie seus dados e direitos LGPD.</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/briefings">Voltar</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Dados cadastrais</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ContaForm cliente={cliente} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Meus dados</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
Baixe uma copia em JSON dos dados associados a sua conta.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<a href="/api/minha-conta/exportar">Baixar JSON</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Solicitar exclusao</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action={solicitarExclusao} className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="motivo">Motivo opcional</Label>
|
||||
<Textarea id="motivo" name="motivo" />
|
||||
</div>
|
||||
<Button type="submit" variant="outline">Solicitar exclusao</Button>
|
||||
</form>
|
||||
{cliente.solicitacoesExclusao.length > 0 ? (
|
||||
<div className="mt-4 space-y-2">
|
||||
{cliente.solicitacoesExclusao.map((solicitacao) => (
|
||||
<p key={solicitacao.id} className="text-sm text-muted-foreground">
|
||||
{solicitacao.status} em {solicitacao.criadoEm.toLocaleDateString("pt-BR")}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use server";
|
||||
|
||||
import argon2 from "argon2";
|
||||
import { redirect } from "next/navigation";
|
||||
import { db } from "@/lib/db";
|
||||
import { encryptCpf, hashCpf, onlyDigits } from "@/lib/crypto";
|
||||
import { cadastroSchema, normalizeWhatsapp } from "@/lib/validators";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export type CadastroState = {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function cadastrarCliente(_state: CadastroState, formData: FormData): Promise<CadastroState> {
|
||||
const emailForLimit = formData.get("email")?.toString().trim().toLowerCase() ?? "sem-email";
|
||||
const limit = checkRateLimit(`cadastro:${emailForLimit}`, 5, 60 * 60 * 1000);
|
||||
|
||||
if (!limit.allowed) {
|
||||
return { ok: false, message: "Muitas tentativas. Tente novamente mais tarde." };
|
||||
}
|
||||
|
||||
const parsed = cadastroSchema.safeParse({
|
||||
nomeCompleto: formData.get("nomeCompleto"),
|
||||
cpf: formData.get("cpf"),
|
||||
whatsapp: formData.get("whatsapp"),
|
||||
email: formData.get("email"),
|
||||
senha: formData.get("senha"),
|
||||
confirmarSenha: formData.get("confirmarSenha"),
|
||||
tipo: formData.get("tipo"),
|
||||
razaoSocial: formData.get("razaoSocial") || undefined,
|
||||
cnpj: formData.get("cnpj") || undefined,
|
||||
cep: formData.get("cep") || undefined,
|
||||
logradouro: formData.get("logradouro") || undefined,
|
||||
numero: formData.get("numero") || undefined,
|
||||
complemento: formData.get("complemento") || undefined,
|
||||
bairro: formData.get("bairro") || undefined,
|
||||
cidade: formData.get("cidade") || undefined,
|
||||
uf: formData.get("uf") || undefined,
|
||||
lgpdAceite: formData.get("lgpdAceite")
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
message: parsed.error.issues[0]?.message ?? "Revise os dados informados."
|
||||
};
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const cpfHash = hashCpf(data.cpf);
|
||||
const whatsapp = normalizeWhatsapp(data.whatsapp);
|
||||
|
||||
if (!whatsapp) {
|
||||
return { ok: false, message: "Informe um WhatsApp com DDD." };
|
||||
}
|
||||
|
||||
try {
|
||||
await db.$transaction(async (tx) => {
|
||||
const exists = await tx.user.findFirst({
|
||||
where: {
|
||||
OR: [{ email: data.email }, { cliente: { cpfHash } }]
|
||||
},
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
if (exists) {
|
||||
throw new Error("DUPLICADO");
|
||||
}
|
||||
|
||||
await tx.user.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
senhaHash: await argon2.hash(data.senha),
|
||||
papel: "CLIENTE",
|
||||
cliente: {
|
||||
create: {
|
||||
nomeCompleto: data.nomeCompleto,
|
||||
cpfCifrado: encryptCpf(data.cpf),
|
||||
cpfHash,
|
||||
whatsapp,
|
||||
tipo: data.tipo,
|
||||
razaoSocial: data.tipo === "PJ" ? data.razaoSocial : null,
|
||||
cnpj: data.tipo === "PJ" && data.cnpj ? onlyDigits(data.cnpj) : null,
|
||||
cep: data.cep ? onlyDigits(data.cep) : null,
|
||||
logradouro: data.logradouro || null,
|
||||
numero: data.numero || null,
|
||||
complemento: data.complemento || null,
|
||||
bairro: data.bairro || null,
|
||||
cidade: data.cidade || null,
|
||||
uf: data.uf || null,
|
||||
lgpdAceiteEm: new Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "DUPLICADO") {
|
||||
return {
|
||||
ok: false,
|
||||
message: "Nao foi possivel concluir o cadastro com os dados informados."
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
message: "Nao foi possivel concluir o cadastro agora. Tente novamente."
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await sendMail({
|
||||
to: data.email,
|
||||
subject: "Cadastro criado - Briefing SeConectai",
|
||||
text: [
|
||||
"Seu cadastro foi criado com sucesso.",
|
||||
"",
|
||||
"Acesse o sistema para iniciar ou acompanhar seu briefing.",
|
||||
"",
|
||||
"Este e-mail nao inclui CPF completo nem senhas."
|
||||
].join("\n")
|
||||
});
|
||||
} catch {
|
||||
// Falha de e-mail nao desfaz cadastro.
|
||||
}
|
||||
|
||||
redirect("/login");
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useFormState, useFormStatus } from "react-dom";
|
||||
import { useState } from "react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cadastrarCliente, type CadastroState } from "./actions";
|
||||
|
||||
const initialState: CadastroState = { ok: false };
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button type="submit" className="w-full" disabled={pending}>
|
||||
{pending ? "Criando cadastro..." : "Criar cadastro"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function CadastroForm() {
|
||||
const [state, action] = useFormState(cadastrarCliente, initialState);
|
||||
const [tipo, setTipo] = useState<"PF" | "PJ">("PF");
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-5">
|
||||
{state.message ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<input type="hidden" name="tipo" value={tipo} />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="nomeCompleto">Nome completo</Label>
|
||||
<Input id="nomeCompleto" name="nomeCompleto" autoComplete="name" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cpf">CPF</Label>
|
||||
<Input id="cpf" name="cpf" inputMode="numeric" autoComplete="off" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="whatsapp">WhatsApp</Label>
|
||||
<Input id="whatsapp" name="whatsapp" inputMode="tel" autoComplete="tel" required />
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="email">E-mail</Label>
|
||||
<Input id="email" name="email" type="email" autoComplete="email" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="senha">Senha</Label>
|
||||
<Input id="senha" name="senha" type="password" autoComplete="new-password" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmarSenha">Confirmar senha</Label>
|
||||
<Input id="confirmarSenha" name="confirmarSenha" type="password" autoComplete="new-password" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset className="space-y-3 rounded-lg border p-4">
|
||||
<legend className="px-1 text-sm font-medium">Este site e para uma empresa?</legend>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Button type="button" variant={tipo === "PF" ? "default" : "outline"} onClick={() => setTipo("PF")}>
|
||||
Nao
|
||||
</Button>
|
||||
<Button type="button" variant={tipo === "PJ" ? "default" : "outline"} onClick={() => setTipo("PJ")}>
|
||||
Sim
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{tipo === "PJ" ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="razaoSocial">Razao social</Label>
|
||||
<Input id="razaoSocial" name="razaoSocial" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cnpj">CNPJ</Label>
|
||||
<Input id="cnpj" name="cnpj" inputMode="numeric" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cep">CEP</Label>
|
||||
<Input id="cep" name="cep" inputMode="numeric" autoComplete="postal-code" />
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="logradouro">Logradouro</Label>
|
||||
<Input id="logradouro" name="logradouro" autoComplete="address-line1" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="numero">Numero</Label>
|
||||
<Input id="numero" name="numero" autoComplete="address-line2" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="complemento">Complemento</Label>
|
||||
<Input id="complemento" name="complemento" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bairro">Bairro</Label>
|
||||
<Input id="bairro" name="bairro" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cidade">Cidade</Label>
|
||||
<Input id="cidade" name="cidade" autoComplete="address-level2" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="uf">UF</Label>
|
||||
<Input id="uf" name="uf" maxLength={2} autoComplete="address-level1" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="flex items-start gap-3 rounded-lg border p-4 text-sm leading-6">
|
||||
<Checkbox name="lgpdAceite" required />
|
||||
<span>
|
||||
Li e aceito a Politica de Privacidade e autorizo o tratamento dos dados para criacao e acompanhamento do briefing.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<SubmitButton />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CadastroForm } from "./cadastro-form";
|
||||
|
||||
export default function CadastroPage() {
|
||||
return (
|
||||
<main className="container flex min-h-screen items-center justify-center py-8">
|
||||
<Card className="w-full max-w-2xl shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Crie seu cadastro</CardTitle>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
Depois do cadastro, voce podera iniciar e continuar seu briefing de site.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CadastroForm />
|
||||
<p className="mt-4 text-center text-xs leading-5 text-muted-foreground">
|
||||
Ao criar cadastro, consulte tambem a{" "}
|
||||
<Link href="/privacidade" className="text-primary underline-offset-4 hover:underline">
|
||||
Politica de Privacidade
|
||||
</Link>{" "}
|
||||
e os{" "}
|
||||
<Link href="/termos" className="text-primary underline-offset-4 hover:underline">
|
||||
Termos de Uso
|
||||
</Link>.
|
||||
</p>
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||
Ja tem cadastro?{" "}
|
||||
<Link href="/login" className="font-medium text-primary underline-offset-4 hover:underline">
|
||||
Entrar
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use server";
|
||||
|
||||
import { AuthError } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { signIn } from "@/auth";
|
||||
|
||||
export type LoginState = {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function login(_state: LoginState, formData: FormData): Promise<LoginState> {
|
||||
const callbackUrl = formData.get("callbackUrl")?.toString();
|
||||
|
||||
try {
|
||||
await signIn("credentials", {
|
||||
email: formData.get("email"),
|
||||
senha: formData.get("senha"),
|
||||
redirectTo: callbackUrl || undefined
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "E-mail ou senha invalidos."
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
redirect(callbackUrl || "/briefings");
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useFormState, useFormStatus } from "react-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { login, type LoginState } from "./actions";
|
||||
|
||||
const initialState: LoginState = { ok: false };
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button type="submit" className="w-full" disabled={pending}>
|
||||
{pending ? "Entrando..." : "Entrar"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const [state, action] = useFormState(login, initialState);
|
||||
const callbackUrl = searchParams.get("callbackUrl") ?? "";
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-4">
|
||||
<input type="hidden" name="callbackUrl" value={callbackUrl} />
|
||||
{state.message ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-mail</Label>
|
||||
<Input id="email" name="email" type="email" autoComplete="email" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="senha">Senha</Label>
|
||||
<Input id="senha" name="senha" type="password" autoComplete="current-password" required />
|
||||
</div>
|
||||
<SubmitButton />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { LoginForm } from "./login-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<main className="container flex min-h-screen items-center justify-center py-8">
|
||||
<Card className="w-full max-w-md shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Entrar no briefing</CardTitle>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
Acesse sua area para preencher ou acompanhar o briefing.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LoginForm />
|
||||
<div className="mt-6 flex items-center justify-between text-sm">
|
||||
<Link href="/recuperar-senha" className="text-primary underline-offset-4 hover:underline">
|
||||
Esqueci minha senha
|
||||
</Link>
|
||||
<Link href="/cadastro" className="text-primary underline-offset-4 hover:underline">
|
||||
Criar cadastro
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function PrivacidadePage() {
|
||||
return (
|
||||
<main className="container max-w-3xl py-10">
|
||||
<Link href="/" className="text-sm font-medium text-primary underline-offset-4 hover:underline">
|
||||
Voltar
|
||||
</Link>
|
||||
<article className="mt-8 space-y-5 rounded-lg border bg-card p-6">
|
||||
<h1 className="text-3xl font-semibold">Politica de Privacidade</h1>
|
||||
<p className="rounded-md border bg-muted p-3 text-sm font-medium">
|
||||
Texto-base: revisar com assessoria juridica antes de publicar em producao.
|
||||
</p>
|
||||
<p className="text-sm leading-7 text-muted-foreground">
|
||||
Texto-base para revisao juridica. Os dados informados no cadastro e no briefing sao usados para identificar o cliente,
|
||||
organizar o projeto e permitir o acompanhamento pela SeConectai.
|
||||
</p>
|
||||
<p className="text-sm leading-7 text-muted-foreground">
|
||||
Dados sensiveis, como CPF, devem ser protegidos por criptografia e nunca devem aparecer em logs, e-mails ou listagens
|
||||
publicas do sistema.
|
||||
</p>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use server";
|
||||
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
import { db } from "@/lib/db";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export type RecuperarSenhaState = {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export async function solicitarRecuperacao(
|
||||
_state: RecuperarSenhaState,
|
||||
formData: FormData
|
||||
): Promise<RecuperarSenhaState> {
|
||||
const email = formData.get("email")?.toString().trim().toLowerCase();
|
||||
const genericMessage = "Se o e-mail estiver cadastrado, enviaremos as instrucoes de redefinicao.";
|
||||
const limit = checkRateLimit(`recuperar-senha:${email ?? "sem-email"}`, 5, 60 * 60 * 1000);
|
||||
|
||||
if (!limit.allowed) {
|
||||
return { ok: true, message: genericMessage };
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
return { ok: true, message: genericMessage };
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({
|
||||
where: { email },
|
||||
select: { id: true, email: true }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return { ok: true, message: genericMessage };
|
||||
}
|
||||
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const tokenHash = hashToken(token);
|
||||
const expiraEm = new Date(Date.now() + 60 * 60 * 1000);
|
||||
|
||||
await db.passwordResetToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tokenHash,
|
||||
expiraEm
|
||||
}
|
||||
});
|
||||
|
||||
const baseUrl = process.env.APP_URL ?? "http://localhost:3000";
|
||||
const resetUrl = `${baseUrl}/redefinir-senha?token=${token}`;
|
||||
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: "Redefinicao de senha - Briefing SeConectai",
|
||||
text: `Use o link abaixo para redefinir sua senha. Ele expira em 1 hora.\n\n${resetUrl}`
|
||||
});
|
||||
|
||||
return { ok: true, message: genericMessage };
|
||||
}
|
||||
|
||||
export { hashToken };
|
||||
@@ -0,0 +1,26 @@
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { RecuperarSenhaForm } from "./recuperar-senha-form";
|
||||
|
||||
export default function RecuperarSenhaPage() {
|
||||
return (
|
||||
<main className="container flex min-h-screen items-center justify-center py-8">
|
||||
<Card className="w-full max-w-md shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Recuperar senha</CardTitle>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
Informe seu e-mail para receber as instrucoes de redefinicao.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RecuperarSenhaForm />
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||
<Link href="/login" className="text-primary underline-offset-4 hover:underline">
|
||||
Voltar para o login
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useFormState, useFormStatus } from "react-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { solicitarRecuperacao, type RecuperarSenhaState } from "./actions";
|
||||
|
||||
const initialState: RecuperarSenhaState = { ok: false };
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button type="submit" className="w-full" disabled={pending}>
|
||||
{pending ? "Enviando..." : "Enviar instrucoes"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecuperarSenhaForm() {
|
||||
const [state, action] = useFormState(solicitarRecuperacao, initialState);
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-4">
|
||||
{state.message ? (
|
||||
<Alert>
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-mail</Label>
|
||||
<Input id="email" name="email" type="email" autoComplete="email" required />
|
||||
</div>
|
||||
<SubmitButton />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use server";
|
||||
|
||||
import argon2 from "argon2";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { hashToken } from "@/app/(publico)/recuperar-senha/actions";
|
||||
|
||||
const redefinirSenhaSchema = z
|
||||
.object({
|
||||
token: z.string().min(1),
|
||||
senha: z.string().min(8, "A senha precisa ter pelo menos 8 caracteres."),
|
||||
confirmarSenha: z.string()
|
||||
})
|
||||
.refine((data) => data.senha === data.confirmarSenha, {
|
||||
path: ["confirmarSenha"],
|
||||
message: "As senhas nao conferem."
|
||||
});
|
||||
|
||||
export type RedefinirSenhaState = {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export async function redefinirSenha(
|
||||
_state: RedefinirSenhaState,
|
||||
formData: FormData
|
||||
): Promise<RedefinirSenhaState> {
|
||||
const parsed = redefinirSenhaSchema.safeParse({
|
||||
token: formData.get("token"),
|
||||
senha: formData.get("senha"),
|
||||
confirmarSenha: formData.get("confirmarSenha")
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
message: parsed.error.issues[0]?.message ?? "Revise os dados informados."
|
||||
};
|
||||
}
|
||||
|
||||
const tokenHash = hashToken(parsed.data.token);
|
||||
const resetToken = await db.passwordResetToken.findUnique({
|
||||
where: { tokenHash },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
expiraEm: true,
|
||||
usadoEm: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!resetToken || resetToken.usadoEm || resetToken.expiraEm < new Date()) {
|
||||
return { ok: false, message: "Link invalido ou expirado." };
|
||||
}
|
||||
|
||||
await db.$transaction([
|
||||
db.user.update({
|
||||
where: { id: resetToken.userId },
|
||||
data: { senhaHash: await argon2.hash(parsed.data.senha) }
|
||||
}),
|
||||
db.passwordResetToken.update({
|
||||
where: { id: resetToken.id },
|
||||
data: { usadoEm: new Date() }
|
||||
})
|
||||
]);
|
||||
|
||||
redirect("/login");
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { RedefinirSenhaForm } from "./redefinir-senha-form";
|
||||
|
||||
export default function RedefinirSenhaPage({
|
||||
searchParams
|
||||
}: {
|
||||
searchParams: { token?: string };
|
||||
}) {
|
||||
const token = searchParams.token ?? "";
|
||||
|
||||
return (
|
||||
<main className="container flex min-h-screen items-center justify-center py-8">
|
||||
<Card className="w-full max-w-md shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Redefinir senha</CardTitle>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
Crie uma nova senha para acessar o briefing.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{token ? (
|
||||
<RedefinirSenhaForm token={token} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Link de redefinicao invalido.</p>
|
||||
)}
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||
<Link href="/login" className="text-primary underline-offset-4 hover:underline">
|
||||
Voltar para o login
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useFormState, useFormStatus } from "react-dom";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { redefinirSenha, type RedefinirSenhaState } from "./actions";
|
||||
|
||||
const initialState: RedefinirSenhaState = { ok: false };
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button type="submit" className="w-full" disabled={pending}>
|
||||
{pending ? "Salvando..." : "Redefinir senha"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function RedefinirSenhaForm({ token }: { token: string }) {
|
||||
const [state, action] = useFormState(redefinirSenha, initialState);
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-4">
|
||||
<input type="hidden" name="token" value={token} />
|
||||
{state.message ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{state.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="senha">Nova senha</Label>
|
||||
<Input id="senha" name="senha" type="password" autoComplete="new-password" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmarSenha">Confirmar nova senha</Label>
|
||||
<Input id="confirmarSenha" name="confirmarSenha" type="password" autoComplete="new-password" required />
|
||||
</div>
|
||||
<SubmitButton />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function TermosPage() {
|
||||
return (
|
||||
<main className="container max-w-3xl py-10">
|
||||
<Link href="/" className="text-sm font-medium text-primary underline-offset-4 hover:underline">
|
||||
Voltar
|
||||
</Link>
|
||||
<article className="mt-8 space-y-5 rounded-lg border bg-card p-6">
|
||||
<h1 className="text-3xl font-semibold">Termos de Uso</h1>
|
||||
<p className="rounded-md border bg-muted p-3 text-sm font-medium">
|
||||
Texto-base: revisar com assessoria juridica antes de publicar em producao.
|
||||
</p>
|
||||
<p className="text-sm leading-7 text-muted-foreground">
|
||||
Texto-base para revisao juridica. O sistema deve ser usado para preenchimento do briefing de criacao de site, sem envio
|
||||
de senhas ou acessos sensiveis.
|
||||
</p>
|
||||
<p className="text-sm leading-7 text-muted-foreground">
|
||||
Informacoes enviadas pelo cliente podem orientar escopo, prazos e prioridades do projeto. Mudancas relevantes depois do
|
||||
envio podem exigir reavaliacao.
|
||||
</p>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createReadStream } from "fs";
|
||||
import { stat } from "fs/promises";
|
||||
import { NextResponse } from "next/server";
|
||||
import { Readable } from "stream";
|
||||
import { getAnexoAutorizado } from "@/lib/anexo-service";
|
||||
import { db } from "@/lib/db";
|
||||
import { assertSameOrigin } from "@/lib/request-security";
|
||||
import { removeStoredFile } from "@/lib/storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function safeFileName(name: string) {
|
||||
return name.replace(/["\r\n]/g, "_");
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: { id: string } }) {
|
||||
const anexo = await getAnexoAutorizado(params.id);
|
||||
|
||||
if (!anexo) {
|
||||
return NextResponse.json({ message: "Anexo nao encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStat = await stat(anexo.caminho);
|
||||
const stream = Readable.toWeb(createReadStream(anexo.caminho)) as ReadableStream;
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": anexo.mime,
|
||||
"Content-Length": String(fileStat.size),
|
||||
"Content-Disposition": `attachment; filename="${safeFileName(anexo.nomeOriginal)}"`,
|
||||
"X-Content-Type-Options": "nosniff"
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ message: "Arquivo nao encontrado no armazenamento." }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
|
||||
const originError = assertSameOrigin(request);
|
||||
if (originError) return originError;
|
||||
|
||||
const anexo = await getAnexoAutorizado(params.id);
|
||||
|
||||
if (!anexo) {
|
||||
return NextResponse.json({ message: "Anexo nao encontrado." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (anexo.briefing.status !== "RASCUNHO") {
|
||||
return NextResponse.json(
|
||||
{ message: "Briefing enviado nao aceita remocao de anexos." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.anexo.delete({
|
||||
where: { id: anexo.id }
|
||||
});
|
||||
await removeStoredFile(anexo.caminho);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { getBriefingPerguntaParaUpload } from "@/lib/anexo-service";
|
||||
import { assertSameOrigin, rateLimitResponse } from "@/lib/request-security";
|
||||
import { removeStoredFile, validateAndStoreUpload } from "@/lib/storage";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{
|
||||
params
|
||||
}: {
|
||||
params: { briefingId: string; perguntaId: string };
|
||||
}
|
||||
) {
|
||||
const originError = assertSameOrigin(request);
|
||||
if (originError) return originError;
|
||||
|
||||
const rateLimitError = rateLimitResponse(`upload:${params.briefingId}:${params.perguntaId}`, 30, 60 * 60 * 1000);
|
||||
if (rateLimitError) return rateLimitError;
|
||||
|
||||
const { briefing } = await getBriefingPerguntaParaUpload(params.briefingId, params.perguntaId);
|
||||
|
||||
if (briefing.status !== "RASCUNHO") {
|
||||
return NextResponse.json(
|
||||
{ message: "Briefing enviado nao aceita novos anexos." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
const count = await db.anexo.count({
|
||||
where: {
|
||||
briefingId: params.briefingId,
|
||||
perguntaId: params.perguntaId
|
||||
}
|
||||
});
|
||||
|
||||
if (count >= 20) {
|
||||
return NextResponse.json(
|
||||
{ message: "Limite de 20 arquivos por pergunta atingido." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ message: "Arquivo nao enviado." }, { status: 400 });
|
||||
}
|
||||
|
||||
let storedFile: Awaited<ReturnType<typeof validateAndStoreUpload>> | null = null;
|
||||
|
||||
try {
|
||||
storedFile = await validateAndStoreUpload(file);
|
||||
|
||||
const anexo = await db.anexo.create({
|
||||
data: {
|
||||
briefingId: params.briefingId,
|
||||
perguntaId: params.perguntaId,
|
||||
nomeOriginal: storedFile.originalName,
|
||||
caminho: storedFile.storedPath,
|
||||
mime: storedFile.mime,
|
||||
tamanho: storedFile.size
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
nomeOriginal: true,
|
||||
mime: true,
|
||||
tamanho: true
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ anexo });
|
||||
} catch (error) {
|
||||
if (storedFile) {
|
||||
await removeStoredFile(storedFile.storedPath);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: error instanceof Error ? error.message : "Nao foi possivel enviar o arquivo." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { gerarBriefingPdfBuffer } from "@/lib/briefing-pdf";
|
||||
import { getBriefingPdfData } from "@/lib/pdf-data";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function safeFileName(value: string) {
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-zA-Z0-9-_]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: { id: string } }) {
|
||||
const data = await getBriefingPdfData(params.id);
|
||||
const buffer = await gerarBriefingPdfBuffer(data);
|
||||
const fileName = `${safeFileName(data.briefing.titulo || "briefing")}.pdf`;
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="${fileName}"`,
|
||||
"X-Content-Type-Options": "nosniff"
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await db.$queryRaw`SELECT 1`;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
app: "ok",
|
||||
db: "ok",
|
||||
checkedAt: new Date().toISOString()
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
app: "ok",
|
||||
db: "error",
|
||||
checkedAt: new Date().toISOString()
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getExportacaoDadosCliente } from "@/lib/account-service";
|
||||
|
||||
export async function GET() {
|
||||
const data = await getExportacaoDadosCliente();
|
||||
|
||||
return new NextResponse(JSON.stringify(data, null, 2), {
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Disposition": "attachment; filename=\"meus-dados-briefing.json\"",
|
||||
"X-Content-Type-Options": "nosniff"
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 42 33% 97%;
|
||||
--foreground: 210 22% 16%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 210 22% 16%;
|
||||
--primary: 184 73% 28%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 31 85% 55%;
|
||||
--secondary-foreground: 210 22% 16%;
|
||||
--muted: 190 18% 91%;
|
||||
--muted-foreground: 210 10% 40%;
|
||||
--accent: 160 37% 88%;
|
||||
--accent-foreground: 184 73% 20%;
|
||||
--destructive: 0 72% 51%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 190 18% 84%;
|
||||
--input: 190 18% 84%;
|
||||
--ring: 184 73% 28%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Briefing de Site | SeConectai",
|
||||
description: "Sistema de briefing para criação de sites da SeConectai."
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="pt-BR">
|
||||
<body className={inter.className}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ArrowRight, CheckCircle2, FileText, ShieldCheck } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
const etapas = [
|
||||
"Cadastro seguro",
|
||||
"Briefing guiado",
|
||||
"Ajuda por pergunta",
|
||||
"Envio para análise"
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="min-h-screen">
|
||||
<section className="container grid min-h-screen gap-10 py-8 lg:grid-cols-[1fr_420px] lg:items-center lg:py-14">
|
||||
<div className="max-w-3xl">
|
||||
<div className="mb-6 inline-flex items-center gap-2 rounded-full border bg-card px-3 py-1 text-sm text-muted-foreground">
|
||||
<ShieldCheck className="h-4 w-4 text-primary" />
|
||||
Dados organizados, rascunho salvo e sem envio de senhas.
|
||||
</div>
|
||||
<h1 className="text-4xl font-semibold tracking-normal text-foreground sm:text-5xl lg:text-6xl">
|
||||
Briefing de Site SeConectai
|
||||
</h1>
|
||||
<p className="mt-5 max-w-2xl text-lg leading-8 text-muted-foreground">
|
||||
Um formulário guiado para reunir as informações do projeto com clareza,
|
||||
contexto e segurança antes do desenvolvimento do site.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<Button size="lg" className="gap-2" asChild>
|
||||
<Link href="/login">
|
||||
Entrar no sistema
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" asChild>
|
||||
<Link href="/cadastro">Criar cadastro</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border bg-card shadow-sm">
|
||||
<CardHeader>
|
||||
<div className="mb-3 flex h-11 w-11 items-center justify-center rounded-md bg-accent text-primary">
|
||||
<FileText className="h-6 w-6" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Como o briefing avança</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Progress value={25} aria-label="Progresso demonstrativo do briefing" />
|
||||
<div className="space-y-4">
|
||||
{etapas.map((etapa) => (
|
||||
<div key={etapa} className="flex items-center gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 flex-none text-primary" />
|
||||
<span className="text-sm font-medium">{etapa}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="rounded-md bg-muted p-4 text-sm leading-6 text-muted-foreground">
|
||||
A estrutura inicial está pronta para evoluir em etapas: banco,
|
||||
autenticação, formulário dirigido por dados, anexos e painel admin.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user