Projeto bot pontos
This commit is contained in:
3
web/.dockerignore
Normal file
3
web/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.next
|
||||
*.md
|
||||
32
web/Dockerfile
Normal file
32
web/Dockerfile
Normal 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"]
|
||||
103
web/app/(painel)/auditoria/page.tsx
Normal file
103
web/app/(painel)/auditoria/page.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
88
web/app/(painel)/configuracoes/form.tsx
Normal file
88
web/app/(painel)/configuracoes/form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
web/app/(painel)/configuracoes/page.tsx
Normal file
43
web/app/(painel)/configuracoes/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
164
web/app/(painel)/contas/forms.tsx
Normal file
164
web/app/(painel)/contas/forms.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
web/app/(painel)/contas/page.tsx
Normal file
84
web/app/(painel)/contas/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
web/app/(painel)/feriados/form.tsx
Normal file
46
web/app/(painel)/feriados/form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
web/app/(painel)/feriados/page.tsx
Normal file
86
web/app/(painel)/feriados/page.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
web/app/(painel)/layout.tsx
Normal file
44
web/app/(painel)/layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
112
web/app/(painel)/notificacoes/page.tsx
Normal file
112
web/app/(painel)/notificacoes/page.tsx
Normal 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
79
web/app/(painel)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
web/app/(painel)/usuarios/[id]/forms.tsx
Normal file
209
web/app/(painel)/usuarios/[id]/forms.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
web/app/(painel)/usuarios/[id]/page.tsx
Normal file
137
web/app/(painel)/usuarios/[id]/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
97
web/app/(painel)/usuarios/page.tsx
Normal file
97
web/app/(painel)/usuarios/page.tsx
Normal 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
385
web/app/globals.css
Normal 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
6
web/app/healthz/route.ts
Normal 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
16
web/app/layout.tsx
Normal 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
52
web/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
web/components/ActionButton.tsx
Normal file
46
web/components/ActionButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
web/components/LogoutButton.tsx
Normal file
18
web/components/LogoutButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
web/components/WindowFields.tsx
Normal file
40
web/components/WindowFields.tsx
Normal 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
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;
|
||||
}
|
||||
18
web/middleware.ts
Normal file
18
web/middleware.ts
Normal 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
21
web/next.config.ts
Normal 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
22
web/package.json
Normal 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
21
web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user