Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@ NEXT_PUBLIC_SITE_URL=https://seu-dominio.com.br
# NÃO coloque RUN_INTEGRATION aqui nem no .env.local: os testes de integração
# criam usuários reais no Supabase, e o gate só funciona se vier da invocação.
# Para rodá-los de propósito: npm run test:integration

# Segredo do cron de lembrete de avaliação (GET /api/cron/lembretes-avaliacao).
# O endpoint só roda com `Authorization: Bearer <este valor>`. O Vercel Cron manda
# esse header automaticamente quando a env existe; sem ela, o endpoint recusa.
CRON_SECRET=uma-string-aleatoria-longa

3 changes: 3 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
49 changes: 49 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Portão de qualidade: roda em cada push e em cada PR contra a main. Os testes
# existiam e eram bons, mas dependiam da disciplina do dev — sem CI, nada
# garantia que typecheck, lint e testes passavam antes do merge. Este workflow
# fecha essa lacuna (parecer do Conselho, Eng. de Software).
#
# Só a suíte unitária roda aqui (npm test). A de integração (test:integration)
# fica de fora de propósito: ela cria usuários reais no Supabase e precisa de
# service_role — não é para rodar em cada PR. Ver docs/adr/0010.
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

# Um PR que recebe vários pushes só mantém a checagem mais recente rodando.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
verificar:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Configurar Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

# npm ci: instalação limpa e reprodutível a partir do package-lock.
- name: Instalar dependências
run: npm ci

- name: Typecheck
run: npm run typecheck

- name: Lint
run: npm run lint

- name: Testes unitários
run: npm test
115 changes: 115 additions & 0 deletions app/(app)/admin/metricas/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth/roles";
import { createAdminClient } from "@/lib/supabase/admin";
import { nomeCategoria } from "@/lib/categorias";
import {
resumoMarketplace,
porGrupo,
formatDuracaoHoras,
pct,
type GrupoMetrica,
} from "@/lib/metricas-marketplace";

/**
* Rota `/admin/metricas` (sysadmin): a saúde do marketplace numa olhada — taxa
* de preenchimento, tempo até o match e cancelamento, no total e por cidade e
* categoria. São as métricas causais do negócio que o parecer cobrou: sem elas,
* não se sabe se o mercado gira. Agregação cross-workspace usa o admin client
* (RLS ignorada) DEPOIS do gate estrito em sysadmin — nunca antes.
*/
function Kpi({ label, valor, nota }: { label: string; valor: string; nota?: string }) {
return (
<div className="card">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted">{label}</p>
<p className="mt-1 text-2xl font-bold text-brand tabular-nums">{valor}</p>
{nota ? <p className="mt-0.5 text-xs text-muted">{nota}</p> : null}
</div>
);
}

function GrupoBarras({
titulo,
grupos,
rotulo,
}: {
titulo: string;
grupos: GrupoMetrica[];
rotulo: (chave: string) => string;
}) {
if (grupos.length === 0) {
return (
<section className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-ink">{titulo}</h2>
<div className="card-vazio">Ainda não há vagas para agregar.</div>
</section>
);
}
return (
<section className="flex flex-col gap-2">
<h2 className="text-sm font-semibold text-ink">{titulo}</h2>
<div className="flex flex-col gap-2">
{grupos.map((g) => (
<div key={g.chave} className="flex items-center gap-3">
<div className="w-40 shrink-0">
<p className="truncate text-sm font-medium">{rotulo(g.chave)}</p>
<p className="text-xs text-muted tabular-nums">
{g.preenchidas}/{g.total} preenchidas
</p>
</div>
<div className="h-6 flex-1 overflow-hidden rounded bg-black/5" role="img" aria-label={`${pct(g.taxaPreenchimento)} preenchidas`}>
<div className="h-6 rounded bg-action-dark" style={{ width: `${g.taxaPreenchimento * 100}%` }} />
</div>
<span className="w-10 text-right text-sm font-semibold tabular-nums">{pct(g.taxaPreenchimento)}</span>
</div>
))}
</div>
</section>
);
}

