Projeto bot pontos
This commit is contained in:
7
web/lib/access.ts
Normal file
7
web/lib/access.ts
Normal 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
68
web/lib/client-api.ts
Normal 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
86
web/lib/format.ts
Normal 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
34
web/lib/server-api.ts
Normal 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
174
web/lib/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user