Bariki cashback, order edit, and current work in progress
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
dda917fc71
commit
45477fb018
94 files changed
+10955
-650
No files matched your search
@@ -2,6 +2,7 @@ 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 { forgetWebLogin, isWeb } from './auth.ts';
|
||||
import { Company } from './screens/Company.tsx';
|
||||
import { History } from './screens/History.tsx';
|
||||
import { Job } from './screens/Job.tsx';
|
||||
@@ -15,10 +16,14 @@ export function App({ hasInitData }: { hasInitData: boolean }) {
|
||||
const me = useQuery({ ...trpc.pro.me.queryOptions(), enabled: hasInitData });
|
||||
const locale = pickLocale(me.data?.locale, telegramLanguage(), navigator.language);
|
||||
const status = me.error?.data?.httpStatus;
|
||||
useEffect(() => {
|
||||
if (status === 401 && isWeb()) forgetWebLogin();
|
||||
}, [status]);
|
||||
return (
|
||||
<I18nProvider initial={locale}>
|
||||
{!hasInitData || status === 401 ? (
|
||||
<Message titleKey="pro.openInTelegram.title" textKey="pro.openInTelegram.text" />
|
||||
// в браузере без входа или с устаревшим входом — подсказка, как получить ссылку из бота
|
||||
<Message titleKey="pro.openInTelegram.title" textKey={isWeb() ? 'pro.web.loginHint' : 'pro.openInTelegram.text'} />
|
||||
) : status === 403 ? (
|
||||
<Message titleKey="pro.notLinked.title" textKey="pro.notLinked.text" />
|
||||
) : me.isPending ? (
|
||||
|
||||
@@ -203,3 +203,60 @@
|
||||
.invite .bc-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* кабинет менеджера партнёра: в браузере на компьютере — шире, карточки в две колонки */
|
||||
.app--wide {
|
||||
max-width: 1040px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
.cab-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
@media (min-width: 820px) {
|
||||
.app--wide .cab-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.cab-row2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.cab-link {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 6px 0;
|
||||
color: var(--bc-plum);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.cab-copy {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 8px 8px 12px;
|
||||
border-radius: 16px;
|
||||
background: var(--bc-bg);
|
||||
}
|
||||
.cab-copy code {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
.cab-switch {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cab-switch input {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-top: 2px;
|
||||
accent-color: var(--bc-plum);
|
||||
}
|
||||
@@ -1,5 +1,54 @@
|
||||
import { getInitData } from '@barev/web';
|
||||
|
||||
/**
|
||||
* Внутри Telegram запросы подписаны initData. В браузере (кабинет менеджера партнёра на компьютере)
|
||||
* — токен: бот выдаёт одноразовую ссылку ?login=<код>, страница меняет её на токен и хранит в localStorage.
|
||||
*/
|
||||
const initData = getInitData();
|
||||
export const hasInitData = !!initData;
|
||||
export const authHeader = () => ({ authorization: `tma ${initData}` });
|
||||
const TOKEN_KEY = 'barev.pro.web';
|
||||
|
||||
function readToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
let webToken = initData ? null : readToken();
|
||||
|
||||
export const hasAuth = () => !!initData || !!webToken;
|
||||
export const isWeb = () => !initData;
|
||||
export const authHeader = (): Record<string, string> => (initData ? { authorization: `tma ${initData}` } : webToken ? { authorization: `pro ${webToken}` } : {});
|
||||
|
||||
export function forgetWebLogin() {
|
||||
webToken = null;
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
} catch {
|
||||
/* приватный режим */
|
||||
}
|
||||
}
|
||||
|
||||
/** Ссылка из бота: обменять одноразовый код на токен до первого запроса */
|
||||
export async function consumeLoginLink(): Promise<void> {
|
||||
if (initData) return;
|
||||
const url = new URL(window.location.href);
|
||||
const code = url.searchParams.get('login');
|
||||
if (!code) return;
|
||||
url.searchParams.delete('login');
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
try {
|
||||
const res = await fetch('/trpc/pro.webLogin', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ json: { code } }) });
|
||||
const body = (await res.json()) as { result?: { data?: { json?: { token: string } } } };
|
||||
const token = body.result?.data?.json?.token;
|
||||
if (!token) return;
|
||||
webToken = token;
|
||||
try {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
} catch {
|
||||
/* без localStorage вход действует до закрытия вкладки */
|
||||
}
|
||||
} catch {
|
||||
/* сеть — покажем экран «откройте ссылку из бота» */
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,21 @@ import { ApiProvider, loadTextOverrides, initTelegram, ToastProvider } from '@ba
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App.tsx';
|
||||
import { authHeader, hasInitData } from './auth.ts';
|
||||
import { authHeader, consumeLoginLink, hasAuth } from './auth.ts';
|
||||
|
||||
initTelegram();
|
||||
// названия и описания услуг, изменённые в админке
|
||||
void loadTextOverrides();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ApiProvider headers={authHeader}>
|
||||
<ToastProvider>
|
||||
<App hasInitData={hasInitData} />
|
||||
</ToastProvider>
|
||||
</ApiProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
// в браузере: ссылка из бота ?login=… меняется на токен до первого запроса
|
||||
void consumeLoginLink().then(() => {
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ApiProvider headers={authHeader}>
|
||||
<ToastProvider>
|
||||
<App hasInitData={hasAuth()} />
|
||||
</ToastProvider>
|
||||
</ApiProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
@@ -1,20 +1,24 @@
|
||||
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 { LOCALES, type Locale } from '@barev/i18n';
|
||||
import { Badge, Button, Card, Center, Chip, Field, Icon, Segmented, Sheet, Spinner } from '@barev/ui';
|
||||
import { confirmDialog, errorText, haptic, useI18n, useToast, useTRPC } from '@barev/web';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { forgetWebLogin, isWeb } from '../auth.ts';
|
||||
import { JobRow, PageHead } from '../lib.tsx';
|
||||
|
||||
/** PRO-7: диспетчер компании распределяет заказы, назначенные на компанию */
|
||||
type Tab = 'orders' | 'staff' | 'money' | 'settings';
|
||||
type StaffCheck = 'ok' | 'busy' | 'gap' | 'limit' | 'notLinked';
|
||||
|
||||
/** Кабинет менеджера партнёра: заказы и распределение, сотрудники, доход, настройки */
|
||||
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);
|
||||
const [tab, setTab] = useState<Tab>('orders');
|
||||
const cab = useCabinet(companyId);
|
||||
|
||||
return (
|
||||
<main className="app">
|
||||
<PageHead title={board.data?.company.name ?? t('pro.company.title')} back={false} />
|
||||
<main className={isWeb() ? 'app app--wide' : 'app'}>
|
||||
<PageHead title={cab.data?.company.name ?? t('pro.company.title')} back={false} />
|
||||
{companies.length > 1 && (
|
||||
<div className="bc-chips">
|
||||
{companies.map((c) => (
|
||||
@@ -24,111 +28,587 @@ export function Company({ companies }: { companies: { id: string; name: string }
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!board.data ? (
|
||||
<Segmented
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
label={t('pro.company.title')}
|
||||
options={[
|
||||
{ value: 'orders', label: t('pro.cab.tab.orders') + (cab.data?.pending.length ? ` · ${cab.data.pending.length}` : '') },
|
||||
{ value: 'staff', label: t('pro.cab.tab.staff') },
|
||||
{ value: 'money', label: t('pro.cab.tab.money') },
|
||||
{ value: 'settings', label: t('pro.cab.tab.settings') },
|
||||
]}
|
||||
/>
|
||||
{!cab.data ? (
|
||||
<Center>
|
||||
<Spinner />
|
||||
</Center>
|
||||
) : tab === 'orders' ? (
|
||||
<Orders companyId={companyId} data={cab.data} />
|
||||
) : tab === 'staff' ? (
|
||||
<Staff companyId={companyId} data={cab.data} />
|
||||
) : tab === 'money' ? (
|
||||
<Money data={cab.data} />
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
<Settings companyId={companyId} data={cab.data} />
|
||||
)}
|
||||
{picking && <StaffSheet companyId={companyId} orderId={picking} onClose={() => setPicking(null)} />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function StaffSheet({ companyId, orderId, onClose }: { companyId: string; orderId: string; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
function useCabinet(companyId: string) {
|
||||
const trpc = useTRPC();
|
||||
return useQuery({ ...trpc.pro.dispatcher.cabinet.queryOptions({ companyId }), refetchInterval: 15_000 });
|
||||
}
|
||||
type Cabinet = NonNullable<ReturnType<typeof useCabinet>['data']>;
|
||||
|
||||
function useAction() {
|
||||
const { t } = useI18n();
|
||||
const qc = useQueryClient();
|
||||
const toast = useToast();
|
||||
return {
|
||||
onSuccess: (msg?: string) => () => {
|
||||
haptic.ok();
|
||||
if (msg) toast(msg);
|
||||
void qc.invalidateQueries();
|
||||
},
|
||||
onError: (e: unknown) => toast(errorText(t, e), 'error'),
|
||||
};
|
||||
}
|
||||
|
||||
// ───── заказы ─────
|
||||
|
||||
function Orders({ companyId, data }: { companyId: string; data: Cabinet }) {
|
||||
const { t, dt, amd } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const act = useAction();
|
||||
const [picking, setPicking] = useState<string | null>(null);
|
||||
const decline = useMutation(trpc.pro.dispatcher.decline.mutationOptions({ onSuccess: act.onSuccess(t('pro.cab.declined')), onError: act.onError }));
|
||||
const unassign = useMutation(trpc.pro.dispatcher.unassign.mutationOptions({ onSuccess: act.onSuccess(t('pro.cab.unassigned')), onError: act.onError }));
|
||||
return (
|
||||
<>
|
||||
{data.company.autoAssign && (
|
||||
<Card tight tone="blue" className="bc-row" style={{ gap: 10 }}>
|
||||
<Icon name="repeat" size={20} />
|
||||
<span className="bc-small">{t('pro.cab.autoOn')}</span>
|
||||
</Card>
|
||||
)}
|
||||
<section className="bc-stack" style={{ gap: 8 }}>
|
||||
<h2 className="bc-h3">
|
||||
{t('pro.company.pending')} · {data.pending.length}
|
||||
</h2>
|
||||
{data.pending.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.company.noPending')}</p>}
|
||||
<div className="cab-grid">
|
||||
{data.pending.map((o) => (
|
||||
<Card key={o.id} tone="pink" tight className="bc-stack" style={{ gap: 8 }}>
|
||||
<JobRow j={o} right={<b className="bc-small">{dt(o.scheduledAt, { day: 'numeric', month: 'short' })}</b>} />
|
||||
<div className="bc-small" style={{ display: 'flex', flexWrap: 'wrap', gap: '4px 12px' }}>
|
||||
<span>
|
||||
{t('pro.cab.income')}: <b>{amd(o.income)}</b>
|
||||
</span>
|
||||
{o.deadline && !o.offeredTo && <span>{t('pro.cab.deadline', { time: dt(o.deadline, { hour: '2-digit', minute: '2-digit' }) })}</span>}
|
||||
{o.offeredTo && <span>{t('pro.cab.offeredTo', { name: o.offeredTo })}</span>}
|
||||
</div>
|
||||
<div className="bc-row" style={{ gap: 8 }}>
|
||||
<Button size="sm" onClick={() => setPicking(o.id)}>
|
||||
{t('pro.company.assign')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={decline.isPending}
|
||||
onClick={async () => {
|
||||
if (await confirmDialog(t('pro.cab.declineConfirm'))) decline.mutate({ companyId, orderId: o.id });
|
||||
}}
|
||||
>
|
||||
{t('pro.cab.decline')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bc-stack" style={{ gap: 8 }}>
|
||||
<h2 className="bc-h3">{t('pro.company.assigned')}</h2>
|
||||
{data.assigned.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.company.noAssigned')}</p>}
|
||||
<div className="cab-grid">
|
||||
{data.assigned.map((o) => (
|
||||
<Card key={o.id} tight className="bc-stack" style={{ gap: 6 }}>
|
||||
<JobRow j={o} right={<b className="bc-small">{dt(o.scheduledAt, { day: 'numeric', month: 'short' })}</b>} />
|
||||
<div className="bc-between bc-small">
|
||||
<span>
|
||||
<Icon name="user" size={16} /> <b>{o.providerName}</b> · {t(`status.${o.flow}.${o.status}`)}
|
||||
</span>
|
||||
{o.canUnassign && (
|
||||
<button
|
||||
type="button"
|
||||
className="cab-link"
|
||||
disabled={unassign.isPending}
|
||||
onClick={async () => {
|
||||
if (await confirmDialog(t('pro.cab.unassignConfirm', { name: o.providerName ?? '' }))) unassign.mutate({ companyId, orderId: o.id });
|
||||
}}
|
||||
>
|
||||
{t('pro.cab.reassign')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{picking && <AssignSheet companyId={companyId} orderId={picking} onClose={() => setPicking(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const CHECK_TONE: Record<StaffCheck, string> = { ok: 'var(--bc-home)', notLinked: 'var(--bc-docs)', gap: 'var(--bc-docs)', limit: 'var(--bc-docs)', busy: 'var(--bc-bg)' };
|
||||
|
||||
function AssignSheet({ companyId, orderId, onClose }: { companyId: string; orderId: string; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const act = useAction();
|
||||
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();
|
||||
act.onSuccess(t('pro.company.assigned_ok'))();
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast(errorText(t, e), 'error'),
|
||||
onError: act.onError,
|
||||
}),
|
||||
);
|
||||
// свободные и говорящие на языке клиента — выше
|
||||
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>
|
||||
<SheetHead title={t('pro.company.chooseStaff')} onClose={onClose} />
|
||||
{staff.data && <p className="bc-small bc-muted" style={{ margin: 0 }}>{t('pro.cab.gapHint', { min: staff.data.gap })}</p>}
|
||||
{!staff.data ? (
|
||||
<Spinner />
|
||||
) : staff.data.rows.length === 0 ? (
|
||||
<p className="bc-muted">{t('pro.cab.noStaff')}</p>
|
||||
) : (
|
||||
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 })}
|
||||
staff.data.rows.map((p) => {
|
||||
const can = p.check !== 'busy';
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className="job"
|
||||
style={{ border: 'none', textAlign: 'left', cursor: can ? 'pointer' : 'not-allowed', opacity: can ? 1 : 0.5 }}
|
||||
disabled={!can || assign.isPending}
|
||||
onClick={async () => {
|
||||
if (p.check !== 'ok' && !(await confirmDialog(t(`pro.cab.check.${p.check}_confirm`)))) return;
|
||||
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.cab.dayLoad', { n: p.dayOrders, max: p.maxOrdersPerDay ?? '∞' })} · {t('pro.cab.weekLoad', { n: p.weekOrders })}
|
||||
{p.rating != null && ` · ★ ${p.rating}`}
|
||||
</div>
|
||||
</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>
|
||||
))
|
||||
<span className="bc-row" style={{ flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
{p.language && <Badge style={{ background: 'var(--bc-home)' }}>{t('pro.company.speaksLanguage')}</Badge>}
|
||||
{p.district && <Badge style={{ background: 'var(--bc-blue)' }}>{t('pro.cab.district')}</Badge>}
|
||||
<Badge style={{ background: CHECK_TONE[p.check] }}>{t(`pro.cab.check.${p.check}`)}</Badge>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHead({ title, onClose }: { title: ReactNode; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="bc-between">
|
||||
<h2 className="bc-h2">{title}</h2>
|
||||
<button className="back" onClick={onClose} aria-label={t('common.close')}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── сотрудники ─────
|
||||
|
||||
type StaffRow = Cabinet['staff'][number];
|
||||
|
||||
function Staff({ companyId, data }: { companyId: string; data: Cabinet }) {
|
||||
const { t } = useI18n();
|
||||
const [edit, setEdit] = useState<StaffRow | 'new' | null>(null);
|
||||
return (
|
||||
<>
|
||||
<div className="bc-between">
|
||||
<h2 className="bc-h3">
|
||||
{t('pro.company.staff')} · {data.staff.filter((s) => s.active).length}
|
||||
</h2>
|
||||
<Button size="sm" onClick={() => setEdit('new')}>
|
||||
<Icon name="plus" size={18} /> {t('pro.cab.addStaff')}
|
||||
</Button>
|
||||
</div>
|
||||
{data.staff.length === 0 && <p className="bc-muted">{t('pro.cab.noStaff')}</p>}
|
||||
<div className="cab-grid">
|
||||
{data.staff.map((p) => (
|
||||
<button key={p.id} type="button" className="job" style={{ border: 'none', textAlign: 'left', cursor: 'pointer', opacity: p.active ? 1 : 0.5 }} onClick={() => setEdit(p)}>
|
||||
<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.cab.todayLoad', { n: p.today, max: p.maxOrdersPerDay ?? '∞' })}
|
||||
{p.maxHoursPerDay ? ` · ${t('pro.cab.hoursMax', { n: p.maxHoursPerDay })}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span className="bc-row">
|
||||
{!p.active ? (
|
||||
<Badge style={{ background: 'var(--bc-bg)' }}>{t('pro.cab.off')}</Badge>
|
||||
) : !p.linked ? (
|
||||
<Badge style={{ background: 'var(--bc-docs)' }}>{t('pro.company.notLinked')}</Badge>
|
||||
) : (
|
||||
<Badge style={{ background: p.onShift ? 'var(--bc-home)' : 'var(--bc-bg)' }}>{t(p.onShift ? 'pro.today.onShift' : 'pro.today.offShift')}</Badge>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{edit && <StaffSheet companyId={companyId} districts={data.districts} staff={edit === 'new' ? null : edit} onClose={() => setEdit(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const DAYS = [0, 1, 2, 3, 4, 5, 6];
|
||||
const hhmm = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
|
||||
const toMin = (s: string) => {
|
||||
const [h, m] = s.split(':').map(Number);
|
||||
return (h ?? 0) * 60 + (m ?? 0);
|
||||
};
|
||||
|
||||
function StaffSheet({ companyId, districts, staff, onClose }: { companyId: string; districts: { id: string; code: string }[]; staff: StaffRow | null; onClose: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const act = useAction();
|
||||
const first = staff?.schedule[0];
|
||||
const [f, setF] = useState({
|
||||
name: staff?.name ?? '',
|
||||
phone: staff?.phone ?? '',
|
||||
languages: (staff?.languages ?? ['ru']) as Locale[],
|
||||
districtIds: staff?.districtIds ?? [],
|
||||
days: staff ? [...new Set(staff.schedule.map((s) => s.weekday))] : [0, 1, 2, 3, 4, 5],
|
||||
from: hhmm(first?.startMinute ?? 9 * 60),
|
||||
to: hhmm(first?.endMinute ?? 19 * 60),
|
||||
maxOrders: staff?.maxOrdersPerDay ? String(staff.maxOrdersPerDay) : '',
|
||||
maxHours: staff?.maxHoursPerDay ? String(staff.maxHoursPerDay) : '',
|
||||
});
|
||||
const [link, setLink] = useState<string | null>(staff?.inviteLink ?? null);
|
||||
const toggle = <T,>(list: T[], v: T) => (list.includes(v) ? list.filter((x) => x !== v) : [...list, v]);
|
||||
const payload = () => ({
|
||||
name: f.name,
|
||||
phone: f.phone || null,
|
||||
languages: f.languages,
|
||||
districtIds: f.districtIds,
|
||||
schedule: f.days.map((weekday) => ({ weekday, startMinute: toMin(f.from), endMinute: toMin(f.to) })),
|
||||
maxOrdersPerDay: Number(f.maxOrders) || null,
|
||||
maxHoursPerDay: Number(f.maxHours) || null,
|
||||
});
|
||||
const add = useMutation(
|
||||
trpc.pro.dispatcher.addStaff.mutationOptions({
|
||||
onSuccess: (r) => {
|
||||
act.onSuccess(t('pro.cab.staffAdded'))();
|
||||
setLink(r.inviteLink);
|
||||
},
|
||||
onError: act.onError,
|
||||
}),
|
||||
);
|
||||
const save = useMutation(
|
||||
trpc.pro.dispatcher.updateStaff.mutationOptions({
|
||||
onSuccess: () => {
|
||||
act.onSuccess(t('pro.cab.saved'))();
|
||||
onClose();
|
||||
},
|
||||
onError: act.onError,
|
||||
}),
|
||||
);
|
||||
const reset = useMutation(trpc.pro.dispatcher.resetStaffInvite.mutationOptions({ onSuccess: (r) => setLink(r.inviteLink), onError: act.onError }));
|
||||
const created = !staff && !!link;
|
||||
|
||||
return (
|
||||
<Sheet open onClose={onClose} label={staff ? staff.name : t('pro.cab.addStaff')}>
|
||||
<div className="bc-stack">
|
||||
<SheetHead title={staff ? staff.name : t('pro.cab.addStaff')} onClose={onClose} />
|
||||
{created ? (
|
||||
<>
|
||||
<p style={{ margin: 0 }}>{t('pro.cab.inviteText')}</p>
|
||||
<CopyBox url={link!} />
|
||||
<Button block onClick={onClose}>
|
||||
{t('pro.cab.done')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field label={t('pro.cab.f.name')}>
|
||||
<input className="bc-input" value={f.name} maxLength={100} onChange={(e) => setF({ ...f, name: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('pro.cab.f.phone')}>
|
||||
<input className="bc-input" type="tel" value={f.phone} maxLength={30} onChange={(e) => setF({ ...f, phone: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('pro.cab.f.languages')}>
|
||||
<div className="bc-chips">
|
||||
{LOCALES.map((l) => (
|
||||
<Chip key={l} pressed={f.languages.includes(l)} onClick={() => setF({ ...f, languages: toggle(f.languages, l) })}>
|
||||
{l.toUpperCase()}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label={t('pro.cab.f.districts')}>
|
||||
<div className="bc-chips">
|
||||
{districts.map((d) => (
|
||||
<Chip key={d.id} pressed={f.districtIds.includes(d.id)} onClick={() => setF({ ...f, districtIds: toggle(f.districtIds, d.id) })}>
|
||||
{t(`districts.${d.code}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label={t('pro.cab.f.days')}>
|
||||
<div className="bc-chips">
|
||||
{DAYS.map((d) => (
|
||||
<Chip key={d} pressed={f.days.includes(d)} onClick={() => setF({ ...f, days: toggle(f.days, d) })}>
|
||||
{t(`pro.cab.wd.${d}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<div className="cab-row2">
|
||||
<Field label={t('pro.cab.f.from')}>
|
||||
<input className="bc-input" type="time" step={1800} value={f.from} onChange={(e) => setF({ ...f, from: e.target.value })} />
|
||||
</Field>
|
||||
<Field label={t('pro.cab.f.to')}>
|
||||
<input className="bc-input" type="time" step={1800} value={f.to} onChange={(e) => setF({ ...f, to: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="cab-row2">
|
||||
<Field label={t('pro.cab.f.maxOrders')}>
|
||||
<input className="bc-input" inputMode="numeric" placeholder="∞" value={f.maxOrders} onChange={(e) => setF({ ...f, maxOrders: e.target.value.replace(/\D/g, '').slice(0, 2) })} />
|
||||
</Field>
|
||||
<Field label={t('pro.cab.f.maxHours')}>
|
||||
<input className="bc-input" inputMode="numeric" placeholder="∞" value={f.maxHours} onChange={(e) => setF({ ...f, maxHours: e.target.value.replace(/\D/g, '').slice(0, 2) })} />
|
||||
</Field>
|
||||
</div>
|
||||
<p className="bc-small bc-muted" style={{ margin: 0 }}>
|
||||
{t('pro.cab.f.loadHint')}
|
||||
</p>
|
||||
{staff && link && (
|
||||
<>
|
||||
<p className="bc-small" style={{ margin: 0 }}>
|
||||
{t('pro.cab.inviteText')}
|
||||
</p>
|
||||
<CopyBox url={link} />
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
block
|
||||
disabled={!f.name.trim() || !f.languages.length || toMin(f.to) <= toMin(f.from) || add.isPending || save.isPending}
|
||||
onClick={() => (staff ? save.mutate({ companyId, providerId: staff.id, staff: payload() }) : add.mutate({ companyId, staff: payload() }))}
|
||||
>
|
||||
{staff ? t('pro.cab.save') : t('pro.cab.addStaff')}
|
||||
</Button>
|
||||
{staff && (
|
||||
<div className="bc-row" style={{ gap: 8, flexWrap: 'wrap' }}>
|
||||
{staff.linked && (
|
||||
<Button size="sm" variant="ghost" disabled={reset.isPending} onClick={async () => (await confirmDialog(t('pro.cab.resetConfirm'))) && reset.mutate({ companyId, providerId: staff.id })}>
|
||||
{t('pro.cab.resetInvite')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={staff.active ? 'danger' : 'outline'}
|
||||
disabled={save.isPending}
|
||||
onClick={async () => {
|
||||
if (!staff.active || (await confirmDialog(t('pro.cab.offConfirm', { name: staff.name })))) save.mutate({ companyId, providerId: staff.id, staff: payload(), active: !staff.active });
|
||||
}}
|
||||
>
|
||||
{staff.active ? t('pro.cab.turnOff') : t('pro.cab.turnOn')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyBox({ url }: { url: string }) {
|
||||
const { t } = useI18n();
|
||||
const toast = useToast();
|
||||
return (
|
||||
<div className="cab-copy">
|
||||
<code>{url}</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(url);
|
||||
toast(t('pro.cab.copied'));
|
||||
}}
|
||||
>
|
||||
{t('pro.cab.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── доход ─────
|
||||
|
||||
function Money({ data }: { data: Cabinet }) {
|
||||
const { t, amd, dt, locale } = useI18n();
|
||||
const m = data.money;
|
||||
const terms = data.company.terms;
|
||||
const month = (p: string) => new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(new Date(`${p}-15T12:00:00+04:00`));
|
||||
return (
|
||||
<>
|
||||
<Card tone="dark" className="bc-stack" style={{ gap: 6 }}>
|
||||
<span className="bc-small" style={{ opacity: 0.75 }}>
|
||||
{t('pro.cab.balance')}
|
||||
</span>
|
||||
<div className="stat__value" style={{ fontSize: 30 }}>
|
||||
{amd(m.balance)}
|
||||
</div>
|
||||
<span className="bc-small" style={{ opacity: 0.85 }}>
|
||||
{t('pro.cab.accrued')}: {amd(m.accrued)} · {t('pro.cab.paidOut')}: {amd(m.paid)}
|
||||
</span>
|
||||
</Card>
|
||||
<Card tight className="bc-small">
|
||||
{terms.deal === 'COMMISSION' ? t('pro.cab.terms.COMMISSION', { pct: terms.commissionPct }) : t('pro.cab.terms.PROFIT_SHARE', { pct: terms.profitSharePct, fot: terms.fotPct, tax: terms.taxPct })}
|
||||
</Card>
|
||||
{m.months.length > 0 && (
|
||||
<section className="bc-stack" style={{ gap: 8 }}>
|
||||
<h2 className="bc-h3">{t('pro.cab.byMonth')}</h2>
|
||||
<Card tight className="bc-stack" style={{ gap: 6 }}>
|
||||
{m.months.map((x) => (
|
||||
<div key={x.period} className="bc-between">
|
||||
<span style={{ textTransform: 'capitalize' }}>{month(x.period)}</span>
|
||||
<span className="bc-small bc-muted">{t('pro.cab.ordersN', { n: x.orders })}</span>
|
||||
<b>{amd(x.amount)}</b>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
<section className="bc-stack" style={{ gap: 8 }}>
|
||||
<h2 className="bc-h3">{t('pro.cab.byOrder')}</h2>
|
||||
{m.recent.length === 0 && <p className="bc-muted" style={{ margin: 0 }}>{t('pro.cab.noIncome')}</p>}
|
||||
<div className="cab-grid">
|
||||
{m.recent.map((e) => (
|
||||
<Card key={e.orderId} tight className="bc-stack" style={{ gap: 2 }}>
|
||||
<div className="bc-between">
|
||||
<b>
|
||||
#{e.number} · {dt(e.at, { day: 'numeric', month: 'short' })}
|
||||
</b>
|
||||
<b>{amd(e.amount)}</b>
|
||||
</div>
|
||||
<span className="bc-small bc-muted">
|
||||
{e.staff ? `${e.staff} · ` : ''}
|
||||
{t('pro.cab.paidBy', { sum: amd(e.paid) })}
|
||||
{e.deal === 'PROFIT_SHARE' ? ` · ${t('pro.cab.minus', { fot: amd(e.fot), tax: amd(e.tax), pct: e.pct })}` : ` · ${t('pro.cab.minusCommission', { pct: e.pct })}`}
|
||||
</span>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{m.payouts.length > 0 && (
|
||||
<section className="bc-stack" style={{ gap: 8 }}>
|
||||
<h2 className="bc-h3">{t('pro.cab.payouts')}</h2>
|
||||
<Card tight className="bc-stack" style={{ gap: 6 }}>
|
||||
{m.payouts.map((p) => (
|
||||
<div key={p.id} className="bc-between">
|
||||
<span>{p.paidAt ? dt(p.paidAt, { day: 'numeric', month: 'long' }) : '—'}</span>
|
||||
<Badge style={{ background: p.status === 'PAID' ? 'var(--bc-home)' : 'var(--bc-bg)' }}>{t(`pro.cab.payout.${p.status === 'PAID' ? 'paid' : 'pending'}`)}</Badge>
|
||||
<b>{amd(p.amount)}</b>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ───── настройки ─────
|
||||
|
||||
function Settings({ companyId, data }: { companyId: string; data: Cabinet }) {
|
||||
const { t } = useI18n();
|
||||
const trpc = useTRPC();
|
||||
const act = useAction();
|
||||
const c = data.company;
|
||||
const [auto, setAuto] = useState(c.autoAssign);
|
||||
const [gap, setGap] = useState(String(c.travelGapMinutes));
|
||||
const save = useMutation(trpc.pro.dispatcher.saveSettings.mutationOptions({ onSuccess: act.onSuccess(t('pro.cab.saved')), onError: act.onError }));
|
||||
const remove = useMutation(trpc.pro.dispatcher.removeManager.mutationOptions({ onSuccess: act.onSuccess(), onError: act.onError }));
|
||||
const logout = useMutation(
|
||||
trpc.pro.webLogout.mutationOptions({
|
||||
onSettled: () => {
|
||||
forgetWebLogin();
|
||||
window.location.reload();
|
||||
},
|
||||
}),
|
||||
);
|
||||
const gapN = Number(gap) || 0;
|
||||
return (
|
||||
<>
|
||||
<Card className="bc-stack">
|
||||
<label className="cab-switch">
|
||||
<input type="checkbox" checked={auto} onChange={(e) => setAuto(e.target.checked)} />
|
||||
<span>
|
||||
<b>{t('pro.cab.auto')}</b>
|
||||
<span className="bc-small bc-muted" style={{ display: 'block' }}>
|
||||
{t('pro.cab.autoHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<Field label={t('pro.cab.gap')} error={gapN < c.minTravelGap ? t('pro.cab.gapMin', { min: c.minTravelGap }) : undefined}>
|
||||
<input className="bc-input" inputMode="numeric" value={gap} onChange={(e) => setGap(e.target.value.replace(/\D/g, '').slice(0, 3))} />
|
||||
</Field>
|
||||
<p className="bc-small bc-muted" style={{ margin: 0 }}>
|
||||
{t('pro.cab.timeoutHint', { min: c.acceptTimeoutMinutes })}
|
||||
</p>
|
||||
<Button disabled={save.isPending || gapN < c.minTravelGap} onClick={() => save.mutate({ companyId, autoAssign: auto, travelGapMinutes: gapN })}>
|
||||
{t('pro.cab.save')}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<section className="bc-stack" style={{ gap: 8 }}>
|
||||
<h2 className="bc-h3">{t('pro.cab.managers')}</h2>
|
||||
<Card tight className="bc-stack" style={{ gap: 6 }}>
|
||||
{data.managers.map((m) => (
|
||||
<div key={m.id} className="bc-between" style={{ minHeight: 36 }}>
|
||||
<span>
|
||||
{m.name}
|
||||
{m.me && <span className="bc-muted"> · {t('pro.cab.you')}</span>}
|
||||
</span>
|
||||
{!m.me && (
|
||||
<button type="button" className="cab-link" onClick={async () => (await confirmDialog(t('pro.cab.removeManagerConfirm', { name: m.name }))) && remove.mutate({ companyId, managerId: m.id })}>
|
||||
{t('pro.cab.remove')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
<p className="bc-small" style={{ margin: 0 }}>
|
||||
{t('pro.cab.managerInvite')}
|
||||
</p>
|
||||
<CopyBox url={c.managerLink} />
|
||||
</section>
|
||||
|
||||
{isWeb() && (
|
||||
<Button variant="ghost" onClick={() => logout.mutate()}>
|
||||
{t('pro.cab.logout')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -64,9 +64,11 @@ export function History() {
|
||||
<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) })}
|
||||
: history.data.rate.value == null
|
||||
? t('pro.history.ratePartner')
|
||||
: 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>
|
||||
|
||||
@@ -81,7 +81,7 @@ export function Job({ id }: { id: string }) {
|
||||
</dd>
|
||||
<dt>{t('pro.job.price')}</dt>
|
||||
<dd>
|
||||
{amd(j.totalPrice)} · {t(`payment.${j.paymentStatus}`)}
|
||||
{amd(j.dueAmount)} · {t(`payment.${j.paymentStatus}`)}
|
||||
</dd>
|
||||
</dl>
|
||||
{paramsExtras(t, j.params).length > 0 && (
|
||||
@@ -141,7 +141,7 @@ export function Job({ id }: { id: string }) {
|
||||
</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 })}>
|
||||
<Button variant="outline" block disabled={paid.isPending} onClick={async () => (await confirmDialog(t('pro.job.confirmPaid', { amount: amd(j.dueAmount) }))) && paid.mutate({ orderId: j.id })}>
|
||||
{t('pro.job.markPaid')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -2,12 +2,14 @@ import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// В разработке API проксируется — Mini App и API на одном origin (удобно и для туннеля https)
|
||||
// API_PROXY — другой локальный API (например, второй экземпляр на соседнем порту)
|
||||
const api = process.env.API_PROXY ?? 'http://localhost:4000';
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
allowedHosts: true,
|
||||
proxy: { '/trpc': 'http://localhost:4000', '/files': 'http://localhost:4000', '/client-log': 'http://localhost:4000' },
|
||||
proxy: { '/trpc': api, '/files': api, '/client-log': api },
|
||||
},
|
||||
build: { target: 'es2022', sourcemap: true },
|
||||
});
|
||||
Reference in new issue
Block a user