Compare commits

..

No commits in common. "cf16bf1fb634400686e5f5212e08a8f709767d1f" and "0b19e8ac5c1b79f7dc80beb8a0d22e6b3a5c82d0" have entirely different histories.

162 changed files with 20939 additions and 21160 deletions

View File

@ -17,11 +17,7 @@ DEMO_MANAGER_PASSWORD=SinkaManager!1
DEMO_OPERATOR_EMAIL=ursula.b@example.com DEMO_OPERATOR_EMAIL=ursula.b@example.com
DEMO_OPERATOR_PASSWORD=SinkaOperador!1 DEMO_OPERATOR_PASSWORD=SinkaOperador!1
# OAuth Google (console: origens e callback do ambiente) # OAuth Google (console: origem http://localhost:3000, callback abaixo)
# Local: origem http://localhost:3000
# callback http://localhost:3000/api/auth/google/callback
# Demo: origem https://armandosoares.tech
# callback https://armandosoares.tech/api/auth/google/callback
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback

1
.gitignore vendored
View File

@ -20,4 +20,3 @@ msg.txt
*.tsbuildinfo *.tsbuildinfo
docker-compose.vps.yml docker-compose.vps.yml
deploy/ deploy/
msg.txt

View File

@ -1,17 +0,0 @@
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 { IntegrationsService } from './integrations.service';
@Controller('platform')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('PLATFORM_ADMIN')
export class IntegrationsController {
constructor(private readonly integrations: IntegrationsService) {}
@Get('integrations')
list() {
return this.integrations.list();
}
}

View File

@ -1,80 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { buildIntegrations, type Integration } from './integrations';
@Injectable()
export class IntegrationsService {
constructor(private readonly config: ConfigService) {}
async list(): Promise<{ items: Integration[] }> {
const [viaCep, nominatim, firebase] = await Promise.all([
this.probeViaCep(),
this.probeNominatim(),
this.probeFirebase(),
]);
return {
items: buildIntegrations({
viaCep,
nominatim,
google: this.googleConfigured(),
firebase,
}),
};
}
private googleConfigured() {
return Boolean(this.config.get<string>('GOOGLE_CLIENT_ID') && this.config.get<string>('GOOGLE_CLIENT_SECRET'));
}
private async probeViaCep() {
return this.ok(async () => {
const response = await fetch('https://viacep.com.br/ws/01001000/json/', {
signal: AbortSignal.timeout(4000),
});
if (!response.ok) {
return false;
}
const data = (await response.json()) as { erro?: boolean; localidade?: string };
return !data.erro && Boolean(data.localidade);
});
}
private async probeNominatim() {
return this.ok(async () => {
const query = new URLSearchParams({
format: 'json',
limit: '1',
countrycodes: 'br',
q: 'Sao Paulo, SP, Brasil',
});
const response = await fetch(`https://nominatim.openstreetmap.org/search?${query.toString()}`, {
headers: { 'User-Agent': 'SinkaLogistica/1.0 (desafio; tina.r@example.net)' },
signal: AbortSignal.timeout(4000),
});
if (!response.ok) {
return false;
}
const rows = (await response.json()) as unknown[];
return rows.length > 0;
});
}
private async probeFirebase() {
const url = (this.config.get<string>('FIREBASE_DATABASE_URL')?.replace(/\/$/, '') ||
'https://sinka-8ec10-default-rtdb.firebaseio.com');
return this.ok(async () => {
const response = await fetch(`${url}/.json?shallow=true`, {
signal: AbortSignal.timeout(4000),
});
return response.ok;
});
}
private async ok(probe: () => Promise<boolean>) {
try {
return await probe();
} catch {
return false;
}
}
}

View File

@ -1,19 +0,0 @@
import { buildIntegrations } from './integrations';
describe('buildIntegrations', () => {
it('marks each integration from the probe result', () => {
expect(
buildIntegrations({
viaCep: true,
nominatim: false,
google: true,
firebase: false,
}),
).toEqual([
expect.objectContaining({ id: 'viacep', status: 'active' }),
expect.objectContaining({ id: 'nominatim', status: 'inactive' }),
expect.objectContaining({ id: 'google', status: 'active' }),
expect.objectContaining({ id: 'firebase', status: 'inactive' }),
]);
});
});

