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:
parent
8e4203e4c9
commit
6e48ac7804
@ -1,6 +1,12 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { buildIntegrations, type Integration } from './integrations';
|
import {
|
||||||
|
buildIntegrations,
|
||||||
|
firebaseProbeUrl,
|
||||||
|
nominatimIsHealthy,
|
||||||
|
nominatimProbeUrl,
|
||||||
|
type Integration,
|
||||||
|
} from './integrations';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class IntegrationsService {
|
export class IntegrationsService {
|
||||||
@ -41,21 +47,16 @@ export class IntegrationsService {
|
|||||||
|
|
||||||
private async probeNominatim() {
|
private async probeNominatim() {
|
||||||
return this.ok(async () => {
|
return this.ok(async () => {
|
||||||
const query = new URLSearchParams({
|
const response = await fetch(nominatimProbeUrl(), {
|
||||||
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)' },
|
headers: { 'User-Agent': 'SinkaLogistica/1.0 (desafio; tina.r@example.net)' },
|
||||||
signal: AbortSignal.timeout(4000),
|
signal: AbortSignal.timeout(4000),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const rows = (await response.json()) as unknown[];
|
return nominatimIsHealthy(
|
||||||
return rows.length > 0;
|
(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(/\/$/, '') ||
|
const url = (this.config.get<string>('FIREBASE_DATABASE_URL')?.replace(/\/$/, '') ||
|
||||||
'https://sinka-8ec10-default-rtdb.firebaseio.com');
|
'https://sinka-8ec10-default-rtdb.firebaseio.com');
|
||||||
return this.ok(async () => {
|
return this.ok(async () => {
|
||||||
const response = await fetch(`${url}/.json?shallow=true`, {
|
const response = await fetch(firebaseProbeUrl(url), {
|
||||||
signal: AbortSignal.timeout(4000),
|
signal: AbortSignal.timeout(4000),
|
||||||
});
|
});
|
||||||
return response.ok;
|
return response.ok;
|
||||||
|
|||||||
@ -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', () => {
|
describe('buildIntegrations', () => {
|
||||||
it('marks each integration from the probe result', () => {
|
it('marks each integration from the probe result', () => {
|
||||||
|
|||||||
@ -7,6 +7,18 @@ export type Integration = {
|
|||||||
status: IntegrationStatus;
|
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: {
|
export function buildIntegrations(probes: {
|
||||||
viaCep: boolean;
|
viaCep: boolean;
|
||||||
nominatim: boolean;
|
nominatim: boolean;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useEffect, useState } from "react";
|
import { FormEvent, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { Modal, RegistryTable, fieldClass } from "@/components/registry-table";
|
import { Modal, RegistryTable, fieldClass } from "@/components/registry-table";
|
||||||
|
|
||||||
@ -30,6 +30,166 @@ const roleLabel = {
|
|||||||
OPERATOR: "Operador",
|
OPERATOR: "Operador",
|
||||||
} as const;
|
} 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) {
|
function slugFromName(value: string) {
|
||||||
return value
|
return value
|
||||||
.normalize("NFD")
|
.normalize("NFD")
|
||||||
@ -155,34 +315,11 @@ 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.slug}</td>
|
||||||
<td className="px-5 py-3 text-[#E8F4E5]/70">{tenant.status === "ACTIVE" ? "Ativo" : "Suspenso"}</td>
|
<td className="px-5 py-3 text-[#E8F4E5]/70">{tenant.status === "ACTIVE" ? "Ativo" : "Suspenso"}</td>
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<TenantRowActions
|
||||||
<button
|
tenant={tenant}
|
||||||
className="btn-ghost cursor-pointer px-3 py-1.5 text-[11px]"
|
chatCount={chatCounts[tenant.slug] ?? 0}
|
||||||
type="button"
|
onUsers={() => void openUsers(tenant)}
|
||||||
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user