Files
DeskcommCRM/app/admin/(protected)/users/[id]/_client.tsx
T
Rafael César Rocha MonteiroandClaude Opus 5 72d2ed4c35 build(tailwind): migra para o Tailwind 4 com config em CSS
O `tailwind.config.ts` deixa de existir. O que era `theme.extend` virou um
bloco `@theme inline` em `app/globals.css` — o modo `inline` é o que preserva o
tema, porque o utilitário recebe o `var(--color-…)` literal em vez de uma cópia
do valor claro. Os três blocos de token (`:root`, `[data-theme="light"]`,
`[data-theme="dark"]`) ficam exatamente onde estavam: `extrairRegua`
(lib/branding/contraste.ts) acha o bloco claro por igualdade EXATA de seletor, e
`branding-tema-claro-escopavel` compara prop a prop os blocos claro e escuro.

O que a migração destapou, e é o item de maior impacto visual:

  No v3, modificador de opacidade sobre cor declarada como `var(--…)` sem
  `<alpha-value>` fazia o Tailwind NÃO EMITIR a regra — em silêncio. Todo token
  deste produto é `var(--color-*)`, então 62 classes distintas em 252 usos eram
  letra morta: `bg-destructive/10` não pintava fundo nenhum, `border-destructive/30`
  caía na cor neutra da regra global de borda, `text-muted-foreground/60` herdava
  a cor do pai. O v4 resolve opacidade por `color-mix()` e as 252 passaram a
  pintar. Medido contra um `tailwindcss@3.4.19` de descarte: alimentado com 7
  classes, ele emitiu 4 — nenhuma das 3 com barra.

Renomes mecânicos (58 `rounded` puro → `rounded-md`, 22 `outline-none` →
`outline-hidden`, 4 `flex-shrink-0` → `shrink-0`). O `rounded` puro não podia
ficar: o v3 remapeava o DEFAULT para `--radius-md` (8px) e o v4 tem um `rounded`
embutido de 0.25rem que nem `@utility` sobrescreve — o raio encolheria pela
metade em 33 arquivos, sem erro nenhum.

Guardas novas em `tests/unit/tailwind-tokens.test.ts`, porque a migração troca um
arquivo que o TypeScript conferia por um que ninguém confere: reprova config em
JS de volta, `:root` embrulhado em `@layer` (que ligaria a auto-referência que o
`@theme inline` emite), token referenciado que não existe, pasta de UI fora do
`@source`, e a volta dos três utilitários renomeados.

`branding-rampa` fatiava o CSS com `indexOf('[data-theme="dark"]')` e quebrou:
o `@custom-variant` pôs essa string no topo do arquivo. Passa a recortar o
`:root` por casamento de chaves.

Provado na tela, em instalação fresca (baseline.sql + bootstrap-owner + Supabase
local), medindo por `getComputedStyle` e nunca a olho — onboarding completo em
claro e escuro, 0 erro de console. Sonda em `tests/sonda-tailwind-4.ts`,
capturas e medições em `evidence/tailwind-4/`.

NÃO verificado: o build da imagem Docker. Os binários musl de `@tailwindcss/oxide`
e `lightningcss` estão no lockfile para linux x64 e arm64, mas docker.io está
inalcançável nesta máquina — quem prova é o `imagens-ok` no PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 01:48:00 -03:00

277 lines
9.2 KiB
TypeScript