View File

@ -1,42 +0,0 @@
export type IntegrationStatus = 'active' | 'inactive';
export type Integration = {
id: string;
name: string;
description: string;
status: IntegrationStatus;
};
export function buildIntegrations(probes: {
viaCep: boolean;
nominatim: boolean;
google: boolean;
firebase: boolean;
}): Integration[] {
return [
{
id: 'viacep',
name: 'ViaCEP',
description: 'Cidade e UF a partir do CEP de origem e destino.',
status: probes.viaCep ? 'active' : 'inactive',
},
{
id: 'nominatim',
name: 'OpenStreetMap Nominatim',
description: 'Coordenadas da cidade para calcular a distância do frete.',
status: probes.nominatim ? 'active' : 'inactive',
},
{
id: 'google',
name: 'Google',
description: 'Login com conta Google no admin e nas empresas.',
status: probes.google ? 'active' : 'inactive',
},
{
id: 'firebase',
name: 'Firebase Realtime Database',
description: 'Chat em tempo real entre o admin e o cliente.',
status: probes.firebase ? 'active' : 'inactive',
},
];
}

View File

@ -1,18 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { IntegrationsController } from './integrations.controller';
import { IntegrationsService } from './integrations.service';
import { PlatformController } from './platform.controller'; import { PlatformController } from './platform.controller';
import { PlatformOverviewController } from './platform-overview.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: [ controllers: [PlatformController, PlatformOverviewController, PublicTenantsController],
PlatformController, providers: [PlatformService],
PlatformOverviewController,
IntegrationsController,
PublicTenantsController,
],
providers: [PlatformService, IntegrationsService],
}) })
export class PlatformModule {} export class PlatformModule {}

File diff suppressed because one or more lines are too long

Binary file not shown.

22
msg.txt
View File

@ -1,22 +0,0 @@
Olá,
Sinka: SaaS de frete. NestJS + Next.js + MySQL + Redis.
Isolamento: um banco MySQL por empresa. Admin em /admin, cliente em /<empresa>.
Auth: e-mail/senha (JWT + cookie) e Google. Papéis Administrador, Manager, Operador.
Frete: ViaCEP + mapa, cotação por transportadora, dashboard de custo.
Comunicação em tempo real: Firebase Realtime com um chat interno entre a Sinka e o cliente. Cada empresa só vê a conversa dela.
Arquivos: tela para anexar CSV/XLSX (e PDF/TXT), tabela e botão de baixar. Fica no disco, separado por empresa.
Testes: Jest na API. `npm test`.
Landing: http://localhost:3000
Admin: /admin/login — tina.r@example.net / SinkaPlatform!1
Demo: /demo/login — xena.w@example.org / SinkaAdmin!1
Obrigado,
[seu nome]

View File

@ -2,8 +2,8 @@
# This is NOT the Next.js you know # This is NOT the Next.js you know
This version has breaking changes APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. This version has breaking changes. APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. This block is written and re-added by `next dev`. Verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules --> <!-- END:nextjs-agent-rules -->

View File

