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

3
web/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.next
*.md

32
web/Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
# syntax=docker/dockerfile:1
# ---- dependências ----
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
# Enquanto o package-lock.json não existir no repositório, usa npm install (ver README).
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
# ---- build ----
FROM node:22-alpine AS build
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
# Os rewrites do Next são resolvidos no build, então o endereço da API entra aqui.
ARG API_INTERNAL_URL=http://api:8080
ENV API_INTERNAL_URL=${API_INTERNAL_URL}
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# ---- runtime ----
FROM node:22-alpine AS run
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0
COPY --from=build --chown=node:node /app/.next/standalone ./
COPY --from=build --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]

View File

@@ -0,0 +1,103 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { apiGet } from "@/lib/server-api";
import { fmtDateTime } from "@/lib/format";
import { isRestricted } from "@/lib/access";
import type { AuditLog, Me } from "@/lib/types";
type Search = Promise<Record<string, string | string[] | undefined>>;
const FILTERS = ["from", "to", "source", "event_type", "success", "target_user_id", "actor_account"];
export default async function AuditPage({ searchParams }: { searchParams: Search }) {
const me = await apiGet<Me>("/auth/me");
if (isRestricted(me.account)) redirect("/");
const sp = await searchParams;
const params = new URLSearchParams();
for (const key of [...FILTERS, "before"]) {
const v = sp[key];
if (typeof v === "string" && v) params.set(key, v);
}
const { logs, next_before } = await apiGet<{ logs: AuditLog[]; next_before: number | null }>(`/audit?${params}`);
const value = (key: string) => (typeof sp[key] === "string" ? (sp[key] as string) : "");
const nextParams = new URLSearchParams(params);
if (next_before) nextParams.set("before", String(next_before));
return (
<>
<h1>Auditoria</h1>
<form className="filters" method="get">
<label>
De <input type="date" name="from" defaultValue={value("from")} />
</label>
<label>
Até <input type="date" name="to" defaultValue={value("to")} />
</label>
<select name="source" defaultValue={value("source")}>
<option value="">Todas as origens</option>
<option value="telegram">Telegram</option>
<option value="web">Painel</option>
<option value="worker">Worker</option>
<option value="cli">Terminal</option>
</select>
<input name="event_type" placeholder="Evento (ex.: user_activated)" defaultValue={value("event_type")} />
<select name="success" defaultValue={value("success")}>
<option value="">Qualquer resultado</option>
<option value="true">Sucesso</option>
<option value="false">Falha</option>
</select>
{value("target_user_id") && <input type="hidden" name="target_user_id" value={value("target_user_id")} />}
<button className="btn secondary" type="submit">
Filtrar
</button>
<Link href="/auditoria">Limpar</Link>
</form>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Quando</th>
<th>Origem</th>
<th>Quem</th>
<th>Evento</th>
<th>Alvo</th>
<th>Resultado</th>
<th>Detalhes</th>
</tr>
</thead>
<tbody>
{logs.length === 0 && (
<tr>
<td colSpan={7} className="muted">
Nenhum evento encontrado.
</td>
</tr>
)}
{logs.map((l) => (
<tr key={l.id}>
<td className="nowrap">{fmtDateTime(l.occurred_at)}</td>
<td>{l.source}</td>
<td>{l.actor_username ?? l.actor_user_name ?? "—"}</td>
<td>
<code>{l.event_type}</code>
</td>
<td>{l.target_user_id ? <Link href={`/usuarios/${l.target_user_id}`}>{l.target_user_name}</Link> : "—"}</td>
<td>{l.success ? <span className="badge ok">sucesso</span> : <span className="badge error">falha</span>}</td>
<td className="small">
{Object.keys(l.details).length > 0 && <code className="details">{JSON.stringify(l.details)}</code>}
</td>
</tr>
))}
</tbody>
</table>
</div>
{next_before && (
<p>
<Link href={`/auditoria?${nextParams}`}>Mais antigos →</Link>
</p>
)}
</>
);
}

View File

@@ -0,0 +1,88 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";
import { WindowFields } from "@/components/WindowFields";
import { apiFetch, errorMessage } from "@/lib/client-api";
import type { QueryWindow, WindowPreview } from "@/lib/types";
export function SettingsForm({ initial, disabled }: { initial: QueryWindow; disabled: boolean }) {
const router = useRouter();
const [value, setValue] = useState<QueryWindow>(initial);
const [preview, setPreview] = useState<WindowPreview | null>(null);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const [busy, setBusy] = useState(false);
async function runPreview() {
setBusy(true);
setMessage(null);
try {
setPreview(await apiFetch<WindowPreview>("/settings/preview", { method: "POST", body: value }));
} catch (err) {
setMessage({ ok: false, text: errorMessage(err) });
} finally {
setBusy(false);
}
}
async function save(e: FormEvent) {
e.preventDefault();
if (!window.confirm("Salvar a nova janela global? Ela vale para todos os usuários sem janela personalizada.")) return;
setBusy(true);
setMessage(null);
try {
await apiFetch("/settings", { method: "PUT", body: value });
setMessage({ ok: true, text: "Janela salva." });
router.refresh();
} catch (err) {
setMessage({ ok: false, text: errorMessage(err) });
} finally {
setBusy(false);
}
}
return (
<form onSubmit={save}>
<WindowFields value={value} onChange={(w) => { setValue(w); setPreview(null); }} disabled={disabled} />
{!disabled && (
<div className="actions">
<button className="btn secondary" type="button" onClick={runPreview} disabled={busy}>
Pré-visualizar efeito
</button>
<button className="btn primary" type="submit" disabled={busy}>
Salvar
</button>
</div>
)}
{message && <p className={message.ok ? "ok-text" : "error-text"}>{message.text}</p>}
{preview && (
<div className="preview">
{preview.errors.length > 0 ? (
<ul className="error-text">
{preview.errors.map((e) => (
<li key={e.field + e.message}>{e.message}</li>
))}
</ul>
) : (
<dl className="kv">
<dt>Consultas por usuário (máximo)</dt>
<dd>{preview.max_queries_per_user} por dia</dd>
<dt>Consultas num dia típico</dt>
<dd>{preview.typical_queries_per_user} por usuário</dd>
<dt>Consultas num dia de folga</dt>
<dd>{preview.day_off_queries_per_user} por usuário</dd>
<dt>Lembretes que sairiam atrasados</dt>
<dd>
{preview.affected_users} usuário(s), {preview.affected_days} dia(s) nos últimos 30 dias (aviso de retorno
antes do início da janela)
</dd>
<dt>Não afetados</dt>
<dd>{preview.custom_window_users} usuário(s) com janela personalizada</dd>
</dl>
)}
</div>
)}
</form>
);
}

