Public/Authoral/Ponto.ps1

# Uma linha do arquivo ~/.pontos.tsf: sequência, dia, horário e rótulo (Entrada, Almoço, Retorno, Saída, Extra ou Falta).
class PontoEntry {
    [int]$Id
    [datetime]$Date
    [datetime]$Time
    [string]$Label
}
function Ponto {
    <#
    .SYNOPSIS
        Registra e analisa o ponto (batidas de entrada, almoço, retorno e saída).
    .DESCRIPTION
        Mantém as batidas em ~/.pontos.tsf (uma por linha, separada por TAB) e oferece as ações:

        - Registrar: grava uma batida; o rótulo segue a ordem do dia (Entrada, Almoço, Retorno, Saída, Extra).
        - Listar: mostra as batidas de um dia.
        - Falta: sinaliza falta num dia útil sem batidas; o Dash desconta a jornada inteira do banco de horas.
        - Alertas: lista as batidas a ajustar nos 7 dias anteriores a -Date, no formato
          "matricula;data;horario-esperado;horario-marcado".
        - Dash: calcula banco de horas, atrasos, saídas antecipadas, médias e alertas da CLT
          (intervalo < 1h, jornada > 10h, descanso < 11h). Com -Monthly, resume um mês e compara com o anterior.

        A jornada é configurável em ~/.pontos.config.json (chaves Entrada, Saida, Almoco, Tolerancia,
        IntervaloMinimo, LimiteSemIntervalo, Interjornada, JornadaMaxima, InicioAlmoco e Matricula).
        Sem o arquivo, vale 07:30-17:15 com 1h de almoço, tolerância de 5 min e a matrícula $env:USERNAME.
    .PARAMETER Action
        Ação a executar: Registrar, Listar, Falta, Alertas ou Dash.
    .PARAMETER Date
        Data/hora de referência. Registrar: horário da batida. Listar: dia exibido. Falta: dia da falta.
        Alertas: os 7 dias anteriores a esta data são analisados. Padrão: agora.
    .PARAMETER Inicio
        Dash: primeiro dia do período analisado. Padrão: início do histórico.
    .PARAMETER Fim
        Dash: último dia do período analisado. Padrão: fim do histórico.
    .PARAMETER Monthly
        Dash: resume um mês inteiro (o atual ou o informado em -Mes), com saldo de meses anteriores e comparação com o mês anterior.
    .PARAMETER Mes
        Dash: número do mês (1-12) para o resumo mensal. Mês ainda não chegado no ano corrente é do ano anterior. Informar -Mes já ativa o modo mensal.
    .PARAMETER PassThru
        Dash: além de exibir, devolve um objeto com Dias, Resumo (valores em minutos) e MesAnterior.
    .INPUTS
        Nenhum.
    .OUTPUTS
        System.String. Mensagens de Registrar, Falta e as linhas de Alertas.
        PontoEntry. Batidas exibidas por Listar.
        System.Management.Automation.PSCustomObject. Dash com -PassThru.
    .EXAMPLE
        PS> Ponto Registrar

        Registra uma batida com o horário atual.
    .EXAMPLE
        PS> Ponto Registrar -Date "2026-10-27T07:40:00"

        Registra uma batida retroativa.
    .EXAMPLE
        PS> Ponto Listar -Date 2026-10-26

        Lista as batidas de 26/10/2026.
    .EXAMPLE
        PS> Ponto Falta -Date 2026-10-21

        Sinaliza falta em 21/10/2026; o dia passa a descontar a jornada do banco de horas.
    .EXAMPLE
        PS> Ponto Alertas | Set-Clipboard

        Copia os incidentes dos últimos 7 dias para enviar ao RH.
    .EXAMPLE
        PS> Ponto Dash -Inicio 2026-10-01 -Fim 2026-10-31

        Mostra os últimos 10 dias do período e o resumo de banco de horas, atrasos e alertas.
    .EXAMPLE
        PS> Ponto Dash -Monthly 9

        Resume setembro, com saldo transportado e comparação com agosto.
    .EXAMPLE
        PS> (Ponto Dash -Monthly -PassThru).Dias | Export-Csv mes.csv -NoTypeInformation

        Exporta os dias do mês atual para CSV.
    .NOTES
        Regras de tolerância (5 min por batida, até 10 min no dia) seguem o art. 58, §1º, da CLT.
        Dia com número ímpar de batidas fica fora do saldo; o dia de hoje só entra a partir da 4ª batida.
    .LINK
        https://github.com/rodcordeiro/PSROD/blob/main/Public/Authoral/Ponto.ps1
    #>

    [CmdletBinding(DefaultParameterSetName = "Registrar")]
    param(
        [Parameter(Mandatory = $true, ParameterSetName = "Registrar", Position = 0)]
        [Parameter(Mandatory = $true, ParameterSetName = "Listar", Position = 0)]
        [Parameter(Mandatory = $true, ParameterSetName = "Falta", Position = 0)]
        [Parameter(Mandatory = $true, ParameterSetName = "Alertas", Position = 0)]
        [Parameter(Mandatory = $true, ParameterSetName = "Dash", Position = 0)]
        [ValidateSet("Registrar", "Listar", "Falta", "Alertas", "Dash")]
        [string]$Action,
        [Parameter(Mandatory = $false, ParameterSetName = "Registrar")]
        [Parameter(Mandatory = $false, ParameterSetName = "Listar")]
        [Parameter(Mandatory = $false, ParameterSetName = "Falta")]
        [Parameter(Mandatory = $false, ParameterSetName = "Alertas")]
        [datetime]$Date = (Get-Date),
        [Parameter(Mandatory = $false, ParameterSetName = "Dash")]
        [datetime]$Inicio = [datetime]::MinValue,
        [Parameter(Mandatory = $false, ParameterSetName = "Dash")]
        [datetime]$Fim = [datetime]::MaxValue,
        # Resume um mês inteiro: o mês atual ou o informado em -Mes (ex.: Ponto Dash -Monthly 10).
        [Parameter(Mandatory = $false, ParameterSetName = "Dash")]
        [switch]$Monthly,
        [Parameter(Mandatory = $false, ParameterSetName = "Dash", Position = 1)]
        [ValidateRange(1, 12)]
        [int]$Mes,
        # Além de exibir, devolve os dias e o resumo como objetos (ex.: para Export-Csv).
        [Parameter(Mandatory = $false, ParameterSetName = "Dash")]
        [switch]$PassThru
    )
    begin {
        $FilePath = Join-Path -Path $HOME -ChildPath ".pontos.tsf"
        $ConfigPath = Join-Path -Path $HOME -ChildPath ".pontos.config.json"

        function Get-DiaRegistro {
            <#
            .SYNOPSIS
                Extrai a data (yyyy-MM-dd) de uma linha do arquivo, em qualquer formato já gravado.
            .PARAMETER Linha
                Linha bruta do ~/.pontos.tsf.
            .OUTPUTS
                System.String. A data encontrada, ou nada se a linha não tiver data.
            #>

            param([string]$Linha)
            if ($Linha -match '\d{4}-\d{2}-\d{2}') { $Matches[0] }
        }

        function Get-PontoEntry {
            <#
            .SYNOPSIS
                Lê o ~/.pontos.tsf e converte cada linha (TSV ou formatos antigos) em PontoEntry.
            .DESCRIPTION
                Linhas sem data/hora reconhecível são ignoradas (veja -Verbose). Sem sequência na linha, usa o número da linha como Id.
            .OUTPUTS
                PontoEntry.
            #>

            $numero = 0
            foreach ($linha in @(Get-Content -Path $FilePath -Encoding UTF8)) {
                $numero++
                if ($linha -notmatch '(?<dia>\d{4}-\d{2}-\d{2})\s+(?<hora>\d{2}:\d{2}(:\d{2})?)\s*(?<label>.*)$') {
                    Write-Verbose "Linha $numero ignorada: '$linha'"
                    continue
                }
                $dia = [datetime]::ParseExact($Matches.dia, "yyyy-MM-dd", $null)
                $hora = [timespan]::Parse($Matches.hora)
                $label = $Matches.label.Trim()
                $id = if ($linha -match '^\s*(\d+)\s') { [int]$Matches[1] } else { $numero }
                [PontoEntry]@{
                    Id    = $id
                    Date  = $dia
                    Time  = $dia.Add($hora)
                    Label = $label
                }
            }
        }

        function Format-Minuto {
            <#
            .SYNOPSIS
                Formata uma quantidade de minutos como [+|-]HH:mm.
            .PARAMETER Minutos
                Minutos a formatar; negativos recebem "-".
            .PARAMETER Sinal
                Prefixa "+" em valores positivos.
            .OUTPUTS
                System.String.
            #>

            param([double]$Minutos, [switch]$Sinal)
            $prefixo = if ($Minutos -lt 0) { "-" } elseif ($Sinal -and $Minutos -gt 0) { "+" } else { "" }
            $total = [math]::Abs([math]::Round($Minutos))
            "{0}{1:00}:{2:00}" -f $prefixo, [math]::Floor($total / 60), ($total % 60)
        }

        function Get-PontoConfig {
            <#
            .SYNOPSIS
                Monta a configuração da jornada e dos limites da CLT.
            .DESCRIPTION
                Parte dos padrões (07:30-17:15, almoço 1h, tolerância 5 min, intervalo mínimo 1h acima de 6h, interjornada 11h, jornada máxima 10h, almoço às 12:00, matrícula $env:USERNAME) e sobrescreve com as chaves do ~/.pontos.config.json. Chave desconhecida gera aviso; JSON ou valor inválido gera erro.
            .OUTPUTS
                System.Management.Automation.PSCustomObject. Horários como TimeSpan, Tolerancia em minutos.
            #>

            $valores = [ordered]@{
                Entrada            = "07:30"
                Saida              = "17:15"
                Almoco             = "01:00"
                Tolerancia         = 5
                IntervaloMinimo    = "01:00"
                LimiteSemIntervalo = "06:00"
                Interjornada       = "11:00"
                JornadaMaxima      = "10:00"
                InicioAlmoco       = "12:00"
                Matricula          = $env:USERNAME
            }
            if (Test-Path -Path $ConfigPath) {
                try {
                    $arquivo = Get-Content -Path $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json
                }
                catch {
                    throw "Configuração inválida em ${ConfigPath}: $($_.Exception.Message)"
                }
                foreach ($propriedade in $arquivo.PSObject.Properties) {
                    if ($valores.Contains($propriedade.Name)) {
                        $valores[$propriedade.Name] = $propriedade.Value
                    }
                    else {
                        Write-Warning "Chave desconhecida em ${ConfigPath}: '$($propriedade.Name)'."
                    }
                }
            }
            try {
                [pscustomobject]@{
                    Entrada            = [timespan]$valores.Entrada
                    Saida              = [timespan]$valores.Saida
                    Almoco             = [timespan]$valores.Almoco
                    Tolerancia         = [int]$valores.Tolerancia
                    IntervaloMinimo    = [timespan]$valores.IntervaloMinimo
                    LimiteSemIntervalo = [timespan]$valores.LimiteSemIntervalo
                    Interjornada       = [timespan]$valores.Interjornada
                    JornadaMaxima      = [timespan]$valores.JornadaMaxima
                    InicioAlmoco       = [timespan]$valores.InicioAlmoco
                    Matricula          = [string]$valores.Matricula
                }
            }
            catch {
                throw "Valor inválido em ${ConfigPath}: $($_.Exception.Message). Use 'HH:mm' para horários e minutos inteiros para Tolerancia."
            }
        }

        function Get-DiaPonto {
            <#
            .SYNOPSIS
                Calcula trabalho, intervalo, atraso, saída antecipada, saldo e alertas da CLT de cada dia registrado.
            .DESCRIPTION
                Processa o histórico inteiro, para que o alerta de interjornada (art. 66) enxergue o dia anterior mesmo fora do período pedido. Batidas formam pares (1-2, 3-4, ...); dia com falta sinalizada desconta a jornada inteira.
            .PARAMETER Config
                Objeto devolvido por Get-PontoConfig.
            .OUTPUTS
                System.Management.Automation.PSCustomObject. Um por dia, valores em minutos.
            #>

            param($Config)
            $esperado = ($Config.Saida - $Config.Entrada - $Config.Almoco).TotalMinutes
            $hoje = (Get-Date).Date
            $ultimaMarcacao = $null
            $grupos = @(
                Get-PontoEntry |
                Group-Object { $_.Date.ToString("yyyy-MM-dd") } |
                Sort-Object Name
            )
            foreach ($grupo in $grupos) {
                $data = [datetime]::ParseExact($grupo.Name, "yyyy-MM-dd", $null)
                $util = $data.DayOfWeek -notin @([DayOfWeek]::Saturday, [DayOfWeek]::Sunday)
                $falta = [bool]($grupo.Group | Where-Object Label -eq "Falta")
                $marcacoes = @($grupo.Group | Where-Object Label -ne "Falta" | Sort-Object Time)
                $horas = @($marcacoes | ForEach-Object { $_.Time.TimeOfDay })

                $trabalhado = 0
                $intervalo = 0
                $atraso = 0
                $saidaAntecipada = 0
                $saldo = $null
                $obs = @()
                $alertas = @()

                if ($falta) {
                    # Falta sinalizada: desconta a jornada inteira do banco.
                    $completo = $true
                    $saldo = if ($util) { -$esperado } else { 0 }
                    $obs += "Falta"
                }
                else {
                    # Dia de hoje só entra no saldo depois da 4ª marcação, para não contar a jornada em andamento como débito.
                    $completo = ($horas.Count % 2 -eq 0) -and ($data -lt $hoje -or $horas.Count -ge 4)
                    for ($i = 0; $i + 1 -lt $horas.Count; $i += 2) { $trabalhado += ($horas[$i + 1] - $horas[$i]).TotalMinutes }
                    for ($i = 1; $i + 1 -lt $horas.Count; $i += 2) { $intervalo += ($horas[$i + 1] - $horas[$i]).TotalMinutes }

                    if ($util) {
                        $desvioEntrada = ($horas[0] - $Config.Entrada).TotalMinutes
                        if ($desvioEntrada -gt $Config.Tolerancia) { $atraso = $desvioEntrada }
                    }
                    else {
                        $obs += "Fim de semana"
                    }

                    if ($completo) {
                        $semIntervaloLegal = $trabalhado -gt $Config.LimiteSemIntervalo.TotalMinutes -and $intervalo -lt $Config.IntervaloMinimo.TotalMinutes
                        if ($util) {
                            $desvioSaida = ($Config.Saida - $horas[-1]).TotalMinutes
                            if ($desvioSaida -gt $Config.Tolerancia) { $saidaAntecipada = $desvioSaida }
                            if ($horas.Count -eq 2 -and -not $semIntervaloLegal) { $obs += "Sem intervalo registrado" }
                        }
                        $saldo = $trabalhado - $(if ($util) { $esperado } else { 0 })
                        # Tolerância diária: 5 min por marcação, até 10 min no dia.
                        if ([math]::Abs($saldo) -le (2 * $Config.Tolerancia)) { $saldo = 0 }

                        if ($semIntervaloLegal) {
                            $alertas += "Intervalo {0} < {1} (art. 71)" -f (Format-Minuto $intervalo), (Format-Minuto $Config.IntervaloMinimo.TotalMinutes)
                        }
                        if ($trabalhado -gt $Config.JornadaMaxima.TotalMinutes) {
                            $alertas += "Jornada {0} > {1} (art. 59)" -f (Format-Minuto $trabalhado), (Format-Minuto $Config.JornadaMaxima.TotalMinutes)
                        }
                    }
                    else {
                        $obs += "Incompleto ($($horas.Count) marcações)"
                    }

                    if ($null -ne $ultimaMarcacao) {
                        $descanso = ($marcacoes[0].Time - $ultimaMarcacao).TotalMinutes
                        if ($descanso -lt $Config.Interjornada.TotalMinutes) {
                            $alertas += "Descanso {0} < {1} (art. 66)" -f (Format-Minuto $descanso), (Format-Minuto $Config.Interjornada.TotalMinutes)
                        }
                    }
                    $ultimaMarcacao = $marcacoes[-1].Time
                }

                [pscustomobject]@{
                    Data            = $data
                    Util            = $util
                    Falta           = $falta
                    Completo        = $completo
                    Entrada         = if ($horas.Count) { $horas[0] } else { $null }
                    Marcacoes       = ($horas | ForEach-Object { $_.ToString("hh\:mm") }) -join " "
                    Trabalhado      = $trabalhado
                    Intervalo       = $intervalo
                    Atraso          = $atraso
                    SaidaAntecipada = $saidaAntecipada
                    Saldo           = $saldo
                    Obs             = $obs -join "; "
                    Alertas         = $alertas -join "; "
                }
            }
        }

        function Get-ResumoPonto {
            <#
            .SYNOPSIS
                Consolida os dias de Get-DiaPonto em totais e médias.
            .PARAMETER Dias
                Dias do período, como devolvidos por Get-DiaPonto.
            .PARAMETER Esperado
                Jornada diária esperada, em minutos.
            .OUTPUTS
                System.Management.Automation.PSCustomObject. Banco, créditos, débitos, atrasos, faltas e médias, em minutos.
            #>

            param([object[]]$Dias, [double]$Esperado)
            $completos = @($Dias | Where-Object Completo)
            $presentes = @($Dias | Where-Object { $_.Util -and -not $_.Falta -and $null -ne $_.Entrada })
            $comAtraso = @($Dias | Where-Object { $_.Atraso -gt 0 })
            $comSaidaAntecipada = @($Dias | Where-Object { $_.SaidaAntecipada -gt 0 })
            $comIntervalo = @($completos | Where-Object { $_.Intervalo -gt 0 })
            $atrasoTotal = [double]($comAtraso | Measure-Object Atraso -Sum).Sum

            [pscustomobject]@{
                Inicio               = $Dias[0].Data
                Fim                  = $Dias[-1].Data
                DiasComRegistro      = $Dias.Count
                DiasNoSaldo          = $completos.Count
                DiasIncompletos      = $Dias.Count - $completos.Count
                Faltas               = @($Dias | Where-Object Falta).Count
                Trabalhado           = [double]($completos | Measure-Object Trabalhado -Sum).Sum
                Esperado             = @($completos | Where-Object Util).Count * $Esperado
                Banco                = [double]($completos | Measure-Object Saldo -Sum).Sum
                Creditos             = [double]($completos | Where-Object { $_.Saldo -gt 0 } | Measure-Object Saldo -Sum).Sum
                Debitos              = [double]($completos | Where-Object { $_.Saldo -lt 0 } | Measure-Object Saldo -Sum).Sum
                DiasUteisPresentes   = $presentes.Count
                Atrasos              = $comAtraso.Count
                AtrasoTotal          = $atrasoTotal
                AtrasoMedio          = if ($comAtraso.Count) { $atrasoTotal / $comAtraso.Count } else { 0 }
                SaidasAntecipadas    = $comSaidaAntecipada.Count
                SaidaAntecipadaTotal = [double]($comSaidaAntecipada | Measure-Object SaidaAntecipada -Sum).Sum
                EntradaMedia         = ($presentes | ForEach-Object { $_.Entrada.TotalMinutes } | Measure-Object -Average).Average
                IntervaloMedio       = ($comIntervalo | Measure-Object Intervalo -Average).Average
                DiasComAlerta        = @($Dias | Where-Object Alertas).Count
            }
        }

        function Registrar {
            <#
            .SYNOPSIS
                Grava uma batida no ~/.pontos.tsf com o rótulo da posição no dia (Entrada, Almoço, Retorno, Saída ou Extra).
            .PARAMETER Date
                Data e hora da batida. Padrão: agora.
            .OUTPUTS
                System.String. Confirmação do registro.
            #>

            param(
                [Parameter(Mandatory = $false)]
                [datetime]$Date = (Get-Date)
            )
            begin {
                if (!(Test-Path -Path $FilePath)) {
                    New-Item -Path $FilePath -ItemType File -Force | Out-Null
                }
                $Labels = @(
                    "Entrada",
                    "Almoço",
                    "Retorno",
                    "Saída"
                )
            }
            process {
                $day = $Date.ToString("yyyy-MM-dd")
                $hour = $Date.ToString("HH:mm:ss")
                $registered = @(Get-Content -Path $FilePath -Encoding UTF8)
                $doDia = @($registered | Where-Object { (Get-DiaRegistro $_) -eq $day })
                if ($doDia | Where-Object { $_ -match '\sFalta\s*$' }) {
                    throw "O dia $day tem falta sinalizada. Remova a linha 'Falta' do arquivo antes de registrar ponto."
                }
                if ($doDia.Count -lt $Labels.Count) {
                    $Label = $Labels[$doDia.Count]
                }
                else {
                    $Label = "Extra"
                    Write-Warning "Já existem $($doDia.Count) pontos em $day. Registrando como '$Label'."
                }
                $DateString = "$($registered.Count + 1)`t$day`t$hour`t$Label"
                Add-Content -Path $FilePath -Value $DateString -Encoding UTF8
                Write-Output "Ponto registrado em: $day $hour $Label"
            }
        }

        function Listar {
            <#
            .SYNOPSIS
                Lista as batidas registradas num dia.
            .PARAMETER Date
                Dia a listar.
            .OUTPUTS
                System.String e PontoEntry.
            #>

            param(
                [Parameter(Mandatory = $false)]
                [datetime]$Date
            )
            begin {
                if (!(Test-Path -Path $FilePath)) {
                    throw "Arquivo não encontrado!"
                }
            }
            process {
                [PontoEntry[]]$registered = @(Get-PontoEntry)
                if ($null -ne $Date) {
                    $day = $Date.ToString("yyyy-MM-dd")
                    $registered = @($registered | Where-Object { $_.Date.ToString("yyyy-MM-dd") -eq $day })
                }
                if ($registered.Count -eq 0) {
                    Write-Output "Nenhum ponto registrado para o dia $day."
                }
                else {
                    Write-Output "Pontos registrados para o dia ${day}:"
                    foreach ($entry in $registered) {
                        Write-Output $entry
                    }
                }
            }
        }

        function Falta {
            <#
            .SYNOPSIS
                Sinaliza falta num dia útil sem batidas; o Dash desconta a jornada inteira do banco de horas.
            .DESCRIPTION
                Recusa fim de semana, dia que já tem batidas e falta repetida. Para desfazer, apague a linha "Falta" do ~/.pontos.tsf.
            .PARAMETER Date
                Dia da falta. Padrão: hoje.
            .OUTPUTS
                System.String. Confirmação.
            #>

            param(
                [Parameter(Mandatory = $false)]
                [datetime]$Date = (Get-Date)
            )
            process {
                if (!(Test-Path -Path $FilePath)) {
                    New-Item -Path $FilePath -ItemType File -Force | Out-Null
                }
                $day = $Date.ToString("yyyy-MM-dd")
                if ($Date.DayOfWeek -in @([DayOfWeek]::Saturday, [DayOfWeek]::Sunday)) {
                    throw "$day é fim de semana; falta só vale para dia útil."
                }
                $doDia = @(Get-PontoEntry | Where-Object { $_.Date -eq $Date.Date })
                if ($doDia | Where-Object Label -eq "Falta") {
                    throw "Falta já sinalizada em $day."
                }
                if ($doDia.Count) {
                    throw "O dia $day já tem $($doDia.Count) marcação(ões). Remova-as antes de sinalizar falta."
                }
                $registered = @(Get-Content -Path $FilePath -Encoding UTF8)
                Add-Content -Path $FilePath -Value "$($registered.Count + 1)`t$day`t00:00:00`tFalta" -Encoding UTF8
                Write-Output "Falta sinalizada em $day (será descontada do banco de horas)."
            }
        }

        function Alertas {
            <#
            .SYNOPSIS
                Lista as batidas a ajustar nos 7 dias anteriores a -Date.
            .DESCRIPTION
                Saída no formato "matricula;data;horario-esperado;horario-marcado", com cabeçalho. Campo vazio indica batida ausente (marcado) ou não prevista (esperado). Considera entrada/saída fora da tolerância, retorno com intervalo diferente do configurado, batidas faltantes, dias úteis sem batida e batidas extras. Ignora fins de semana e faltas sinalizadas.
            .PARAMETER Date
                Data de referência; analisa de Date-7 a Date-1. Padrão: hoje.
            .OUTPUTS
                System.String. Uma linha por incidente.
            #>

            param(
                [Parameter(Mandatory = $false)]
                [datetime]$Date = (Get-Date)
            )
            begin {
                if (!(Test-Path -Path $FilePath)) {
                    throw "Arquivo não encontrado!"
                }
                $Config = Get-PontoConfig
                $fim = $Date.Date.AddDays(-1)
                $inicio = $Date.Date.AddDays(-7)
                $formatar = { param($hora) if ($null -ne $hora) { $hora.ToString("hh\:mm") } else { "" } }
            }
            process {
                $registros = @(Get-PontoEntry | Where-Object { $_.Date -ge $inicio -and $_.Date -le $fim })
                Write-Output "matricula;data;horario-esperado;horario-marcado"
                for ($dia = $inicio; $dia -le $fim; $dia = $dia.AddDays(1)) {
                    if ($dia.DayOfWeek -in @([DayOfWeek]::Saturday, [DayOfWeek]::Sunday)) { continue }
                    $doDia = @($registros | Where-Object { $_.Date -eq $dia })
                    # Falta sinalizada já foi tratada; não é incidente.
                    if ($doDia | Where-Object Label -eq "Falta") { continue }

                    # Posições na ordem do dia: Entrada, Almoço, Retorno, Saída; o retorno esperado é almoço + duração do intervalo.
                    $horas = @($doDia | Sort-Object Time | ForEach-Object { $_.Time.TimeOfDay })
                    $almoco = if ($horas.Count -ge 2) { $horas[1] } else { $Config.InicioAlmoco }
                    $esperados = @($Config.Entrada, $Config.InicioAlmoco, ($almoco + $Config.Almoco), $Config.Saida)

                    for ($i = 0; $i -lt [math]::Max($esperados.Count, $horas.Count); $i++) {
                        $esperado = if ($i -lt $esperados.Count) { $esperados[$i] } else { $null }
                        $marcado = if ($i -lt $horas.Count) { $horas[$i] } else { $null }
                        $incidente = if ($null -eq $marcado -or $null -eq $esperado) {
                            $true
                        }
                        elseif ($i -eq 1) {
                            # Saída para o almoço não tem horário fixo; o intervalo é cobrado no retorno.
                            $false
                        }
                        else {
                            [math]::Abs(($marcado - $esperado).TotalMinutes) -gt $Config.Tolerancia
                        }
                        if ($incidente) {
                            Write-Output ("{0};{1:dd/MM/yyyy};{2};{3}" -f $Config.Matricula, $dia, (& $formatar $esperado), (& $formatar $marcado))
                        }
                    }
                }
            }
        }

        function Dash {
            <#
            .SYNOPSIS
                Exibe a tabela diária e o resumo de banco de horas, atrasos e alertas da CLT de um período.
            .PARAMETER Inicio
                Primeiro dia do período.
            .PARAMETER Fim
                Último dia do período.
            .PARAMETER Limite
                Quantidade de dias exibidos na tabela; 0 mostra todos. O resumo considera o período inteiro.
            .PARAMETER Mensal
                Acrescenta saldo de meses anteriores, banco acumulado e comparação com o mês anterior.
            .PARAMETER PassThru
                Além de exibir, devolve Dias, Resumo e MesAnterior.
            .OUTPUTS
                System.Management.Automation.PSCustomObject, apenas com -PassThru.
            #>

            param(
                [datetime]$Inicio,
                [datetime]$Fim,
                [int]$Limite = 10,
                [switch]$Mensal,
                [switch]$PassThru
            )
            begin {
                if (!(Test-Path -Path $FilePath)) {
                    throw "Arquivo não encontrado!"
                }
                $Config = Get-PontoConfig
                $Esperado = ($Config.Saida - $Config.Entrada - $Config.Almoco).TotalMinutes
            }
            process {
                $todos = @(Get-DiaPonto -Config $Config)
                $dias = @($todos | Where-Object { $_.Data -ge $Inicio.Date -and $_.Data -le $Fim.Date })
                if ($dias.Count -eq 0) {
                    "Nenhum ponto registrado no período." | Out-Host
                    return
                }

                # A tabela mostra só os últimos $Limite dias com registro; o resumo considera o período inteiro.
                $exibidos = if ($Limite -gt 0) { $dias | Select-Object -Last $Limite } else { $dias }
                $exibidos | Format-Table -AutoSize -Wrap @(
                    @{ Label = "Data"; Expression = { $_.Data.ToString("dd/MM ddd") } },
                    @{ Label = "Marcações"; Expression = { $_.Marcacoes } },
                    @{ Label = "Trabalhado"; Expression = { Format-Minuto $_.Trabalhado } },
                    @{ Label = "Intervalo"; Expression = { Format-Minuto $_.Intervalo } },
                    @{ Label = "Atraso"; Expression = { if ($_.Atraso) { Format-Minuto $_.Atraso } } },
                    @{ Label = "Saída antec."; Expression = { if ($_.SaidaAntecipada) { Format-Minuto $_.SaidaAntecipada } } },
                    @{ Label = "Saldo"; Expression = { if ($null -ne $_.Saldo) { Format-Minuto $_.Saldo -Sinal } } },
                    @{ Label = "Obs"; Expression = { $_.Obs } },
                    @{ Label = "Alertas CLT"; Expression = { $_.Alertas } }
                ) | Out-Host

                $r = Get-ResumoPonto -Dias $dias -Esperado $Esperado
                $situacao = if ($r.Banco -gt 0) { "Banco positivo (horas a favor)" } elseif ($r.Banco -lt 0) { "Banco negativo (horas devidas)" } else { "Banco zerado" }
                $resumo = [ordered]@{
                    "Período"                = "{0:dd/MM/yyyy} a {1:dd/MM/yyyy}" -f $r.Inicio, $r.Fim
                    "Jornada"                = "{0:hh\:mm}-{1:hh\:mm}, almoço {2:hh\:mm}, tolerância {3} min ({4}/dia)" -f $Config.Entrada, $Config.Saida, $Config.Almoco, $Config.Tolerancia, (Format-Minuto $Esperado)
                    "Dias com registro"      = $r.DiasComRegistro
                    "Dias no saldo"          = $r.DiasNoSaldo
                    "Dias incompletos"       = $r.DiasIncompletos
                    "Faltas"                 = $r.Faltas
                    "Horas trabalhadas"      = Format-Minuto $r.Trabalhado
                    "Horas esperadas"        = Format-Minuto $r.Esperado
                    "Banco de horas"         = Format-Minuto $r.Banco -Sinal
                    "Situação"               = $situacao
                    "Créditos (extras)"      = Format-Minuto $r.Creditos -Sinal
                    "Débitos"                = Format-Minuto $r.Debitos -Sinal
                    "Atrasos"                = "{0} de {1} dias úteis" -f $r.Atrasos, $r.DiasUteisPresentes
                    "Atraso total"           = Format-Minuto $r.AtrasoTotal
                    "Atraso médio"           = Format-Minuto $r.AtrasoMedio
                    "Saídas antecipadas"     = $r.SaidasAntecipadas
                    "Saída antecipada total" = Format-Minuto $r.SaidaAntecipadaTotal
                    "Entrada média"          = if ($null -ne $r.EntradaMedia) { Format-Minuto $r.EntradaMedia } else { "-" }
                    "Intervalo médio"        = if ($null -ne $r.IntervaloMedio) { Format-Minuto $r.IntervaloMedio } else { "-" }
                    "Dias com alerta CLT"    = $r.DiasComAlerta
                }

                $anterior = $null
                if ($Mensal) {
                    $saldoAntes = [double](@($todos | Where-Object { $_.Data -lt $Inicio.Date -and $null -ne $_.Saldo }) | Measure-Object Saldo -Sum).Sum
                    $resumo["Saldo de meses anteriores"] = Format-Minuto $saldoAntes -Sinal
                    $resumo["Banco acumulado"] = Format-Minuto ($saldoAntes + $r.Banco) -Sinal

                    $inicioAnterior = $Inicio.AddMonths(-1)
                    $diasAnterior = @($todos | Where-Object { $_.Data -ge $inicioAnterior -and $_.Data -lt $Inicio.Date })
                    if ($diasAnterior.Count) {
                        $anterior = Get-ResumoPonto -Dias $diasAnterior -Esperado $Esperado
                        $rotulo = $inicioAnterior.ToString("MMM/yy")
                        $resumo["Saldo vs. $rotulo"] = "{0} (antes {1})" -f (Format-Minuto ($r.Banco - $anterior.Banco) -Sinal), (Format-Minuto $anterior.Banco -Sinal)
                        $resumo["Atraso médio vs. $rotulo"] = "{0} (antes {1})" -f (Format-Minuto ($r.AtrasoMedio - $anterior.AtrasoMedio) -Sinal), (Format-Minuto $anterior.AtrasoMedio)
                        $resumo["Atrasos vs. $rotulo"] = "{0:+0;-0;0} (antes {1})" -f ($r.Atrasos - $anterior.Atrasos), $anterior.Atrasos
                        $resumo["Faltas vs. $rotulo"] = "{0:+0;-0;0} (antes {1})" -f ($r.Faltas - $anterior.Faltas), $anterior.Faltas
                    }
                    else {
                        $resumo["Mês anterior"] = "Sem registros"
                    }
                }

                [pscustomobject]$resumo | Format-List | Out-Host

                if ($PassThru) {
                    [pscustomobject]@{
                        Dias         = $dias
                        Resumo       = $r
                        MesAnterior  = $anterior
                    }
                }
            }
        }
    }
    process {
        switch ($Action) {
            "Registrar" {
                Registrar -Date:$Date
            }
            "Listar" {
                Listar -Date:$Date
            }
            "Falta" {
                Falta -Date:$Date
            }
            "Alertas" {
                Alertas -Date:$Date
            }
            "Dash" {
                if ($Monthly -or $PSBoundParameters.ContainsKey("Mes")) {
                    $hoje = Get-Date
                    $ano = $hoje.Year
                    if (!$PSBoundParameters.ContainsKey("Mes")) {
                        $Mes = $hoje.Month
                    }
                    elseif ($Mes -gt $hoje.Month) {
                        # Mês ainda não chegou neste ano: considera o do ano anterior.
                        $ano--
                    }
                    $primeiroDia = Get-Date -Year $ano -Month $Mes -Day 1 -Hour 0 -Minute 0 -Second 0 -Millisecond 0
                    ("Resumo de {0}" -f $primeiroDia.ToString("MMMM/yyyy")) | Out-Host
                    Dash -Inicio $primeiroDia -Fim $primeiroDia.AddMonths(1).AddDays(-1) -Limite 0 -Mensal -PassThru:$PassThru
                }
                else {
                    Dash -Inicio:$Inicio -Fim:$Fim -PassThru:$PassThru
                }
            }
            default {
                Write-Error "Ação inválida. Use 'Registrar', 'Listar', 'Falta', 'Alertas' ou 'Dash'."
            }
        }
    }
}