@ -1,300 +0,0 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { Modal, RegistryTable, fieldClass } from "@/components/registry-table";
type Tenant = {
id: string;
name: string;
slug: string;
status: string;
createdAt: string;
};
type Created = Tenant & {
access: { email: string; password: string; path: string };
};
type TenantUser = {
id: string;
name: string;
email: string;
role: "ADMIN" | "MANAGER" | "OPERATOR";
password: string | null;
};
const roleLabel = {
ADMIN: "Administrador",
MANAGER: "Manager",
OPERATOR: "Operador",
} as const;
function slugFromName(value: string) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 30);
}
export default function PlatformClientesPage() {
const [tenants, setTenants] = useState<Tenant[]>([]);
const [chatCounts, setChatCounts] = useState<Record<string, number>>({});
const [error, setError] = useState("");
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [created, setCreated] = useState<Created | null>(null);
const [saving, setSaving] = useState(false);
const [usersOf, setUsersOf] = useState<Tenant | null>(null);
const [users, setUsers] = useState<TenantUser[]>([]);
const [usersError, setUsersError] = useState("");
const [issuing, setIssuing] = useState<string | null>(null);
async function load() {
const [list, counts] = await Promise.all([
api<Tenant[]>("/api/platform/tenants"),
api<Record<string, number>>("/api/chat/counts").catch(() => ({})),
]);
setTenants(list);
setChatCounts(counts);
}
useEffect(() => {
load().catch((err: unknown) => setError(err instanceof Error ? err.message : "Erro"));
}, []);
function closeModal() {
setOpen(false);
setCreated(null);
setName("");
setSlug("");
setError("");
}
async function openUsers(tenant: Tenant) {
setUsersError("");
setUsersOf(tenant);
setUsers([]);
try {
const result = await api<{ users: TenantUser[] }>(`/api/platform/tenants/${tenant.id}/users`);
setUsers(result.users);
} catch (err) {
setUsersError(err instanceof Error ? err.message : "Não foi possível listar os usuários.");
}
}
function closeUsers() {
setUsersOf(null);
setUsers([]);
setUsersError("");
setIssuing(null);
}
async function issuePassword(userId: string) {
if (!usersOf) {
return;
}
setUsersError("");
setIssuing(userId);
try {
const result = await api<{ id: string; password: string }>(
`/api/platform/tenants/${usersOf.id}/users/${userId}/password`,
{ method: "POST" },
);
setUsers((current) =>
current.map((user) => (user.id === userId ? { ...user, password: result.password } : user)),
);
} catch (err) {
setUsersError(err instanceof Error ? err.message : "Não foi possível gerar a senha.");
} finally {
setIssuing(null);
}
}
async function onCreate(event: FormEvent) {
event.preventDefault();
setError("");
setSaving(true);
try {
const result = await api<Created>("/api/platform/tenants", {
method: "POST",
body: JSON.stringify({ name, slug }),
});
setCreated(result);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "Não foi possível criar o cliente.");
} finally {
setSaving(false);
}
}
return (
<section className="text-center">
{error && !open ? <p className="mb-4 text-sm text-red-300">{error}</p> : null}
<div className="mx-auto w-full max-w-5xl">
<RegistryTable
align="center"
countLabel={`${tenants.length} cliente${tenants.length === 1 ? "" : "s"}`}
columns={["Cliente", "Endereço", "Situação", "Acesso"]}
isEmpty={tenants.length === 0}
empty="Nenhum cliente ainda."
action={
<button className="btn-sinka px-4 py-2 text-xs" type="button" onClick={() => setOpen(true)}>
Adicionar
</button>
}
>
{tenants.map((tenant) => (
<tr key={tenant.id} className="border-t border-white/10">
<td className="px-5 py-3 font-medium">{tenant.name}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">/{tenant.slug}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{tenant.status === "ACTIVE" ? "Ativo" : "Suspenso"}</td>
<td className="px-5 py-3">
<div className="flex flex-wrap items-center justify-center gap-2">
<button
className="btn-ghost cursor-pointer px-3 py-1.5 text-[11px]"
type="button"
onClick={() => void openUsers(tenant)}
>
Usuários
</button>
<a
href={`/admin/chat/${tenant.slug}`}
className="relative btn-ghost cursor-pointer px-3 py-1.5 text-[11px]"
>
Chat
{chatCounts[tenant.slug] ? (
<span className="absolute -right-1 -top-1 inline-flex min-w-[1.15rem] items-center justify-center rounded-full bg-[#82C85C] px-1 text-[10px] font-bold leading-4 text-black">
{chatCounts[tenant.slug] > 99 ? "99+" : chatCounts[tenant.slug]}
</span>
) : null}
</a>
<a
href={`/${tenant.slug}/login`}
className="btn-sinka cursor-pointer px-3 py-1.5 text-[11px]"
target="_blank"
rel="noopener noreferrer"
>
Entrar
</a>
</div>
</td>
</tr>
))}
</RegistryTable>
</div>
{open ? (
<Modal onClose={closeModal}>
{created ? (
<div>
<h2 className="font-display text-2xl font-bold uppercase">Cliente criado</h2>
<p className="mt-3 text-sm text-[#E8F4E5]/65">Guarde o acesso do administrador:</p>
<p className="mt-4 text-sm">
Endereço:{" "}
<a className="text-[#82C85C]" href={created.access.path} target="_blank" rel="noopener noreferrer">
{created.access.path}
</a>
</p>
<p className="mt-2 text-sm">E-mail: {created.access.email}</p>
<p className="mt-2 text-sm">Senha: {created.access.password}</p>
<button className="btn-sinka mt-6" type="button" onClick={closeModal}>
Fechar
</button>
</div>
) : (
<form className="grid gap-3" onSubmit={onCreate}>
<h2 className="font-display text-2xl font-bold uppercase">Novo cliente</h2>
{error ? <p className="text-sm text-red-300">{error}</p> : null}
<input
className={fieldClass}
placeholder="Nome"
value={name}
onChange={(event) => {
setName(event.target.value);
setSlug(slugFromName(event.target.value));
}}
required
/>
<input
className={fieldClass}
placeholder="Endereço (ex: cliente-1)"
value={slug}
onChange={(event) => setSlug(event.target.value.toLowerCase())}
required
/>
<div className="mt-2 flex gap-2">
<button className="btn-sinka" disabled={saving} type="submit">
{saving ? "Criando…" : "Adicionar"}
</button>
<button className="btn-ghost" type="button" onClick={closeModal}>
Cancelar
</button>
</div>
</form>
)}
</Modal>
) : null}
{usersOf ? (
<Modal wide onClose={closeUsers}>
<h2 className="font-display text-2xl font-bold uppercase">Usuários · {usersOf.name}</h2>
<p className="mt-2 text-sm text-[#E8F4E5]/60">
Contas de {usersOf.name}. Se a senha estiver vazia, gere uma nova.
</p>
{usersError ? <p className="mt-3 text-sm text-red-300">{usersError}</p> : null}
<div className="mt-5 overflow-x-auto">
<table className="w-full min-w-[36rem] text-center text-sm">
<thead className="text-xs uppercase tracking-wider text-[#E8F4E5]/50">
<tr>
<th className="pb-3 font-medium">Nome</th>
<th className="pb-3 font-medium">Usuário</th>
<th className="pb-3 font-medium">Senha</th>
<th className="pb-3 font-medium">Papel</th>
<th className="pb-3 font-medium" />
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr>
<td className="py-6 text-[#E8F4E5]/50" colSpan={5}>
Nenhum usuário neste cliente.
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id} className="border-t border-white/10">
<td className="py-3 pr-4 font-medium">{user.name}</td>
<td className="py-3 pr-4 text-[#E8F4E5]/80">{user.email}</td>
<td className="py-3 pr-4 font-mono text-[#82C85C]">{user.password || "-"}</td>
<td className="py-3 pr-4 text-[#E8F4E5]/70">{roleLabel[user.role]}</td>
<td className="py-3">
<button
className="text-xs text-[#E8F4E5]/70"
type="button"
disabled={issuing === user.id}
onClick={() => void issuePassword(user.id)}
>
{issuing === user.id ? "Gerando…" : "Gerar nova senha"}
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<button className="btn-sinka mt-6" type="button" onClick={closeUsers}>
Fechar
</button>
</Modal>
) : null}
</section>
);
}