"use client";
import Link from "next/link";
import { formatDistanceToNow, format } from "date-fns";
import { ptBR } from "date-fns/locale";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { CaretLeft } from "@/lib/ui/icons";
import { useAdminUser } from "@/hooks/useAdminUser";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const ROLE_VARIANTS: Record<
string,
"success" | "info" | "warning" | "error" | "neutral"
> = {
admin: "error",
manager: "warning",
agent: "info",
viewer: "neutral",
};
const ROLE_LABELS: Record<string, string> = {
admin: "Admin",
manager: "Manager",
agent: "Agente",
viewer: "Viewer",
};
function RoleBadge({ role }: { role: string }) {
return (
<Badge variant={ROLE_VARIANTS[role] ?? "neutral"}>
{ROLE_LABELS[role] ?? role}
</Badge>
);
}
function relativeDate(iso: string | null): string {
if (!iso) return "—";
try {
return formatDistanceToNow(new Date(iso), { addSuffix: true, locale: ptBR });
} catch {
return iso;
}
}
function absoluteDate(iso: string | null): string {
if (!iso) return "—";
try {
return format(new Date(iso), "dd/MM/yyyy HH:mm", { locale: ptBR });
} catch {
return iso;
}
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface UserDetailClientProps {
id: string;
}
export function UserDetailClient({ id }: UserDetailClientProps) {
const { data, isLoading, isError } = useAdminUser(id);
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-48 w-full rounded-lg" />
<Skeleton className="h-64 w-full rounded-lg" />
</div>
);
}
if (isError || !data?.data) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center text-muted-foreground">
<p className="text-sm font-medium">Usuário não encontrado</p>
<Button asChild variant="outline" size="sm">
<Link href="/admin/users">
<CaretLeft size={14} aria-hidden />
Voltar
</Link>
</Button>
</div>
);
}
const { user, memberships, recent_audit } = data.data;
const hasMfa = user.factors.some((f) => f.status === "verified");
return (
<div className="space-y-6">
{/* Back link */}
<div>
<Link
href="/admin/users"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<CaretLeft size={14} aria-hidden />
Usuários
</Link>
</div>
{/* Header */}
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">
{user.full_name ?? user.email ?? "Usuário sem nome"}
</h1>
{user.full_name && (
<p className="font-mono text-sm text-muted-foreground">{user.email}</p>
)}
<p className="text-xs text-muted-foreground font-mono">{user.id}</p>
</div>
<Separator />
{/* User info card */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Informações do usuário</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm sm:grid-cols-3">
<div>
<dt className="text-xs text-muted-foreground mb-0.5">Email confirmado</dt>
<dd>{user.email_confirmed_at ? absoluteDate(user.email_confirmed_at) : <Badge variant="warning">Pendente</Badge>}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground mb-0.5">Último acesso</dt>
<dd className="text-sm">{relativeDate(user.last_sign_in_at)}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground mb-0.5">Criado em</dt>
<dd className="text-sm">{absoluteDate(user.created_at)}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground mb-0.5">MFA</dt>
<dd>
{hasMfa ? (
<Badge variant="success">Ativo</Badge>
) : (
<Badge variant="neutral">Inativo</Badge>
)}
</dd>
</div>
{user.phone && (
<div>
<dt className="text-xs text-muted-foreground mb-0.5">Telefone</dt>
<dd className="font-mono text-sm">{user.phone}</dd>
</div>
)}
</dl>
</CardContent>
</Card>
{/* Memberships table */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">
Memberships ({memberships.length})
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{memberships.length === 0 ? (
<p className="px-6 py-4 text-xs text-muted-foreground">
Sem memberships registrados.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Tenant</TableHead>
<TableHead className="w-[100px]">Role</TableHead>
<TableHead className="w-[140px]">Aceito em</TableHead>
<TableHead className="w-[100px]">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{memberships.map((m) => (
<TableRow key={m.organization_id}>
<TableCell>
<div className="flex flex-col gap-0.5">
<Link
href={`/admin/tenants/${m.organization_id}`}
className="text-sm font-medium text-accent hover:underline"
>
{m.tenant_name ?? m.organization_id}
</Link>
{m.tenant_slug && (
<span className="font-mono text-[10px] text-muted-foreground">
{m.tenant_slug}
</span>
)}
</div>
</TableCell>
<TableCell>
<RoleBadge role={m.role} />
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{absoluteDate(m.accepted_at)}
</TableCell>
<TableCell>
{m.revoked_at ? (
<Badge variant="error">Revogado</Badge>
) : (
<Badge variant="success">Ativo</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Recent audit timeline */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">
Audit recente ({recent_audit.length}{recent_audit.length === 50 ? "+" : ""})
</CardTitle>
</CardHeader>
<CardContent>
{recent_audit.length === 0 ? (
<p className="text-xs text-muted-foreground">
Nenhuma entrada de auditoria encontrada para este usuário.
</p>
) : (
<div className="space-y-3 max-h-96 overflow-auto pr-1">
{recent_audit.map((entry) => (
<div key={entry.id} className="flex items-start gap-2 text-xs">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/50" />
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-muted-foreground">
{entry.action}
</span>
<span className="text-muted-foreground/60">
{format(new Date(entry.created_at), "dd/MM HH:mm:ss", {
locale: ptBR,
})}
</span>
</div>
{entry.resource_type && (
<p className="text-muted-foreground/60">
{entry.resource_type}
{entry.resource_id ? ` · ${entry.resource_id.slice(0, 8)}…` : ""}
</p>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}