novas features, geracao pdf, regras de usuarios, novo perfil de acesso

This commit is contained in:
Vitex Tecnologia
2026-05-13 17:43:25 -03:00
parent 343aae602f
commit 517c9751bc
22 changed files with 1945 additions and 253 deletions
@@ -1,9 +1,11 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import {
AlertCircle,
ArrowLeft,
CheckCircle2,
ExternalLink,
FileText,
ListChecks,
Loader2,
Pencil,
@@ -17,6 +19,7 @@ import {
Wallet,
} from "lucide-react";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -30,6 +33,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
@@ -103,8 +107,55 @@ function sanitizePontuacaoLancamentoDigitando(raw: string): string {
return intPart + sep + fracStr;
}
/** Valor em R$ no modal de lançamento: dígitos + um separador decimal, até 2 casas (pt-BR). */
function sanitizeValorRealLancamentoDigitando(raw: string): string {
const cleaned = raw.replace(/[^\d.,]/g, "");
if (!cleaned) return "";
let int = "";
let frac = "";
let sawSeparator = false;
for (const ch of cleaned) {
if (ch === "," || ch === ".") {
if (!sawSeparator) sawSeparator = true;
continue;
}
if (!sawSeparator) int += ch;
else if (frac.length < 2) frac += ch;
}
if (!sawSeparator) return int;
return frac.length > 0 ? `${int},${frac}` : `${int},`;
}
function parseValorRealMonetarioInput(raw: string): number {
let t = raw.trim().replace(/\s/g, "").replace(/R\$\s?/gi, "");
if (!t) return Number.NaN;
if (t.includes(",") && t.includes(".")) {
t = t.replace(/\./g, "").replace(",", ".");
} else if (t.includes(",")) {
t = t.replace(",", ".");
}
return Number(t);
}
/** Pontuação: no máximo 2 casas decimais, sempre para cima (ex.: 1,1111 → 1,12). Valores já em centésimos exatos (ex.: 1,00) não são inflados por erro de float. */
function pontuacaoParaCimaAte2Casas(value: number): number {
if (!Number.isFinite(value)) return value;
if (value === 0) return 0;
const sign = value < 0 ? -1 : 1;
const m = Math.abs(value);
const scaled = m * 100;
const nearest = Math.round(scaled);
const n =
Math.abs(scaled - nearest) < 1e-7 ? nearest : Math.ceil(scaled - 1e-9);
return sign * (n / 100);
}
function toPontuacaoInput(value: number): string {
const rounded = Math.round((value + Number.EPSILON) * 100) / 100;
if (!Number.isFinite(value)) return "0";
const rounded = pontuacaoParaCimaAte2Casas(value);
return String(rounded);
}
@@ -164,7 +215,7 @@ type FechamentoDetalhesLocationState = {
export default function FechamentoDetalhes() {
const location = useLocation();
const navigate = useNavigate();
const { me } = useAuthAccess();
const { me, papel } = useAuthAccess();
const { id: fechamentoId = "" } = useParams();
const initialState = (location.state as FechamentoDetalhesLocationState | null) ?? null;
const [competenciaId, setCompetenciaId] = useState(initialState?.competenciaId ?? "");
@@ -178,6 +229,7 @@ export default function FechamentoDetalhes() {
const [savingLancamento, setSavingLancamento] = useState(false);
const [isConcluirOpen, setIsConcluirOpen] = useState(false);
const [concluindo, setConcluindo] = useState(false);
const [exportandoPdf, setExportandoPdf] = useState(false);
const [pontuacaoPagaInput, setPontuacaoPagaInput] = useState("");
const [motivoAjuste, setMotivoAjuste] = useState("");
const [isReabrirOpen, setIsReabrirOpen] = useState(false);
@@ -185,10 +237,13 @@ export default function FechamentoDetalhes() {
const [motivoReabertura, setMotivoReabertura] = useState("");
const [lancamentoTipo, setLancamentoTipo] = useState<"bonus" | "desconto">("bonus");
const [lancamentoDescricao, setLancamentoDescricao] = useState("");
const [lancamentoModo, setLancamentoModo] = useState<"pontos" | "real">("pontos");
const [lancamentoPontuacao, setLancamentoPontuacao] = useState("0");
const [lancamentoValorReal, setLancamentoValorReal] = useState("");
const [pontuacaoMeta, setPontuacaoMeta] = useState<number | null>(null);
const [parceiroId, setParceiroId] = useState<string | null>(null);
const [parceiroNome, setParceiroNome] = useState<string | null>(null);
const [parceiroFator, setParceiroFator] = useState<number | null>(null);
const [saldoBancoAtual, setSaldoBancoAtual] = useState<number | null>(null);
const [loadingSaldoBanco, setLoadingSaldoBanco] = useState(false);
const [erroSaldoBanco, setErroSaldoBanco] = useState(false);
@@ -224,7 +279,31 @@ export default function FechamentoDetalhes() {
const isFechado = fechamentoStatus === "fechado";
const isCompetenciaConcluida = competenciaStatus === "concluido";
const isReadonly = Boolean(initialState?.readonlyView) || isFechado || isCompetenciaConcluida;
const isAdmin = me?.papel === "admin";
const caps = me?.capabilities;
const podeTarefaEditarRevisao = caps?.fechamentos.tarefaEditarRevisao ?? false;
const podeTarefaLancarAjuste = caps?.fechamentos.tarefaLancarAjuste ?? false;
const verFatorParceiro = caps?.parceiros.verFator ?? false;
const fatorParceiroValidoParaReal =
parceiroFator != null && Number.isFinite(parceiroFator) && parceiroFator > 0;
/** Quem vê o fator na API sabe se está cadastrado; sem fator não dá para lançar em Real no front. */
const lancamentoEmRealBloqueado = verFatorParceiro && !fatorParceiroValidoParaReal;
const podeReprocessarAsana = caps?.fechamentos.reprocessarAsana ?? false;
const podeConcluirFechamento = caps?.fechamentos.concluir ?? false;
const podeReabrirFechamento = caps?.fechamentos.reabrir ?? false;
const podeExportarPdf = caps?.fechamentos.exportarPdf ?? false;
const supervisorPodeExportarPdf =
podeExportarPdf &&
(papel !== "supervisor" || Boolean(me?.parceiroId && parceiroId != null && parceiroId === me.parceiroId));
const podeAlterarCompetenciaTarefa = caps?.fechamentos.tarefaAlterarCompetencia ?? false;
const podeExcluirLancamentoManual = caps?.fechamentos.tarefaExcluirManual ?? false;
const bloqueioEdicaoTarefa = isReadonly || !podeTarefaEditarRevisao;
const lancamentoPreviewPontos = useMemo(() => {
if (lancamentoModo !== "real" || parceiroFator == null || !(parceiroFator > 0)) return null;
const valor = parseValorRealMonetarioInput(lancamentoValorReal.trim());
if (!Number.isFinite(valor) || valor <= 0) return null;
return pontuacaoParaCimaAte2Casas(valor / (parceiroFator * 3));
}, [lancamentoModo, parceiroFator, lancamentoValorReal]);
const totais = useMemo(() => {
const aprovadas = tarefas.filter((t) => t.estaRevisada);
@@ -322,6 +401,10 @@ export default function FechamentoDetalhes() {
setCompetenciaStatus(competenciaAtual?.status ?? "em_aberto");
setParceiroId(current.parceiroId);
setParceiroNome(current.parceiroNome);
const f = current.parceiroFator;
setParceiroFator(
f !== undefined && f !== null && Number.isFinite(Number(f)) && Number(f) > 0 ? Number(f) : null,
);
if (!competenciaId) {
setCompetenciaId(current.competenciaId);
}
@@ -371,6 +454,11 @@ export default function FechamentoDetalhes() {
}
}, [isFechado]);
useEffect(() => {
if (!isLancamentoOpen || !lancamentoEmRealBloqueado) return;
setLancamentoModo("pontos");
}, [isLancamentoOpen, lancamentoEmRealBloqueado]);
useEffect(() => {
if (isConcluirOpen && parceiroId) {
void loadSaldoBanco(parceiroId);
@@ -499,7 +587,9 @@ export default function FechamentoDetalhes() {
const resetLancamentoForm = () => {
setLancamentoTipo("bonus");
setLancamentoDescricao("");
setLancamentoModo("pontos");
setLancamentoPontuacao("0");
setLancamentoValorReal("");
};
const handleSalvarLancamento = async () => {
@@ -512,27 +602,54 @@ export default function FechamentoDetalhes() {
return;
}
const descricao = lancamentoDescricao.trim();
const pontuacao = (() => {
const n = parsePontuacaoInput(lancamentoPontuacao);
if (!Number.isFinite(n)) return Number.NaN;
return Math.round(n * 100) / 100;
})();
if (!descricao) {
toast.error("Informe a descrição do lançamento.");
return;
}
if (!Number.isFinite(pontuacao) || pontuacao <= 0) {
toast.error("Informe uma pontuação válida maior que zero.");
return;
if (lancamentoModo === "real") {
if (lancamentoEmRealBloqueado) {
toast.error("Primeiro cadastre o fator do parceiro para poder lançar em Real.");
return;
}
const valorNum = parseValorRealMonetarioInput(lancamentoValorReal.trim());
if (!Number.isFinite(valorNum) || valorNum <= 0) {
toast.error("Informe um valor em real válido maior que zero.");
return;
}
} else {
const pontuacao = (() => {
const n = parsePontuacaoInput(lancamentoPontuacao);
if (!Number.isFinite(n)) return Number.NaN;
return pontuacaoParaCimaAte2Casas(n);
})();
if (!Number.isFinite(pontuacao) || pontuacao <= 0) {
toast.error("Informe uma pontuação válida maior que zero.");
return;
}
}
try {
setSavingLancamento(true);
await fechamentoFechamentosService.criarLancamento(fechamentoId, {
tipo: lancamentoTipo,
descricao,
pontuacao,
});
if (lancamentoModo === "real") {
const valorNum = parseValorRealMonetarioInput(lancamentoValorReal.trim());
await fechamentoFechamentosService.criarLancamento(fechamentoId, {
tipo: lancamentoTipo,
descricao,
valorReal: valorNum,
});
} else {
const pontuacao = (() => {
const n = parsePontuacaoInput(lancamentoPontuacao);
if (!Number.isFinite(n)) return Number.NaN;
return pontuacaoParaCimaAte2Casas(n);
})();
await fechamentoFechamentosService.criarLancamento(fechamentoId, {
tipo: lancamentoTipo,
descricao,
pontuacao,
});
}
toast.success("Lançamento incluído com sucesso.");
setIsLancamentoOpen(false);
resetLancamentoForm();
@@ -559,6 +676,29 @@ export default function FechamentoDetalhes() {
setIsConcluirOpen(true);
};
const handleExportarPdf = async () => {
if (!fechamentoId || !isFechado) return;
try {
setExportandoPdf(true);
const { buffer, filename } = await fechamentoFechamentosService.exportarPdf(fechamentoId);
const blob = new Blob([buffer], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename ?? `fechamento-${fechamentoId}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("PDF exportado com sucesso.");
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao exportar PDF.";
toast.error(message);
} finally {
setExportandoPdf(false);
}
};
const handleConcluirFechamento = async () => {
if (isCompetenciaConcluida) {
toastBloqueioCompetenciaConcluida();
@@ -590,6 +730,25 @@ export default function FechamentoDetalhes() {
});
toast.success(`Fechamento concluído. Banco de pontos: ${formatPontosAte2Casas(data.pontuacaoBanco)}.`);
setFechamentoStatus("fechado");
if (supervisorPodeExportarPdf) {
try {
const { buffer, filename } = await fechamentoFechamentosService.exportarPdf(fechamentoId);
const blob = new Blob([buffer], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename ?? `fechamento-${fechamentoId}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("PDF do fechamento baixado.");
} catch {
toast.warning(
"Fechamento concluído, mas o PDF não pôde ser gerado automaticamente. Use Exportar PDF na tela.",
);
}
}
navigate(`/intelligence-score/competencias/${data.competenciaId}`);
} catch (error) {
const message = error instanceof Error ? error.message : "Erro ao concluir fechamento.";
@@ -705,8 +864,8 @@ export default function FechamentoDetalhes() {
};
const openAlterarCompetencia = async (tarefa: FechamentoTarefaItem) => {
if (!isAdmin) {
toast.error("Apenas administradores podem alterar competência.");
if (!podeAlterarCompetenciaTarefa) {
toast.error("Sem permissão para alterar competência da tarefa.");
return;
}
if (isCompetenciaConcluida) {
@@ -768,7 +927,7 @@ export default function FechamentoDetalhes() {
}
const isManual = editingTarefa.tipo === "bonus" || editingTarefa.tipo === "desconto";
const descricao = edicaoDescricao.trim();
const pontuacao = parsePontuacaoInput(edicaoPontuacao);
const pontuacao = pontuacaoParaCimaAte2Casas(parsePontuacaoInput(edicaoPontuacao));
if (!descricao) {
toast.error("Descrição é obrigatória.");
return;
@@ -866,14 +1025,17 @@ export default function FechamentoDetalhes() {
<Button
size="sm"
variant="outline"
onClick={() => setIsLancamentoOpen(true)}
disabled={isReadonly}
onClick={() => {
resetLancamentoForm();
setIsLancamentoOpen(true);
}}
disabled={isReadonly || !podeTarefaLancarAjuste}
className="min-w-[152px] hover:bg-muted/60 hover:text-foreground"
>
<Plus className="mr-2 h-4 w-4" />
Fazer lançamento
</Button>
{!isReadonly ? (
{!isReadonly && podeReprocessarAsana ? (
<Button
size="sm"
variant="outline"
@@ -886,17 +1048,33 @@ export default function FechamentoDetalhes() {
</Button>
) : null}
{isFechado ? (
<Button
size="sm"
variant="outline"
onClick={() => setIsReabrirOpen(true)}
disabled={reabrindo || isCompetenciaConcluida}
className="min-w-[152px]"
>
<RotateCcw className="mr-2 h-4 w-4" />
Reabrir fechamento
</Button>
) : (
<>
{supervisorPodeExportarPdf ? (
<Button
size="sm"
variant="outline"
onClick={() => void handleExportarPdf()}
disabled={exportandoPdf || isCompetenciaConcluida}
className="min-w-[152px] hover:bg-muted/60 hover:text-foreground"
>
<FileText className="mr-2 h-4 w-4" />
{exportandoPdf ? "Gerando PDF..." : "Exportar PDF"}
</Button>
) : null}
{podeReabrirFechamento ? (
<Button
size="sm"
variant="outline"
onClick={() => setIsReabrirOpen(true)}
disabled={reabrindo || isCompetenciaConcluida}
className="min-w-[152px]"
>
<RotateCcw className="mr-2 h-4 w-4" />
Reabrir fechamento
</Button>
) : null}
</>
) : podeConcluirFechamento ? (
<Button
size="sm"
variant="secondary"
@@ -907,7 +1085,7 @@ export default function FechamentoDetalhes() {
<CheckCircle2 className="mr-2 h-4 w-4" />
Concluir fechamento
</Button>
)}
) : null}
</div>
) : null}
</div>
@@ -980,7 +1158,7 @@ export default function FechamentoDetalhes() {
size="sm"
variant="outline"
onClick={() => void handleToggleTodasAprovadas()}
disabled={isReadonly || bulkUpdatingRevisao}
disabled={bloqueioEdicaoTarefa || bulkUpdatingRevisao}
>
{bulkUpdatingRevisao ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -1070,7 +1248,7 @@ export default function FechamentoDetalhes() {
<Checkbox
checked={tarefa.estaRevisada}
onCheckedChange={(checked) => void handleToggleAprovada(tarefa, Boolean(checked))}
disabled={isReadonly || bulkUpdatingRevisao}
disabled={bloqueioEdicaoTarefa || bulkUpdatingRevisao}
/>
</div>
</TableCell>
@@ -1110,18 +1288,18 @@ export default function FechamentoDetalhes() {
size="sm"
variant="ghost"
onClick={() => openEditarTarefa(tarefa)}
disabled={isReadonly}
disabled={bloqueioEdicaoTarefa}
>
<Pencil className="h-4 w-4" />
<span className="ml-1">Editar</span>
</Button>
{isAdmin && tarefa.tipo === "tarefa" ? (
{podeAlterarCompetenciaTarefa && tarefa.tipo === "tarefa" ? (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => void openAlterarCompetencia(tarefa)}
disabled={isReadonly || savingAlteracaoCompetencia}
disabled={bloqueioEdicaoTarefa || savingAlteracaoCompetencia}
>
<Repeat className="h-4 w-4" />
<span className="ml-1">Alterar competência</span>
@@ -1134,7 +1312,7 @@ export default function FechamentoDetalhes() {
variant="ghost"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => void handleExcluirLancamento(tarefa)}
disabled={isReadonly || deletingTaskId === tarefa.id}
disabled={bloqueioEdicaoTarefa || !podeExcluirLancamentoManual || deletingTaskId === tarefa.id}
>
{deletingTaskId === tarefa.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -1163,13 +1341,18 @@ export default function FechamentoDetalhes() {
)}
</div>
<Dialog open={isLancamentoOpen} onOpenChange={setIsLancamentoOpen}>
<Dialog
open={isLancamentoOpen}
onOpenChange={(open) => {
setIsLancamentoOpen(open);
if (!open) {
resetLancamentoForm();
}
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Fazer lançamento</DialogTitle>
<DialogDescription>
Adicione uma bonificação ou desconto em pontuação para este fechamento.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
@@ -1190,17 +1373,76 @@ export default function FechamentoDetalhes() {
</div>
<div className="space-y-2">
<Label htmlFor="lancamento-pontuacao">Pontuação</Label>
<Input
id="lancamento-pontuacao"
type="text"
inputMode="decimal"
value={lancamentoPontuacao}
onChange={(e) => setLancamentoPontuacao(sanitizePontuacaoLancamentoDigitando(e.target.value))}
placeholder="Ex.: 1,5"
/>
<Label>Forma do lançamento</Label>
{lancamentoEmRealBloqueado ? (
<Alert variant="destructive" className="py-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Primeiro cadastre o fator do parceiro para poder lançar em Real.
</AlertDescription>
</Alert>
) : null}
<RadioGroup
value={lancamentoModo}
onValueChange={(v) => setLancamentoModo(v as "pontos" | "real")}
className="flex flex-wrap gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="pontos" id="lancamento-modo-pontos" />
<Label htmlFor="lancamento-modo-pontos" className="cursor-pointer font-normal">
Pontuação
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="real" id="lancamento-modo-real" disabled={lancamentoEmRealBloqueado} />
<Label
htmlFor="lancamento-modo-real"
className={
lancamentoEmRealBloqueado ? "cursor-not-allowed font-normal text-muted-foreground" : "cursor-pointer font-normal"
}
>
Valor em real (R$)
</Label>
</div>
</RadioGroup>
</div>
{lancamentoModo === "pontos" ? (
<div className="space-y-2">
<Label htmlFor="lancamento-pontuacao">Pontuação</Label>
<Input
id="lancamento-pontuacao"
type="text"
inputMode="decimal"
value={lancamentoPontuacao}
onChange={(e) => setLancamentoPontuacao(sanitizePontuacaoLancamentoDigitando(e.target.value))}
placeholder="Ex.: 1,5"
/>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="lancamento-valor-real">Valor (R$)</Label>
<Input
id="lancamento-valor-real"
type="text"
inputMode="decimal"
autoComplete="off"
value={lancamentoValorReal}
onChange={(e) => setLancamentoValorReal(sanitizeValorRealLancamentoDigitando(e.target.value))}
placeholder="Ex.: 90,00"
/>
{lancamentoPreviewPontos != null ? (
<p className="text-xs font-medium text-foreground">
Pontuação estimada: {formatPontos(lancamentoPreviewPontos)}
</p>
) : (
<p className="text-xs text-muted-foreground">
A conversão é feita ao salvar com o fator cadastrado do parceiro (valor ÷ (fator × 3)).
</p>
)}
</div>
)}
<div className="space-y-2">
<Label htmlFor="lancamento-descricao">Descrição</Label>
<Input
@@ -1224,7 +1466,15 @@ export default function FechamentoDetalhes() {
>
Cancelar
</Button>
<Button onClick={() => void handleSalvarLancamento()} disabled={savingLancamento || isReadonly}>
<Button
onClick={() => void handleSalvarLancamento()}
disabled={
savingLancamento ||
isReadonly ||
!podeTarefaLancarAjuste ||
(lancamentoModo === "real" && lancamentoEmRealBloqueado)
}
>
{savingLancamento ? "Salvando..." : "Salvar lançamento"}
</Button>
</DialogFooter>
@@ -1531,7 +1781,7 @@ export default function FechamentoDetalhes() {
</Button>
<Button
onClick={() => void handleConcluirFechamento()}
disabled={concluindo || isReadonly}
disabled={concluindo || isReadonly || !podeConcluirFechamento}
className="min-w-[160px] shadow-md hover:shadow-primary/40"
>
{concluindo ? (
@@ -1717,7 +1967,7 @@ export default function FechamentoDetalhes() {
>
Cancelar
</Button>
<Button onClick={() => void handleSalvarEdicao()} disabled={savingEdicao || isReadonly}>
<Button onClick={() => void handleSalvarEdicao()} disabled={savingEdicao || bloqueioEdicaoTarefa}>
{savingEdicao ? "Salvando..." : "Salvar"}
</Button>
</DialogFooter>