export default async function AdminMetricasPage() {
const user = await getCurrentUser();
if (!user || user.role !== "sysadmin") redirect("/inicio");

const db = createAdminClient();
const [{ data: vagas }, { data: cands }] = await Promise.all([
db.from("vagas").select("id, status, categoria, cidade, created_at"),
db.from("candidaturas").select("vaga_id, status, created_at"),
]);

const vlist = vagas ?? [];
const clist = cands ?? [];
const resumo = resumoMarketplace(vlist, clist);
const porCidade = porGrupo(vlist, clist, "cidade");
const porCategoria = porGrupo(vlist, clist, "categoria");

return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-xl font-semibold">Saúde do marketplace</h1>
<p className="text-sm text-muted">
Se o mercado gira: preenchimento, tempo até o match e cancelamento, na plataforma toda.
</p>
</div>

<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<Kpi label="Preenchimento" valor={pct(resumo.taxaPreenchimento)} nota={`${resumo.preenchidas} de ${resumo.totalVagas} vagas`} />
<Kpi
label="Tempo até o match"
valor={formatDuracaoHoras(resumo.tempoMedioMatchHoras)}
nota="média até a candidatura aceita"
/>
<Kpi label="Cancelamento" valor={pct(resumo.taxaCancelamento)} nota={`${resumo.canceladas} canceladas`} />
<Kpi label="Vagas publicadas" valor={String(resumo.totalVagas)} nota="no período todo" />
</div>

<GrupoBarras titulo="Preenchimento por cidade" grupos={porCidade} rotulo={(c) => c} />
<GrupoBarras titulo="Preenchimento por categoria" grupos={porCategoria} rotulo={nomeCategoria} />

<p className="text-xs text-muted">
O tempo até o match é aproximado pela candidatura aceita mais antiga de cada vaga — o banco
ainda não guarda o instante exato do aceite.
</p>
</div>
);
}
2 changes: 1 addition & 1 deletion app/(app)/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default function Error({ error, reset }: { error: Error & { digest?: stri
}, [error]);

return (
<div className="flex flex-col items-start gap-3 rounded-2xl border border-line bg-white p-6">
<div className="flex flex-col items-start gap-3 rounded-2xl border border-line bg-card p-6">
<h1 className="text-lg font-semibold">Essa tela não carregou</h1>
<p className="text-sm leading-relaxed text-muted">
Pode ter sido a internet. Tente de novo — nada do que você já fez foi perdido.
Expand Down
6 changes: 3 additions & 3 deletions app/(app)/inicio/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ function AcaoCard({
>
<p className="text-base font-semibold">{titulo}</p>
<p className="mt-1 text-sm leading-relaxed text-muted">{desc}</p>
<p className={`mt-3 text-sm font-semibold ${tone === "action" ? "text-action-dark" : "text-brand"}`}>{cta}</p>
<p className={`mt-3 text-sm font-semibold ${tone === "action" ? "text-ok" : "text-brand"}`}>{cta}</p>
</Link>
);
}
Expand Down Expand Up @@ -127,7 +127,7 @@ export default async function InicioPage() {
return (
<div className="flex flex-col gap-5">
{banner?.texto ? (
<div className="rounded-2xl border border-accent bg-accent/15 px-4 py-3 text-sm text-[#3a2f00]">
<div className="rounded-2xl border border-accent bg-tint-warn px-4 py-3 text-sm text-tint-warn-ink">
{banner.texto}
</div>
) : null}
Expand Down Expand Up @@ -191,7 +191,7 @@ export default async function InicioPage() {
<Link
key={m.key}
href={m.href}
className="flex min-h-11 items-center justify-center rounded-xl border border-line bg-white px-3 py-3 text-center text-sm font-medium transition hover:border-brand focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
className="flex min-h-11 items-center justify-center rounded-xl border border-line bg-card px-3 py-3 text-center text-sm font-medium transition hover:border-brand focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
>
{m.label}
</Link>
Expand Down
2 changes: 1 addition & 1 deletion app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
<div className="min-h-screen md:flex">
<a
href="#conteudo"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-xl focus:bg-brand focus:px-4 focus:py-3 focus:text-sm focus:text-white"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-xl focus:bg-brand-fill focus:px-4 focus:py-3 focus:text-sm focus:text-white"
>
Pular para o conteúdo
</a>
Expand Down
17 changes: 14 additions & 3 deletions app/(app)/mapa/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export default async function MapaPage() {
descricao = "Diárias abertas na região. A localização é aproximada até você ser contratado.";
const { data } = await sb
.from("vagas")
.select("id, titulo, valor_diaria, cidade, local_aprox_lat, local_aprox_lng")
.select("id, titulo, valor_diaria, cidade, categoria, data_servico, local_aprox_lat, local_aprox_lng")
.eq("status", "aberta")
.not("local_aprox_lat", "is", null);
points = (data ?? [])
Expand All @@ -38,6 +38,8 @@ export default async function MapaPage() {
titulo: v.titulo,
valor: v.valor_diaria,
cidade: v.cidade,
categoria: v.categoria,
data: v.data_servico,
}));
} else {
titulo = "Mapa da equipe";
Expand All @@ -46,7 +48,7 @@ export default async function MapaPage() {
if (ws) {
const { data: vagas } = await sb
.from("vagas")
.select("id, titulo, valor_diaria, cidade")
.select("id, titulo, valor_diaria, cidade, categoria, data_servico")
.eq("workspace_id", ws.workspace_id);
const ids = (vagas ?? []).map((v) => v.id);
const { data: locais } = ids.length
Expand All @@ -57,7 +59,16 @@ export default async function MapaPage() {
.map((v) => {
const l = locDe.get(v.id);
return l
? { id: v.id, lat: l.lat, lng: l.lng, titulo: v.titulo, valor: v.valor_diaria, cidade: v.cidade }
? {
id: v.id,
lat: l.lat,
lng: l.lng,
titulo: v.titulo,
valor: v.valor_diaria,
cidade: v.cidade,
categoria: v.categoria,
data: v.data_servico,
}
: null;
})
.filter((p): p is VagaPonto => p !== null);
Expand Down
14 changes: 12 additions & 2 deletions app/(app)/vagas/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export default async function VagaDetalhePage({
) : null}
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<span className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-brand/10 text-brand">
<span className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-tint-info text-brand">
<CategoriaIcon slug={vaga.categoria} className="h-5 w-5" />
</span>
<div>
Expand Down Expand Up @@ -137,8 +137,18 @@ export default async function VagaDetalhePage({
<div className="flex flex-col gap-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted">Local da obra</h2>
<VagasMap
filtros={false}
points={[
{ id: vaga.id, lat: local.lat, lng: local.lng, titulo: vaga.titulo, valor: vaga.valor_diaria, cidade: vaga.cidade },
{
id: vaga.id,
lat: local.lat,
lng: local.lng,
titulo: vaga.titulo,
valor: vaga.valor_diaria,
cidade: vaga.cidade,
categoria: vaga.categoria,
data: vaga.data_servico,
},
]}
/>
<CompartilharLocal modo="obra" lat={local.lat} lng={local.lng} />
Expand Down
4 changes: 2 additions & 2 deletions app/(app)/vagas/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,11 @@ export default async function BuscarVagasPage({
))}
</div>

<details open={refinos > 0} className="rounded-xl border border-line bg-white">
<details open={refinos > 0} className="rounded-xl border border-line bg-card">
<summary className="flex min-h-11 cursor-pointer items-center gap-2 px-4 text-sm font-medium marker:content-['']">
Filtros
{refinos > 0 ? (
<span className="grid h-5 min-w-5 place-items-center rounded-full bg-brand px-1.5 text-xs font-semibold text-white">
<span className="grid h-5 min-w-5 place-items-center rounded-full bg-brand-fill px-1.5 text-xs font-semibold text-white">
{refinos}
</span>
) : null}
Expand Down
6 changes: 3 additions & 3 deletions app/(auth)/cadastro/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,11 @@ export function CadastroForm({
<h1 className="text-2xl font-semibold text-brand">Criar conta</h1>
<form
action={formAction}
className="flex flex-col gap-3 rounded-2xl border border-line bg-white p-6 shadow-[0_1px_3px_rgba(15,23,42,0.07)]"
className="flex flex-col gap-3 rounded-2xl border border-line bg-card p-6 shadow-[0_1px_3px_rgba(15,23,42,0.07)]"
>
{convite ? (
// Cadastro via convite: o papel é definido pelo convite, não escolhido.
<div className="rounded-xl border border-brand bg-[#e6effb] p-3">
<div className="rounded-xl border border-brand bg-tint-info p-3">
<input type="hidden" name="convite_token" value={convite.token} />
<p className="text-sm">
Você foi convidado para <strong className="font-semibold">{convite.equipeNome}</strong>{" "}
Expand All @@ -74,7 +74,7 @@ export function CadastroForm({
{PAPEIS.map((p) => (
<label
key={p.valor}
className="flex min-h-[76px] cursor-pointer flex-col justify-center gap-0.5 rounded-xl border-2 border-line bg-white px-4 py-3 text-left transition hover:border-brand has-[:checked]:border-brand has-[:checked]:bg-brand has-[:checked]:text-white has-[:focus-visible]:outline has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-brand"
className="flex min-h-[76px] cursor-pointer flex-col justify-center gap-0.5 rounded-xl border-2 border-line bg-card px-4 py-3 text-left transition hover:border-brand has-[:checked]:border-brand has-[:checked]:bg-brand-fill has-[:checked]:text-white has-[:focus-visible]:outline has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-brand"
>
<input
type="radio"
Expand Down
2 changes: 1 addition & 1 deletion app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default function LoginPage() {
</div>
<form
action={formAction}
className="flex flex-col gap-3 rounded-2xl border border-line bg-white p-6 shadow-[0_1px_3px_rgba(15,23,42,0.07)]"
className="flex flex-col gap-3 rounded-2xl border border-line bg-card p-6 shadow-[0_1px_3px_rgba(15,23,42,0.07)]"
>
<div>
<label className="label" htmlFor="email">
Expand Down
2 changes: 1 addition & 1 deletion app/(auth)/nova-senha/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default function NovaSenhaPage() {
</div>
<form
action={formAction}
className="flex flex-col gap-3 rounded-2xl border border-line bg-white p-6 shadow-[0_1px_3px_rgba(15,23,42,0.07)]"
className="flex flex-col gap-3 rounded-2xl border border-line bg-card p-6 shadow-[0_1px_3px_rgba(15,23,42,0.07)]"
>
<h1 className="text-lg font-semibold">Criar senha nova</h1>
<p className="text-sm leading-relaxed text-muted">
Expand Down
4 changes: 2 additions & 2 deletions app/(auth)/recuperar-senha/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function RecuperarSenhaForm({ expirado }: { expirado: boolean }) {
</div>

{enviado ? (
<div className="flex flex-col gap-3 rounded-2xl border border-line bg-white p-6 shadow-[0_1px_3px_rgba(15,23,42,0.07)]">
<div className="flex flex-col gap-3 rounded-2xl border border-line bg-card p-6 shadow-[0_1px_3px_rgba(15,23,42,0.07)]">
<h1 className="text-lg font-semibold">Confira seu e-mail</h1>
{/* Mensagem neutra de propósito: confirmar que o e-mail existe
transformaria esta tela num verificador de quem está na base. */}
Expand All @@ -47,7 +47,7 @@ export function RecuperarSenhaForm({ expirado }: { expirado: boolean }) {
) : (
<form
action={formAction}
className="flex flex-col gap-3 rounded-2xl border border-line bg-white p-6 shadow-[0_1px_3px_rgba(15,23,42,0.07)]"
className="flex flex-col gap-3 rounded-2xl border border-line bg-card p-6 shadow-[0_1px_3px_rgba(15,23,42,0.07)]"
>
<h1 className="text-lg font-semibold">Esqueci minha senha</h1>
<p className="text-sm leading-relaxed text-muted">
Expand Down
2 changes: 1 addition & 1 deletion app/(legal)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Logo } from "@/components/logo";
export default function LegalLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-surface">
<header className="border-b border-line bg-white">
<header className="border-b border-line bg-card">
<div className="mx-auto flex max-w-[720px] items-center justify-between px-6 py-4">
<Link href="/" aria-label="Início do MeAjuda Aí" className="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand">
<Logo />
Expand Down
Loading
Loading