View File

@ -1,53 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
type Integration = {
id: string;
name: string;
description: string;
status: "active" | "inactive";
};
export default function PlatformIntegracoesPage() {
const [items, setItems] = useState<Integration[]>([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
api<{ items: Integration[] }>("/api/platform/integrations")
.then((result) => setItems(result.items))
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Erro"))
.finally(() => setLoading(false));
}, []);
return (
<section>
<h1 className="font-display text-4xl font-extrabold uppercase">Integrações</h1>
{error ? <p className="mt-4 text-sm text-red-300">{error}</p> : null}
{loading ? <p className="mt-4 text-sm text-[#E8F4E5]/50">Verificando integrações</p> : null}
<ul className="mt-8 grid gap-3 md:grid-cols-2">
{items.map((item) => {
const active = item.status === "active";
return (
<li key={item.id} className="rounded-2xl border border-white/10 px-5 py-5">
<div className="flex items-start justify-between gap-3">
<h2 className="text-lg font-semibold">{item.name}</h2>
<span
className={`shrink-0 rounded-full px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider ${
active ? "bg-[#82C85C]/15 text-[#82C85C]" : "bg-red-400/15 text-red-300"
}`}
>
{active ? "Ativo" : "Inativo"}
</span>
</div>
<p className="mt-2 text-sm text-[#E8F4E5]/60">{item.description}</p>
</li>
);
})}
</ul>
</section>
);
}

