From 0ce7d0917cc89971e13bec66fc5b39c527a82b5a Mon Sep 17 00:00:00 2001 From: Pedro Paes Date: Fri, 1 May 2026 18:04:41 -0300 Subject: [PATCH] feat(admin): scaffold admin area with contagens table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an /admin section with its own chrome (sidebar + topbar) so the existing public Navbar/Footer don't render there. Read-only UI scaffold for now — data is fetched from the existing public contagensQueryOptions; CRUD wiring is a follow-up once a write API is in place. Routes - /admin — dashboard with stat cards + link to contagens - /admin/contagens — searchable, sortable table of all counts Components - app/components/Admin/{AdminSidebar,AdminTopbar,ContagensTable}.tsx - shadcn primitives added: card, table, input, badge, separator - Uses ~/lib/utils cn() and shadcn neutral tokens Wiring - __root.tsx + MainContent skip the public Navbar/Footer and the pt-14 main padding for /admin and /admin/* (same pattern as /dados/ciclodados). - robots.txt now Disallows /admin - /admin and /admin/contagens emit `noindex, nofollow` meta - Sitemap is an explicit allowlist, so admin is naturally excluded. Misc - .tanstack/ added to .gitignore (TanStack Start dev tmp dir) Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + app/components/Admin/AdminSidebar.tsx | 55 +++++++++ app/components/Admin/AdminTopbar.tsx | 19 +++ app/components/Admin/ContagensTable.tsx | 152 ++++++++++++++++++++++++ app/components/Commom/MainContent.tsx | 8 +- app/components/ui/badge.tsx | 48 ++++++++ app/components/ui/card.tsx | 92 ++++++++++++++ app/components/ui/input.tsx | 21 ++++ app/components/ui/separator.tsx | 28 +++++ app/components/ui/table.tsx | 114 ++++++++++++++++++ app/routes/__root.tsx | 23 ++-- app/routes/admin.contagens.index.tsx | 49 ++++++++ app/routes/admin.index.tsx | 73 ++++++++++++ app/routes/admin.route.tsx | 26 ++++ app/routes/robots[.]txt.ts | 1 + 15 files changed, 695 insertions(+), 15 deletions(-) create mode 100644 app/components/Admin/AdminSidebar.tsx create mode 100644 app/components/Admin/AdminTopbar.tsx create mode 100644 app/components/Admin/ContagensTable.tsx create mode 100644 app/components/ui/badge.tsx create mode 100644 app/components/ui/card.tsx create mode 100644 app/components/ui/input.tsx create mode 100644 app/components/ui/separator.tsx create mode 100644 app/components/ui/table.tsx create mode 100644 app/routes/admin.contagens.index.tsx create mode 100644 app/routes/admin.index.tsx create mode 100644 app/routes/admin.route.tsx diff --git a/.gitignore b/.gitignore index 47628764..dd984801 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules .env .gemini/ .playwright-cli/ +.tanstack/ docs diff --git a/app/components/Admin/AdminSidebar.tsx b/app/components/Admin/AdminSidebar.tsx new file mode 100644 index 00000000..8ae8bd98 --- /dev/null +++ b/app/components/Admin/AdminSidebar.tsx @@ -0,0 +1,55 @@ +import { Link, useRouterState } from "@tanstack/react-router"; +import { LayoutDashboard, Users2 } from "lucide-react"; +import { cn } from "~/lib/utils"; + +type NavItem = { + to: string; + label: string; + icon: React.ComponentType<{ className?: string }>; + exact?: boolean; +}; + +const NAV: NavItem[] = [ + { to: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true }, + { to: "/admin/contagens", label: "Contagens", icon: Users2 }, +]; + +export function AdminSidebar() { + const pathname = useRouterState({ select: (s) => s.location.pathname }); + + return ( + + ); +} diff --git a/app/components/Admin/AdminTopbar.tsx b/app/components/Admin/AdminTopbar.tsx new file mode 100644 index 00000000..1e3e2798 --- /dev/null +++ b/app/components/Admin/AdminTopbar.tsx @@ -0,0 +1,19 @@ +type AdminTopbarProps = { + title: string; + description?: string; + actions?: React.ReactNode; +}; + +export function AdminTopbar({ title, description, actions }: AdminTopbarProps) { + return ( +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ {actions &&
{actions}
} +
+ ); +} diff --git a/app/components/Admin/ContagensTable.tsx b/app/components/Admin/ContagensTable.tsx new file mode 100644 index 00000000..3d26ea9a --- /dev/null +++ b/app/components/Admin/ContagensTable.tsx @@ -0,0 +1,152 @@ +import { useMemo, useState } from "react"; +import { Link } from "@tanstack/react-router"; +import { Search, Pencil } from "lucide-react"; +import { Input } from "~/components/ui/input"; +import { Button } from "~/components/ui/button"; +import { Badge } from "~/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table"; + +export type ContagemRow = { + id: number | string; + name: string; + slug: string; + date: string; + total_cyclists: number; +}; + +type SortKey = "name" | "date" | "total_cyclists"; +type SortDir = "asc" | "desc"; + +const dateFormatter = new Intl.DateTimeFormat("pt-BR", { + day: "2-digit", + month: "2-digit", + year: "numeric", +}); + +function formatDate(value: string): string { + if (!value) return "—"; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return value; + return dateFormatter.format(d); +} + +export function ContagensTable({ rows }: { rows: ContagemRow[] }) { + const [query, setQuery] = useState(""); + const [sortKey, setSortKey] = useState("date"); + const [sortDir, setSortDir] = useState("desc"); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + const base = q ? rows.filter((r) => r.name.toLowerCase().includes(q)) : rows; + const sorted = [...base].sort((a, b) => { + const av = a[sortKey]; + const bv = b[sortKey]; + if (typeof av === "number" && typeof bv === "number") return av - bv; + return String(av).localeCompare(String(bv)); + }); + return sortDir === "desc" ? sorted.reverse() : sorted; + }, [rows, query, sortKey, sortDir]); + + function toggleSort(key: SortKey) { + if (sortKey === key) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortKey(key); + setSortDir(key === "name" ? "asc" : "desc"); + } + } + + return ( +
+
+
+ + setQuery(e.target.value)} + placeholder="Buscar por nome do ponto..." + className="pl-9" + /> +
+ + {filtered.length.toLocaleString("pt-BR")} de{" "} + {rows.length.toLocaleString("pt-BR")} + +
+ +
+ + + + toggleSort("name")} /> + toggleSort("date")} /> + toggleSort("total_cyclists")} /> + Ações + + + + {filtered.length === 0 ? ( + + + Nenhum ponto corresponde à busca. + + + ) : ( + filtered.map((row, idx) => ( + + {row.name} + {formatDate(row.date)} + + {row.total_cyclists.toLocaleString("pt-BR")} + + + + + + )) + )} + +
+
+
+ ); +} + +function SortableHead({ + label, + active, + dir, + onClick, + align = "left", +}: { + label: string; + active: boolean; + dir: SortDir; + onClick: () => void; + align?: "left" | "right"; +}) { + return ( + + + + ); +} diff --git a/app/components/Commom/MainContent.tsx b/app/components/Commom/MainContent.tsx index 0c99e7a1..2847dbcf 100644 --- a/app/components/Commom/MainContent.tsx +++ b/app/components/Commom/MainContent.tsx @@ -6,10 +6,12 @@ interface MainContentProps { export function MainContent({ children }: MainContentProps) { const location = useRouterState({ select: (s) => s.location }); - const isCicloDadosPage = location.pathname === '/dados/ciclodados'; - + const path = location.pathname; + const isFullBleed = + path === '/dados/ciclodados' || path === '/admin' || path.startsWith('/admin/'); + return ( -
+
{children}
); diff --git a/app/components/ui/badge.tsx b/app/components/ui/badge.tsx new file mode 100644 index 00000000..c07608e6 --- /dev/null +++ b/app/components/ui/badge.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "~/lib/utils" + +const badgeVariants = cva( + "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90", + outline: + "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + link: "text-primary underline-offset-4 [a&]:hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/app/components/ui/card.tsx b/app/components/ui/card.tsx new file mode 100644 index 00000000..018d0ce3 --- /dev/null +++ b/app/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "~/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/app/components/ui/input.tsx b/app/components/ui/input.tsx new file mode 100644 index 00000000..e3e4e95b --- /dev/null +++ b/app/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react" + +import { cn } from "~/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/app/components/ui/separator.tsx b/app/components/ui/separator.tsx new file mode 100644 index 00000000..b7204916 --- /dev/null +++ b/app/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import { Separator as SeparatorPrimitive } from "radix-ui" + +import { cn } from "~/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/app/components/ui/table.tsx b/app/components/ui/table.tsx new file mode 100644 index 00000000..734abede --- /dev/null +++ b/app/components/ui/table.tsx @@ -0,0 +1,114 @@ +import * as React from "react" + +import { cn } from "~/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/app/routes/__root.tsx b/app/routes/__root.tsx index a29571cc..29d62a26 100644 --- a/app/routes/__root.tsx +++ b/app/routes/__root.tsx @@ -132,24 +132,23 @@ function NotFoundComponent() { ); } +function shouldHidePublicChrome(pathname: string): boolean { + // Routes that bring their own chrome and shouldn't render the public Navbar/Footer. + return ( + pathname === "/dados/ciclodados" || + pathname === "/admin" || + pathname.startsWith("/admin/") + ); +} + function ConditionalNavbar() { const location = useRouterState({ select: (s) => s.location }); - const isCicloDadosPage = location.pathname === "/dados/ciclodados"; - - if (isCicloDadosPage) { - return null; - } - + if (shouldHidePublicChrome(location.pathname)) return null; return ; } function ConditionalFooter() { const location = useRouterState({ select: (s) => s.location }); - const isCicloDadosPage = location.pathname === "/dados/ciclodados"; - - if (isCicloDadosPage) { - return null; - } - + if (shouldHidePublicChrome(location.pathname)) return null; return