Files
BarevClub/apps/admin/src/App.tsx
T

148 lines
6.4 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { LOCALE_LABELS, LOCALES, type Locale } from '@barev/i18n';
import { Button, Center, Chip, Icon, Logo, Spinner, type IconName } from '@barev/ui';
import { I18nProvider, pickLocale, useI18n, useLocation, useTRPC } from '@barev/web';
import { useState, type ReactNode } from 'react';
import { Login } from './pages/Login.tsx';
import { Kanban } from './pages/Kanban.tsx';
import { Providers } from './pages/Providers.tsx';
import { Presets } from './pages/Presets.tsx';
import { Companies } from './pages/Companies.tsx';
import { Services } from './pages/Services.tsx';
import { Agencies } from './pages/Agencies.tsx';
import { Clients } from './pages/Clients.tsx';
import { Settings } from './pages/Settings.tsx';
import { WebRequests } from './pages/WebRequests.tsx';
import { Leads } from './pages/Leads.tsx';
import { Requests } from './pages/Requests.tsx';
import { Concierge } from './pages/Concierge.tsx';
import { Partners } from './pages/Partners.tsx';
import { Templates } from './pages/Templates.tsx';
import { Telegram } from './pages/Telegram.tsx';
const LOCALE_KEY = 'barev.admin.locale';
function savedLocale(): Locale {
try {
return pickLocale(localStorage.getItem(LOCALE_KEY), navigator.language);
} catch {
return 'ru';
}
}
export function App() {
const [locale] = useState(savedLocale);
return (
<I18nProvider initial={locale}>
<Gate />
</I18nProvider>
);
}
function Gate() {
const trpc = useTRPC();
const me = useQuery({ ...trpc.admin.auth.me.queryOptions(), retry: false });
if (me.isPending)
return (
<Center>
<Spinner />
</Center>
);
if (!me.data || !me.data.verified) return <Login stage={me.data ? (me.data.totpEnabled ? 'totp' : 'totp_setup') : 'password'} />;
return <Shell role={me.data.role} name={me.data.name} />;
}
const NAV: { path: string; key: string; icon: IconName; owner?: boolean }[] = [
{ path: '/', key: 'admin.nav.orders', icon: 'grid' },
{ path: '/leads', key: 'admin.nav.leads', icon: 'globe' },
{ path: '/templates', key: 'admin.nav.templates', icon: 'docs' },
{ path: '/concierge', key: 'admin.nav.concierge', icon: 'chat' },
{ path: '/app-requests', key: 'admin.nav.requests', icon: 'star' },
{ path: '/requests', key: 'admin.nav.webRequests', icon: 'chat' },
{ path: '/clients', key: 'admin.nav.clients', icon: 'user' },
{ path: '/providers', key: 'admin.nav.providers', icon: 'star' },
{ path: '/presets', key: 'admin.nav.presets', icon: 'repeat' },
{ path: '/companies', key: 'admin.nav.companies', icon: 'home' },
{ path: '/services', key: 'admin.nav.services', icon: 'docs' },
{ path: '/agencies', key: 'admin.nav.agencies', icon: 'gift' },
{ path: '/partners', key: 'admin.nav.partners', icon: 'gift' },
{ path: '/telegram', key: 'admin.nav.telegram', icon: 'chat' },
{ path: '/settings', key: 'admin.nav.settings', icon: 'clock', owner: true },
];
function Shell({ role, name }: { role: 'OWNER' | 'OPERATOR'; name: string }) {
const { t, locale, setLocale } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const { path } = useLocation();
const logout = useMutation(trpc.admin.auth.logout.mutationOptions({ onSuccess: () => qc.invalidateQueries() }));
const isOwner = role === 'OWNER';
// новые заявки с сайта — счётчик в меню, обновляется сам
const openRequests = useQuery({ ...trpc.admin.webRequests.openCount.queryOptions(), refetchInterval: 30_000 });
const openLeads = useQuery({ ...trpc.admin.leads.openCount.queryOptions(), refetchInterval: 30_000 });
const openAppRequests = useQuery({ ...trpc.admin.requests.openCount.queryOptions(), refetchInterval: 30_000 });
const unreadChats = useQuery({ ...trpc.admin.concierge.unreadCount.queryOptions(), refetchInterval: 15_000 });
const counts: Record<string, number | undefined> = { '/requests': openRequests.data, '/leads': openLeads.data, '/app-requests': openAppRequests.data, '/concierge': unreadChats.data };
let page: ReactNode;
if (path === '/leads') page = <Leads />;
else if (path === '/concierge') page = <Concierge />;
else if (path === '/app-requests') page = <Requests />;
else if (path === '/partners') page = <Partners />;
else if (path === '/templates') page = <Templates />;
else if (path === '/telegram') page = <Telegram />;
else if (path === '/requests') page = <WebRequests />;
else if (path === '/clients') page = <Clients isOwner={isOwner} />;
else if (path === '/providers') page = <Providers />;
else if (path === '/presets') page = <Presets isOwner={isOwner} />;
else if (path === '/companies') page = <Companies />;
else if (path === '/services') page = <Services isOwner={isOwner} />;
else if (path === '/agencies') page = <Agencies isOwner={isOwner} />;
else if (path === '/settings' && isOwner) page = <Settings />;
else page = <Kanban />;
return (
<div className="layout">
<aside className="sidebar">
<div style={{ padding: '4px 10px 16px' }}>
<Logo size={22} />
</div>
{NAV.filter((n) => !n.owner || isOwner).map((n) => (
<a key={n.path} href={`#${n.path}`} aria-current={path === n.path ? 'page' : undefined}>
<Icon name={n.icon} size={18} />
{t(n.key)}
{!!counts[n.path] && <span className="nav-count">{counts[n.path]}</span>}
</a>
))}
<div style={{ marginTop: 'auto', display: 'flex', flexDirection: 'column', gap: 8, padding: '0 6px' }}>
<div className="bc-chips">
{LOCALES.map((l) => (
<Chip
key={l}
pressed={l === locale}
onClick={() => {
setLocale(l);
try {
localStorage.setItem(LOCALE_KEY, l);
} catch {
/* приватный режим */
}
}}
style={{ minHeight: 32, padding: '4px 10px', fontSize: 12 }}
>
{LOCALE_LABELS[l]}
</Chip>
))}
</div>
<div className="bc-small bc-muted">
{name} · {t(`admin.roles.${role}`)}
</div>
<Button size="sm" variant="outline" onClick={() => logout.mutate()}>
{t('admin.logout')}
</Button>
</div>
</aside>
<main className="main">{page}</main>
</div>
);
}