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 { 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 {}

View File

@ -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 {

View File

@ -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<Tenant[]>([]);
const [overview, setOverview] = useState<Overview | null>(null);
const [chatCounts, setChatCounts] = useState<Record<string, number>>({});
const [error, setError] = useState("");
const [open, setOpen] = useState(false);
@ -54,12 +62,14 @@ export default function PlatformPage() {
const [issuing, setIssuing] = useState<string | null>(null);
async function load() {
const [list, counts] = await Promise.all([
const [list, counts, stats] = await Promise.all([
api<Tenant[]>("/api/platform/tenants"),
api<Record<string, number>>("/api/chat/counts").catch(() => ({})),
api<Overview>("/api/platform/overview").catch(() => null),
]);
setTenants(list);
setChatCounts(counts);
setOverview(stats);
}
useEffect(() => {
@ -135,12 +145,57 @@ export default function PlatformPage() {
return (
<section>
<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>
<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>
<h1 className="font-display mt-2 text-4xl font-extrabold uppercase">Visão geral</h1>
{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
countLabel={`${tenants.length} cliente${tenants.length === 1 ? "" : "s"}`}
columns={["Cliente", "Endereço", "Situação", "Acesso"]}
@ -190,6 +245,7 @@ export default function PlatformPage() {
</tr>
))}
</RegistryTable>
</div>
{open ? (
<Modal onClose={closeModal}>

View File

@ -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<HTMLDivElement>(null);
const filmRef = useRef<HTMLVideoElement>(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;
}

View File

@ -20,6 +20,7 @@ export function RestrictedShell({
const [user, setUser] = useState<SessionUser | null>(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 (
<div className="min-h-dvh bg-black text-[#E8F4E5]">
<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]"
}
>
{link.label}
</Link>
);
})}
</nav>
</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>
);
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({
<Image src="/brand/sinka-logo.webp" alt="Sinka" width={140} height={46} className="h-6 w-auto" />
</Link>
<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>
</div>
<nav className="flex flex-1 flex-col gap-0.5 px-3">
{links.map((link) => {
const active = link.href === basePath ? pathname === basePath : pathname.startsWith(link.href);
return (
<Link
key={link.href}
href={link.href}
className={`rounded-lg px-3 py-3 text-base ${
active
? "bg-[#82C85C]/12 font-medium text-[#82C85C]"
: "text-[#E8F4E5]/70 hover:bg-white/5 hover:text-[#E8F4E5]"
}`}
>
{link.label}
</Link>
);
})}
{links.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => {
if (kind === "platform") {
setHash(link.href.includes("#") ? "#clientes" : "");
}
}}
className={`rounded-lg px-3 py-3 text-base ${
isActive(link.href)
? "bg-[#82C85C]/12 font-medium text-[#82C85C]"
: "text-[#E8F4E5]/70 hover:bg-white/5 hover:text-[#E8F4E5]"
}`}
>
{link.label}
</Link>
))}
</nav>
<div className="mt-auto border-t border-white/10 px-5 py-5">
<p className="truncate text-sm font-medium">{user.name}</p>