From 0b19e8ac5c1b79f7dc80beb8a0d22e6b3a5c82d0 Mon Sep 17 00:00:00 2001 From: Armando Soares Date: Wed, 16 Sep 2026 06:06:34 +0000 Subject: [PATCH] video so no scroll e menu vertical no admin --- .../platform/platform-overview.controller.ts | 17 +++ api/src/platform/platform-overview.spec.ts | 20 ++++ api/src/platform/platform-overview.ts | 16 +++ api/src/platform/platform.module.ts | 3 +- api/src/platform/platform.service.ts | 11 ++ web/src/app/admin/(panel)/page.tsx | 66 ++++++++++- web/src/components/landing/scroll-film.tsx | 34 +----- web/src/components/restricted-shell.tsx | 103 ++++++++---------- 8 files changed, 172 insertions(+), 98 deletions(-) create mode 100644 api/src/platform/platform-overview.controller.ts create mode 100644 api/src/platform/platform-overview.spec.ts create mode 100644 api/src/platform/platform-overview.ts diff --git a/api/src/platform/platform-overview.controller.ts b/api/src/platform/platform-overview.controller.ts new file mode 100644 index 0000000..a4f23e6 --- /dev/null +++ b/api/src/platform/platform-overview.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { PlatformService } from './platform.service'; + +@Controller('platform') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles('PLATFORM_ADMIN') +export class PlatformOverviewController { + constructor(private readonly platform: PlatformService) {} + + @Get('overview') + overview() { + return this.platform.overview(); + } +} diff --git a/api/src/platform/platform-overview.spec.ts b/api/src/platform/platform-overview.spec.ts new file mode 100644 index 0000000..dd50b97 --- /dev/null +++ b/api/src/platform/platform-overview.spec.ts @@ -0,0 +1,20 @@ +import { buildPlatformOverview } from './platform-overview'; + +describe('buildPlatformOverview', () => { + it('counts clients, active companies and users', () => { + expect( + buildPlatformOverview( + [ + { status: 'ACTIVE' }, + { status: 'ACTIVE' }, + { status: 'SUSPENDED' }, + ], + 8, + ), + ).toEqual({ clients: 3, active: 2, users: 8 }); + }); + + it('starts empty when the platform has no companies', () => { + expect(buildPlatformOverview([], 0)).toEqual({ clients: 0, active: 0, users: 0 }); + }); +}); diff --git a/api/src/platform/platform-overview.ts b/api/src/platform/platform-overview.ts new file mode 100644 index 0000000..0aecced --- /dev/null +++ b/api/src/platform/platform-overview.ts @@ -0,0 +1,16 @@ +export type PlatformOverview = { + clients: number; + active: number; + users: number; +}; + +export function buildPlatformOverview( + tenants: { status: string }[], + users: number, +): PlatformOverview { + return { + clients: tenants.length, + active: tenants.filter((tenant) => tenant.status === 'ACTIVE').length, + users, + }; +} diff --git a/api/src/platform/platform.module.ts b/api/src/platform/platform.module.ts index 6299210..902029a 100644 --- a/api/src/platform/platform.module.ts +++ b/api/src/platform/platform.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { PlatformController } from './platform.controller'; +import { PlatformOverviewController } from './platform-overview.controller'; import { PlatformService } from './platform.service'; import { PublicTenantsController } from './public-tenants.controller'; @Module({ - controllers: [PlatformController, PublicTenantsController], + controllers: [PlatformController, PlatformOverviewController, PublicTenantsController], providers: [PlatformService], }) export class PlatformModule {} diff --git a/api/src/platform/platform.service.ts b/api/src/platform/platform.service.ts index 64a9eb9..c507357 100644 --- a/api/src/platform/platform.service.ts +++ b/api/src/platform/platform.service.ts @@ -7,6 +7,7 @@ import { publicTenantView } from '../tenancy/public-tenant'; import { TenantConnectionService } from '../tenancy/tenant-connection.service'; import { normalizeSlug } from '../tenancy/tenant-url'; import type { CreateTenantDto } from './dto/create-tenant.dto'; +import { buildPlatformOverview } from './platform-overview'; @Injectable() export class PlatformService { @@ -22,6 +23,16 @@ export class PlatformService { }); } + async overview() { + const tenants = await this.platform.tenant.findMany({ + select: { status: true, database: true }, + }); + const users = ( + await Promise.all(tenants.map((tenant) => this.tenants.getByDatabase(tenant.database).user.count())) + ).reduce((sum, count) => sum + count, 0); + return buildPlatformOverview(tenants, users); + } + async publicBySlug(raw: string) { let slug: string; try { diff --git a/web/src/app/admin/(panel)/page.tsx b/web/src/app/admin/(panel)/page.tsx index 85d2a3a..af012a4 100644 --- a/web/src/app/admin/(panel)/page.tsx +++ b/web/src/app/admin/(panel)/page.tsx @@ -9,6 +9,13 @@ type Tenant = { name: string; slug: string; status: string; + createdAt: string; +}; + +type Overview = { + clients: number; + active: number; + users: number; }; type Created = Tenant & { @@ -41,6 +48,7 @@ function slugFromName(value: string) { export default function PlatformPage() { const [tenants, setTenants] = useState([]); + const [overview, setOverview] = useState(null); const [chatCounts, setChatCounts] = useState>({}); const [error, setError] = useState(""); const [open, setOpen] = useState(false); @@ -54,12 +62,14 @@ export default function PlatformPage() { const [issuing, setIssuing] = useState(null); async function load() { - const [list, counts] = await Promise.all([ + const [list, counts, stats] = await Promise.all([ api("/api/platform/tenants"), api>("/api/chat/counts").catch(() => ({})), + api("/api/platform/overview").catch(() => null), ]); setTenants(list); setChatCounts(counts); + setOverview(stats); } useEffect(() => { @@ -135,12 +145,57 @@ export default function PlatformPage() { return (

Admin

-

Clientes

-

- Quem contrata a Sinka. Cada cliente tem um endereço próprio para o time entrar. -

+

Visão geral

{error && !open ?

{error}

: null} +
    + {[ + { + label: "Clientes", + value: String(overview?.clients ?? tenants.length), + hint: "empresas na plataforma", + }, + { + label: "Ativos", + value: String(overview?.active ?? tenants.filter((tenant) => tenant.status === "ACTIVE").length), + hint: "prontos para operar", + }, + { + label: "Usuários", + value: String(overview?.users ?? "-"), + hint: "contas em todos os clientes", + }, + { + label: "Mensagens", + value: String(Object.values(chatCounts).reduce((sum, count) => sum + count, 0)), + hint: "no chat de suporte", + }, + ].map((card) => ( +
  • +

    {card.label}

    +

    {card.value}

    +

    {card.hint}

    +
  • + ))} +
+ +
+ {tenants.slice(0, 4).map((tenant) => ( +
+

/{tenant.slug}

+

{tenant.name}

+

+ {tenant.status === "ACTIVE" ? "Ativo" : "Suspenso"} + {chatCounts[tenant.slug] ? ` · ${chatCounts[tenant.slug]} msg no chat` : " · sem chat pendente"} +

+ + Abrir operação + +
+ ))} +
+ +
))} +
{open ? ( diff --git a/web/src/components/landing/scroll-film.tsx b/web/src/components/landing/scroll-film.tsx index c85c9b5..bd47a8a 100644 --- a/web/src/components/landing/scroll-film.tsx +++ b/web/src/components/landing/scroll-film.tsx @@ -8,10 +8,6 @@ type ScrollFilmProps = { children: ReactNode; }; -function easeInOutCubic(value: number) { - return value < 0.5 ? 4 * value * value * value : 1 - (-2 * value + 2) ** 3 / 2; -} - export function ScrollFilm({ src, poster, children }: ScrollFilmProps) { const orbitRef = useRef(null); const filmRef = useRef(null); @@ -34,9 +30,6 @@ export function ScrollFilm({ src, poster, children }: ScrollFilmProps) { let current = 0; let frame = 0; let blobUrl: string | null = null; - let introStartedAt = 0; - let introDone = false; - const introMs = 2400; const markReady = () => { if (ready) { @@ -46,9 +39,6 @@ export function ScrollFilm({ src, poster, children }: ScrollFilmProps) { film.pause(); film.classList.add("is-ready"); setLoading(false); - const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; - introDone = reduce; - introStartedAt = reduce ? 0 : performance.now(); }; fetch(src) @@ -69,33 +59,13 @@ export function ScrollFilm({ src, poster, children }: ScrollFilmProps) { return y / total; }; - const introSeconds = (now: number, duration: number) => { - if (introDone || !introStartedAt) { - return null; - } - if (progressFromScroll() > 0.02) { - introDone = true; - return null; - } - const elapsed = now - introStartedAt; - const t = Math.min(elapsed / introMs, 1); - const peak = Math.min(2, Math.max(duration * 0.22, 0.6)); - const trip = t < 0.5 ? easeInOutCubic(t * 2) : 1 - easeInOutCubic((t - 0.5) * 2); - if (t >= 1) { - introDone = true; - return 0; - } - return trip * peak; - }; - - const tick = (now: number) => { + const tick = () => { const target = progressFromScroll(); current += (target - current) * 0.42; orbit.style.setProperty("--scrub", current.toFixed(4)); if (ready && film.duration && !film.seeking) { - const intro = introSeconds(now, film.duration); - const time = intro ?? current * (film.duration - 0.04); + const time = current * (film.duration - 0.04); if (Math.abs(film.currentTime - time) >= 0.02) { film.currentTime = time; } diff --git a/web/src/components/restricted-shell.tsx b/web/src/components/restricted-shell.tsx index 7775b9a..99aeeed 100644 --- a/web/src/components/restricted-shell.tsx +++ b/web/src/components/restricted-shell.tsx @@ -20,6 +20,7 @@ export function RestrictedShell({ const [user, setUser] = useState(null); const [error, setError] = useState(""); const [menuOpen, setMenuOpen] = useState(false); + const [hash, setHash] = useState(""); const loginPath = kind === "platform" ? "/admin/login" : `${basePath}/login`; useEffect(() => { @@ -49,8 +50,16 @@ export function RestrictedShell({ useEffect(() => { setMenuOpen(false); + setHash(typeof window === "undefined" ? "" : window.location.hash); }, [pathname]); + useEffect(() => { + const sync = () => setHash(window.location.hash); + sync(); + window.addEventListener("hashchange", sync); + return () => window.removeEventListener("hashchange", sync); + }, []); + async function logout() { await api("/api/auth/logout", { method: "POST" }).catch(() => undefined); router.replace(loginPath); @@ -66,7 +75,10 @@ export function RestrictedShell({ const links = kind === "platform" - ? [{ href: "/admin", label: "Clientes" }] + ? [ + { href: "/admin", label: "Dashboard" }, + { href: "/admin#clientes", label: "Clientes" }, + ] : [ { href: basePath, label: "Dashboard" }, { href: `${basePath}/simular`, label: "Simular" }, @@ -82,48 +94,17 @@ export function RestrictedShell({ : []), ]; - if (kind === "platform") { - return ( -
-
-
-
- - Sinka - - -
-
-
-

{user.name}

-

{user.roleLabel}

-
- -
-
-
-
{children}
-
- ); + function isActive(href: string) { + if (kind === "platform") { + if (href === "/admin") { + return (pathname === "/admin" || pathname === "/admin/") && hash !== "#clientes"; + } + if (href.includes("#clientes")) { + return (pathname === "/admin" || pathname === "/admin/") && hash === "#clientes"; + } + return pathname.startsWith(href); + } + return href === basePath ? pathname === basePath : pathname.startsWith(href); } const nav = ( @@ -133,26 +114,28 @@ export function RestrictedShell({ Sinka

- {user.tenantName || "Operação"} + {kind === "platform" ? "Admin" : user.tenantName || "Operação"}

{user.name}