View File

@@ -0,0 +1,43 @@
import { redirect } from "next/navigation";
import { isRestricted } from "@/lib/access";
import { apiGet } from "@/lib/server-api";
import type { Me, Settings } from "@/lib/types";
import { SettingsForm } from "./form";
export default async function SettingsPage() {
const me = await apiGet<Me>("/auth/me");
if (isRestricted(me.account)) redirect("/");
const settings = await apiGet<Settings>("/settings");
return (
<>
<h1>Configurações</h1>
<section className="card">
<h2>Janela de consulta global</h2>
<p className="muted">
A PontoMais só é consultada dentro desta janela, no fuso de cada usuário. Usuários com janela personalizada não
são afetados por mudanças aqui. A mudança vale a partir da próxima consulta.
</p>
<SettingsForm initial={settings.window} disabled={me.account.role === "viewer"} />
</section>
<section className="card">
<h2>Parâmetros do servidor</h2>
<p className="muted">Definidos por variáveis de ambiente; alterá-los exige reiniciar os containers.</p>
<dl className="kv">
<dt>Reconciliação</dt>
<dd>a cada {settings.reconciliation_minutes} min</dd>
<dt>Consultas lentas</dt>
<dd>a cada {settings.slow_check_minutes} min (dia sem pontos e tarde com 3 pontos)</dd>
<dt>Despacho dos lembretes</dt>
<dd>a cada {settings.dispatch_minutes} min</dd>
<dt>Tolerância de atraso</dt>
<dd>{settings.late_tolerance_minutes} min (acima disso o lembrete expira)</dd>
<dt>Pausa por falhas</dt>
<dd>após {settings.api_failure_threshold} falhas seguidas na PontoMais</dd>
</dl>
</section>
</>
);
}

View File

