Keep admin client actions on one row, with an Opcoes menu when space runs out.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
armando 2026-09-16 04:15:16 -03:00
parent 8e4203e4c9
commit 6e48ac7804
4 changed files with 211 additions and 42 deletions

View File

@ -1,6 +1,12 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { buildIntegrations, type Integration } from './integrations';
import {
buildIntegrations,
firebaseProbeUrl,
nominatimIsHealthy,
nominatimProbeUrl,
type Integration,
} from './integrations';
@Injectable()
export class IntegrationsService {
@ -41,21 +47,16 @@ export class IntegrationsService {
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()}`, {
const response = await fetch(nominatimProbeUrl(), {
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;
return nominatimIsHealthy(
(await response.json()) as { status?: number; message?: string },
);
});
}
@ -63,7 +64,7 @@ export class IntegrationsService {
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`, {
const response = await fetch(firebaseProbeUrl(url), {
signal: AbortSignal.timeout(4000),
});
return response.ok;

View File

@ -1,4 +1,23 @@
import { buildIntegrations } from './integrations';
import { buildIntegrations, firebaseProbeUrl, nominatimIsHealthy, nominatimProbeUrl } from './integrations';
describe('firebaseProbeUrl', () => {
it('checks the chat path, not the database root', () => {
expect(firebaseProbeUrl('https://sinka-8ec10-default-rtdb.firebaseio.com')).toBe(
'https://sinka-8ec10-default-rtdb.firebaseio.com/chats/_health/messages.json',
);
});
});
describe('nominatim probe', () => {
it('uses the status endpoint instead of a search that OSM often blocks', () => {
expect(nominatimProbeUrl()).toBe('https://nominatim.openstreetmap.org/status.php?format=json');
});
it('treats status 0 as healthy', () => {
expect(nominatimIsHealthy({ status: 0, message: 'OK' })).toBe(true);
expect(nominatimIsHealthy({ status: 1, message: 'ERROR' })).toBe(false);
});
});
describe('buildIntegrations', () => {
it('marks each integration from the probe result', () => {

View File

@ -7,6 +7,18 @@ export type Integration = {
status: IntegrationStatus;
};
export function firebaseProbeUrl(databaseUrl: string) {
return `${databaseUrl.replace(/\/$/, '')}/chats/_health/messages.json`;
}
export function nominatimProbeUrl() {
return 'https://nominatim.openstreetmap.org/status.php?format=json';
}
export function nominatimIsHealthy(payload: { status?: number; message?: string }) {
return payload.status === 0 || payload.message === 'OK';
}
export function buildIntegrations(probes: {
viaCep: boolean;
nominatim: boolean;

View File

@ -1,6 +1,6 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import { FormEvent, useEffect, useLayoutEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { Modal, RegistryTable, fieldClass } from "@/components/registry-table";
@ -30,6 +30,166 @@ const roleLabel = {
OPERATOR: "Operador",
} as const;
function TenantRowActions({
tenant,
chatCount,
onUsers,
}: {
tenant: Tenant;
chatCount: number;
onUsers: () => void;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const rowRef = useRef<HTMLDivElement>(null);
const neededRef = useRef(0);
const [compact, setCompact] = useState(false);
const [menu, setMenu] = useState<{ top: number; right: number } | null>(null);
useLayoutEffect(() => {
const host = hostRef.current;
if (!host) {
return;
}
function measure() {
const node = hostRef.current;
const row = rowRef.current;
if (!node) {
return;
}
if (row) {
neededRef.current = row.scrollWidth;
}
setCompact(node.clientWidth < neededRef.current);
}
measure();
const observer = new ResizeObserver(measure);
observer.observe(host);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!menu) {
return;
}
function close() {
setMenu(null);
}
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") {
close();
}
}
window.addEventListener("click", close);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("keydown", onKey);
};
}, [menu]);
const buttons = (
<>
<button
className="btn-ghost shrink-0 cursor-pointer px-3 py-1.5 text-[11px]"
type="button"
onClick={onUsers}
>
Usuários
</button>
<a
href={`/admin/chat/${tenant.slug}`}
className="relative btn-ghost shrink-0 cursor-pointer px-3 py-1.5 text-[11px]"
>
Chat
{chatCount ? (
<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">
{chatCount > 99 ? "99+" : chatCount}
</span>
) : null}
</a>
<a
href={`/${tenant.slug}/login`}
className="btn-sinka shrink-0 cursor-pointer px-3 py-1.5 text-[11px]"
target="_blank"
rel="noopener noreferrer"
>
Entrar
</a>
</>
);
return (
<div ref={hostRef} className="relative min-w-0">
<div
ref={rowRef}
className={`flex flex-nowrap items-center gap-2 ${compact ? "pointer-events-none invisible absolute" : ""}`}
aria-hidden={compact}
>
{buttons}
</div>
{compact ? (
<>
<button
className="btn-ghost cursor-pointer px-3 py-1.5 text-[11px]"
type="button"
aria-expanded={Boolean(menu)}
onClick={(event) => {
event.stopPropagation();
if (menu) {
setMenu(null);
return;
}
const rect = event.currentTarget.getBoundingClientRect();
setMenu({ top: rect.bottom + 6, right: window.innerWidth - rect.right });
}}
>
Opções
</button>
{menu ? (
<div
className="fixed z-50 min-w-40 overflow-hidden rounded-xl border border-white/10 bg-black py-1 shadow-xl"
style={{ top: menu.top, right: menu.right }}
onClick={(event) => event.stopPropagation()}
>
<button
className="block w-full px-4 py-2 text-left text-sm text-[#E8F4E5] hover:bg-white/5"
type="button"
onClick={() => {
setMenu(null);
onUsers();
}}
>
Usuários
</button>
<a
href={`/admin/chat/${tenant.slug}`}
className="flex items-center justify-between px-4 py-2 text-sm text-[#E8F4E5] hover:bg-white/5"
>
Chat
{chatCount ? (
<span className="ml-3 inline-flex min-w-[1.15rem] items-center justify-center rounded-full bg-[#82C85C] px-1 text-[10px] font-bold leading-4 text-black">
{chatCount > 99 ? "99+" : chatCount}
</span>
) : null}
</a>
<a
href={`/${tenant.slug}/login`}
className="block px-4 py-2 text-sm text-[#E8F4E5] hover:bg-white/5"
target="_blank"
rel="noopener noreferrer"
>
Entrar
</a>
</div>
) : null}
</>
) : null}
</div>
);
}
function slugFromName(value: string) {
return value
.normalize("NFD")
@ -155,35 +315,12 @@ export default function PlatformClientesPage() {
<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>
<TenantRowActions
tenant={tenant}
chatCount={chatCounts[tenant.slug] ?? 0}
onUsers={() => void openUsers(tenant)}
/>
</td>
</tr>
))}
</RegistryTable>