View File

@ -1,7 +1,16 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { Modal, RegistryTable, fieldClass } from "@/components/registry-table";
type Tenant = {
id: string;
name: string;
slug: string;
status: string;
createdAt: string;
};
type Overview = { type Overview = {
clients: number; clients: number;
@ -9,37 +18,146 @@ type Overview = {
users: number; users: number;
}; };
type Created = Tenant & {
access: { email: string; password: string; path: string };
};
type TenantUser = {
id: string;
name: string;
email: string;
role: "ADMIN" | "MANAGER" | "OPERATOR";
password: string | null;
};
const roleLabel = {
ADMIN: "Administrador",
MANAGER: "Manager",
OPERATOR: "Operador",
} as const;
function slugFromName(value: string) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 30);
}
export default function PlatformPage() { export default function PlatformPage() {
const [tenants, setTenants] = useState<Tenant[]>([]);
const [overview, setOverview] = useState<Overview | null>(null); const [overview, setOverview] = useState<Overview | null>(null);
const [messages, setMessages] = useState(0); const [chatCounts, setChatCounts] = useState<Record<string, number>>({});
const [error, setError] = useState(""); const [error, setError] = useState("");
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [created, setCreated] = useState<Created | null>(null);
const [saving, setSaving] = useState(false);
const [usersOf, setUsersOf] = useState<Tenant | null>(null);
const [users, setUsers] = useState<TenantUser[]>([]);
const [usersError, setUsersError] = useState("");
const [issuing, setIssuing] = useState<string | null>(null);
async function load() {
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(() => { useEffect(() => {
Promise.all([ load().catch((err: unknown) => setError(err instanceof Error ? err.message : "Erro"));
api<Overview>("/api/platform/overview").catch(() => null),
api<Record<string, number>>("/api/chat/counts").catch(() => ({})),
])
.then(([stats, counts]) => {
setOverview(stats);
setMessages(Object.values(counts).reduce((sum, count) => sum + count, 0));
})
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Erro"));
}, []); }, []);
function closeModal() {
setOpen(false);
setCreated(null);
setName("");
setSlug("");
setError("");
}
async function openUsers(tenant: Tenant) {
setUsersError("");
setUsersOf(tenant);
setUsers([]);
try {
const result = await api<{ users: TenantUser[] }>(`/api/platform/tenants/${tenant.id}/users`);
setUsers(result.users);
} catch (err) {
setUsersError(err instanceof Error ? err.message : "Não foi possível listar os usuários.");
}
}
function closeUsers() {
setUsersOf(null);
setUsers([]);
setUsersError("");
setIssuing(null);
}
async function issuePassword(userId: string) {
if (!usersOf) {
return;
}
setUsersError("");
setIssuing(userId);
try {
const result = await api<{ id: string; password: string }>(
`/api/platform/tenants/${usersOf.id}/users/${userId}/password`,
{ method: "POST" },
);
setUsers((current) =>
current.map((user) => (user.id === userId ? { ...user, password: result.password } : user)),
);
} catch (err) {
setUsersError(err instanceof Error ? err.message : "Não foi possível gerar a senha.");
} finally {
setIssuing(null);
}
}
async function onCreate(event: FormEvent) {
event.preventDefault();
setError("");
setSaving(true);
try {
const result = await api<Created>("/api/platform/tenants", {
method: "POST",
body: JSON.stringify({ name, slug }),
});
setCreated(result);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : "Não foi possível criar o cliente.");
} finally {
setSaving(false);
}
}
return ( return (
<section> <section>
{error ? <p className="mb-4 text-sm text-red-300">{error}</p> : null} <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">Visão geral</h1>
{error && !open ? <p className="mt-4 text-sm text-red-300">{error}</p> : null}
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <ul className="mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{[ {[
{ {
label: "Clientes", label: "Clientes",
value: String(overview?.clients ?? "-"), value: String(overview?.clients ?? tenants.length),
hint: "empresas na plataforma", hint: "empresas na plataforma",
}, },
{ {
label: "Ativos", label: "Ativos",
value: String(overview?.active ?? "-"), value: String(overview?.active ?? tenants.filter((tenant) => tenant.status === "ACTIVE").length),
hint: "prontos para operar", hint: "prontos para operar",
}, },
{ {
@ -49,7 +167,7 @@ export default function PlatformPage() {
}, },
{ {
label: "Mensagens", label: "Mensagens",
value: String(messages), value: String(Object.values(chatCounts).reduce((sum, count) => sum + count, 0)),
hint: "no chat de suporte", hint: "no chat de suporte",
}, },
].map((card) => ( ].map((card) => (
@ -60,6 +178,180 @@ export default function PlatformPage() {
</li> </li>
))} ))}
</ul> </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"]}
isEmpty={tenants.length === 0}
empty="Nenhum cliente ainda."
action={
<button className="btn-sinka px-4 py-2 text-xs" type="button" onClick={() => setOpen(true)}>
Adicionar
</button>
}
>
{tenants.map((tenant) => (
<tr key={tenant.id} className="border-t border-white/10">
<td className="px-5 py-3 font-medium">{tenant.name}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">/{tenant.slug}</td>
<td className="px-5 py-3 text-[#E8F4E5]/70">{tenant.status === "ACTIVE" ? "Ativo" : "Suspenso"}</td>
<td className="px-5 py-3">
<div className="flex flex-wrap items-center gap-2">
<button
className="btn-ghost cursor-pointer px-3 py-1.5 text-[11px]"
type="button"
onClick={() => void openUsers(tenant)}
>
Usuários
</button>
<a
href={`/admin/chat/${tenant.slug}`}
className="relative btn-ghost cursor-pointer px-3 py-1.5 text-[11px]"
>
Chat
{chatCounts[tenant.slug] ? (
<span className="absolute -right-1 -top-1 inline-flex min-w-[1.15rem] items-center justify-center rounded-full bg-[#82C85C] px-1 text-[10px] font-bold leading-4 text-black">
{chatCounts[tenant.slug] > 99 ? "99+" : chatCounts[tenant.slug]}
</span>
) : null}
</a>
<a
href={`/${tenant.slug}/login`}
className="btn-sinka cursor-pointer px-3 py-1.5 text-[11px]"
target="_blank"
rel="noopener noreferrer"
>
Entrar
</a>
</div>
</td>
</tr>
))}
</RegistryTable>
</div>
{open ? (
<Modal onClose={closeModal}>
{created ? (
<div>
<h2 className="font-display text-2xl font-bold uppercase">Cliente criado</h2>
<p className="mt-3 text-sm text-[#E8F4E5]/65">Guarde o acesso do administrador:</p>
<p className="mt-4 text-sm">
Endereço:{" "}
<a className="text-[#82C85C]" href={created.access.path} target="_blank" rel="noopener noreferrer">
{created.access.path}
</a>
</p>
<p className="mt-2 text-sm">E-mail: {created.access.email}</p>
<p className="mt-2 text-sm">Senha: {created.access.password}</p>
<button className="btn-sinka mt-6" type="button" onClick={closeModal}>
Fechar
</button>
</div>
) : (
<form className="grid gap-3" onSubmit={onCreate}>
<h2 className="font-display text-2xl font-bold uppercase">Novo cliente</h2>
{error ? <p className="text-sm text-red-300">{error}</p> : null}
<input
className={fieldClass}
placeholder="Nome"
value={name}
onChange={(event) => {
setName(event.target.value);
setSlug(slugFromName(event.target.value));
}}
required
/>
<input
className={fieldClass}
placeholder="Endereço (ex: cliente-1)"
value={slug}
onChange={(event) => setSlug(event.target.value.toLowerCase())}
required
/>
<div className="mt-2 flex gap-2">
<button className="btn-sinka" disabled={saving} type="submit">
{saving ? "Criando…" : "Adicionar"}
</button>
<button className="btn-ghost" type="button" onClick={closeModal}>
Cancelar
</button>
</div>
</form>
)}
</Modal>
) : null}
{usersOf ? (
<Modal wide onClose={closeUsers}>
<h2 className="font-display text-2xl font-bold uppercase">Usuários · {usersOf.name}</h2>
<p className="mt-2 text-sm text-[#E8F4E5]/60">
Contas de {usersOf.name}. Se a senha estiver vazia, gere uma nova.
</p>
{usersError ? <p className="mt-3 text-sm text-red-300">{usersError}</p> : null}
<div className="mt-5 overflow-x-auto">
<table className="w-full min-w-[36rem] text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-[#E8F4E5]/50">
<tr>
<th className="pb-3 font-medium">Nome</th>
<th className="pb-3 font-medium">Usuário</th>
<th className="pb-3 font-medium">Senha</th>
<th className="pb-3 font-medium">Papel</th>
<th className="pb-3 font-medium" />
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr>
<td className="py-6 text-[#E8F4E5]/50" colSpan={5}>
Nenhum usuário neste cliente.
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id} className="border-t border-white/10">
<td className="py-3 pr-4 font-medium">{user.name}</td>
<td className="py-3 pr-4 text-[#E8F4E5]/80">{user.email}</td>
<td className="py-3 pr-4 font-mono text-[#82C85C]">{user.password || "-"}</td>
<td className="py-3 pr-4 text-[#E8F4E5]/70">{roleLabel[user.role]}</td>
<td className="py-3 text-right">
<button
className="text-xs text-[#E8F4E5]/70"
type="button"
disabled={issuing === user.id}
onClick={() => void issuePassword(user.id)}
>
{issuing === user.id ? "Gerando…" : "Gerar nova senha"}
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<button className="btn-sinka mt-6" type="button" onClick={closeUsers}>
Fechar
</button>
</Modal>
) : null}
</section> </section>
); );
} }

