Projeto bot pontos
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user