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:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 01:23:32 +04:00
1 parent 45d7512d2b
commit d452824c62
314 files changed
+44394 -121

No files matched your search

+95
View File
@@ -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>
);
}
+205
View File
@@ -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%;
}
+5
View File
@@ -0,0 +1,5 @@
import { getInitData } from '@barev/web';
const initData = getInitData();
export const hasInitData = !!initData;
export const authHeader = () => ({ authorization: `tma ${initData}` });
+65
View File
@@ -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>
);
}
+19
View File
@@ -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>,
);
+17
View File
@@ -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 }} />;
}
+134
View File
@@ -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>
);
}
+87
View File
@@ -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>
);
}
+207
View File
@@ -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;
}
}
+211
View 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>
);
}
+167
View File
@@ -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>
);
}