View File

@ -291,6 +291,22 @@ h3,
color: rgba(232, 244, 229, 0.5); color: rgba(232, 244, 229, 0.5);
} }
.live {
color: #82c85c;
letter-spacing: 0.18em;
animation: live-blink 1.8s ease-in-out infinite;
}
@keyframes live-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.dash-grid { .dash-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);

View File

@ -141,7 +141,9 @@ export function SinkaLanding() {
</RevealSection> </RevealSection>
<RevealSection className="chapter-live"> <RevealSection className="chapter-live">
<p className="kicker">Tempo real</p> <p className="kicker">
<span className="live">LIVE</span> Tempo real
</p>
<h2>Sua operação em movimento.</h2> <h2>Sua operação em movimento.</h2>
<ul className="live-feed"> <ul className="live-feed">
{liveEvents.map((item) => ( {liveEvents.map((item) => (

View File

@ -99,6 +99,7 @@ export function VisionBoard() {
<div ref={rootRef} className="dash" data-ready={active}> <div ref={rootRef} className="dash" data-ready={active}>
<div className="dash-top"> <div className="dash-top">
<span>Sinka · operação</span> <span>Sinka · operação</span>
<span className="live">LIVE</span>
</div> </div>
<div className="dash-grid"> <div className="dash-grid">

View File

@ -7,7 +7,6 @@ export function RegistryTable({
empty, empty,
isEmpty, isEmpty,
children, children,
align = "left",
}: { }: {
countLabel: string; countLabel: string;
action?: ReactNode; action?: ReactNode;
@ -15,7 +14,6 @@ export function RegistryTable({
empty?: string; empty?: string;
isEmpty: boolean; isEmpty: boolean;
children: ReactNode; children: ReactNode;
align?: "left" | "center";
}) { }) {
return ( return (
<div className="mt-8 overflow-hidden rounded-2xl border border-white/10"> <div className="mt-8 overflow-hidden rounded-2xl border border-white/10">
@ -24,7 +22,7 @@ export function RegistryTable({
{action ?? <span />} {action ?? <span />}
</div> </div>
<div className="overflow-x-auto border-t border-white/10"> <div className="overflow-x-auto border-t border-white/10">
<table className={`w-full min-w-[36rem] text-sm ${align === "center" ? "text-center" : "text-left"}`}> <table className="w-full min-w-[36rem] text-left text-sm">
<thead className="bg-white/5 text-xs uppercase tracking-wider text-[#E8F4E5]/50"> <thead className="bg-white/5 text-xs uppercase tracking-wider text-[#E8F4E5]/50">
<tr> <tr>
{columns.map((column) => ( {columns.map((column) => (

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);
@ -68,8 +77,7 @@ export function RestrictedShell({
kind === "platform" kind === "platform"
? [ ? [
{ href: "/admin", label: "Dashboard" }, { href: "/admin", label: "Dashboard" },
{ href: "/admin/clientes", label: "Clientes" }, { href: "/admin#clientes", label: "Clientes" },
{ href: "/admin/integracoes", label: "Integrações" },
] ]
: [ : [
{ href: basePath, label: "Dashboard" }, { href: basePath, label: "Dashboard" },
@ -89,9 +97,12 @@ export function RestrictedShell({
function isActive(href: string) { function isActive(href: string) {
if (kind === "platform") { if (kind === "platform") {
if (href === "/admin") { if (href === "/admin") {
return pathname === "/admin" || pathname === "/admin/"; return (pathname === "/admin" || pathname === "/admin/") && hash !== "#clientes";
} }
return pathname === href || pathname.startsWith(`${href}/`); if (href.includes("#clientes")) {
return (pathname === "/admin" || pathname === "/admin/") && hash === "#clientes";
}
return pathname.startsWith(href);
} }
return href === basePath ? pathname === basePath : pathname.startsWith(href); return href === basePath ? pathname === basePath : pathname.startsWith(href);
} }
@ -111,6 +122,11 @@ export function RestrictedShell({
<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 ${
isActive(link.href) isActive(link.href)
? "bg-[#82C85C]/12 font-medium text-[#82C85C]" ? "bg-[#82C85C]/12 font-medium text-[#82C85C]"

View File

@ -95,7 +95,7 @@ export function SupportChat({
<section className="flex min-h-[28rem] flex-col"> <section className="flex min-h-[28rem] flex-col">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-[#82C85C]">Chat</p> <p className="text-xs font-semibold uppercase tracking-[0.28em] text-[#82C85C]">Chat</p>
{user?.kind === "platform" ? ( {user?.kind === "platform" ? (
<a href="/admin/clientes" className="mt-4 inline-flex text-sm text-[#82C85C] hover:underline"> <a href="/admin" className="mt-4 inline-flex text-sm text-[#82C85C] hover:underline">
Voltar para clientes Voltar para clientes
</a> </a>
) : null} ) : null}