Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
45d7512d2b
commit
3489e0b816
319 files changed
+45483
-121
No files matched your search
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#FBF6EE" />
|
||||
<title>Barev Pro</title>
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,400;0,500;0,700;0,800;1,400;1,500&family=Onest:wght@400;500;600&family=Noto+Sans+Armenian:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@barev/miniapp-pro",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 5175 --strictPort",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5175",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@barev/core": "workspace:*",
|
||||
"@barev/i18n": "workspace:*",
|
||||
"@barev/ui": "workspace:*",
|
||||
"@barev/web": "workspace:*",
|
||||
"@tanstack/react-query": "^5.104.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@barev/api": "workspace:*",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Center, Icon, Spinner } from '@barev/ui';
|
||||
import { I18nProvider, match, navigate, pickLocale, telegramLanguage, useI18n, useLocation, useTelegramBack, useTRPC } from '@barev/web';
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { Company } from './screens/Company.tsx';
|
||||
import { History } from './screens/History.tsx';
|
||||
import { Job } from './screens/Job.tsx';
|
||||
import { Partner } from './screens/Partner.tsx';
|
||||
import { Today } from './screens/Today.tsx';
|
||||
|
||||
const ROOTS = ['/', '/history', '/company', '/partner'];
|
||||
|
||||
export function App({ hasInitData }: { hasInitData: boolean }) {
|
||||
const trpc = useTRPC();
|
||||
const me = useQuery({ ...trpc.pro.me.queryOptions(), enabled: hasInitData });
|
||||
const locale = pickLocale(me.data?.locale, telegramLanguage(), navigator.language);
|
||||
const status = me.error?.data?.httpStatus;
|
||||
return (
|
||||
<I18nProvider initial={locale}>
|
||||
{!hasInitData || status === 401 ? (
|
||||
<Message titleKey="pro.openInTelegram.title" textKey="pro.openInTelegram.text" />
|
||||
) : status === 403 ? (
|
||||
<Message titleKey="pro.notLinked.title" textKey="pro.notLinked.text" />
|
||||
) : me.isPending ? (
|
||||
<Center>
|
||||
<Spinner />
|
||||
</Center>
|
||||
) : me.error ? (
|
||||
<Message titleKey="errors.generic" textKey="common.retryLater" />
|
||||
) : (
|
||||
<Routes isProvider={!!me.data.provider} companies={me.data.companies} partners={me.data.partners} />
|
||||
)}
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Routes({ isProvider, companies, partners }: { isProvider: boolean; companies: { id: string; name: string }[]; partners: { id: string; name: string; role: 'OWNER' | 'STAFF' }[] }) {
|
||||
const { path } = useLocation();
|
||||
useTelegramBack(path, ROOTS);
|
||||
// не исполнитель — сразу на экран компании или кабинет партнёра
|
||||
useEffect(() => {
|
||||
if (!isProvider && path === '/') navigate(companies.length ? '/company' : '/partner', { replace: true });
|
||||
}, [isProvider, path, companies.length]);
|
||||
|
||||
let screen: ReactNode;
|
||||
let m: Record<string, string> | null;
|
||||
if (path === '/' && isProvider) screen = <Today />;
|
||||
else if ((m = match('/orders/:id', path)) && isProvider) screen = <Job id={m.id!} />;
|
||||
else if (path === '/history' && isProvider) screen = <History />;
|
||||
else if (path === '/company' && companies.length) screen = <Company companies={companies} />;
|
||||
else if (path === '/partner' && partners.length) screen = <Partner partners={partners} />;
|
||||
else screen = isProvider ? <Today /> : companies.length ? <Company companies={companies} /> : <Partner partners={partners} />;
|
||||
// вкладки: Сегодня и История у исполнителя, Компания у диспетчера, Партнёр у собственника и сотрудников партнёра
|
||||
const tabs = (isProvider ? 2 : 0) + (companies.length ? 1 : 0) + (partners.length ? 1 : 0);
|
||||
const showNav = ROOTS.includes(path) && tabs > 1;
|
||||
return (
|
||||
<>
|
||||
{screen}
|
||||
{showNav && <Nav path={path} isProvider={isProvider} hasCompany={companies.length > 0} hasPartner={partners.length > 0} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Nav({ path, isProvider, hasCompany, hasPartner }: { path: string; isProvider: boolean; hasCompany: boolean; hasPartner: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const item = (href: string, icon: Parameters<typeof Icon>[0]['name'], label: string) => (
|
||||
<a href={`#${href}`} aria-current={path === href ? 'page' : undefined}>
|
||||
<Icon name={icon} />
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
return (
|
||||
<nav className="bc-bottomnav" aria-label={t('pro.nav.label')}>
|
||||
{isProvider && item('/', 'clock', t('pro.nav.today'))}
|
||||
{isProvider && item('/history', 'star', t('pro.nav.history'))}
|
||||
{hasCompany && item('/company', 'grid', t('pro.nav.company'))}
|
||||
{hasPartner && item('/partner', 'gift', t('pro.nav.partner'))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function Message({ titleKey, textKey }: { titleKey: string; textKey: string }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="app app--plain">
|
||||
<Center>
|
||||
<div className="bc-stack" style={{ textAlign: 'center', alignItems: 'center' }}>
|
||||
<Icon name="logo" size={48} style={{ color: 'var(--bc-plum)' }} />
|
||||
<h1 className="bc-h2">{t(titleKey)}</h1>
|
||||
<p className="bc-muted">{t(textKey)}</p>
|
||||
</div>
|
||||
</Center>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
.app {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 16px calc(100px + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.app--plain {
|
||||
padding-bottom: calc(24px + env(safe-area-inset-bottom));
|
||||
}
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.back {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--bc-surface);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.stat {
|
||||
padding: 12px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.stat__value {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: 20px;
|
||||
}
|
||||
.job {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-radius: 22px;
|
||||
background: var(--bc-surface);
|
||||
text-decoration: none;
|
||||
min-height: 64px;
|
||||
}
|
||||
.job__time {
|
||||
font-family: var(--bc-font-head);
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
min-width: 56px;
|
||||
}
|
||||
.job__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.ellipsis {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 6px 14px 6px 6px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle__knob {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #8a827c;
|
||||
}
|
||||
.toggle[aria-pressed='true'] .toggle__knob {
|
||||
background: #a8c38a;
|
||||
}
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 8px 14px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.kv dt {
|
||||
color: var(--bc-ink-2);
|
||||
}
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* кабинет партнёра */
|
||||
.scan-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 26px 18px;
|
||||
border: 0;
|
||||
border-radius: 32px;
|
||||
background: var(--bc-plum);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.scan-btn b {
|
||||
font-family: var(--bc-font-head);
|
||||
font-size: 22px;
|
||||
}
|
||||
.scan-btn span:last-child {
|
||||
font-size: 14px;
|
||||
color: #e9c9d5;
|
||||
}
|
||||
/* рамка видоискателя */
|
||||
.scan-btn__icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(var(--bc-pink), var(--bc-pink)) top left / 18px 4px no-repeat,
|
||||
linear-gradient(var(--bc-pink), var(--bc-pink)) top left / 4px 18px no-repeat,
|
||||
linear-gradient(var(--bc-pink), var(--bc-pink)) top right / 18px 4px no-repeat,
|
||||
linear-gradient(var(--bc-pink), var(--bc-pink)) top right / 4px 18px no-repeat,
|
||||
linear-gradient(var(--bc-pink), var(--bc-pink)) bottom left / 18px 4px no-repeat,
|
||||
linear-gradient(var(--bc-pink), var(--bc-pink)) bottom left / 4px 18px no-repeat,
|
||||
linear-gradient(var(--bc-pink), var(--bc-pink)) bottom right / 18px 4px no-repeat,
|
||||
linear-gradient(var(--bc-pink), var(--bc-pink)) bottom right / 4px 18px no-repeat;
|
||||
}
|
||||
.check-card__ok {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: var(--bc-home);
|
||||
color: var(--bc-home-ink);
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.p-offers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.p-offers li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
background: var(--bc-pink);
|
||||
color: var(--bc-plum);
|
||||
}
|
||||
.p-offers li > span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.p-offers small {
|
||||
font-size: 12px;
|
||||
}
|
||||
.p-offers li[data-state='locked'],
|
||||
.p-offers li[data-state='used'] {
|
||||
background: var(--bc-bg);
|
||||
color: var(--bc-ink-3);
|
||||
}
|
||||
.invite {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border-radius: 20px;
|
||||
background: var(--bc-bg);
|
||||
}
|
||||
.invite > div:first-child {
|
||||
padding: 10px;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
line-height: 0;
|
||||
}
|
||||
.invite label,
|
||||
.invite .bc-field {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { getInitData } from '@barev/web';
|
||||
|
||||
const initData = getInitData();
|
||||
export const hasInitData = !!initData;
|
||||
export const authHeader = () => ({ authorization: `tma ${initData}` });
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Icon } from '@barev/ui';
|
||||
import { paramsSummary, useI18n } from '@barev/web';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export function PageHead({ title, back = true }: { title: ReactNode; back?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="page-head">
|
||||
{back && (
|
||||
<button className="back" onClick={() => window.history.back()} aria-label={t('common.back')}>
|
||||
<Icon name="back" />
|
||||
</button>
|
||||
)}
|
||||
<h1 className="bc-h2" style={{ flex: 1 }}>
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface JobLike {
|
||||
id: string;
|
||||
number: number;
|
||||
flow: string;
|
||||
status: string;
|
||||
serviceCode: string;
|
||||
scheduledAt: Date;
|
||||
isOnline: boolean;
|
||||
addressText: string | null;
|
||||
districtCode: string | null;
|
||||
clientLocale: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function useJobSummary() {
|
||||
const { t } = useI18n();
|
||||
return (j: Pick<JobLike, 'serviceCode' | 'params'>) => paramsSummary(t, j.serviceCode, j.params) || t(`services.${j.serviceCode}.name`);
|
||||
}
|
||||
|
||||
/** Строка заказа: время, район/онлайн, язык клиента */
|
||||
export function JobRow({ j, href, right }: { j: JobLike; href?: string; right?: ReactNode }) {
|
||||
const { t, dt } = useI18n();
|
||||
const summary = useJobSummary();
|
||||
const body = (
|
||||
<>
|
||||
<div className="job__time">{dt(j.scheduledAt, { hour: '2-digit', minute: '2-digit' })}</div>
|
||||
<div className="job__main">
|
||||
<div className="ellipsis" style={{ fontWeight: 600 }}>
|
||||
{summary(j)}
|
||||
</div>
|
||||
<div className="bc-small bc-muted ellipsis">
|
||||
{j.isOnline ? t('common.online') : j.districtCode ? t(`districts.${j.districtCode}`) : ''} · {j.clientLocale.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
{right ?? <span className="bc-badge" style={{ background: 'var(--bc-bg)' }}>{t(`status.${j.flow}.${j.status}`)}</span>}
|
||||
</>
|
||||
);
|
||||
return href ? (
|
||||
<a className="job" href={href}>
|
||||
{body}
|
||||
</a>
|
||||
) : (
|
||||
<div className="job">{body}</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import '@barev/ui/styles.css';
|
||||
import './app.css';
|
||||
import { ApiProvider, initTelegram, ToastProvider } from '@barev/web';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App.tsx';
|
||||
import { authHeader, hasInitData } from './auth.ts';
|
||||
|
||||
initTelegram();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ApiProvider headers={authHeader}>
|
||||
<ToastProvider>
|
||||
<App hasInitData={hasInitData} />
|
||||
</ToastProvider>
|
||||
</ApiProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
import QRCode from 'qrcode';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** QR как SVG — чёткий на любом экране, без canvas */
|
||||
export function Qr({ value, size = 200, label }: { value: string; size?: number; label: string }) {
|
||||
const [svg, setSvg] = useState('');
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void QRCode.toString(value, { type: 'svg', margin: 1, errorCorrectionLevel: 'M', color: { dark: '#4A1028', light: '#ffffff' } }).then((s) => {
|
||||
if (alive) setSvg(s);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [value]);
|
||||
return <div role="img" aria-label={label} style={{ width: size, height: size }} dangerouslySetInnerHTML={{ __html: svg }} />;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Badge, Button, Card, Center, Chip, Icon, Sheet, Spinner } from '@barev/ui';
|
||||
import { errorText, haptic, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useState } from 'react';
|
||||
import { JobRow, PageHead } from '../lib.tsx';
|
||||
|
||||
/** PRO-7: диспетчер компании распределяет заказы, назначенные на компанию */
|
||||
export function Company({ companies }: { companies: { id: string; name: string }[] }) {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const [companyId, setCompanyId] = useState(companies[0]!.id);
|
||||
const board = useQuery({ ...trpc.pro.dispatcher.board.queryOptions({ companyId }), refetchInterval: 15_000 });
|
||||
const [picking, setPicking] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<main className="app">
|
||||
<PageHead title={board.data?.company.name ?? t('pro.company.title')} back={false} />
|
||||
{companies.length > 1 && (
|
||||
<div className="bc-chips">
|
||||
{companies.map((c) => (
|
||||
<Chip key={c.id} pressed={c.id === companyId} onClick={() => setCompanyId(c.id)}>
|
||||
{c.name}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!board.data ? (
|
||||
<Center>
|
||||
<Spinner />
|
||||
</Center>
|
||||
) : (
|
||||
<>
|
||||
<section className="bc-stack" style={{ gap: 8 }}>
|
||||
<h2 className="bc-h3">
|
||||
{t('pro.company.pending')} · {board.data.pending.length}
|
||||
</h2>
|
||||
{board.data.pending.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.company.noPending')}</p>}
|
||||
{board.data.pending.map((o) => (
|
||||
<Card key={o.id} tone="pink" tight className="bc-stack" style={{ gap: 8 }}>
|
||||
<JobRow j={o} />
|
||||
<Button size="sm" onClick={() => setPicking(o.id)}>
|
||||
{t('pro.company.assign')}
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="bc-stack" style={{ gap: 8 }}>
|
||||
<h2 className="bc-h3">{t('pro.company.assigned')}</h2>
|
||||
{board.data.assigned.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.company.noAssigned')}</p>}
|
||||
{board.data.assigned.map((o) => (
|
||||
<JobRow key={o.id} j={o} right={<span className="bc-small" style={{ fontWeight: 600 }}>{o.providerName}</span>} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="bc-stack" style={{ gap: 8 }}>
|
||||
<h2 className="bc-h3">{t('pro.company.staff')}</h2>
|
||||
<Card tight className="bc-stack" style={{ gap: 6 }}>
|
||||
{board.data.staff.map((p) => (
|
||||
<div key={p.id} className="bc-between" style={{ minHeight: 40 }}>
|
||||
<span style={{ fontWeight: 600 }}>{p.name}</span>
|
||||
<span className="bc-row bc-small">
|
||||
{p.languages.map((l) => l.toUpperCase()).join(' ')}
|
||||
<Badge style={{ background: p.onShift ? 'var(--bc-home)' : 'var(--bc-bg)' }}>{t(p.onShift ? 'pro.today.onShift' : 'pro.today.offShift')}</Badge>
|
||||
{!p.linked && <Badge style={{ background: 'var(--bc-docs)' }}>{t('pro.company.notLinked')}</Badge>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
{picking && <StaffSheet companyId={companyId} orderId={picking} onClose={() => setPicking(null)} />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function StaffSheet({ companyId, orderId, onClose }: { companyId: string; orderId: string; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const staff = useQuery(trpc.pro.dispatcher.staffForOrder.queryOptions({ companyId, orderId }));
|
||||
const assign = useMutation(
|
||||
trpc.pro.dispatcher.assign.mutationOptions({
|
||||
onSuccess: () => {
|
||||
haptic.ok();
|
||||
toast(t('pro.company.assigned_ok'));
|
||||
void qc.invalidateQueries();
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
// свободные и говорящие на языке клиента — выше
|
||||
const sorted = [...(staff.data ?? [])].sort((a, b) => Number(b.free) - Number(a.free) || Number(b.speaksClientLanguage) - Number(a.speaksClientLanguage) || a.load - b.load);
|
||||
return (
|
||||
<Sheet open onClose={onClose} label={t('pro.company.assign')}>
|
||||
<div className="bc-stack">
|
||||
<div className="bc-between">
|
||||
<h2 className="bc-h2">{t('pro.company.chooseStaff')}</h2>
|
||||
<button className="back" onClick={onClose} aria-label={t('common.close')}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
{!staff.data ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
sorted.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className="job"
|
||||
style={{ border: 'none', textAlign: 'left', cursor: p.free ? 'pointer' : 'not-allowed', opacity: p.free ? 1 : 0.5 }}
|
||||
disabled={!p.free || assign.isPending}
|
||||
onClick={() => assign.mutate({ companyId, orderId, providerId: p.id })}
|
||||
>
|
||||
<div className="job__main">
|
||||
<div style={{ fontWeight: 700 }}>{p.name}</div>
|
||||
<div className="bc-small bc-muted">
|
||||
{p.languages.map((l) => l.toUpperCase()).join(' ')} · {t('pro.company.load', { count: p.load })}
|
||||
</div>
|
||||
</div>
|
||||
<span className="bc-row">
|
||||
{p.speaksClientLanguage && <Badge style={{ background: 'var(--bc-home)' }}>{t('pro.company.speaksLanguage')}</Badge>}
|
||||
<Badge style={{ background: p.free ? 'var(--bc-blue)' : 'var(--bc-bg)' }}>{t(p.free ? 'pro.company.free' : 'pro.company.busy')}</Badge>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, Center, Segmented, Spinner } from '@barev/ui';
|
||||
import { useI18n, useTRPC, yerevanDateKey } from '@barev/web';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { JobRow, PageHead } from '../lib.tsx';
|
||||
|
||||
type Period = 'week' | 'month' | 'prevMonth';
|
||||
|
||||
function range(p: Period): { from: string; to: string } {
|
||||
const today = yerevanDateKey(new Date());
|
||||
const [y, m] = today.split('-').map(Number) as [number, number];
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
if (p === 'week') return { from: yerevanDateKey(new Date(Date.now() - 6 * 86_400_000)), to: today };
|
||||
if (p === 'month') return { from: `${y}-${pad(m)}-01`, to: today };
|
||||
const pm = m === 1 ? 12 : m - 1;
|
||||
const py = m === 1 ? y - 1 : y;
|
||||
const last = new Date(Date.UTC(py, pm, 0)).getUTCDate();
|
||||
return { from: `${py}-${pad(pm)}-01`, to: `${py}-${pad(pm)}-${pad(last)}` };
|
||||
}
|
||||
|
||||
/** PRO-6: история и заработок за период */
|
||||
export function History() {
|
||||
const { t, amd, dt } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const [period, setPeriod] = useState<Period>('week');
|
||||
const r = useMemo(() => range(period), [period]);
|
||||
const history = useQuery(trpc.pro.history.queryOptions(r));
|
||||
return (
|
||||
<main className="app">
|
||||
<PageHead title={t('pro.history.title')} back={false} />
|
||||
<Segmented
|
||||
value={period}
|
||||
onChange={setPeriod}
|
||||
options={[
|
||||
{ value: 'week', label: t('pro.history.week') },
|
||||
{ value: 'month', label: t('pro.history.month') },
|
||||
{ value: 'prevMonth', label: t('pro.history.prevMonth') },
|
||||
]}
|
||||
/>
|
||||
{!history.data ? (
|
||||
<Center>
|
||||
<Spinner />
|
||||
</Center>
|
||||
) : (
|
||||
<>
|
||||
<Card tone="dark" className="bc-between">
|
||||
<div>
|
||||
<div className="bc-small" style={{ color: '#CFC8C2' }}>
|
||||
{t('pro.history.earned')}
|
||||
</div>
|
||||
<div className="bc-num" style={{ fontSize: 30 }}>
|
||||
{amd(history.data.totalEarned)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="bc-small" style={{ color: '#CFC8C2' }}>
|
||||
{t('pro.history.done')}
|
||||
</div>
|
||||
<div className="bc-num" style={{ fontSize: 30 }}>
|
||||
{history.data.doneCount}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="bc-small bc-muted">
|
||||
{history.data.rate.kind === 'preset'
|
||||
? t('pro.history.ratePreset', { name: history.data.rate.name })
|
||||
: history.data.rate.type === 'PERCENT'
|
||||
? t('pro.history.ratePercent', { value: history.data.rate.value })
|
||||
: t('pro.history.rateFixed', { value: amd(history.data.rate.value) })}
|
||||
</div>
|
||||
{history.data.orders.length === 0 ? (
|
||||
<p className="bc-muted">{t('pro.history.empty')}</p>
|
||||
) : (
|
||||
history.data.orders.map((o) => (
|
||||
<div key={o.id}>
|
||||
<div className="bc-small bc-muted" style={{ margin: '4px 4px' }}>
|
||||
{dt(o.scheduledAt, { weekday: 'short', day: 'numeric', month: 'long' })}
|
||||
</div>
|
||||
<JobRow j={o} href={`#/orders/${o.id}`} right={o.payout > 0 ? <span className="bc-num">{amd(o.payout)}</span> : undefined} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button, Card, Center, Icon, Spinner } from '@barev/ui';
|
||||
import { confirmDialog, errorText, haptic, paramsExtras, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useRef, useState } from 'react';
|
||||
import { authHeader } from '../auth.ts';
|
||||
import { PageHead, useJobSummary } from '../lib.tsx';
|
||||
|
||||
/**
|
||||
* PRO-3/PRO-4: заказ по шагам. Кнопки берутся из state machine (бэкенд их же и проверяет),
|
||||
* закрытие недоступно, пока не отмечен весь чек-лист и не приложено фото.
|
||||
*/
|
||||
export function Job({ id }: { id: string }) {
|
||||
const { t, amd, dt } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const summary = useJobSummary();
|
||||
const job = useQuery({ ...trpc.pro.order.queryOptions({ id }), refetchInterval: 20_000 });
|
||||
const refresh = () => qc.invalidateQueries();
|
||||
const action = useMutation(
|
||||
trpc.pro.action.mutationOptions({
|
||||
onSuccess: () => {
|
||||
haptic.ok();
|
||||
void refresh();
|
||||
},
|
||||
onError: (e) => {
|
||||
haptic.error();
|
||||
toast(errorText(t, e), 'error');
|
||||
void refresh();
|
||||
},
|
||||
}),
|
||||
);
|
||||
const check = useMutation(trpc.pro.checklist.mutationOptions({ onSuccess: refresh, onError: (e) => toast(errorText(t, e), 'error') }));
|
||||
const paid = useMutation(
|
||||
trpc.pro.markPaid.mutationOptions({
|
||||
onSuccess: () => {
|
||||
toast(t('pro.job.paidMarked'));
|
||||
void refresh();
|
||||
},
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
}),
|
||||
);
|
||||
|
||||
if (!job.data)
|
||||
return (
|
||||
<Center>
|
||||
<Spinner />
|
||||
</Center>
|
||||
);
|
||||
const j = job.data;
|
||||
const checklistDone = j.checklist.every((c) => c.checked && (!c.requiresPhoto || c.hasPhoto));
|
||||
const finished = j.actions.length === 0 && ['DONE', 'COMPLETED', 'PLAN_SENT'].includes(j.status);
|
||||
|
||||
return (
|
||||
<main className="app app--plain">
|
||||
<PageHead title={`#${j.number}`} />
|
||||
<Card tone="home" className="bc-stack">
|
||||
<div className="bc-between" style={{ alignItems: 'flex-start' }}>
|
||||
<h2 className="bc-h2">{summary(j)}</h2>
|
||||
<span className="bc-badge">{t(`status.${j.flow}.${j.status}`)}</span>
|
||||
</div>
|
||||
<dl className="kv" style={{ margin: 0 }}>
|
||||
<dt>{t('pro.job.when')}</dt>
|
||||
<dd>
|
||||
{dt(j.scheduledAt, { weekday: 'short', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })} · {t('notify.duration', { minutes: j.durationMinutes })}
|
||||
</dd>
|
||||
<dt>{t('pro.job.where')}</dt>
|
||||
<dd>
|
||||
{j.isOnline ? (
|
||||
t('common.online')
|
||||
) : (
|
||||
<a href={`https://yandex.ru/maps/?text=${encodeURIComponent(`Ереван, ${j.addressText ?? ''}`)}`} target="_blank" rel="noreferrer">
|
||||
{j.districtCode ? `${t(`districts.${j.districtCode}`)}, ` : ''}
|
||||
{j.addressText}
|
||||
</a>
|
||||
)}
|
||||
</dd>
|
||||
<dt>{t('pro.job.client')}</dt>
|
||||
<dd>
|
||||
{j.clientName ?? '—'} · {t('pro.job.clientLanguage')}: {j.clientLocale.toUpperCase()}
|
||||
</dd>
|
||||
<dt>{t('pro.job.price')}</dt>
|
||||
<dd>
|
||||
{amd(j.totalPrice)} · {t(`payment.${j.paymentStatus}`)}
|
||||
</dd>
|
||||
</dl>
|
||||
{paramsExtras(t, j.params).length > 0 && (
|
||||
<div className="bc-card bc-card--tight" style={{ background: 'rgba(255,255,255,.6)' }}>
|
||||
<div className="bc-small" style={{ fontWeight: 700, marginBottom: 4 }}>
|
||||
{t('client.order.extras')}
|
||||
</div>
|
||||
{paramsExtras(t, j.params).map((e) => (
|
||||
<div key={e.code}>• {e.label}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{j.note && (
|
||||
<div className="bc-card bc-card--tight" style={{ background: 'rgba(255,255,255,.6)' }}>
|
||||
💬 {j.note}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{j.checklist.length > 0 && (j.checklistActive || finished) && (
|
||||
<Card className="bc-stack">
|
||||
<h2 className="bc-h3">{t('pro.job.checklist')}</h2>
|
||||
{j.checklist.map((c) =>
|
||||
c.requiresPhoto ? (
|
||||
<PhotoItem key={c.code} orderId={j.id} code={c.code} done={c.checked && c.hasPhoto} disabled={!j.checklistActive} onDone={refresh} />
|
||||
) : (
|
||||
<label key={c.code} className={`bc-check ${c.checked ? 'bc-check--done' : ''}`}>
|
||||
<input type="checkbox" checked={c.checked} disabled={!j.checklistActive || check.isPending} onChange={(e) => check.mutate({ orderId: j.id, code: c.code, checked: e.target.checked })} />
|
||||
<span style={{ fontWeight: 600 }}>{t(`checklist.${c.code}`)}</span>
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="bc-stack" style={{ gap: 8 }}>
|
||||
{j.actions.map((a) => {
|
||||
const blocked = a.guards.includes('checklistComplete') && !checklistDone;
|
||||
return (
|
||||
<Button
|
||||
key={a.code}
|
||||
block
|
||||
variant={a.kind === 'event' ? 'light' : 'dark'}
|
||||
disabled={action.isPending || blocked}
|
||||
onClick={async () => {
|
||||
if (a.to && ['DONE', 'COMPLETED'].includes(a.to) && !(await confirmDialog(t('pro.job.confirmFinish')))) return;
|
||||
action.mutate({ orderId: j.id, action: a.code });
|
||||
}}
|
||||
>
|
||||
{t(`pro.action.${a.code}`)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{j.actions.some((a) => a.guards.includes('checklistComplete')) && !checklistDone && (
|
||||
<p className="bc-small bc-muted" style={{ margin: 0, textAlign: 'center' }}>
|
||||
{t('pro.job.checklistFirst')}
|
||||
</p>
|
||||
)}
|
||||
{j.paymentStatus === 'UNPAID' && j.status !== 'NEW' && (
|
||||
<Button variant="outline" block disabled={paid.isPending} onClick={async () => (await confirmDialog(t('pro.job.confirmPaid', { amount: amd(j.totalPrice) }))) && paid.mutate({ orderId: j.id })}>
|
||||
{t('pro.job.markPaid')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/** Пункт чек-листа с обязательным фото: камера/галерея → загрузка → пункт отмечен */
|
||||
function PhotoItem({ orderId, code, done, disabled, onDone }: { orderId: string; code: string; done: boolean; disabled: boolean; onDone: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const toast = useToast();
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function upload(file: File) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.set('orderId', orderId);
|
||||
body.set('code', code);
|
||||
body.set('file', await shrink(file));
|
||||
const res = await fetch('/files/pro/checklist-photo', { method: 'POST', headers: authHeader(), body });
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw Object.assign(new Error(err.error ?? 'UPLOAD'), { data: { domainCode: err.error } });
|
||||
}
|
||||
haptic.ok();
|
||||
onDone();
|
||||
} catch (e) {
|
||||
toast(errorText(t, e), 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bc-check ${done ? 'bc-check--done' : ''}`} style={{ cursor: 'default' }}>
|
||||
<Icon name={done ? 'check' : 'camera'} style={{ color: done ? 'var(--bc-ok)' : undefined }} />
|
||||
<span style={{ flex: 1, fontWeight: 600 }}>{t(`checklist.${code}`)}</span>
|
||||
<input ref={input} type="file" accept="image/*" capture="environment" hidden onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])} />
|
||||
<Button size="sm" variant={done ? 'light' : 'dark'} disabled={disabled || busy} onClick={() => input.current?.click()}>
|
||||
{busy ? t('common.loading') : done ? t('pro.job.retakePhoto') : t('pro.job.takePhoto')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Уменьшаем фото до 1600 px — быстрее на мобильном интернете */
|
||||
async function shrink(file: File): Promise<Blob> {
|
||||
if (!file.type.startsWith('image/') || file.type === 'image/heic') return file;
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(bitmap.width * scale);
|
||||
canvas.height = Math.round(bitmap.height * scale);
|
||||
canvas.getContext('2d')!.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
return await new Promise<Blob>((resolve) => canvas.toBlob((b) => resolve(b ?? file), 'image/jpeg', 0.85));
|
||||
} catch {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button, Card, Center, Chip, Field, Spinner } from '@barev/ui';
|
||||
import { confirmDialog, errorText, haptic, scanQr, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useState } from 'react';
|
||||
import { PageHead } from '../lib.tsx';
|
||||
import { Qr } from '../qr.tsx';
|
||||
|
||||
const LEVEL_NAME: Record<string, string> = { BAREV: 'Barev', BAREV_PLUS: 'Barev+', ELITE: 'Barev Elite' };
|
||||
type L = Record<string, string>;
|
||||
|
||||
/**
|
||||
* Кабинет партнёра клуба: сканер QR клиента → уровень и что положено, списание лимитных предложений.
|
||||
* Собственник дополнительно видит сотрудников и приглашает новых по QR.
|
||||
*/
|
||||
export function Partner({ partners }: { partners: { id: string; name: string; role: 'OWNER' | 'STAFF' }[] }) {
|
||||
const { t, locale, dt } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [partnerId, setPartnerId] = useState(partners[0]!.id);
|
||||
const cab = useQuery({ ...trpc.pro.partner.cabinet.queryOptions({ partnerId }), refetchInterval: 30_000 });
|
||||
const [manual, setManual] = useState('');
|
||||
const [invite, setInvite] = useState<{ url: string; expiresAt: Date } | null>(null);
|
||||
const name = (l: L | null | undefined) => (l ? l[locale] || l.ru || '' : '');
|
||||
const fail = (e: unknown) => {
|
||||
haptic.error();
|
||||
toast(errorText(t, e), 'error');
|
||||
};
|
||||
const check = useMutation(trpc.pro.partner.check.mutationOptions({ onError: fail }));
|
||||
const [result, setResult] = useState<NonNullable<typeof check.data> | null>(null);
|
||||
const redeem = useMutation(
|
||||
trpc.pro.partner.redeem.mutationOptions({
|
||||
onSuccess: (o) => {
|
||||
haptic.ok();
|
||||
toast(t('pro.partner.redeemed', { offer: name(o.title) }));
|
||||
// остаток в карточке проверки — без повторной проверки (она записалась бы как ещё один визит)
|
||||
setResult((cur) => (cur ? { ...cur, offers: cur.offers.map((x) => (x.id === o.id ? { ...x, remaining: o.remaining } : x)) } : cur));
|
||||
void qc.invalidateQueries({ queryKey: trpc.pro.partner.cabinet.queryKey() });
|
||||
},
|
||||
onError: fail,
|
||||
}),
|
||||
);
|
||||
const makeInvite = useMutation(trpc.pro.partner.invite.mutationOptions({ onSuccess: setInvite, onError: fail }));
|
||||
const remove = useMutation(trpc.pro.partner.removeMember.mutationOptions({ onSuccess: () => void qc.invalidateQueries(), onError: fail }));
|
||||
function run(token: string) {
|
||||
setResult(null);
|
||||
check.mutate(
|
||||
{ token, partnerId },
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
haptic.ok();
|
||||
setResult(r);
|
||||
void qc.invalidateQueries({ queryKey: trpc.pro.partner.cabinet.queryKey() });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
async function scan() {
|
||||
const p = scanQr(t('pro.partner.scanHint'));
|
||||
if (!p) return toast(t('pro.partner.noScanner'), 'error');
|
||||
run(await p);
|
||||
}
|
||||
|
||||
if (!cab.data)
|
||||
return (
|
||||
<Center>
|
||||
<Spinner />
|
||||
</Center>
|
||||
);
|
||||
const c = cab.data;
|
||||
const r = result;
|
||||
|
||||
return (
|
||||
<main className="app">
|
||||
<PageHead title={c.partner.name} back={false} />
|
||||
{partners.length > 1 && (
|
||||
<div className="bc-chips">
|
||||
{partners.map((p) => (
|
||||
<Chip key={p.id} pressed={p.id === partnerId} onClick={() => setPartnerId(p.id)}>
|
||||
{p.name}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="bc-small bc-muted">
|
||||
{t(c.role === 'OWNER' ? 'pro.partner.owner' : 'pro.partner.staff')} · {t('pro.partner.visits30', { count: c.visits30 })}
|
||||
</div>
|
||||
|
||||
<button className="scan-btn" onClick={scan} disabled={check.isPending}>
|
||||
<span className="scan-btn__icon" aria-hidden="true" />
|
||||
<b>{t('pro.partner.scan')}</b>
|
||||
<span>{t('pro.partner.scanSub')}</span>
|
||||
</button>
|
||||
<form
|
||||
className="bc-row"
|
||||
style={{ gap: 8 }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (manual.trim()) run(manual.trim());
|
||||
}}
|
||||
>
|
||||
<input className="bc-input" style={{ flex: 1 }} value={manual} placeholder={t('pro.partner.manual')} onChange={(e) => setManual(e.target.value)} />
|
||||
<Button type="submit" variant="light" disabled={!manual.trim()}>
|
||||
{t('pro.partner.check')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{r && (
|
||||
<Card className="bc-stack check-card">
|
||||
<div className="check-card__ok">✓</div>
|
||||
<h2 className="bc-h3" style={{ margin: 0 }}>
|
||||
{t('pro.partner.valid', { name: r.client.firstName, level: LEVEL_NAME[r.level] ?? r.level })}
|
||||
</h2>
|
||||
{r.endsAt && <div className="bc-small bc-muted">{t('pro.partner.until', { date: dt(r.endsAt, { day: 'numeric', month: 'long' }) })}</div>}
|
||||
<ul className="p-offers">
|
||||
{r.offers.map((o) => (
|
||||
<li key={o.id} data-state={!o.eligible ? 'locked' : o.remaining === 0 ? 'used' : 'ok'}>
|
||||
<span>
|
||||
<b>{name(o.title)}</b>
|
||||
<small>
|
||||
{!o.eligible
|
||||
? t('pro.partner.needLevel', { level: LEVEL_NAME[o.minLevel ?? 'BAREV'] })
|
||||
: o.remaining === null
|
||||
? t('pro.partner.unlimited')
|
||||
: o.remaining > 0
|
||||
? t(`bot.partner.left_${o.limit}`, { count: o.remaining })
|
||||
: t(`bot.partner.used_${o.limit}`)}
|
||||
</small>
|
||||
</span>
|
||||
{o.eligible && o.remaining !== null && o.remaining > 0 && (
|
||||
<Button size="sm" disabled={redeem.isPending} onClick={() => redeem.mutate({ checkId: r.checkId, offerId: o.id })}>
|
||||
{t('bot.partner.redeem')}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{r.offers.length === 0 && <li>{t('bot.partner.noOffers')}</li>}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="bc-stack">
|
||||
<h2 className="bc-h3">{t('pro.partner.offers')}</h2>
|
||||
{c.offers.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.partner.noOffers')}</p>}
|
||||
{c.offers.map((o) => (
|
||||
<div key={o.id} className="bc-between" style={{ gap: 10 }}>
|
||||
<span style={{ fontWeight: 600 }}>{name(o.title)}</span>
|
||||
<span className="bc-small bc-muted" style={{ textAlign: 'right' }}>
|
||||
{o.minLevel ? LEVEL_NAME[o.minLevel] : t('pro.partner.allMembers')}
|
||||
{o.limit !== 'NONE' && ` · ${t(`pro.partner.limit_${o.limit}`, { count: o.limitCount })}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<p className="bc-small bc-muted" style={{ margin: 0 }}>
|
||||
{t('pro.partner.offersHint')}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{c.role === 'OWNER' && (
|
||||
<Card className="bc-stack">
|
||||
<div className="bc-between">
|
||||
<h2 className="bc-h3">{t('pro.partner.team')}</h2>
|
||||
<Button size="sm" onClick={() => makeInvite.mutate({ partnerId })} disabled={makeInvite.isPending}>
|
||||
{t('pro.partner.invite')}
|
||||
</Button>
|
||||
</div>
|
||||
{invite && (
|
||||
<div className="invite">
|
||||
<Qr value={invite.url} size={200} label={t('pro.partner.inviteQr')} />
|
||||
<p className="bc-small" style={{ margin: 0, textAlign: 'center' }}>
|
||||
{t('pro.partner.inviteHint', { time: dt(invite.expiresAt, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) })}
|
||||
</p>
|
||||
<Field label={t('pro.partner.inviteLink')}>
|
||||
<input className="bc-input mono" readOnly value={invite.url} onFocus={(e) => e.target.select()} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
{c.members.map((m) => (
|
||||
<div key={m.id} className="bc-between">
|
||||
<span>
|
||||
<b>{m.name || (m.username ? `@${m.username}` : '—')}</b>
|
||||
<span className="bc-small bc-muted"> · {t(m.role === 'OWNER' ? 'pro.partner.owner' : 'pro.partner.staff')}</span>
|
||||
</span>
|
||||
{m.role === 'STAFF' && (
|
||||
<Button size="sm" variant="ghost" onClick={async () => (await confirmDialog(t('pro.partner.removeConfirm', { name: m.name }))) && remove.mutate({ memberId: m.id })}>
|
||||
{t('pro.partner.remove')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="bc-stack">
|
||||
<h2 className="bc-h3">{t('pro.partner.recent')}</h2>
|
||||
{c.recent.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.partner.noRecent')}</p>}
|
||||
{c.recent.map((u) => (
|
||||
<div key={u.id} className="bc-between bc-small">
|
||||
<span>
|
||||
<b>{u.client}</b> · {u.offer ? name(u.offer) : t('pro.partner.checked')}
|
||||
</span>
|
||||
<span className="bc-muted">
|
||||
{LEVEL_NAME[u.level ?? 'BAREV']} · {dt(u.at, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button, Card, Center, Icon, Spinner } from '@barev/ui';
|
||||
import { errorText, haptic, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { JobRow, useJobSummary } from '../lib.tsx';
|
||||
|
||||
/** PRO-1, PRO-2, PRO-5: шапка, предложения, «Сейчас», заказы на сегодня */
|
||||
export function Today() {
|
||||
const { t, amd, dt } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
const summary = useJobSummary();
|
||||
const today = useQuery({ ...trpc.pro.today.queryOptions(), refetchInterval: 15_000 });
|
||||
const shift = useMutation(trpc.pro.setShift.mutationOptions({ onSuccess: () => qc.invalidateQueries() }));
|
||||
const respond = useMutation(
|
||||
trpc.pro.respondOffer.mutationOptions({
|
||||
onSuccess: (_, v) => {
|
||||
haptic.ok();
|
||||
toast(t(v.accept ? 'pro.offer.accepted' : 'pro.offer.declined'));
|
||||
void qc.invalidateQueries();
|
||||
},
|
||||
onError: (e) => {
|
||||
toast(errorText(t, e), 'error');
|
||||
void qc.invalidateQueries();
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (!today.data)
|
||||
return (
|
||||
<Center>
|
||||
<Spinner />
|
||||
</Center>
|
||||
);
|
||||
const d = today.data;
|
||||
const current = d.orders.find((o) => o.id === d.currentOrderId);
|
||||
|
||||
return (
|
||||
<main className="app">
|
||||
<Card tone="dark" className="bc-stack" style={{ padding: 20 }}>
|
||||
<div className="bc-between" style={{ alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div className="bc-h2">{d.provider.name}</div>
|
||||
<div className="bc-small" style={{ color: '#CFC8C2' }}>
|
||||
{t(`roles.${d.provider.role}`)}
|
||||
{d.provider.companyName ? ` · ${d.provider.companyName}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button className="toggle" aria-pressed={d.provider.onShift} onClick={() => shift.mutate({ onShift: !d.provider.onShift })} disabled={shift.isPending}>
|
||||
<span className="toggle__knob" />
|
||||
{t(d.provider.onShift ? 'pro.today.onShift' : 'pro.today.offShift')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="stats">
|
||||
<div className="stat">
|
||||
<div className="bc-small" style={{ color: '#CFC8C2' }}>
|
||||
{t('pro.today.earned')}
|
||||
</div>
|
||||
<div className="stat__value">{amd(d.earningsToday)}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="bc-small" style={{ color: '#CFC8C2' }}>
|
||||
{t('pro.today.orders')}
|
||||
</div>
|
||||
<div className="stat__value">{d.orders.length}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="bc-small" style={{ color: '#CFC8C2' }}>
|
||||
{t('pro.today.rating')}
|
||||
</div>
|
||||
<div className="stat__value">{d.provider.rating ?? '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{d.offers.map((f) => (
|
||||
<Card key={f.offerId} tone="pink" className="bc-stack">
|
||||
<div className="bc-between">
|
||||
<h2 className="bc-h3">{t('pro.offer.title')}</h2>
|
||||
{f.expiresAt && <Countdown until={f.expiresAt} />}
|
||||
</div>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
#{f.order.number} · {summary(f.order)}
|
||||
</div>
|
||||
<div className="bc-small">
|
||||
{dt(f.order.scheduledAt, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} ·{' '}
|
||||
{f.order.districtCode ? t(`districts.${f.order.districtCode}`) : t('common.online')} · {f.order.clientLocale.toUpperCase()} · {amd(f.order.totalPrice)}
|
||||
</div>
|
||||
<div className="bc-row">
|
||||
<Button style={{ flex: 1 }} onClick={() => respond.mutate({ offerId: f.offerId, accept: true })} disabled={respond.isPending}>
|
||||
{t('pro.offer.accept')}
|
||||
</Button>
|
||||
<Button variant="light" style={{ flex: 1 }} onClick={() => respond.mutate({ offerId: f.offerId, accept: false })} disabled={respond.isPending}>
|
||||
{t('pro.offer.decline')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{current && (
|
||||
<a href={`#/orders/${current.id}`} className="bc-card bc-tone-home" style={{ textDecoration: 'none', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div className="bc-between">
|
||||
<span className="bc-badge">{t('pro.today.now')}</span>
|
||||
<span className="bc-badge">{t(`status.${current.flow}.${current.status}`)}</span>
|
||||
</div>
|
||||
<div className="bc-h2">
|
||||
{dt(current.scheduledAt, { hour: '2-digit', minute: '2-digit' })} · {summary(current)}
|
||||
</div>
|
||||
<div className="bc-row">
|
||||
<Icon name="pin" size={18} />
|
||||
{current.isOnline ? t('common.online') : `${current.districtCode ? t(`districts.${current.districtCode}`) + ', ' : ''}${current.addressText ?? ''}`}
|
||||
</div>
|
||||
<div className="bc-row bc-small">
|
||||
<Icon name="globe" size={16} />
|
||||
{t('pro.job.clientLanguage')}: {current.clientLocale.toUpperCase()}
|
||||
{current.clientName ? ` · ${current.clientName}` : ''}
|
||||
</div>
|
||||
{current.note && <div className="bc-small">💬 {current.note}</div>}
|
||||
<div className="bc-row" style={{ fontWeight: 700 }}>
|
||||
{t('pro.today.open')} <Icon name="arrow" size={18} />
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<section className="bc-stack" style={{ gap: 8 }}>
|
||||
<h2 className="bc-h3">{t('pro.today.title')}</h2>
|
||||
{d.orders.length === 0 ? (
|
||||
<p className="bc-muted" style={{ margin: 0 }}>
|
||||
{t('pro.today.empty')}
|
||||
</p>
|
||||
) : (
|
||||
d.orders.map((o) => <JobRow key={o.id} j={o} href={`#/orders/${o.id}`} />)
|
||||
)}
|
||||
</section>
|
||||
|
||||
{d.upcoming.length > 0 && (
|
||||
<section className="bc-stack" style={{ gap: 8 }}>
|
||||
<h2 className="bc-h3">{t('pro.today.upcoming')}</h2>
|
||||
{d.upcoming.map((o) => (
|
||||
<div key={o.id}>
|
||||
<div className="bc-small bc-muted" style={{ margin: '4px 4px' }}>
|
||||
{dt(o.scheduledAt, { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||
</div>
|
||||
<JobRow j={o} href={`#/orders/${o.id}`} />
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Countdown({ until }: { until: Date }) {
|
||||
const { t } = useI18n();
|
||||
const [now, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
const left = Math.max(0, Math.floor((until.getTime() - now) / 1000));
|
||||
return (
|
||||
<span className="bc-badge">
|
||||
{t('pro.offer.left')} {Math.floor(left / 60)}:{String(left % 60).padStart(2, '0')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "lib": ["ES2023", "DOM", "DOM.Iterable"], "jsx": "react-jsx", "types": ["node", "vite/client"] },
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// В разработке API проксируется — Mini App и API на одном origin (удобно и для туннеля https)
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
allowedHosts: true,
|
||||
proxy: { '/trpc': 'http://localhost:4000', '/files': 'http://localhost:4000', '/client-log': 'http://localhost:4000' },
|
||||
},
|
||||
build: { target: 'es2022', sourcemap: true },
|
||||
});
|
||||
Reference in new issue
Block a user