Projeto bot pontos

This commit is contained in:
2026-10-23 22:31:02 -03:00
parent 29614855ff
commit b35e1a7d69
116 changed files with 14523 additions and 0 deletions

7
web/lib/access.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { Account } from "./types";
// Conta de leitura ligada a um usuário do Telegram: só vê e altera esse usuário.
// A regra de verdade está na API Go; aqui só se escolhe o que mostrar.
export function isRestricted(account: Account): boolean {
return account.role === "viewer" && account.user_id !== null;
}

68
web/lib/client-api.ts Normal file
View File

@@ -0,0 +1,68 @@
"use client";
// Chamadas feitas pelo navegador. Vão para /api/v1 na mesma origem; o Next
// repassa para a API Go. Toda mutação envia o token CSRF (double submit).
import type { Problem } from "./types";
export class ApiError extends Error {
status: number;
problem: Problem | null;
constructor(status: number, problem: Problem | null) {
super(problem?.title ?? `Erro ${status}`);
this.status = status;
this.problem = problem;
}
/** Mensagem com os erros de campo, se houver. */
details(): string {
const fields = this.problem?.errors ?? [];
if (fields.length === 0) return this.message;
return `${this.message}: ${fields.map((e) => `${e.field} — ${e.message}`).join("; ")}`;
}
}
function readCookie(name: string): string {
const match = document.cookie.split("; ").find((c) => c.startsWith(`${name}=`));
return match ? decodeURIComponent(match.slice(name.length + 1)) : "";
}
export async function apiFetch<T = unknown>(
path: string,
options: { method?: string; body?: unknown } = {},
): Promise<T> {
const method = options.method ?? "GET";
const headers: Record<string, string> = { accept: "application/json" };
if (method !== "GET") {
headers["X-CSRF-Token"] = readCookie("bp_csrf");
}
if (options.body !== undefined) {
headers["content-type"] = "application/json";
}
const res = await fetch(`/api/v1${path}`, {
method,
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
credentials: "same-origin",
});
if (res.status === 401 && path !== "/auth/login") {
window.location.href = "/login";
throw new ApiError(401, null);
}
if (!res.ok) {
const problem = (await res.json().catch(() => null)) as Problem | null;
throw new ApiError(res.status, problem);
}
if (res.status === 204) {
return undefined as T;
}
return (await res.json()) as T;
}
export function errorMessage(err: unknown): string {
if (err instanceof ApiError) return err.details();
if (err instanceof Error) return err.message;
return "Erro inesperado";
}

86
web/lib/format.ts Normal file
View File

