@@ -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 .
< DialogContent className = "gap-0 overflow-hidden p-0 sm:max-w-2x l" >
< 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 >
< 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 >
{ 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 >
< 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 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 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 >
< / 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-[172 px] shadow-lg hover:shadow-primary/5 0"
className = "min-w-[160 px] shadow-md hover:shadow-primary/4 0"
>
{ 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 >