video so no scroll e menu vertical no admin

This commit is contained in:
Armando Soares 2026-09-16 06:06:34 +00:00
parent 6cc1a2bc00
commit 0b19e8ac5c
8 changed files with 172 additions and 98 deletions

View File

@ -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();
}
}

View File

@ -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 });
});
});

View File

@ -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,
};
}

View File

@ -1,10 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PlatformController } from './platform.controller'; import { PlatformController } from './platform.controller';
import { PlatformOverviewController } from './platform-overview.controller';
import { PlatformService } from './platform.service'; import { PlatformService } from './platform.service';
import { PublicTenantsController } from './public-tenants.controller'; import { PublicTenantsController } from './public-tenants.controller';
@Module({ @Module({
controllers: [PlatformController, PublicTenantsController], controllers: [PlatformController, PlatformOverviewController, PublicTenantsController],
providers: [PlatformService], providers: [PlatformService],
}) })
export class PlatformModule {} export class PlatformModule {}

View File

@ -7,6 +7,7 @@ import { publicTenantView } from '../tenancy/public-tenant';
import { TenantConnectionService } from '../tenancy/tenant-connection.service'; import { TenantConnectionService } from '../tenancy/tenant-connection.service';
import { normalizeSlug } from '../tenancy/tenant-url'; import { normalizeSlug } from '../tenancy/tenant-url';
import type { CreateTenantDto } from './dto/create-tenant.dto'; import type { CreateTenantDto } from './dto/create-tenant.dto';
import { buildPlatformOverview } from './platform-overview';
@Injectable() @Injectable()
export class PlatformService { 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) { async publicBySlug(raw: string) {
let slug: string; let slug: string;
try { try {

View File

@ -9,6 +9,13 @@ type Tenant = {
name: string; name: string;
slug: string; slug: string;
status: string; status: string;
createdAt: string;
};
type Overview = {
clients: number;
active: number;
users: number;
}; };
type Created = Tenant & { type Created = Tenant & {
@ -41,6 +48,7 @@ function slugFromName(value: string) {
export default function PlatformPage() { export default function PlatformPage() {
const [tenants, setTenants] = useState<Tenant[]>([]); const [tenants, setTenants] = useState<Tenant[]>([]);
const [overview, setOverview] = useState<Overview | null>(null);
const [chatCounts, setChatCounts] = useState<Record<string, number>>({}); const [chatCounts, setChatCounts] = useState<Record<string, number>>({});
const [error, setError] = useState(""); const [error, setError] = useState("");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -54,12 +62,14 @@ export default function PlatformPage() {
const [issuing, setIssuing] = useState<string | null>(null); const [issuing, setIssuing] = useState<string | null>(null);
async function load() { async function load() {
const [list, counts] = await Promise.all([ const [list, counts, stats] = await Promise.all([
api<Tenant[]>("/api/platform/tenants"), api<Tenant[]>("/api/platform/tenants"),
api<Record<string, number>>("/api/chat/counts").catch(() => ({})), api<Record<string, number>>("/api/chat/counts").catch(() => ({})),
api<Overview>("/api/platform/overview").catch(() => null),
]); ]);
setTenants(list); setTenants(list);
setChatCounts(counts); setChatCounts(counts);
setOverview(stats);
} }
useEffect(() => { useEffect(() => {
@ -135,12 +145,57 @@ export default function PlatformPage() {
return ( return (
<section> <section>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-[#82C85C]">Admin</p> <p className="text-xs font-semibold uppercase tracking-[0.28em] text-[#82C85C]">Admin</p>
<h1 className="font-display mt-2 text-4xl font-extrabold uppercase">Clientes</h1> <h1 className="font-display mt-2 text-4xl font-extrabold uppercase">Visão geral</h1>
<p className="mt-4 max-w-xl text-[#E8F4E5]/65">
Quem contrata a Sinka. Cada cliente tem um endereço próprio para o time entrar.
</p>
{error && !open ? <p className="mt-4 text-sm text-red-300">{error}</p> : null} {error && !open ? <p className="mt-4 text-sm text-red-300">{error}</p> : null}
<ul className="mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{[
{
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) => (
<li key={card.label} className="rounded-2xl border border-white/10 px-5 py-5">
<p className="text-xs uppercase tracking-wider text-[#E8F4E5]/45">{card.label}</p>
<p className="font-display mt-3 text-3xl font-extrabold">{card.value}</p>
<p className="mt-2 text-xs text-[#E8F4E5]/50">{card.hint}</p>
</li>
))}
</ul>
<div className="mt-6 grid gap-3 md:grid-cols-2">
{tenants.slice(0, 4).map((tenant) => (
<article key={tenant.id} className="rounded-2xl border border-white/10 px-5 py-4">
<p className="text-xs uppercase tracking-wider text-[#E8F4E5]/45">/{tenant.slug}</p>
<h2 className="mt-1 text-lg font-semibold">{tenant.name}</h2>
<p className="mt-2 text-sm text-[#E8F4E5]/60">
{tenant.status === "ACTIVE" ? "Ativo" : "Suspenso"}
{chatCounts[tenant.slug] ? ` · ${chatCounts[tenant.slug]} msg no chat` : " · sem chat pendente"}
</p>
<a href={`/${tenant.slug}/login`} className="mt-3 inline-flex text-sm text-[#82C85C]" target="_blank" rel="noopener noreferrer">
Abrir operação
</a>
</article>
))}
</div>
<div id="clientes" className="scroll-mt-24">
<RegistryTable <RegistryTable
countLabel={`${tenants.length} cliente${tenants.length === 1 ? "" : "s"}`} countLabel={`${tenants.length} cliente${tenants.length === 1 ? "" : "s"}`}
columns={["Cliente", "Endereço", "Situação", "Acesso"]} columns={["Cliente", "Endereço", "Situação", "Acesso"]}
@ -190,6 +245,7 @@ export default function PlatformPage() {
</tr> </tr>
))} ))}
</RegistryTable> </RegistryTable>
</div>
{open ? ( {open ? (
<Modal onClose={closeModal}> <Modal onClose={closeModal}>

View File

@ -8,10 +8,6 @@ type ScrollFilmProps = {
children: ReactNode; 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) { export function ScrollFilm({ src, poster, children }: ScrollFilmProps) {
const orbitRef = useRef<HTMLDivElement>(null); const orbitRef = useRef<HTMLDivElement>(null);
const filmRef = useRef<HTMLVideoElement>(null); const filmRef = useRef<HTMLVideoElement>(null);
@ -34,9 +30,6 @@ export function ScrollFilm({ src, poster, children }: ScrollFilmProps) {
let current = 0; let current = 0;
let frame = 0; let frame = 0;
let blobUrl: string | null = null; let blobUrl: string | null = null;
let introStartedAt = 0;
let introDone = false;
const introMs = 2400;
const markReady = () => { const markReady = () => {
if (ready) { if (ready) {
@ -46,9 +39,6 @@ export function ScrollFilm({ src, poster, children }: ScrollFilmProps) {
film.pause(); film.pause();
film.classList.add("is-ready"); film.classList.add("is-ready");
setLoading(false); setLoading(false);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
introDone = reduce;
introStartedAt = reduce ? 0 : performance.now();
}; };
fetch(src) fetch(src)
@ -69,33 +59,13 @@ export function ScrollFilm({ src, poster, children }: ScrollFilmProps) {
return y / total; return y / total;
}; };
const introSeconds = (now: number, duration: number) => { const tick = () => {
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 target = progressFromScroll(); const target = progressFromScroll();
current += (target - current) * 0.42; current += (target - current) * 0.42;
orbit.style.setProperty("--scrub", current.toFixed(4)); orbit.style.setProperty("--scrub", current.toFixed(4));
if (ready && film.duration && !film.seeking) { if (ready && film.duration && !film.seeking) {
const intro = introSeconds(now, film.duration); const time = current * (film.duration - 0.04);
const time = intro ?? current * (film.duration - 0.04);
if (Math.abs(film.currentTime - time) >= 0.02) { if (Math.abs(film.currentTime - time) >= 0.02) {
film.currentTime = time; film.currentTime = time;
} }

View File

@ -20,6 +20,7 @@ export function RestrictedShell({
const [user, setUser] = useState<SessionUser | null>(null); const [user, setUser] = useState<SessionUser | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [hash, setHash] = useState("");
const loginPath = kind === "platform" ? "/admin/login" : `${basePath}/login`; const loginPath = kind === "platform" ? "/admin/login" : `${basePath}/login`;
useEffect(() => { useEffect(() => {
@ -49,8 +50,16 @@ export function RestrictedShell({
useEffect(() => { useEffect(() => {
setMenuOpen(false); setMenuOpen(false);
setHash(typeof window === "undefined" ? "" : window.location.hash);
}, [pathname]); }, [pathname]);
useEffect(() => {
const sync = () => setHash(window.location.hash);
sync();
window.addEventListener("hashchange", sync);
return () => window.removeEventListener("hashchange", sync);
}, []);
async function logout() { async function logout() {
await api("/api/auth/logout", { method: "POST" }).catch(() => undefined); await api("/api/auth/logout", { method: "POST" }).catch(() => undefined);
router.replace(loginPath); router.replace(loginPath);
@ -66,7 +75,10 @@ export function RestrictedShell({
const links = const links =
kind === "platform" kind === "platform"
? [{ href: "/admin", label: "Clientes" }] ? [
{ href: "/admin", label: "Dashboard" },
{ href: "/admin#clientes", label: "Clientes" },
]
: [ : [
{ href: basePath, label: "Dashboard" }, { href: basePath, label: "Dashboard" },
{ href: `${basePath}/simular`, label: "Simular" }, { href: `${basePath}/simular`, label: "Simular" },
@ -82,48 +94,17 @@ export function RestrictedShell({
: []), : []),
]; ];
function isActive(href: string) {
if (kind === "platform") { if (kind === "platform") {
return ( if (href === "/admin") {
<div className="min-h-dvh bg-black text-[#E8F4E5]"> return (pathname === "/admin" || pathname === "/admin/") && hash !== "#clientes";
<header className="border-b border-white/10">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4 px-5 py-4">
<div className="flex flex-wrap items-center gap-8">
<Link href={basePath}>
<Image src="/brand/sinka-logo.webp" alt="Sinka" width={160} height={52} className="h-7 w-auto" />
</Link>
<nav className="flex items-center gap-6 text-base">
{links.map((link) => {
const active = pathname === link.href;
return (
<Link
key={link.href}
href={link.href}
className={
active
? "font-medium text-[#82C85C]"
: "text-[#E8F4E5]/70 transition hover:text-[#E8F4E5]"
} }
> if (href.includes("#clientes")) {
{link.label} return (pathname === "/admin" || pathname === "/admin/") && hash === "#clientes";
</Link> }
); return pathname.startsWith(href);
})} }
</nav> return href === basePath ? pathname === basePath : pathname.startsWith(href);
</div>
<div className="flex items-center gap-3 text-sm">
<div className="hidden text-right sm:block">
<p className="font-medium">{user.name}</p>
<p className="text-xs text-[#82C85C]">{user.roleLabel}</p>
</div>
<button className="btn-ghost px-3 py-2 text-xs" type="button" onClick={() => void logout()}>
Sair
</button>
</div>
</div>
</header>
<main className="mx-auto max-w-6xl px-5 py-10">{children}</main>
</div>
);
} }
const nav = ( const nav = (
@ -133,26 +114,28 @@ export function RestrictedShell({
<Image src="/brand/sinka-logo.webp" alt="Sinka" width={140} height={46} className="h-6 w-auto" /> <Image src="/brand/sinka-logo.webp" alt="Sinka" width={140} height={46} className="h-6 w-auto" />
</Link> </Link>
<p className="mt-5 text-[11px] font-semibold uppercase tracking-[0.22em] text-[#82C85C]"> <p className="mt-5 text-[11px] font-semibold uppercase tracking-[0.22em] text-[#82C85C]">
{user.tenantName || "Operação"} {kind === "platform" ? "Admin" : user.tenantName || "Operação"}
</p> </p>
</div> </div>
<nav className="flex flex-1 flex-col gap-0.5 px-3"> <nav className="flex flex-1 flex-col gap-0.5 px-3">
{links.map((link) => { {links.map((link) => (
const active = link.href === basePath ? pathname === basePath : pathname.startsWith(link.href);
return (
<Link <Link
key={link.href} key={link.href}
href={link.href} href={link.href}
onClick={() => {
if (kind === "platform") {
setHash(link.href.includes("#") ? "#clientes" : "");
}
}}
className={`rounded-lg px-3 py-3 text-base ${ className={`rounded-lg px-3 py-3 text-base ${
active isActive(link.href)
? "bg-[#82C85C]/12 font-medium text-[#82C85C]" ? "bg-[#82C85C]/12 font-medium text-[#82C85C]"
: "text-[#E8F4E5]/70 hover:bg-white/5 hover:text-[#E8F4E5]" : "text-[#E8F4E5]/70 hover:bg-white/5 hover:text-[#E8F4E5]"
}`} }`}
> >
{link.label} {link.label}
</Link> </Link>
); ))}
})}
</nav> </nav>
<div className="mt-auto border-t border-white/10 px-5 py-5"> <div className="mt-auto border-t border-white/10 px-5 py-5">
<p className="truncate text-sm font-medium">{user.name}</p> <p className="truncate text-sm font-medium">{user.name}</p>