video so no scroll e menu vertical no admin
This commit is contained in:
parent
6cc1a2bc00
commit
0b19e8ac5c
17
api/src/platform/platform-overview.controller.ts
Normal file
17
api/src/platform/platform-overview.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
20
api/src/platform/platform-overview.spec.ts
Normal file
20
api/src/platform/platform-overview.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
16
api/src/platform/platform-overview.ts
Normal file
16
api/src/platform/platform-overview.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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({
|
||||
: []),
|
||||
];
|
||||
|
||||
function isActive(href: string) {
|
||||
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]"
|
||||
if (href === "/admin") {
|
||||
return (pathname === "/admin" || pathname === "/admin/") && hash !== "#clientes";
|
||||
}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
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 (
|
||||
{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 ${
|
||||
active
|
||||
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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user