novas atualizacoes

This commit is contained in:
Vitex Tecnologia
2026-05-04 10:51:21 -03:00
parent dee9ecfbca
commit 7859230468
4 changed files with 300 additions and 81 deletions
@@ -193,9 +193,13 @@ export default function CompetenciaFechamentos() {
toast.error("Não foi possível identificar o usuário para reabrir a competência.");
return;
}
if (!motivoReabertura.trim()) {
toast.error("Informe o motivo da reabertura da competência.");
return;
}
try {
setReabrindoCompetencia(true);
await fechamentoCompetenciasService.reabrirCompetencia(competenciaId, me.id, motivoReabertura.trim() || undefined);
await fechamentoCompetenciasService.reabrirCompetencia(competenciaId, me.id, motivoReabertura.trim());
toast.success("Competência reaberta com sucesso.");
setIsReabrirModalOpen(false);
setMotivoReabertura("");
@@ -601,12 +605,13 @@ export default function CompetenciaFechamentos() {
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="motivo-reabrir-competencia">Motivo (opcional)</Label>
<Label htmlFor="motivo-reabrir-competencia">Motivo *</Label>
<Input
id="motivo-reabrir-competencia"
value={motivoReabertura}
onChange={(e) => setMotivoReabertura(e.target.value)}
placeholder="Ex.: correção de ajustes pós-fechamento"
required
/>
</div>
<DialogFooter>
@@ -618,7 +623,10 @@ export default function CompetenciaFechamentos() {
>
Cancelar
</Button>
<Button onClick={() => void handleReabrirCompetencia()} disabled={reabrindoCompetencia}>
<Button
onClick={() => void handleReabrirCompetencia()}
disabled={reabrindoCompetencia || !motivoReabertura.trim()}
>
{reabrindoCompetencia ? "Reabrindo..." : "Confirmar reabertura"}
</Button>
</DialogFooter>
@@ -1,6 +1,22 @@
import { useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, CheckCircle2, Clock3, ExternalLink, ListChecks, Loader2, Pencil, Plus, Repeat, RotateCcw, Target, Trash2, TrendingUp } from "lucide-react";
import {
ArrowLeft,
CheckCircle2,
Clock3,
ExternalLink,
ListChecks,
Loader2,
Pencil,
Plus,
Repeat,
RotateCcw,
Target,
Trash2,
TrendingUp,
User,
Wallet,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -17,8 +33,11 @@ import {
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useAuthAccess } from "@/contexts/AuthAccessContext";
import { fechamentoBancoPontosService } from "@/services/fechamento/bancoPontos";
import { fechamentoCompetenciasService, type CompetenciaItem } from "@/services/fechamento/competencias";
import { fechamentoFechamentosService, type FechamentoTarefaItem } from "@/services/fechamento/fechamentos";
@@ -43,6 +62,28 @@ function formatPontos(valor: number): string {
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: 4 });
}
/** Ex.: toast após concluir — vírgula pt-BR e no máximo 2 casas decimais. */
function formatPontosAte2Casas(valor: number): string {
if (!Number.isFinite(valor)) return "—";
return valor.toLocaleString("pt-BR", { minimumFractionDigits: 0, maximumFractionDigits: 2 });
}
/** Alinhado ao `requerMotivoAjuste`: evita "-0" e cor errada por ruído de float. */
const SALDO_PONTOS_EPS = 0.0001;
function corSaldoPontos(valor: number): string {
if (!Number.isFinite(valor)) return "text-foreground";
if (valor > SALDO_PONTOS_EPS) return "text-emerald-600";
if (valor < -SALDO_PONTOS_EPS) return "text-red-600";
return "text-foreground";
}
function formatPontosSaldoModal(valor: number): string {
if (!Number.isFinite(valor)) return "—";
const v = Math.abs(valor) < SALDO_PONTOS_EPS ? 0 : valor;
return formatPontos(v);
}
function parsePontuacaoInput(value: string): number {
const normalized = value.trim().replace(/\s/g, "").replace(",", ".");
const parsed = Number(normalized);
@@ -141,6 +182,11 @@ export default function FechamentoDetalhes() {
const [lancamentoDescricao, setLancamentoDescricao] = useState("");
const [lancamentoPontuacao, setLancamentoPontuacao] = useState("0");
const [pontuacaoMeta, setPontuacaoMeta] = useState<number | null>(null);
const [parceiroId, setParceiroId] = useState<string | null>(null);
const [parceiroNome, setParceiroNome] = useState<string | null>(null);
const [saldoBancoAtual, setSaldoBancoAtual] = useState<number | null>(null);
const [loadingSaldoBanco, setLoadingSaldoBanco] = useState(false);
const [erroSaldoBanco, setErroSaldoBanco] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deletingTarefa, setDeletingTarefa] = useState<FechamentoTarefaItem | null>(null);
const [isReprocessAsanaOpen, setIsReprocessAsanaOpen] = useState(false);
@@ -183,6 +229,16 @@ export default function FechamentoDetalhes() {
const requerMotivoAjuste = Number.isFinite(bancoCalculado) && Math.abs(bancoCalculado) > 0.0001;
const pontuacaoTotalLabel = toPontuacaoInput(totais.pontos);
const isValorEditado = pontuacaoPagaInput.trim() !== pontuacaoTotalLabel;
const saldoBancoPosFechamento =
saldoBancoAtual != null ? saldoBancoAtual + bancoCalculado : null;
const dividaParaQuitar =
saldoBancoAtual != null && saldoBancoAtual < 0 ? -saldoBancoAtual : 0;
const saldoPositivoDisponivel =
saldoBancoAtual != null && saldoBancoAtual > 0 ? saldoBancoAtual : 0;
/** Zera o banco: saldo + (aprovados pago) = 0 ⇒ pago = aprovados + saldo (saldo negativo = dívida). */
const sugestaoQuitarDivida = totais.pontos - dividaParaQuitar;
/** Zera o banco com saldo positivo: precisa movimento negativo no banco ⇒ pago = aprovados + saldo. */
const sugestaoUsarSaldoPositivo = totais.pontos + saldoPositivoDisponivel;
const todasTarefasRevisadas = tarefas.length > 0 && tarefas.every((tarefa) => tarefa.estaRevisada);
const competenciasDestino = useMemo(
() => competenciasAbertas.filter((item) => item.id !== competenciaId),
@@ -227,6 +283,8 @@ export default function FechamentoDetalhes() {
setFechamentoStatus(current.status);
setPontuacaoMeta(current.pontuacaoMeta);
setCompetenciaStatus(competenciaAtual?.status ?? "em_aberto");
setParceiroId(current.parceiroId);
setParceiroNome(current.parceiroNome);
if (!competenciaId) {
setCompetenciaId(current.competenciaId);
}
@@ -235,6 +293,20 @@ export default function FechamentoDetalhes() {
}
};
const loadSaldoBanco = async (id: string) => {
setLoadingSaldoBanco(true);
setErroSaldoBanco(false);
try {
const response = await fechamentoBancoPontosService.listarExtrato(id, { page: 1, perPage: 1 });
setSaldoBancoAtual(response.parceiro?.saldo ?? 0);
} catch {
setErroSaldoBanco(true);
setSaldoBancoAtual(null);
} finally {
setLoadingSaldoBanco(false);
}
};
const toastBloqueioFechado = () => {
toast.error("Fechamento está fechado. Reabra para editar.");
};
@@ -262,6 +334,16 @@ export default function FechamentoDetalhes() {
}
}, [isFechado]);
useEffect(() => {
if (isConcluirOpen && parceiroId) {
void loadSaldoBanco(parceiroId);
}
if (!isConcluirOpen) {
setSaldoBancoAtual(null);
setErroSaldoBanco(false);
}
}, [isConcluirOpen, parceiroId]);
const handleToggleAprovada = async (tarefa: FechamentoTarefaItem, approved: boolean) => {
if (isCompetenciaConcluida) {
toastBloqueioCompetenciaConcluida();
@@ -417,7 +499,7 @@ export default function FechamentoDetalhes() {
pontuacaoPaga: pontuacaoPagaRaw,
motivoAjuste: motivoAjuste.trim() || undefined,
});
toast.success(`Fechamento concluído. Banco de pontos: ${data.pontuacaoBanco}.`);
toast.success(`Fechamento concluído. Banco de pontos: ${formatPontosAte2Casas(data.pontuacaoBanco)}.`);
setFechamentoStatus("fechado");
navigate(`/fechamento/competencias/${data.competenciaId}`);
} catch (error) {
@@ -437,11 +519,15 @@ export default function FechamentoDetalhes() {
toast.error("Não foi possível identificar o usuário para reabertura.");
return;
}
if (!motivoReabertura.trim()) {
toast.error("Informe o motivo da reabertura do fechamento.");
return;
}
try {
setReabrindo(true);
const data = await fechamentoFechamentosService.reabrirFechamento(fechamentoId, {
reabertoPorId: me.id,
motivo: motivoReabertura,
motivo: motivoReabertura.trim(),
});
toast.success("Fechamento reaberto com sucesso.");
setFechamentoStatus(data.status);
@@ -1082,93 +1168,214 @@ export default function FechamentoDetalhes() {
</Dialog>
<Dialog open={isConcluirOpen} onOpenChange={setIsConcluirOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Concluir fechamento</DialogTitle>
<DialogDescription>
Revise os totais e confirme a pontuação paga para concluir este fechamento.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border bg-card p-3">
<p className="text-xs text-muted-foreground">Aprovada</p>
<p className="text-2xl font-bold">{formatPontos(totais.pontos)}</p>
</div>
<div className="rounded-lg border bg-card p-3">
<p className="text-xs text-muted-foreground">Meta</p>
<p className="text-2xl font-bold">{formatPontos(Number(pontuacaoMeta ?? 0))}</p>
</div>
<div className="rounded-lg border bg-card p-3">
<p className="text-xs text-muted-foreground">Diferença</p>
<p className={`text-2xl font-bold ${diferencaParaMeta >= 0 ? "text-emerald-600" : "text-red-600"}`}>
{formatPontos(diferencaParaMeta)}
</p>
</div>
<div className="rounded-lg border bg-card p-3">
<p className="text-xs text-muted-foreground">Banco de Pontos</p>
<p className={`text-2xl font-bold ${bancoCalculado >= 0 ? "text-emerald-600" : "text-red-600"}`}>
{formatPontos(bancoCalculado)}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={() => setPontuacaoPagaInput(pontuacaoTotalLabel)}
>
Pagar total aprovado
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={() => setPontuacaoPagaInput(String(pontuacaoMeta ?? 0))}
>
Pagar meta
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="pontuacao-paga">Pontuação paga</Label>
{isValorEditado ? (
<div>
<button
type="button"
className="text-xs text-muted-foreground underline underline-offset-2 transition hover:text-foreground"
onClick={() => setPontuacaoPagaInput(pontuacaoTotalLabel)}
>
Usar valor total
</button>
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-2xl">
<div className="border-b border-border bg-muted/40 px-6 pb-4 pt-6 sm:px-8">
<DialogHeader className="space-y-1 text-left">
<DialogTitle className="text-xl font-semibold tracking-tight">Concluir fechamento</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
Informe a pontuação paga e confirme.
</DialogDescription>
</DialogHeader>
{parceiroNome ? (
<div className="mt-4 flex items-center gap-2 rounded-lg border border-border/80 bg-background/80 px-3 py-2.5">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted">
<User className="h-4 w-4 text-muted-foreground" aria-hidden />
</div>
) : null}
<div className="min-w-0">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Parceiro</p>
<p className="truncate text-sm font-semibold text-foreground">{parceiroNome}</p>
</div>
</div>
) : null}
</div>
<div className="max-h-[min(75vh,36rem)] space-y-0 overflow-y-auto px-6 py-5 sm:px-8">
<div className="grid gap-4 md:grid-cols-2 md:gap-5">
<section className="rounded-xl border border-border bg-card p-4 shadow-sm">
<div className="mb-3 flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-muted">
<ListChecks className="h-4 w-4 text-muted-foreground" aria-hidden />
</div>
<h3 className="text-sm font-semibold text-foreground">Este mês</h3>
</div>
<dl className="space-y-0">
<div className="flex items-baseline justify-between gap-4 border-b border-border/60 py-2.5 first:pt-0">
<dt className="text-sm text-muted-foreground">Aprovados</dt>
<dd className="text-right text-lg font-semibold tabular-nums tracking-tight text-foreground">
{formatPontos(totais.pontos)}
</dd>
</div>
<div className="flex items-baseline justify-between gap-4 border-b border-border/60 py-2.5">
<dt className="text-sm text-muted-foreground">Meta</dt>
<dd className="text-right text-lg font-semibold tabular-nums tracking-tight text-foreground">
{formatPontos(Number(pontuacaoMeta ?? 0))}
</dd>
</div>
<div className="flex items-baseline justify-between gap-4 py-2.5 last:pb-0">
<dt className="text-sm text-muted-foreground">Diferença</dt>
<dd
className={`text-right text-lg font-semibold tabular-nums tracking-tight ${
diferencaParaMeta >= 0 ? "text-emerald-600" : "text-red-600"
}`}
>
{diferencaParaMeta >= 0 ? "+" : ""}
{formatPontos(diferencaParaMeta)}
</dd>
</div>
</dl>
</section>
<section className="rounded-xl border border-primary/20 bg-primary/[0.06] p-4">
<div className="mb-3 flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-background shadow-sm">
<Wallet className="h-4 w-4 text-primary" aria-hidden />
</div>
<h3 className="text-sm font-semibold text-foreground">Banco de pontos</h3>
</div>
<dl className="space-y-0">
<div className="flex items-baseline justify-between gap-4 border-b border-primary/10 py-2.5 first:pt-0">
<dt className="text-sm text-muted-foreground">Saldo hoje</dt>
<dd className="text-right">
{loadingSaldoBanco ? (
<Skeleton className="ml-auto h-7 w-24 rounded-md" />
) : erroSaldoBanco ? (
<div className="flex flex-col items-end gap-1">
<span className="text-sm font-medium text-destructive">Não foi possível carregar</span>
{parceiroId ? (
<button
type="button"
className="text-xs font-medium text-primary underline-offset-2 hover:underline"
onClick={() => void loadSaldoBanco(parceiroId)}
>
Tentar de novo
</button>
) : null}
</div>
) : (
<span
className={`text-lg font-semibold tabular-nums tracking-tight ${corSaldoPontos(saldoBancoAtual ?? 0)}`}
>
{formatPontosSaldoModal(saldoBancoAtual ?? 0)}
</span>
)}
</dd>
</div>
<div className="flex items-baseline justify-between gap-4 border-b border-primary/10 py-2.5">
<dt className="text-sm text-muted-foreground">Movimento</dt>
<dd
className={`text-right text-lg font-semibold tabular-nums tracking-tight ${corSaldoPontos(bancoCalculado)}`}
>
{bancoCalculado > SALDO_PONTOS_EPS ? "+" : ""}
{formatPontosSaldoModal(bancoCalculado)}
</dd>
</div>
<div className="flex items-baseline justify-between gap-4 py-2.5 last:pb-0">
<dt className="text-sm font-medium text-foreground">Depois</dt>
<dd className="text-right">
{saldoBancoPosFechamento == null ? (
<span className="text-lg font-semibold tabular-nums text-muted-foreground"></span>
) : (
<span
className={`text-lg font-semibold tabular-nums tracking-tight ${corSaldoPontos(saldoBancoPosFechamento)}`}
>
{formatPontosSaldoModal(saldoBancoPosFechamento)}
</span>
)}
</dd>
</div>
</dl>
</section>
</div>
<Separator className="my-5" />
<div className="space-y-3">
<Label htmlFor="pontuacao-paga" className="text-sm font-semibold">
Pontuação paga
</Label>
<Input
id="pontuacao-paga"
type="text"
inputMode="decimal"
className="h-11 text-base font-medium tabular-nums"
value={pontuacaoPagaInput}
onChange={(e) => setPontuacaoPagaInput(e.target.value)}
placeholder="Ex.: 10,5"
/>
{isValorEditado ? (
<button
type="button"
className="text-xs font-medium text-primary underline-offset-2 hover:underline"
onClick={() => setPontuacaoPagaInput(pontuacaoTotalLabel)}
>
Voltar para o total aprovado ({formatPontos(totais.pontos)})
</button>
) : null}
<div className="rounded-lg border border-dashed border-border bg-muted/30 p-3">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Button
type="button"
variant="secondary"
size="sm"
className="h-9 justify-center font-normal"
onClick={() => setPontuacaoPagaInput(pontuacaoTotalLabel)}
>
Igual aos aprovados
</Button>
<Button
type="button"
variant="secondary"
size="sm"
className="h-9 justify-center font-normal"
onClick={() => setPontuacaoPagaInput(String(pontuacaoMeta ?? 0))}
>
Igual à meta
</Button>
{dividaParaQuitar > 0 ? (
<Button
type="button"
variant="secondary"
size="sm"
className="h-9 justify-center border border-red-200 bg-red-50 font-normal text-red-800 hover:bg-red-100 dark:border-red-900/50 dark:bg-red-950/40 dark:text-red-200 dark:hover:bg-red-950/60"
title={`${formatPontos(sugestaoQuitarDivida)} pontos — aprovados menos a dívida; zera o banco`}
onClick={() => setPontuacaoPagaInput(toPontuacaoInput(sugestaoQuitarDivida))}
>
Quitar dívida ({formatPontos(dividaParaQuitar)})
</Button>
) : null}
{saldoPositivoDisponivel > 0 ? (
<Button
type="button"
variant="secondary"
size="sm"
className="h-9 justify-center border border-emerald-200 bg-emerald-50 font-normal text-emerald-900 hover:bg-emerald-100 dark:border-emerald-900/50 dark:bg-emerald-950/40 dark:text-emerald-100 dark:hover:bg-emerald-950/60"
title={`${formatPontos(sugestaoUsarSaldoPositivo)} pontos — aprovados + saldo; zera o banco`}
onClick={() => setPontuacaoPagaInput(toPontuacaoInput(sugestaoUsarSaldoPositivo))}
>
Usar saldo positivo ({formatPontos(saldoPositivoDisponivel)})
</Button>
) : null}
</div>
</div>
</div>
{requerMotivoAjuste ? (
<div className="space-y-2">
<Label htmlFor="motivo-ajuste">Motivo do ajuste *</Label>
<div className="mt-5 space-y-2 rounded-lg border border-amber-200/80 bg-amber-50/80 p-3 dark:border-amber-900/40 dark:bg-amber-950/25">
<Label htmlFor="motivo-ajuste" className="text-sm font-semibold text-amber-950 dark:text-amber-100">
Motivo do ajuste *
</Label>
<Input
id="motivo-ajuste"
value={motivoAjuste}
onChange={(e) => setMotivoAjuste(e.target.value)}
placeholder="Ex.: pagamento parcial acordado com o parceiro"
className="bg-background"
/>
</div>
) : null}
</div>
<DialogFooter>
<DialogFooter className="gap-2 border-t border-border bg-muted/30 px-6 py-4 sm:justify-end sm:px-8">
<Button
variant="outline"
className="hover:bg-muted/60 hover:text-foreground"
@@ -1180,15 +1387,15 @@ export default function FechamentoDetalhes() {
<Button
onClick={() => void handleConcluirFechamento()}
disabled={concluindo || isReadonly}
className="min-w-[172px] shadow-lg hover:shadow-primary/50"
className="min-w-[160px] shadow-md hover:shadow-primary/40"
>
{concluindo ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Concluindo...
</>
) : (
"Confirmar conclusão"
"Concluir"
)}
</Button>
</DialogFooter>
@@ -1204,12 +1411,13 @@ export default function FechamentoDetalhes() {
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="motivo-reabertura">Motivo (opcional)</Label>
<Label htmlFor="motivo-reabertura">Motivo *</Label>
<Input
id="motivo-reabertura"
value={motivoReabertura}
onChange={(e) => setMotivoReabertura(e.target.value)}
placeholder="Ex.: ajuste após revisão financeira"
required
/>
</div>
<DialogFooter>
@@ -1221,7 +1429,10 @@ export default function FechamentoDetalhes() {
>
Cancelar
</Button>
<Button onClick={() => void handleReabrirFechamento()} disabled={reabrindo}>
<Button
onClick={() => void handleReabrirFechamento()}
disabled={reabrindo || !motivoReabertura.trim()}
>
{reabrindo ? "Reabrindo..." : "Confirmar reabertura"}
</Button>
</DialogFooter>
+2 -2
View File
@@ -159,14 +159,14 @@ class FechamentoCompetenciasService {
async reabrirCompetencia(
competenciaId: string,
reabertoPorId: string,
motivo?: string,
motivo: string,
): Promise<ReabrirCompetenciaResponse["data"]> {
try {
const headers = await buildCommanderHeaders();
const baseUrl = resolveCommanderBaseUrl();
const response = await axios.post<ReabrirCompetenciaResponse>(
`${baseUrl}/competencias/${competenciaId}/reabrir`,
{ reaberto_por_id: reabertoPorId, motivo },
{ reaberto_por_id: reabertoPorId, motivo: motivo.trim() },
{ headers },
);
return response.data.data;
+2 -2
View File
@@ -277,7 +277,7 @@ class FechamentoFechamentosService {
async reabrirFechamento(
fechamentoId: string,
input: { reabertoPorId: string; motivo?: string },
input: { reabertoPorId: string; motivo: string },
): Promise<FechamentoReabertoResponse> {
try {
const headers = await buildCommanderHeaders();
@@ -286,7 +286,7 @@ class FechamentoFechamentosService {
`${baseUrl}/fechamentos/${fechamentoId}/reabrir`,
{
reaberto_por_id: input.reabertoPorId,
...(input.motivo?.trim() ? { motivo: input.motivo.trim() } : {}),
motivo: input.motivo.trim(),
},
{ headers },
);