@@ -0,0 +1,86 @@
// Formatação para exibição (pt-BR, fuso de Belo Horizonte).
const TZ = "America/Sao_Paulo";
const dateTime = new Intl.DateTimeFormat("pt-BR", {
timeZone: TZ,
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const clock = new Intl.DateTimeFormat("pt-BR", { timeZone: TZ, hour: "2-digit", minute: "2-digit" });
export function fmtDateTime(value: string | null | undefined): string {
return value ? dateTime.format(new Date(value)) : "—";
}
export function fmtClock(value: string | null | undefined, timeZone = TZ): string {
if (!value) return "—";
return timeZone === TZ
? clock.format(new Date(value))
: new Intl.DateTimeFormat("pt-BR", { timeZone, hour: "2-digit", minute: "2-digit" }).format(new Date(value));
}
export function fmtDay(day: string): string {
const [y, m, d] = day.split("-");
return `${d}/${m}/${y}`;
}
export function fmtMinutes(minutes: number): string {
return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}`;
}
export const userStatusLabel: Record<string, string> = {
pending: "aguardando aprovação",
active: "ativo",
disabled: "desativado",
};
export const notificationStatusLabel: Record<string, string> = {
scheduled: "agendado",
sending: "enviando",
sent: "enviado",
failed: "falhou",
cancelled: "cancelado",
expired: "expirado",
};
export const dayStatusLabel: Record<string, string> = {
awaiting: "aguardando primeira consulta",
no_points: "sem pontos (reverificando)",
working: "acompanhando",
not_working: "sem pontos (encerrado)",
finished: "jornada encerrada",
};
export const pauseReasonLabel: Record<string, string> = {
manual: "pausado pelo usuário",
api_failures: "pausado após falhas na PontoMais",
admin: "pausado pelo painel",
user_disabled: "usuário desativado",
};
export function badgeClass(status: string): string {
switch (status) {
case "active":
case "sent":
case "finished":
case "working":
return "badge ok";
case "failed":
case "disabled":
case "expired":
case "not_working":
return "badge error";
case "pending":
case "scheduled":
case "sending":
case "no_points":
return "badge warn";
default:
return "badge";
}
}

34
web/lib/server-api.ts Normal file
View File

@@ -0,0 +1,34 @@
// Chamadas à API Go feitas pelos Server Components, pela rede interna do Docker.
// O cookie de sessão do navegador é repassado; quem autoriza é sempre o Go.
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import type { Problem } from "./types";
const API = process.env.API_INTERNAL_URL ?? "http://api:8080";
export class ForbiddenError extends Error {}
export async function apiGet<T>(path: string): Promise<T> {
const store = await cookies();
const cookie = store
.getAll()
.map((c) => `${c.name}=${c.value}`)
.join("; ");
const res = await fetch(`${API}/api/v1${path}`, {
headers: { cookie, accept: "application/json" },
cache: "no-store",
});
if (res.status === 401) {
redirect("/login");
}
if (res.status === 403) {
throw new ForbiddenError("Seu papel não permite acessar esta página.");
}
if (!res.ok) {
const problem = (await res.json().catch(() => null)) as Problem | null;
throw new Error(problem?.title ?? `Erro ${res.status} ao consultar a API`);
}
return (await res.json()) as T;
}

174
web/lib/types.ts Normal file
View File

@@ -0,0 +1,174 @@
// Tipos da API (espelham backend/internal/httpapi/dto.go e api/openapi.yaml).
export type Role = "owner" | "admin" | "viewer";
export type UserStatus = "pending" | "active" | "disabled";
export type NotificationStatus = "scheduled" | "sending" | "sent" | "failed" | "cancelled" | "expired";
export type NotificationType = "entrada2" | "saida2";
export type DayStatus = "awaiting" | "no_points" | "working" | "not_working" | "finished";
export interface Problem {
type: string;
title: string;
status: number;
code?: string;
errors?: { field: string; message: string }[];
}
export interface Account {
id: number;
username: string;
role: Role;
user_id: number | null;
disabled: boolean;
locked_until: string | null;
last_login_at: string | null;
created_at: string;
}
export interface Me {
account: Account;
expires_at: string;
}
export interface User {
id: number;
telegram_chat_id: number;
display_name: string;
status: UserStatus;
is_bot_admin: boolean;
notifications_enabled: boolean;
paused_reason: string | null;
timezone: string;
created_at: string;
}
export interface UserSummary extends User {
has_credential: boolean;
credential_validated: boolean;
consecutive_failures: number;
custom_window: boolean;
}
export interface QueryWindow {
query_window_start: string;
query_window_end: string;
day_check_deadline: string;
no_points_recheck_until: string;
}
export interface Schedule {
lunch_minutes: number;
daily_work_minutes: number;
entrada2_lead_minutes: number;
saida2_lead_minutes: number;
window: QueryWindow | null;
}
export interface Credential {
api_email: string;
validated: boolean;
last_validated_at: string | null;
last_error: string | null;
consecutive_failures: number;
last_failure_at: string | null;
updated_at: string;
}
export interface Day {
day: string;
status: DayStatus;
zero_point_checks: number;
last_point_count: number;
next_check_at: string | null;
resumed_by: string | null;
}
export interface UserDetail {
user: User;
schedule: Schedule;
credential: Credential | null;
today: Day;
effective_window: QueryWindow;
custom_window: boolean;
}
export interface Today {
day: string;
timezone: string;
points: string[];
invalid: boolean;
overtime: boolean;
entrada2_at: string | null;
saida2_at: string | null;
saida2_error?: string;
day_state: Day;
window: QueryWindow;
custom_window: boolean;
}
export interface Notification {
id: number;
user_id: number;
user_name?: string;
reference_date: string;
type: NotificationType;
scheduled_at: string;
predicted_at: string;
status: NotificationStatus;
attempts: number;
sent_at: string | null;
error_message: string | null;
}
export interface AuditLog {
id: number;
occurred_at: string;
source: string;
actor_user_id: number | null;
actor_user_name: string | null;
actor_account: number | null;
actor_username: string | null;
target_user_id: number | null;
target_user_name: string | null;
event_type: string;
success: boolean;
details: Record<string, unknown>;
}
export interface Holiday {
day: string;
name: string;
source: string;
}
export interface Dashboard {
users_by_status: Record<string, number>;
notifications_enabled: number;
paused_by_api_failures: number;
today_by_status: Record<string, number>;
failed_last_7_days: number;
expired_last_7_days: number;
custom_windows: number;
outage_since: string | null;
holiday_fallback_day: string | null;
today: string;
}
export interface Settings {
window: QueryWindow;
reconciliation_minutes: number;
dispatch_minutes: number;
late_tolerance_minutes: number;
api_failure_threshold: number;
slow_check_minutes: number;
}
export interface WindowPreview {
errors: { field: string; message: string }[];
max_queries_per_user: number;
typical_queries_per_user: number;
day_off_queries_per_user: number;
affected_users: number;
affected_days: number;
custom_window_users: number;
}