@@ -0,0 +1,164 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";
import { ActionButton } from "@/components/ActionButton";
import { apiFetch, errorMessage } from "@/lib/client-api";
import type { Account, Role } from "@/lib/types";
const ROLES: { value: Role; label: string }[] = [
{ value: "viewer", label: "leitura" },
{ value: "admin", label: "admin" },
{ value: "owner", label: "owner" },
];
export type UserOption = { id: number; name: string };
function UserSelect({
users,
value,
onChange,
label,
}: {
users: UserOption[];
value: number | null;
onChange: (id: number | null) => void;
label: string;
}) {
return (
<select value={value ?? ""} onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)} aria-label={label}>
<option value="">Sem usuário ligado</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
);
}
export function CreateAccountForm({ users }: { users: UserOption[] }) {
const router = useRouter();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>("admin");
const [userId, setUserId] = useState<number | null>(null);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setMessage(null);
try {
await apiFetch("/accounts", { method: "POST", body: { username, password, role, user_id: userId } });
setUsername("");
setUserId(null);
setPassword("");
setMessage({ ok: true, text: "Conta criada." });
router.refresh();
} catch (err) {
setMessage({ ok: false, text: errorMessage(err) });
} finally {
setBusy(false);
}
}
return (
<form className="inline-form" onSubmit={submit} autoComplete="off">
<label className="field">
<span>Usuário</span>
<input value={username} onChange={(e) => setUsername(e.target.value)} pattern="[a-zA-Z0-9._-]{3,50}" required />
</label>
<label className="field">
<span>Senha (mínimo 12 caracteres)</span>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} minLength={12} autoComplete="new-password" required />
</label>
<label className="field">
<span>Papel</span>
<select value={role} onChange={(e) => setRole(e.target.value as Role)}>
{ROLES.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
</label>
<label className="field">
<span>Usuário do Telegram (leitura só vê este)</span>
<UserSelect users={users} value={userId} onChange={setUserId} label="Usuário do Telegram" />
</label>
<button className="btn primary" type="submit" disabled={busy}>
Criar
</button>
{message && <p className={message.ok ? "ok-text" : "error-text"}>{message.text}</p>}
</form>
);
}
export function AccountActions({ account, isSelf, users }: { account: Account; isSelf: boolean; users: UserOption[] }) {
const router = useRouter();
const [error, setError] = useState("");
async function changeRole(role: Role) {
setError("");
try {
await apiFetch(`/accounts/${account.id}`, { method: "PATCH", body: { role } });
router.refresh();
} catch (err) {
setError(errorMessage(err));
}
}
async function changeUser(userId: number | null) {
setError("");
try {
await apiFetch(`/accounts/${account.id}`, { method: "PATCH", body: { user_id: userId } });
router.refresh();
} catch (err) {
setError(errorMessage(err));
}
}
async function resetPassword() {
const password = window.prompt(`Nova senha para ${account.username} (mínimo 12 caracteres):`);
if (!password) return;
setError("");
try {
await apiFetch(`/accounts/${account.id}/reset-password`, { method: "POST", body: { password } });
window.alert("Senha redefinida. As sessões abertas dessa conta foram encerradas.");
} catch (err) {
setError(errorMessage(err));
}
}
return (
<div className="actions">
<select value={account.role} onChange={(e) => changeRole(e.target.value as Role)} aria-label="Papel">
{ROLES.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
<UserSelect users={users} value={account.user_id} onChange={changeUser} label="Usuário do Telegram" />
<button type="button" className="btn secondary" onClick={resetPassword}>
Redefinir senha
</button>
{!isSelf &&
(account.disabled ? (
<ActionButton path={`/accounts/${account.id}`} method="PATCH" body={{ disabled: false }} label="Reativar" />
) : (
<ActionButton
path={`/accounts/${account.id}`}
method="PATCH"
body={{ disabled: true }}
label="Desativar"
variant="danger"
confirm={`Desativar a conta ${account.username}? As sessões abertas serão encerradas.`}
/>
))}
{error && <span className="error-text">{error}</span>}
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { ForbiddenError, apiGet } from "@/lib/server-api";
import { fmtDateTime } from "@/lib/format";
import type { Account, Me, UserSummary } from "@/lib/types";
import { AccountActions, CreateAccountForm } from "./forms";
export default async function AccountsPage() {
const me = await apiGet<Me>("/auth/me");
if (me.account.role !== "owner") {
return (
<>
<h1>Contas do painel</h1>
<p className="muted">Somente contas owner gerenciam as contas do painel.</p>
</>
);
}
let accounts: Account[] = [];
let users: UserSummary[] = [];
try {
[accounts, users] = await Promise.all([
apiGet<{ accounts: Account[] }>("/accounts").then((r) => r.accounts),
apiGet<{ users: UserSummary[] }>("/users").then((r) => r.users),
]);
} catch (err) {
if (!(err instanceof ForbiddenError)) throw err;
}
const userOptions = users.map((u) => ({ id: u.id, name: u.display_name }));
return (
<>
<h1>Contas do painel</h1>
<p className="muted">
<strong>owner</strong> gerencia contas; <strong>admin</strong> altera usuários, lembretes, feriados e
configurações; <strong>leitura</strong> só consulta. Uma conta de leitura ligada a um usuário do Telegram enxerga e
altera somente os dados desse usuário. Sempre existe pelo menos um owner ativo.
</p>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Usuário</th>
<th>Papel</th>
<th>Usuário do Telegram</th>
<th>Situação</th>
<th>Último login</th>
<th></th>
</tr>
</thead>
<tbody>
{accounts.map((a) => (
<tr key={a.id}>
<td>
{a.username}
{a.id === me.account.id && <span className="muted"> (você)</span>}
</td>
<td>{a.role === "viewer" ? "leitura" : a.role}</td>
<td>{userOptions.find((u) => u.id === a.user_id)?.name ?? <span className="muted">—</span>}</td>
<td>
{a.disabled ? (
<span className="badge error">desativada</span>
) : a.locked_until && new Date(a.locked_until) > new Date() ? (
<span className="badge warn">bloqueada até {fmtDateTime(a.locked_until)}</span>
) : (
<span className="badge ok">ativa</span>
)}
</td>
<td>{fmtDateTime(a.last_login_at)}</td>
<td>
<AccountActions account={a} isSelf={a.id === me.account.id} users={userOptions} />
</td>
</tr>
))}
</tbody>
</table>
</div>
<section className="card">
<h2>Nova conta</h2>
<CreateAccountForm users={userOptions} />
</section>
</>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, type FormEvent } from "react";
import { apiFetch, errorMessage } from "@/lib/client-api";
export function AddHolidayForm() {
const router = useRouter();
const [day, setDay] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setError("");
try {
await apiFetch("/holidays", { method: "POST", body: { day, name, source: "manual" } });
setDay("");
setName("");
router.refresh();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<form className="inline-form" onSubmit={submit}>
<label className="field">
<span>Data</span>
<input type="date" value={day} onChange={(e) => setDay(e.target.value)} required />
</label>
<label className="field">
<span>Nome</span>
<input value={name} onChange={(e) => setName(e.target.value)} maxLength={120} required />
</label>
<button className="btn primary" type="submit" disabled={busy}>
Cadastrar
</button>
{error && <p className="error-text">{error}</p>}
</form>
);
}

View File

@@ -0,0 +1,86 @@
import Link from "next/link";
import { ActionButton } from "@/components/ActionButton";
import { apiGet } from "@/lib/server-api";
import { fmtDay } from "@/lib/format";
import type { Holiday, Me } from "@/lib/types";
import { AddHolidayForm } from "./form";
type Search = Promise<Record<string, string | string[] | undefined>>;
export default async function HolidaysPage({ searchParams }: { searchParams: Search }) {
const sp = await searchParams;
const yearParam = typeof sp.year === "string" ? sp.year : "";
const [{ year, holidays }, me] = await Promise.all([
apiGet<{ year: number; holidays: Holiday[] }>(`/holidays${yearParam ? `?year=${encodeURIComponent(yearParam)}` : ""}`),
apiGet<Me>("/auth/me"),
]);
const canEdit = me.account.role !== "viewer";
return (
<>
<h1>Feriados de {year}</h1>
<p className="muted">
Dias úteis: fim de semana não conta; depois vale esta tabela (feriados da feriadosapi.com e manuais). Se a API não
responder e o ano não tiver sido sincronizado, segunda a sexta contam como úteis e os admins são avisados.
</p>
<p>
<Link href={`/feriados?year=${year - 1}`}>← {year - 1}</Link> · <Link href={`/feriados?year=${year + 1}`}>{year + 1} →</Link>
</p>
{canEdit && (
<div className="actions">
<ActionButton path="/holidays/sync" body={{ year }} label={`Sincronizar ${year} com a feriadosapi.com`} />
</div>
)}
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Data</th>
<th>Nome</th>
<th>Origem</th>
{canEdit && <th></th>}
</tr>
</thead>
<tbody>
{holidays.length === 0 && (
<tr>
<td colSpan={canEdit ? 4 : 3} className="muted">
Nenhum feriado registrado para {year}.
</td>
</tr>
)}
{holidays.map((h) => (
<tr key={h.day}>
<td>{fmtDay(h.day)}</td>
<td>{h.name}</td>
<td>{h.source === "manual" ? <span className="badge warn">manual</span> : h.source}</td>
{canEdit && (
<td>
{h.source === "manual" && (
<ActionButton
path={`/holidays/${h.day}`}
method="DELETE"
label="Remover"
variant="danger"
confirm={`Remover o feriado de ${fmtDay(h.day)}?`}
/>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
{canEdit && (
<section className="card">
<h2>Cadastrar feriado manual</h2>
<AddHolidayForm />
</section>
)}
</>
);
}

View File

@@ -0,0 +1,44 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { LogoutButton } from "@/components/LogoutButton";
import { apiGet } from "@/lib/server-api";
import { isRestricted } from "@/lib/access";
import type { Me } from "@/lib/types";
export const dynamic = "force-dynamic";
const roleLabel = { owner: "owner", admin: "admin", viewer: "leitura" } as const;
export default async function PainelLayout({ children }: { children: ReactNode }) {
const me = await apiGet<Me>("/auth/me");
const isOwner = me.account.role === "owner";
const restricted = isRestricted(me.account);
return (
<>
<header className="topbar">
<div className="topbar-inner">
<Link href="/" className="brand">
Bot de Pontos
</Link>
<nav>
<Link href="/">Início</Link>
<Link href="/usuarios">Usuários</Link>
<Link href="/notificacoes">Lembretes</Link>
{!restricted && <Link href="/auditoria">Auditoria</Link>}
<Link href="/feriados">Feriados</Link>
{!restricted && <Link href="/configuracoes">Configurações</Link>}
{isOwner && <Link href="/contas">Contas</Link>}
</nav>
<div className="whoami">
<span className="muted">
{me.account.username} ({roleLabel[me.account.role]})
</span>
<LogoutButton />
</div>
</div>
</header>
<main className="container">{children}</main>
</>
);
}

View File

@@ -0,0 +1,112 @@
import Link from "next/link";
import { ActionButton } from "@/components/ActionButton";
import { apiGet } from "@/lib/server-api";
import { badgeClass, fmtClock, fmtDay, notificationStatusLabel } from "@/lib/format";
import type { Me, Notification } from "@/lib/types";
type Search = Promise<Record<string, string | string[] | undefined>>;
export default async function NotificationsPage({ searchParams }: { searchParams: Search }) {
const sp = await searchParams;
const params = new URLSearchParams();
for (const key of ["day", "status", "type", "user_id"]) {
const v = sp[key];
if (typeof v === "string" && v) params.set(key, v);
}
const [{ notifications }, me] = await Promise.all([
apiGet<{ notifications: Notification[] }>(`/notifications?${params}`),
apiGet<Me>("/auth/me"),
]);
const canEdit = me.account.role !== "viewer";
const value = (key: string) => (typeof sp[key] === "string" ? (sp[key] as string) : "");
return (
<>
<h1>Lembretes</h1>
<form className="filters" method="get">
<input type="date" name="day" defaultValue={value("day")} />
<select name="status" defaultValue={value("status")}>
<option value="">Todos os status</option>
{Object.entries(notificationStatusLabel).map(([k, label]) => (
<option key={k} value={k}>
{label}
</option>
))}
</select>
<select name="type" defaultValue={value("type")}>
<option value="">Todos os tipos</option>
<option value="entrada2">Retorno do almoço</option>
<option value="saida2">Saída</option>
</select>
{value("user_id") && <input type="hidden" name="user_id" value={value("user_id")} />}
<button className="btn secondary" type="submit">
Filtrar
</button>
<Link href="/notificacoes">Limpar</Link>
</form>
<p className="muted">
<strong>expirado</strong> = chegou tarde demais (mais de 10 min de atraso) e não foi enviado;{" "}
<strong>falhou</strong> = erro no envio pelo Telegram. Só lembretes com falha do dia atual podem ser reenviados.
</p>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Dia</th>
<th>Usuário</th>
<th>Tipo</th>
<th>Aviso</th>
<th>Previsto</th>
<th>Status</th>
<th>Detalhe</th>
{canEdit && <th></th>}
</tr>
</thead>
<tbody>
{notifications.length === 0 && (
<tr>
<td colSpan={canEdit ? 8 : 7} className="muted">
Nenhum lembrete encontrado.
</td>
</tr>
)}
{notifications.map((n) => (
<tr key={n.id}>
<td>{fmtDay(n.reference_date)}</td>
<td>
<Link href={`/usuarios/${n.user_id}`}>{n.user_name}</Link>
</td>
<td>{n.type === "entrada2" ? "retorno" : "saída"}</td>
<td>{fmtClock(n.scheduled_at)}</td>
<td>{fmtClock(n.predicted_at)}</td>
<td>
<span className={badgeClass(n.status)}>{notificationStatusLabel[n.status]}</span>
</td>
<td className="muted small">{n.error_message ?? (n.sent_at ? `enviado às ${fmtClock(n.sent_at)}` : "")}</td>
{canEdit && (
<td>
{n.status === "scheduled" && (
<ActionButton path={`/notifications/${n.id}/cancel`} label="Cancelar" confirm="Cancelar este lembrete?" />
)}
{n.status === "failed" && (
<ActionButton
path={`/notifications/${n.id}/resend`}
label="Reenviar"
variant="primary"
confirm={
"Reenviar este lembrete agora?\n\nSe a falha foi um travamento durante o envio, o usuário pode já ter recebido a mensagem original."
}
/>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</>
);
}

79
web/app/(painel)/page.tsx Normal file
View File

@@ -0,0 +1,79 @@
import Link from "next/link";
import { apiGet } from "@/lib/server-api";
import { fmtDateTime, fmtDay, notificationStatusLabel } from "@/lib/format";
import { isRestricted } from "@/lib/access";
import type { Dashboard, Me } from "@/lib/types";
export default async function DashboardPage() {
const [d, me] = await Promise.all([apiGet<Dashboard>("/dashboard"), apiGet<Me>("/auth/me")]);
const users = d.users_by_status;
const restricted = isRestricted(me.account);
return (
<>
<h1>Início</h1>
{d.outage_since && (
<div className="alert error">
A PontoMais está fora do ar desde {fmtDateTime(d.outage_since)}. Ninguém foi pausado; os admins foram avisados
pelo Telegram.
</div>
)}
{d.holiday_fallback_day && (
<div className="alert warn">
Não foi possível obter os feriados deste ano: segunda a sexta estão valendo como dias úteis.{" "}
<Link href="/feriados">Ver feriados</Link>
</div>
)}
{!restricted && (
<section className="stats">
<Stat label="Aguardando aprovação" value={users.pending ?? 0} href="/usuarios?status=pending" />
<Stat label="Usuários ativos" value={users.active ?? 0} href="/usuarios?status=active" />
<Stat label="Com lembretes ligados" value={d.notifications_enabled} />
<Stat label="Pausados por falha na PontoMais" value={d.paused_by_api_failures} tone={d.paused_by_api_failures ? "error" : undefined} />
<Stat label="Falhas de envio (7 dias)" value={d.failed_last_7_days} href="/notificacoes?status=failed" tone={d.failed_last_7_days ? "error" : undefined} />
<Stat label="Expirados (7 dias)" value={d.expired_last_7_days} href="/notificacoes?status=expired" />
<Stat label="Janelas personalizadas" value={d.custom_windows} href="/usuarios?custom_window=true" />
</section>
)}
<section className="card">
<h2>Lembretes de hoje ({fmtDay(d.today)})</h2>
{Object.keys(d.today_by_status).length === 0 ? (
<p className="muted">Nenhum lembrete gerado hoje.</p>
) : (
<ul className="inline-list">
{Object.entries(d.today_by_status).map(([status, count]) => (
<li key={status}>
{restricted ? (
<span>
{notificationStatusLabel[status] ?? status}: <strong>{count}</strong>
</span>
) : (
<Link href={`/notificacoes?day=${d.today}&status=${status}`}>
{notificationStatusLabel[status] ?? status}: <strong>{count}</strong>
</Link>
)}
</li>
))}
</ul>
)}
</section>
</>
);
}
function Stat({ label, value, href, tone }: { label: string; value: number; href?: string; tone?: "error" }) {
const content = (
<>
<span className="stat-value">{value}</span>
<span className="stat-label">{label}</span>
</>
);
return (
<div className={`stat ${tone ?? ""}`}>
{href ? <Link href={href}>{content}</Link> : content}
</div>
);
}

View File

@@ -0,0 +1,209 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, type ChangeEvent, type FormEvent } from "react";
import { WindowFields } from "@/components/WindowFields";
import { apiFetch, errorMessage } from "@/lib/client-api";
import { dayStatusLabel, fmtClock, fmtMinutes } from "@/lib/format";
import type { QueryWindow, Schedule, Today } from "@/lib/types";
/** Grava e valida a credencial na PontoMais; a anterior é mantida se a validação falhar. */
export function CredentialsForm({ userId }: { userId: number }) {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setMessage(null);
try {
await apiFetch(`/users/${userId}/credentials`, { method: "PUT", body: { email, password } });
setPassword("");
setMessage({ ok: true, text: "Credencial validada na PontoMais e gravada." });
router.refresh();
} catch (err) {
setMessage({ ok: false, text: errorMessage(err) });
} finally {
setBusy(false);
}
}
return (
<form className="inline-form" onSubmit={submit} autoComplete="off">
<label className="field">
<span>E-mail de login na PontoMais</span>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label className="field">
<span>Senha da PontoMais</span>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" required />
</label>
<button className="btn primary" type="submit" disabled={busy}>
{busy ? "Validando…" : "Validar e salvar"}
</button>
{message && <p className={message.ok ? "ok-text" : "error-text"}>{message.text}</p>}
</form>
);
}
export function ScheduleForm({
userId,
schedule,
timezone,
effectiveWindow,
disabled,
}: {
userId: number;
schedule: Schedule;
timezone: string;
effectiveWindow: QueryWindow;
disabled: boolean;
}) {
const router = useRouter();
const [form, setForm] = useState({
lunch_minutes: schedule.lunch_minutes,
daily_work_minutes: schedule.daily_work_minutes,
entrada2_lead_minutes: schedule.entrada2_lead_minutes,
saida2_lead_minutes: schedule.saida2_lead_minutes,
timezone,
});
const [useGlobal, setUseGlobal] = useState(schedule.window === null);
const [windowValue, setWindowValue] = useState<QueryWindow>(schedule.window ?? effectiveWindow);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const [busy, setBusy] = useState(false);
function number(key: keyof typeof form) {
return (e: ChangeEvent<HTMLInputElement>) => setForm({ ...form, [key]: Number(e.target.value) });
}
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setMessage(null);
try {
await apiFetch(`/users/${userId}/schedule`, {
method: "PUT",
body: { ...form, window: useGlobal ? null : windowValue },
});
setMessage({ ok: true, text: "Jornada salva. Vale a partir da próxima consulta." });
router.refresh();
} catch (err) {
setMessage({ ok: false, text: errorMessage(err) });
} finally {
setBusy(false);
}
}
return (
<form onSubmit={submit}>
<div className="grid-4">
<label className="field">
<span>Almoço (min)</span>
<input type="number" min={15} max={180} value={form.lunch_minutes} onChange={number("lunch_minutes")} disabled={disabled} />
</label>
<label className="field">
<span>Jornada (min) — {fmtMinutes(form.daily_work_minutes)}</span>
<input type="number" min={60} max={720} value={form.daily_work_minutes} onChange={number("daily_work_minutes")} disabled={disabled} />
</label>
<label className="field">
<span>Antecedência retorno (min)</span>
<input type="number" min={0} max={60} value={form.entrada2_lead_minutes} onChange={number("entrada2_lead_minutes")} disabled={disabled} />
</label>
<label className="field">
<span>Antecedência saída (min)</span>
<input type="number" min={0} max={60} value={form.saida2_lead_minutes} onChange={number("saida2_lead_minutes")} disabled={disabled} />
</label>
</div>
<label className="field">
<span>Fuso horário</span>
<input value={form.timezone} onChange={(e) => setForm({ ...form, timezone: e.target.value })} disabled={disabled} />
</label>
<fieldset className="fieldset">
<legend>Janela de consulta</legend>
<label className="checkbox">
<input type="checkbox" checked={useGlobal} onChange={(e) => setUseGlobal(e.target.checked)} disabled={disabled} />
Usar padrão global
</label>
{useGlobal ? (
<p className="muted">
Janela global: {effectiveWindow.query_window_start} às {effectiveWindow.query_window_end} (confirmação{" "}
{effectiveWindow.day_check_deadline}, reverificações até {effectiveWindow.no_points_recheck_until}).
</p>
) : (
<WindowFields value={windowValue} onChange={setWindowValue} disabled={disabled} />
)}
</fieldset>
{!disabled && (
<button className="btn primary" type="submit" disabled={busy}>
{busy ? "Salvando…" : "Salvar jornada"}
</button>
)}
{message && <p className={message.ok ? "ok-text" : "error-text"}>{message.text}</p>}
</form>
);
}
/** Consulta ao vivo na PontoMais (não é automática para não gastar consultas à toa). */
export function TodayPanel({ userId, canTest }: { userId: number; canTest: boolean }) {
const [today, setToday] = useState<Today | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function load(test: boolean) {
setBusy(true);
setError("");
try {
const path = test ? `/users/${userId}/credentials/test` : `/users/${userId}/today`;
setToday(await apiFetch<Today>(path, { method: test ? "POST" : "GET" }));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div>
<div className="actions">
<button className="btn secondary" type="button" onClick={() => load(false)} disabled={busy}>
{busy ? "Consultando…" : "Consultar PontoMais agora"}
</button>
{canTest && (
<button className="btn secondary" type="button" onClick={() => load(true)} disabled={busy}>
Testar credencial
</button>
)}
</div>
{error && <p className="error-text">{error}</p>}
{today && (
<dl className="kv">
<dt>Pontos</dt>
<dd>{today.points.length ? today.points.map((p) => fmtClock(p, today.timezone)).join(" · ") : "nenhum"}</dd>
{today.invalid && (
<>
<dt>Atenção</dt>
<dd className="error-text">pontos repetidos ou fora de ordem</dd>
</>
)}
{today.overtime && (
<>
<dt>Atenção</dt>
<dd>mais de quatro pontos (hora extra, fora do escopo)</dd>
</>
)}
<dt>Retorno previsto</dt>
<dd>{fmtClock(today.entrada2_at, today.timezone)}</dd>
<dt>Saída prevista</dt>
<dd>{today.saida2_error ? <span className="error-text">{today.saida2_error}</span> : fmtClock(today.saida2_at, today.timezone)}</dd>
<dt>Situação do dia</dt>
<dd>{dayStatusLabel[today.day_state.status]}</dd>
</dl>
)}
</div>
);
}

View File

@@ -0,0 +1,137 @@
import Link from "next/link";
import { apiGet } from "@/lib/server-api";
import { badgeClass, dayStatusLabel, fmtDateTime, pauseReasonLabel, userStatusLabel } from "@/lib/format";
import { isRestricted } from "@/lib/access";
import type { Me, UserDetail } from "@/lib/types";
import { ActionButton } from "@/components/ActionButton";
import { CredentialsForm, ScheduleForm, TodayPanel } from "./forms";
export default async function UserPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [detail, me] = await Promise.all([apiGet<UserDetail>(`/users/${id}`), apiGet<Me>("/auth/me")]);
const { user, credential, today } = detail;
const restricted = isRestricted(me.account);
const canAdmin = me.account.role !== "viewer";
// Conta de leitura ligada a este usuário também altera as próprias configurações.
const canEdit = canAdmin || me.account.user_id === user.id;
const base = `/users/${user.id}`;
return (
<>
{!restricted && (
<p>
<Link href="/usuarios">← Usuários</Link>
</p>
)}
<h1>
{user.display_name} <span className={badgeClass(user.status)}>{userStatusLabel[user.status]}</span>
</h1>
<p className="muted">
ID {user.id} · chat {user.telegram_chat_id} · cadastrado em {fmtDateTime(user.created_at)}
</p>
<section className="card">
<h2>Cadastro e lembretes</h2>
<dl className="kv">
<dt>Lembretes</dt>
<dd>
{user.notifications_enabled
? "ligados"
: user.paused_reason
? pauseReasonLabel[user.paused_reason]
: "desligados"}
</dd>
<dt>Admin do bot</dt>
<dd>{user.is_bot_admin ? "sim" : "não"}</dd>
<dt>Hoje ({today.day})</dt>
<dd>
<span className={badgeClass(today.status)}>{dayStatusLabel[today.status]}</span>{" "}
<span className="muted">
{today.last_point_count} ponto(s) na última consulta · próxima consulta: {fmtDateTime(today.next_check_at)}
</span>
</dd>
</dl>
{canEdit && (
<div className="actions">
{canAdmin && user.status !== "active" && <ActionButton path={`${base}/activate`} label="Ativar" variant="primary" />}
{canAdmin && user.status !== "disabled" && (
<ActionButton
path={`${base}/disable`}
label="Desativar"
variant="danger"
confirm="Desativar este usuário? Os lembretes agendados serão cancelados."
/>
)}
{user.status === "active" &&
(user.notifications_enabled ? (
<ActionButton path={`${base}/notifications/disable`} label="Pausar lembretes" />
) : (
<ActionButton path={`${base}/notifications/enable`} label="Ligar lembretes" variant="primary" />
))}
{canAdmin && (
<ActionButton
path={`${base}/bot-admin`}
method="PUT"
body={{ enabled: !user.is_bot_admin }}
label={user.is_bot_admin ? "Remover admin do bot" : "Tornar admin do bot"}
/>
)}
{canAdmin && (today.status === "no_points" || today.status === "not_working") && (
<ActionButton path={`${base}/today/resume`} label="Retomar consultas de hoje" />
)}
</div>
)}
</section>
<section className="card">
<h2>Credencial PontoMais</h2>
{credential ? (
<dl className="kv">
<dt>E-mail</dt>
<dd>{credential.api_email}</dd>
<dt>Situação</dt>
<dd>
{credential.validated ? (
<span className="badge ok">validada em {fmtDateTime(credential.last_validated_at)}</span>
) : (
<span className="badge warn">não validada</span>
)}
</dd>
<dt>Falhas seguidas</dt>
<dd>{credential.consecutive_failures}</dd>
{credential.last_error && (
<>
<dt>Último erro</dt>
<dd className="error-text">{credential.last_error}</dd>
</>
)}
</dl>
) : (
<p className="muted">Nenhuma credencial configurada. Sem ela o usuário não pode ser ativado.</p>
)}
{canEdit && <CredentialsForm userId={user.id} />}
</section>
<section className="card">
<h2>Jornada e janela de consulta</h2>
<ScheduleForm
userId={user.id}
schedule={detail.schedule}
timezone={user.timezone}
effectiveWindow={detail.effective_window}
disabled={!canEdit}
/>
</section>
<section className="card">
<h2>Pontos de hoje</h2>
<TodayPanel userId={user.id} canTest={canEdit} />
</section>
<p>
<Link href={`/notificacoes?user_id=${user.id}`}>Lembretes deste usuário</Link> ·{" "}
{!restricted && <Link href={`/auditoria?target_user_id=${user.id}`}>Auditoria deste usuário</Link>}
</p>
</>
);
}

View File

@@ -0,0 +1,97 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { apiGet } from "@/lib/server-api";
import { badgeClass, pauseReasonLabel, userStatusLabel } from "@/lib/format";
import { isRestricted } from "@/lib/access";
import type { Me, UserSummary } from "@/lib/types";
type Search = Promise<Record<string, string | string[] | undefined>>;
export default async function UsersPage({ searchParams }: { searchParams: Search }) {
const me = await apiGet<Me>("/auth/me");
if (isRestricted(me.account)) redirect(`/usuarios/${me.account.user_id}`);
const sp = await searchParams;
const params = new URLSearchParams();
for (const key of ["status", "q", "custom_window"]) {
const v = sp[key];
if (typeof v === "string" && v) params.set(key, v);
}
const { users } = await apiGet<{ users: UserSummary[] }>(`/users?${params}`);
return (
<>
<h1>Usuários</h1>
<form className="filters" method="get">
<input name="q" placeholder="Nome ou chat ID" defaultValue={typeof sp.q === "string" ? sp.q : ""} />
<select name="status" defaultValue={typeof sp.status === "string" ? sp.status : ""}>
<option value="">Todos os status</option>
<option value="pending">Aguardando aprovação</option>
<option value="active">Ativos</option>
<option value="disabled">Desativados</option>
</select>
<select name="custom_window" defaultValue={typeof sp.custom_window === "string" ? sp.custom_window : ""}>
<option value="">Qualquer janela</option>
<option value="true">Janela personalizada</option>
<option value="false">Janela global</option>
</select>
<button className="btn secondary" type="submit">
Filtrar
</button>
</form>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Nome</th>
<th>Status</th>
<th>Credencial</th>
<th>Lembretes</th>
<th>Janela</th>
</tr>
</thead>
<tbody>
{users.length === 0 && (
<tr>
<td colSpan={6} className="muted">
Nenhum usuário encontrado.
</td>
</tr>
)}
{users.map((u) => (
<tr key={u.id}>
<td>{u.id}</td>
<td>
<Link href={`/usuarios/${u.id}`}>{u.display_name}</Link>
{u.is_bot_admin && <span className="badge"> admin do bot</span>}
</td>
<td>
<span className={badgeClass(u.status)}>{userStatusLabel[u.status]}</span>
</td>
<td>
{!u.has_credential ? (
<span className="badge warn">não configurada</span>
) : u.credential_validated ? (
<span className="badge ok">validada</span>
) : (
<span className="badge warn">não validada</span>
)}
{u.consecutive_failures > 0 && <span className="badge error"> {u.consecutive_failures} falha(s)</span>}
</td>
<td>
{u.notifications_enabled ? (
<span className="badge ok">ligados</span>
) : (
<span className="muted">{u.paused_reason ? pauseReasonLabel[u.paused_reason] : "desligados"}</span>
)}
</td>
<td>{u.custom_window ? <span className="badge warn">personalizada</span> : <span className="muted">global</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}

385
web/app/globals.css Normal file
View File

@@ -0,0 +1,385 @@
/* Estilo do painel: CSS próprio, sem framework (ARQUITETURA.md, decisão 27). */
:root {
--bg: #f5f6f8;
--surface: #ffffff;
--text: #1c2024;
--muted: #5f6b7a;
--border: #dde1e6;
--accent: #2457c5;
--accent-text: #ffffff;
--ok-bg: #e3f5e8;
--ok-text: #1d6b34;
--warn-bg: #fdf3d8;
--warn-text: #7a5300;
--error-bg: #fde8e8;
--error-text: #a61b1b;
--radius: 8px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111418;
--surface: #1a1f25;
--text: #e6e9ed;
--muted: #9aa5b1;
--border: #2b323b;
--accent: #5b8cff;
--accent-text: #0b1020;
--ok-bg: #173a24;
--ok-text: #7fd89a;
--warn-bg: #3a3014;
--warn-text: #f0c96a;
--error-bg: #3d1a1a;
--error-text: #f39b9b;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 15px;
line-height: 1.5;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1 {
font-size: 1.5rem;
margin: 0 0 16px;
}
h2 {
font-size: 1.1rem;
margin: 0 0 12px;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.85em;
}
/* --- estrutura --- */
.container {
max-width: 1100px;
margin: 0 auto;
padding: 24px 16px 48px;
}
.topbar {
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.topbar-inner {
max-width: 1100px;
margin: 0 auto;
padding: 10px 16px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 24px;
}
.brand {
font-weight: 700;
color: var(--text);
}
.topbar nav {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
flex: 1;
}
.whoami {
display: flex;
align-items: center;
gap: 8px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 20px;
margin: 0 0 16px;
}
.login {
min-height: 100vh;
display: grid;
place-items: center;
padding: 16px;
}
.login .card {
width: 100%;
max-width: 360px;
}
.login .btn {
width: 100%;
}
/* --- textos e selos --- */
.muted {
color: var(--muted);
}
.small {
font-size: 0.85rem;
}
.nowrap {
white-space: nowrap;
}
.error-text {
color: var(--error-text);
}
.ok-text {
color: var(--ok-text);
}
.badge {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
background: var(--border);
white-space: nowrap;
}
.badge.ok {
background: var(--ok-bg);
color: var(--ok-text);
}
.badge.warn {
background: var(--warn-bg);
color: var(--warn-text);
}
.badge.error {
background: var(--error-bg);
color: var(--error-text);
}
.alert {
border-radius: var(--radius);
padding: 12px 16px;
margin-bottom: 16px;
}
.alert.warn {
background: var(--warn-bg);
color: var(--warn-text);
}
.alert.error {
background: var(--error-bg);
color: var(--error-text);
}
/* --- botões e formulários --- */
.btn {
font: inherit;
border-radius: 6px;
padding: 6px 14px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
cursor: pointer;
}
.btn:disabled {
opacity: 0.6;
cursor: progress;
}
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-text);
}
.btn.danger {
border-color: var(--error-text);
color: var(--error-text);
}
.btn.link {
border: none;
background: none;
color: var(--accent);
padding: 0;
}
.action {
display: inline-flex;
flex-direction: column;
gap: 4px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
margin: 12px 0;
}
input,
select {
font: inherit;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 10px;
max-width: 100%;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
.field > span {
font-size: 0.9rem;
font-weight: 600;
}
.checkbox {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.fieldset {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
margin: 8px 0 16px;
}
.grid-4 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0 16px;
}
.inline-form {
display: flex;
flex-wrap: wrap;
gap: 0 16px;
align-items: flex-end;
}
.inline-form .btn {
margin-bottom: 12px;
}
.inline-form p {
flex-basis: 100%;
margin: 0;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 16px;
}
/* --- dados --- */
.table-wrap {
overflow-x: auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 16px;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
th {
font-size: 0.85rem;
color: var(--muted);
font-weight: 600;
}
tr:last-child td {
border-bottom: none;
}
.details {
word-break: break-word;
}
.kv {
display: grid;
grid-template-columns: max-content 1fr;
gap: 6px 16px;
margin: 0;
}
.kv dt {
color: var(--muted);
}
.kv dd {
margin: 0;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.stat {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
}
.stat a {
color: inherit;
text-decoration: none;
}
.stat-value {
display: block;
font-size: 1.6rem;
font-weight: 700;
}
.stat-label {
color: var(--muted);
font-size: 0.85rem;
}
.stat.error .stat-value {
color: var(--error-text);
}
.inline-list {
display: flex;
flex-wrap: wrap;
gap: 8px 24px;
list-style: none;
margin: 0;
padding: 0;
}
.preview {
border-top: 1px solid var(--border);
margin-top: 12px;
padding-top: 12px;
}
@media (max-width: 600px) {
.kv {
grid-template-columns: 1fr;
}
.kv dd {
margin-bottom: 8px;
}
}

6
web/app/healthz/route.ts Normal file
View File

@@ -0,0 +1,6 @@
// Usado pelo healthcheck do Docker: indica só que o servidor Next está de pé.
export const dynamic = "force-dynamic";
export function GET() {
return Response.json({ status: "ok" });
}

16
web/app/layout.tsx Normal file
View File

@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import "./globals.css";
export const metadata: Metadata = {
title: "Bot de Pontos",
description: "Painel administrativo do Bot de Pontos",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="pt-BR">
<body>{children}</body>
</html>
);
}

52
web/app/login/page.tsx Normal file
View File

@@ -0,0 +1,52 @@
"use client";
import { useState, type FormEvent } from "react";
import { apiFetch, errorMessage } from "@/lib/client-api";
export default function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setError("");
try {
await apiFetch("/auth/login", { method: "POST", body: { username, password } });
const next = new URLSearchParams(window.location.search).get("next");
window.location.href = next && next.startsWith("/") && !next.startsWith("//") ? next : "/";
} catch (err) {
setError(errorMessage(err));
setBusy(false);
}
}
return (
<main className="login">
<form className="card" onSubmit={submit}>
<h1>Bot de Pontos</h1>
<p className="muted">Painel administrativo</p>
<label className="field">
<span>Usuário</span>
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" required />
</label>
<label className="field">
<span>Senha</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</label>
{error && <p className="error-text">{error}</p>}
<button className="btn primary" type="submit" disabled={busy}>
{busy ? "Entrando…" : "Entrar"}
</button>
</form>
</main>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { apiFetch, errorMessage } from "@/lib/client-api";
interface Props {
path: string;
label: string;
method?: "POST" | "PUT" | "PATCH" | "DELETE";
body?: unknown;
confirm?: string;
variant?: "primary" | "danger" | "secondary";
onDone?: (result: unknown) => void;
}
/** Botão que executa uma ação na API e recarrega os dados da página. */
export function ActionButton({ path, label, method = "POST", body, confirm, variant = "secondary", onDone }: Props) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function run() {
if (confirm && !window.confirm(confirm)) return;
setBusy(true);
setError("");
try {
const result = await apiFetch(path, { method, body });
onDone?.(result);
router.refresh();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<span className="action">
<button type="button" className={`btn ${variant}`} onClick={run} disabled={busy}>
{busy ? "Aguarde…" : label}
</button>
{error && <span className="error-text">{error}</span>}
</span>
);
}

View File

@@ -0,0 +1,18 @@
"use client";
import { apiFetch } from "@/lib/client-api";
export function LogoutButton() {
async function logout() {
try {
await apiFetch("/auth/logout", { method: "POST" });
} finally {
window.location.href = "/login";
}
}
return (
<button type="button" className="btn link" onClick={logout}>
Sair
</button>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import type { QueryWindow } from "@/lib/types";
const FIELDS: { key: keyof QueryWindow; label: string; hint: string }[] = [
{ key: "query_window_start", label: "Início da janela", hint: "Primeira consulta do dia" },
{ key: "day_check_deadline", label: "Confirmação sem pontos", hint: "2ª verificação sem pontos" },
{ key: "no_points_recheck_until", label: "Fim das reverificações", hint: "Depois disso, dia sem pontos é encerrado" },
{ key: "query_window_end", label: "Fim da janela", hint: "Última consulta do dia" },
];
/** Os quatro horários da janela de consulta (ARQUITETURA.md 9.12). */
export function WindowFields({
value,
onChange,
disabled,
}: {
value: QueryWindow;
onChange: (w: QueryWindow) => void;
disabled?: boolean;
}) {
return (
<div className="grid-4">
{FIELDS.map((f) => (
<label key={f.key} className="field">
<span>{f.label}</span>
<input
type="time"
step={900}
value={value[f.key]}
disabled={disabled}
onChange={(e) => onChange({ ...value, [f.key]: e.target.value })}
required
/>
<small className="muted">{f.hint}</small>
</label>
))}
</div>
);
}

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;
}

18
web/middleware.ts Normal file
View File

@@ -0,0 +1,18 @@
// Só redireciona para /login quando não há cookie de sessão.
// A autorização de verdade acontece sempre na API Go (ARQUITETURA.md 5.4).
import { NextResponse, type NextRequest } from "next/server";
export function middleware(req: NextRequest) {
if (!req.cookies.get("bp_session")) {
const url = req.nextUrl.clone();
url.pathname = "/login";
url.search = req.nextUrl.pathname === "/" ? "" : `?next=${encodeURIComponent(req.nextUrl.pathname)}`;
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
// Tudo, menos login, healthcheck, API (repassada ao Go) e arquivos do Next.
matcher: ["/((?!login|healthz|api/|_next/|favicon.ico).*)"],
};

21
web/next.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { NextConfig } from "next";
// Endereço da API Go na rede interna do Docker. O navegador nunca fala com
// ela diretamente: as chamadas a /api/v1/* chegam ao Next (mesma origem) e
// são repassadas para cá (ARQUITETURA.md, 5.3).
const apiInternalUrl = process.env.API_INTERNAL_URL ?? "http://api:8080";
const nextConfig: NextConfig = {
output: "standalone",
poweredByHeader: false,
async rewrites() {
return [
{
source: "/api/v1/:path*",
destination: `${apiInternalUrl}/api/v1/:path*`,
},
];
},
};
export default nextConfig;

22
web/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "botpontos-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "^15.3.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/node": "^22.15.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"typescript": "^5.8.0"
}
}

21
web/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}