Snapshot of uncommitted work: landing, bots, prod infra, partner cabinet, backoffice stage 1
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
45d7512d2b
commit
d452824c62
314 files changed
+44394
-121
No files matched your search
@@ -0,0 +1,147 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user