Compare commits
No commits in common. "cf16bf1fb634400686e5f5212e08a8f709767d1f" and "0b19e8ac5c1b79f7dc80beb8a0d22e6b3a5c82d0" have entirely different histories.
cf16bf1fb6
...
0b19e8ac5c
@ -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
1
.gitignore
vendored
@ -20,4 +20,3 @@ msg.txt
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
docker-compose.vps.yml
|
docker-compose.vps.yml
|
||||||
deploy/
|
deploy/
|
||||||
msg.txt
|
|
||||||
|
|||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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' }),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@ -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
22
msg.txt
@ -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]
|
|
||||||
@ -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 -->
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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) => (
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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) => (
|
||||||
|
|||||||
@ -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]"
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user