Backoffice stage 5: products, broadcasts, activity log, settings

- Products: services with full setup — content (name, description, extras names
  in 5 languages via text overrides), price tiers and extras, booking window,
  late-cancel fee, on/off, checklist; category discounts by membership level;
  membership plan prices; promo codes; relocation steps and documents.
- Text overrides: TextOverride table on top of the i18n files; API, bots and the
  worker reload them every minute, mini apps fetch public.texts at start.
- Promo codes applied to orders (admin new-order form and client API): not
  stacked with membership discount — the larger wins; once per client; validity,
  limit and categories.
- Broadcasts to the client bot by any client filter, per-language texts with
  Russian fallback, test to yourself, ~25 msg/s, per-recipient status, resume after
  worker restart, history with progress and stop.
- Activity log (who/when/what, filters, details) and Settings page with all
  owner settings grouped, plus payment methods.
- Tests for promo codes and text overrides.

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 03:17:56 +04:00
1 parent ac786cc5cc
commit 0edc6432b7
34 files changed
+2704 -26

No files matched your search

+405
View File
@@ -0,0 +1,405 @@
/** Этап 5: рассылки, журнал действий, настройки */
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useToast, useTRPC } from '@barev/web';
import { useEffect, useState } from 'react';
import { errText, useT } from '../i18n.tsx';
import { Avatar, Dialog, Field, I, Skeleton } from '../components/kit.tsx';
import { actionLabel } from './Dashboard.tsx';
const LANGS = ['ru', 'en', 'hy', 'zh', 'es'] as const;
type L5 = Record<(typeof LANGS)[number], string>;
// ───── рассылка ─────
/** Рассылка по текущим фильтрам списка клиентов */
export function BroadcastDialog({ filters, onClose }: { filters: Record<string, unknown>; onClose: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const preview = useQuery(trpc.bo.broadcasts.preview.queryOptions(filters as never));
const [texts, setTexts] = useState<L5>({ ru: '', en: '', hy: '', zh: '', es: '' });
const [more, setMore] = useState(false);
const [appButton, setAppButton] = useState(true);
const [sure, setSure] = useState(false);
const onError = (e: unknown) => toast(errText(t, e), 'error');
const test = useMutation(trpc.bo.broadcasts.test.mutationOptions({ onSuccess: () => toast(t('bc.testSent')), onError }));
const send = useMutation(
trpc.bo.broadcasts.send.mutationOptions({
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: trpc.bo.broadcasts.list.queryKey() });
toast(t('bc.started', { n: r.total }));
onClose();
location.hash = '#/broadcasts';
},
onError,
}),
);
const p = preview.data;
const missing = p ? LANGS.filter((l) => l !== 'ru' && p.byLocale[l] > 0 && !texts[l].trim()) : [];
return (
<Dialog
open
wide
onClose={onClose}
title={t('bc.title')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" disabled={!texts.ru.trim() || test.isPending} onClick={() => test.mutate({ text: texts.ru, appButton })}>
{t('bc.test')}
</button>
<span className="bo-grow" />
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button
type="button"
className="bo-btn bo-btn--accent"
disabled={!texts.ru.trim() || !p?.total || send.isPending}
onClick={() => (sure ? send.mutate({ filters: filters as never, texts, appButton }) : setSure(true))}
>
{sure ? t('bc.confirm', { n: p?.total ?? 0 }) : t('bc.send', { n: p?.total ?? 0 })}
</button>
</>
}
>
<div className="bo-bcwho">
<I name="clients" size={20} />
{p ? (
<span>
<b>{t('bc.recipients', { n: p.total })}</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{LANGS.filter((l) => p.byLocale[l] > 0)
.map((l) => `${l.toUpperCase()} ${p.byLocale[l]}`)
.join(' · ') || '—'}
</span>
</span>
) : (
<span className="bo-muted">{t('ui.loading')}</span>
)}
</div>
<p className="bo-hint">{t('bc.filtersHint')}</p>
<Field label={t('bc.textRu')}>
<textarea className="bo-textarea" style={{ minHeight: 120 }} value={texts.ru} maxLength={3500} onChange={(e) => (setTexts({ ...texts, ru: e.target.value }), setSure(false))} placeholder={t('bc.textPh')} />
</Field>
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-accent)', padding: 0, alignSelf: 'flex-start' }} onClick={() => setMore((m) => !m)}>
{t('bc.otherLangs')} {more ? '▴' : '▾'}
</button>
{more &&
LANGS.filter((l) => l !== 'ru').map((l) => (
<Field key={l} label={`${l.toUpperCase()}${p?.byLocale[l] ? ` · ${t('bc.clientsN', { n: p.byLocale[l] })}` : ''}`}>
<textarea className="bo-textarea" style={{ minHeight: 70 }} value={texts[l]} maxLength={3500} onChange={(e) => setTexts({ ...texts, [l]: e.target.value })} />
</Field>
))}
{missing.length > 0 && <p className="bo-hint">{t('bc.fallback', { langs: missing.map((l) => l.toUpperCase()).join(', ') })}</p>}
<label className="bo-check">
<input type="checkbox" checked={appButton} onChange={(e) => setAppButton(e.target.checked)} />
{t('bc.appButton')}
</label>
{sure && <p className="bo-hint" style={{ color: 'var(--bc-danger)' }}>{t('bc.sureHint')}</p>}
</Dialog>
);
}
export function Broadcasts() {
const { t, dateTime } = useT();
const trpc = useTRPC();
const toast = useToast();
const qc = useQueryClient();
const list = useQuery({ ...trpc.bo.broadcasts.list.queryOptions(), refetchInterval: (q) => (q.state.data?.some((b) => b.status === 'SENDING') ? 3000 : false) });
const cancel = useMutation(trpc.bo.broadcasts.cancel.mutationOptions({ onSuccess: () => qc.invalidateQueries({ queryKey: trpc.bo.broadcasts.list.queryKey() }), onError: (e) => toast(errText(t, e), 'error') }));
return (
<>
<a className="bo-back" href="#/clients">
<I name="back" size={16} />
{t('card.back')}
</a>
<div className="bo-head">
<h1 className="bo-title">{t('bc.history')}</h1>
</div>
<p className="bo-hint" style={{ marginBottom: 14 }}>
{t('bc.historyHint')}
</p>
{!list.data ? (
<Skeleton h={200} />
) : list.data.length === 0 ? (
<div className="bo-panel bo-empty">{t('bc.none')}</div>
) : (
<div className="bo-col" style={{ gap: 10 }}>
{list.data.map((b) => {
const done = b.sent + b.blocked + b.failed;
return (
<section key={b.id} className="bo-panel">
<div className="bo-panel__head">
<span className="bo-small bo-muted">
{dateTime(b.createdAt)} · {b.by ?? '—'}
</span>
<span className={`bo-chip${b.status === 'DONE' ? ' bo-chip--ok' : b.status === 'SENDING' ? ' bo-chip--warn' : ''}`}>{t(`bc.status.${b.status}`)}</span>
</div>
<p style={{ margin: '0 0 12px', whiteSpace: 'pre-wrap' }}>{b.text}</p>
<div className="bo-bar__track" style={{ height: 8 }}>
<div className="bo-bar__fill" style={{ width: `${b.total ? (done / b.total) * 100 : 0}%` }} />
</div>
<div className="bo-small" style={{ display: 'flex', gap: 14, flexWrap: 'wrap', marginTop: 8 }}>
<span>{t('bc.sent', { n: b.sent, total: b.total })}</span>
{b.blocked > 0 && <span className="bo-muted">{t('bc.blocked', { n: b.blocked })}</span>}
{b.failed > 0 && <span style={{ color: 'var(--bc-danger)' }}>{t('bc.failed', { n: b.failed })}</span>}
{b.status === 'SENDING' && (
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-danger)', padding: 0 }} onClick={() => confirm(t('bc.cancelConfirm')) && cancel.mutate({ id: b.id })}>
{t('bc.cancel')}
</button>
)}
</div>
</section>
);
})}
</div>
)}
</>
);
}
// ───── журнал ─────
/** Куда ведёт запись журнала */
function entityHref(entity: string, id: string | null) {
if (!id) return null;
const map: Record<string, string> = { clients: 'clients', orders: 'orders', providers: 'providers', chats: 'chats', crm: 'crm', agency: 'agencies', venue: 'venues' };
const base = map[entity];
return base ? `#/${base}/${id}` : null;
}
export function Journal() {
const { t, dateTime } = useT();
const trpc = useTRPC();
const [who, setWho] = useState('');
const [entity, setEntity] = useState('');
const [days, setDays] = useState(7);
const [open, setOpen] = useState<string | null>(null);
const facets = useQuery(trpc.bo.journal.facets.queryOptions());
const list = useInfiniteQuery({
...trpc.bo.journal.list.infiniteQueryOptions({ adminUserId: who || undefined, entity: entity || undefined, days }, { getNextPageParam: (p) => p.next ?? undefined }),
placeholderData: keepPreviousData,
});
const items = list.data?.pages.flatMap((p) => p.items) ?? [];
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('jr.title')}</h1>
</div>
<div className="bo-toolbar">
<select className="bo-select" style={{ width: 'auto' }} value={who} onChange={(e) => setWho(e.target.value)} aria-label={t('jr.who')}>
<option value="">{t('jr.everyone')}</option>
{facets.data?.staff.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
<select className="bo-select" style={{ width: 'auto' }} value={entity} onChange={(e) => setEntity(e.target.value)} aria-label={t('jr.what')}>
<option value="">{t('jr.allSections')}</option>
{facets.data?.entities.map((x) => (
<option key={x} value={x}>
{t(`jr.entity.${x}`) === `jr.entity.${x}` ? x : t(`jr.entity.${x}`)}
</option>
))}
</select>
<div className="bo-pills">
{[1, 7, 30, 90].map((d) => (
<button key={d} type="button" className="bo-pill" aria-pressed={days === d} onClick={() => setDays(d)}>
{t('jr.days', { n: d })}
</button>
))}
</div>
</div>
{list.isPending ? (
<Skeleton h={300} />
) : items.length === 0 ? (
<div className="bo-panel bo-empty">{t('jr.empty')}</div>
) : (
<section className="bo-panel" style={{ padding: 8 }}>
<ul className="bo-journal">
{items.map((r) => {
const href = entityHref(r.entity, r.entityId);
return (
<li key={r.id}>
<span className="bo-feed__time">{dateTime(r.at)}</span>
{r.by ? <Avatar name={r.by.name} id={r.by.id} size={28} /> : <span style={{ width: 28 }} />}
<span className="bo-grow" style={{ minWidth: 0, whiteSpace: 'normal' }}>
<b>{r.by?.name ?? t('jr.system')}</b> {actionLabel(t, r.action)}
{href && (
<>
{' '}
<a href={href} className="bo-small">
{t('jr.open')}
</a>
</>
)}
{r.data && (
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-ink-3)', padding: '0 0 0 8px', fontSize: 12 }} onClick={() => setOpen(open === r.id ? null : r.id)}>
{open === r.id ? t('jr.hide') : t('jr.details')}
</button>
)}
{open === r.id && <pre className="bo-json">{JSON.stringify(r.data, null, 2)}</pre>}
</span>
</li>
);
})}
</ul>
{list.hasNextPage && (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" style={{ margin: 10 }} disabled={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('jr.more')}
</button>
)}
</section>
)}
</>
);
}
// ───── настройки ─────
const GROUPS: { key: string; keys: string[] }[] = [
{ key: 'orders', keys: ['cancel.freeHours', 'offer.acceptTimeoutMinutes', 'assign.auto', 'assign.maxAutoOffers'] },
{ key: 'subs', keys: ['barevPlus.monthlyPrice', 'barevPlus.maxDiscountPct', 'barevPlus.giftDays', 'elite.monthlyPrice', 'elite.maxDiscountPct'] },
{ key: 'agencies', keys: ['agency.attributionMonths', 'agency.defaultCommissionPct'] },
{ key: 'sales', keys: ['sales.bonusPerVenue', 'sales.bonusPerAgency', 'sales.bonusAgencyPct', 'sales.bonusAgencyMonths'] },
{ key: 'site', keys: ['relocation.hotelWithPlus', 'relocation.hotelBase', 'relocation.consultation', 'docs.fromPrice', 'docs.socialCardPrice', 'residence.escortFrom', 'residence.stateFee', 'transfer.standardPrice', 'transfer.premiumPrice'] },
{ key: 'other', keys: ['chat.closeAfterHours', 'files.retentionDays', 'consent.version'] },
];
export function Settings() {
const { t, shared } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const s = useQuery(trpc.bo.settings.get.queryOptions());
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
useEffect(() => {
if (s.data && !draft) setDraft({ ...s.data });
}, [s.data, draft]);
const save = useMutation(
trpc.bo.settings.save.mutationOptions({
onSuccess: () => {
qc.invalidateQueries();
toast(t('prv.saved'));
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
if (!draft || !s.data) return <Skeleton h={400} />;
const changed = Object.keys(draft).filter((k) => draft[k] !== (s.data as Record<string, unknown>)[k]);
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('st.title')}</h1>
<button type="button" className="bo-btn bo-btn--accent" disabled={!changed.length || save.isPending} onClick={() => save.mutate(Object.fromEntries(changed.map((k) => [k, draft[k]])) as never)}>
{changed.length ? t('st.saveN', { n: changed.length }) : t('ui.save')}
</button>
</div>
<div className="bo-settings">
{GROUPS.map((g) => (
<section key={g.key} className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t(`st.group.${g.key}`)}</h2>
</div>
<div className="bo-col" style={{ gap: 12 }}>
{g.keys.map((k) => {
const v = draft[k];
const label = shared(`admin.settings.${k.replace('.', '_')}`);
return typeof v === 'boolean' ? (
<label key={k} className="bo-check">
<input type="checkbox" checked={v} onChange={(e) => setDraft({ ...draft, [k]: e.target.checked })} />
{label}
</label>
) : (
<Field key={k} label={label}>
<input
className="bo-input"
inputMode={typeof v === 'number' ? 'numeric' : undefined}
value={String(v ?? '')}
onChange={(e) => setDraft({ ...draft, [k]: typeof v === 'number' ? Number(e.target.value.replace(/\D/g, '')) || 0 : e.target.value })}
data-changed={changed.includes(k) || undefined}
/>
</Field>
);
})}
</div>
</section>
))}
<PaymentMethods />
</div>
</>
);
}
function PaymentMethods() {
const { t, locale } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const list = useQuery(trpc.bo.settings.paymentMethods.queryOptions());
const [edit, setEdit] = useState<{ id?: string; code: string; name: L5; active: boolean } | null>(null);
const save = useMutation(
trpc.bo.settings.savePaymentMethod.mutationOptions({
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.bo.settings.paymentMethods.queryKey() });
setEdit(null);
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('st.payments')}</h2>
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setEdit({ code: '', name: { ru: '', en: '', hy: '', zh: '', es: '' }, active: true })}>
<I name="plus" size={16} />
</button>
</div>
<ul className="bo-list">
{(list.data ?? []).map((m) => {
const name = m.name as L5;
return (
<li key={m.id} style={{ opacity: m.active ? 1 : 0.5 }}>
<span className="bo-grow">{name[locale as keyof L5] || name.ru}</span>
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setEdit({ id: m.id, code: m.code, name: { ...{ ru: '', en: '', hy: '', zh: '', es: '' }, ...name }, active: m.active })}>
{t('prv.edit')}
</button>
</li>
);
})}
</ul>
{edit && (
<Dialog
open
onClose={() => setEdit(null)}
title={t('st.payment')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => setEdit(null)}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn" disabled={!edit.code.trim() || !edit.name.ru.trim() || save.isPending} onClick={() => save.mutate(edit)}>
{t('ui.save')}
</button>
</>
}
>
<Field label={t('st.code')}>
<input className="bo-input" value={edit.code} maxLength={40} onChange={(e) => setEdit({ ...edit, code: e.target.value.replace(/[^a-z0-9_]/gi, '').toLowerCase() })} placeholder="cash" />
</Field>
{LANGS.map((l) => (
<Field key={l} label={l.toUpperCase()}>
<input className="bo-input" value={edit.name[l]} maxLength={100} onChange={(e) => setEdit({ ...edit, name: { ...edit.name, [l]: e.target.value } })} />
</Field>
))}
<label className="bo-check">
<input type="checkbox" checked={edit.active} onChange={(e) => setEdit({ ...edit, active: e.target.checked })} />
{t('st.paymentActive')}
</label>
</Dialog>
)}
</section>
);
}
+15 -4
View File
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react';
import type { Me } from '../App.tsx';
import { errText, useT } from '../i18n.tsx';
import { Avatar, Dialog, download, Field, I, PlanBadge, Skeleton, sourceLabel } from '../components/kit.tsx';
import { BroadcastDialog } from './Admin5.tsx';
type Plan = 'any' | 'none' | 'BAREV_PLUS' | 'ELITE';
interface Filters {
@@ -78,6 +79,8 @@ export function Clients({ me }: { me: Me }) {
}),
);
const [saveOpen, setSaveOpen] = useState(false);
const [broadcast, setBroadcast] = useState(false);
const canBroadcast = isSuper || me.roles.includes('SUPPORT');
const data = list.data;
const from = data ? page * data.pageSize + 1 : 0;
@@ -90,10 +93,17 @@ export function Clients({ me }: { me: Me }) {
<h1 className="bo-title">{t('clients.title')}</h1>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button type="button" className="bo-btn bo-btn--ghost" title={t('clients.broadcastSoon')} disabled>
<I name="send" size={16} />
{t('clients.broadcast')}
</button>
{canBroadcast && (
<>
<a className="bo-btn bo-btn--ghost" href="#/broadcasts">
{t('bc.historyBtn')}
</a>
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => setBroadcast(true)}>
<I name="send" size={16} />
{t('clients.broadcast')}
</button>
</>
)}
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => exportCsv.mutate(f)} disabled={exportCsv.isPending}>
<I name="download" size={16} />
{t('clients.export')}
@@ -339,6 +349,7 @@ export function Clients({ me }: { me: Me }) {
)}
<SaveSegment open={saveOpen} onClose={() => setSaveOpen(false)} filters={f} />
{broadcast && <BroadcastDialog filters={f as Record<string, unknown>} onClose={() => setBroadcast(false)} />}
</>
);
}
+14 -1
View File
@@ -123,8 +123,14 @@ export function NewOrderDialog({ clientId: fixedClient, onClose }: { clientId?:
const [note, setNote] = useState('');
const [payment, setPayment] = useState('');
const [at, setAt] = useState<{ when: Date; busy: boolean } | null>(null);
const [promo, setPromo] = useState('');
const [promoQ, setPromoQ] = useState('');
useEffect(() => {
const id = setTimeout(() => setPromoQ(promo.trim()), 400);
return () => clearTimeout(id);
}, [promo]);
const ready = !!service && Object.keys(params).length > 0;
const quote = useQuery({ ...trpc.bo.orders.quote.queryOptions({ clientId, service: service?.code ?? '', params }), enabled: ready && !!clientId, placeholderData: keepPreviousData });
const quote = useQuery({ ...trpc.bo.orders.quote.queryOptions({ clientId, service: service?.code ?? '', params, promoCode: promoQ || undefined }), enabled: ready && !!clientId, placeholderData: keepPreviousData, retry: false });
const create = useMutation(
trpc.bo.orders.create.mutationOptions({
onSuccess: (r) => {
@@ -178,6 +184,7 @@ export function NewOrderDialog({ clientId: fixedClient, onClose }: { clientId?:
districtId: needsAddress ? districtId : undefined,
note: note || undefined,
paymentMethodId: payment || undefined,
promoCode: promoQ && !quote.isError ? promoQ : undefined,
})
}
>
@@ -244,6 +251,12 @@ export function NewOrderDialog({ clientId: fixedClient, onClose }: { clientId?:
</select>
</Field>
</div>
<Field
label={t('no.promo')}
hint={promoQ ? (quote.isError ? errText(t, quote.error) : quote.data?.promo ? t('no.promoOk', { code: quote.data.promo }) : quote.data ? t('no.promoWeaker') : undefined) : undefined}
>
<input className="bo-input" value={promo} maxLength={40} onChange={(e) => setPromo(e.target.value.toUpperCase())} placeholder={t('no.promoPh')} style={{ maxWidth: 260 }} />
</Field>
<p className="bo-hint">{t('no.hint')}</p>
</Dialog>
);
+837
View File
@@ -0,0 +1,837 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { navigate, useLocation, useToast, useTRPC } from '@barev/web';
import { useEffect, useMemo, useState } from 'react';
import type { Me } from '../App.tsx';
import { errText, useT } from '../i18n.tsx';
import { Dialog, Field, I, Skeleton } from '../components/kit.tsx';
type Tab = 'services' | 'plans' | 'promos' | 'relocation';
const LANGS = ['ru', 'en', 'hy', 'zh', 'es'] as const;
type Lang = (typeof LANGS)[number];
type L5 = Record<Lang, string>;
const EMPTY: L5 = { ru: '', en: '', hy: '', zh: '', es: '' };
const CATS = ['HOME', 'DOCS', 'LEARNING', 'PERKS'] as const;
const num = (v: string) => v.replace(/\D/g, '');
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);
};
export function Products({ me }: { me: Me }) {
const { t } = useT();
const { query } = useLocation();
const tab = (query.get('tab') as Tab) || 'services';
const canEdit = me.roles.includes('SUPERADMIN');
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('pr.title')}</h1>
</div>
<div className="bo-ogroups" role="tablist">
{(['services', 'plans', 'promos', 'relocation'] as const).map((x) => (
<button key={x} type="button" role="tab" className="bo-ogroup" aria-selected={tab === x} onClick={() => navigate(x === 'services' ? '/products' : `/products?tab=${x}`, { replace: true })}>
{t(`pr.tab.${x}`)}
</button>
))}
</div>
{!canEdit && <p className="bo-hint" style={{ marginBottom: 12 }}>{t('pr.readOnly')}</p>}
{tab === 'services' && <Services />}
{tab === 'plans' && <Plans canEdit={canEdit} />}
{tab === 'promos' && <Promos canEdit={canEdit} />}
{tab === 'relocation' && <Relocation canEdit={canEdit} />}
</>
);
}
// ───── услуги ─────
function Services() {
const { t, shared, amd } = useT();
const trpc = useTRPC();
const cat = useQuery(trpc.bo.products.catalog.queryOptions());
if (!cat.data) return <Skeleton h={300} />;
return (
<div className="bo-col" style={{ gap: 18 }}>
{cat.data.map((c) => (
<section key={c.id} className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{shared(`categories.${c.code}.name`)}</h2>
<span className="bo-small bo-muted">{t('pr.catDiscounts', { plus: c.barevPlusDiscountPct, elite: c.eliteDiscountPct })}</span>
</div>
<ul className="bo-list">
{c.services.map((s) => (
<li key={s.id} style={{ opacity: s.active ? 1 : 0.55 }}>
<a href={`#/products/${s.id}`} className="bo-grow" style={{ color: 'inherit', textDecoration: 'none' }}>
<b>{shared(`services.${s.code}.name`)}</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{[s.active ? t('pr.on') : t('pr.off'), t('pr.from', { sum: amd(s.fromPrice) }), shared(`roles.${s.providerRole}`), t('pr.orders30', { n: s.orders30 })].join(' · ')}
</span>
</a>
<a className="bo-btn bo-btn--ghost bo-btn--sm" href={`#/products/${s.id}`}>
{t('pr.setup')}
</a>
</li>
))}
</ul>
</section>
))}
</div>
);
}
/** Поля на 5 языках со стандартным текстом в подсказке */
function L5Editor({ label, value, defaults, onChange, multiline, disabled }: { label: string; value: L5; defaults: Partial<L5>; onChange: (v: L5) => void; multiline?: boolean; disabled?: boolean }) {
return (
<div className="bo-field">
<span className="bo-label">{label}</span>
<div className="bo-l5">
{LANGS.map((l) => (
<label key={l} className="bo-l5__row">
<span className="bo-l5__lang">{l.toUpperCase()}</span>
{multiline ? (
<textarea className="bo-textarea" style={{ minHeight: 60 }} disabled={disabled} value={value[l]} placeholder={defaults[l] ?? ''} onChange={(e) => onChange({ ...value, [l]: e.target.value })} />
) : (
<input className="bo-input" disabled={disabled} value={value[l]} placeholder={defaults[l] ?? ''} onChange={(e) => onChange({ ...value, [l]: e.target.value })} />
)}
</label>
))}
</div>
</div>
);
}
type Catalog = NonNullable<ReturnType<typeof useCatalog>>;
function useCatalog() {
const trpc = useTRPC();
return useQuery(trpc.bo.products.catalog.queryOptions()).data;
}
export function ServiceEditor({ id, me }: { id: string; me: Me }) {
const { t, shared } = useT();
const cat = useCatalog();
const canEdit = me.roles.includes('SUPERADMIN');
const found = useMemo(() => {
for (const c of cat ?? []) for (const s of c.services) if (s.id === id) return { c, s };
return null;
}, [cat, id]);
const back = (
<a className="bo-back" href="#/products">
<I name="back" size={16} />
{t('pr.back')}
</a>
);
if (!cat) return <Skeleton h={300} />;
if (!found) return back;
const { c, s } = found;
return (
<>
{back}
<div className="bo-head">
<div>
<h1 className="bo-title">{shared(`services.${s.code}.name`)}</h1>
<p className="bo-sub">{[shared(`categories.${c.code}.name`), shared(`roles.${s.providerRole}`), s.isOnline ? shared('common.online') : null].filter(Boolean).join(' · ')}</p>
</div>
</div>
<div className="bo-client-grid">
<div className="bo-col">
<ContentPanel code={s.code} canEdit={canEdit} extras={s.pricing.kind === 'rooms' ? Object.keys(s.pricing.extras) : []} />
</div>
<div className="bo-col">
<SettingsPanel service={s} canEdit={canEdit} />
<CategoryPanel category={c} canEdit={canEdit} />
<ChecklistPanel service={s} canEdit={canEdit} />
</div>
</div>
<div style={{ marginTop: 16 }}>
<PricePanel service={s} canEdit={canEdit} />
</div>
</>
);
}
function useSaved() {
const { t } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
return {
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.bo.products.catalog.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.products.texts.queryKey() });
toast(t('prv.saved'));
},
onError: (e: unknown) => toast(errText(t, e), 'error'),
};
}
/** Наполнение: название и описание услуги, названия доп. услуг */
function ContentPanel({ code, canEdit, extras }: { code: string; canEdit: boolean; extras: string[] }) {
const { t, shared } = useT();
const trpc = useTRPC();
const keys = [`services.${code}.name`, `services.${code}.description`, ...extras.map((x) => `extras.${x}.name`)];
const texts = useQuery(trpc.bo.products.texts.queryOptions({ keys }));
const [draft, setDraft] = useState<Record<string, L5> | null>(null);
useEffect(() => {
if (texts.data && !draft) setDraft(Object.fromEntries(keys.map((k) => [k, Object.fromEntries(LANGS.map((l) => [l, texts.data[k]?.[l]?.value ?? ''])) as L5])));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [texts.data]);
const save = useMutation(trpc.bo.products.saveTexts.mutationOptions(useSaved()));
const defaults = (k: string) => Object.fromEntries(LANGS.map((l) => [l, texts.data?.[k]?.[l]?.default ?? ''])) as L5;
const [showExtras, setShowExtras] = useState(false);
if (!draft) return <Skeleton h={200} />;
return (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('pr.content')}</h2>
</div>
<p className="bo-hint" style={{ marginBottom: 12 }}>
{t('pr.contentHint')}
</p>
<div className="bo-col" style={{ gap: 14 }}>
<L5Editor label={t('pr.name')} value={draft[keys[0]!]!} defaults={defaults(keys[0]!)} onChange={(v) => setDraft({ ...draft, [keys[0]!]: v })} disabled={!canEdit} />
<L5Editor label={t('pr.description')} value={draft[keys[1]!]!} defaults={defaults(keys[1]!)} onChange={(v) => setDraft({ ...draft, [keys[1]!]: v })} multiline disabled={!canEdit} />
{extras.length > 0 && (
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-accent)', padding: 0, alignSelf: 'flex-start' }} onClick={() => setShowExtras((x) => !x)}>
{t('pr.extrasNames', { n: extras.length })} {showExtras ? '▴' : '▾'}
</button>
)}
{showExtras &&
extras.map((x) => {
const k = `extras.${x}.name`;
return <L5Editor key={k} label={shared(k)} value={draft[k]!} defaults={defaults(k)} onChange={(v) => setDraft({ ...draft, [k]: v })} disabled={!canEdit} />;
})}
</div>
{canEdit && (
<button
type="button"
className="bo-btn bo-btn--sm"
style={{ marginTop: 14 }}
disabled={save.isPending}
onClick={() => save.mutate(keys.flatMap((k) => LANGS.map((l) => ({ key: k, locale: l, value: draft[k]![l] }))))}
>
{t('ui.save')}
</button>
)}
</section>
);
}
type Service = Catalog[number]['services'][number];
/** Цена: тарифы по варианту (комнаты, санузлы) и доп. услуги — или фиксированная цена */
function PricePanel({ service: s, canEdit }: { service: Service; canEdit: boolean }) {
const { t, shared } = useT();
const trpc = useTRPC();
const [p, setP] = useState(s.pricing);
useEffect(() => setP(s.pricing), [s.pricing]);
const save = useMutation(trpc.bo.products.saveService.mutationOptions(useSaved()));
const unitLabel = (u: string) => t(`pr.unit.${u}`);
const variantLabel = (v: string) => {
if (p.kind !== 'rooms') return v;
const k = `params.${s.code}.${p.variantParam}.${v}`;
const r = shared(k);
return r === k ? v : r;
};
const n = (v: number, on: (x: number) => void) => <input className="bo-input bo-num" inputMode="numeric" disabled={!canEdit} value={String(v)} onChange={(e) => on(Number(num(e.target.value)) || 0)} />;
return (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('pr.price')}</h2>
</div>
{p.kind === 'fixed' ? (
<div className="bo-row2">
<Field label={t('pr.fixedPrice')}>{n(p.price, (x) => setP({ ...p, price: x }))}</Field>
<Field label={t('pr.minutes')}>{n(p.minutes, (x) => setP({ ...p, minutes: Math.max(1, x) }))}</Field>
</div>
) : (
<>
<div className="bo-tablewrap">
<table className="bo-table bo-ptab">
<thead>
<tr>
<th />
<th>{t('pr.base')}</th>
<th>{t('pr.perRoom')}</th>
<th>{t('pr.perBath')}</th>
<th>{t('pr.baseMin')}</th>
<th>{t('pr.perRoomMin')}</th>
<th>{t('pr.perBathMin')}</th>
</tr>
</thead>
<tbody>
{Object.entries(p.tiers).map(([v, tier]) => {
const set = (patch: Partial<typeof tier>) => setP({ ...p, tiers: { ...p.tiers, [v]: { ...tier, ...patch } } });
return (
<tr key={v}>
<td>
<b>{variantLabel(v)}</b>
</td>
<td>{n(tier.base, (x) => set({ base: x }))}</td>
<td>{n(tier.perRoom, (x) => set({ perRoom: x }))}</td>
<td>{n(tier.perBathroom, (x) => set({ perBathroom: x }))}</td>
<td>{n(tier.baseMinutes, (x) => set({ baseMinutes: Math.max(1, x) }))}</td>
<td>{n(tier.perRoomMinutes, (x) => set({ perRoomMinutes: x }))}</td>
<td>{n(tier.perBathroomMinutes, (x) => set({ perBathroomMinutes: x }))}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<p className="bo-hint" style={{ margin: '8px 0 16px' }}>
{t('pr.roomsHint')}
</p>
<h3 className="bo-h3" style={{ marginBottom: 8 }}>
{t('pr.extras')}
</h3>
<div className="bo-tablewrap">
<table className="bo-table bo-ptab">
<thead>
<tr>
<th />
<th>{t('pr.unitCol')}</th>
<th>{t('pr.extraPrice')}</th>
<th>{t('pr.extraMin')}</th>
</tr>
</thead>
<tbody>
{Object.entries(p.extras).map(([code, ex]) => {
const set = (patch: Partial<typeof ex>) => setP({ ...p, extras: { ...p.extras, [code]: { ...ex, ...patch } } });
return (
<tr key={code}>
<td>{shared(`extras.${code}.name`)}</td>
<td className="bo-small bo-muted">{unitLabel(ex.unit)}</td>
<td>{n(ex.price, (x) => set({ price: x }))}</td>
<td>{n(ex.minutes, (x) => set({ minutes: x }))}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
{canEdit && (
<button type="button" className="bo-btn bo-btn--sm" style={{ marginTop: 14 }} disabled={save.isPending} onClick={() => save.mutate({ id: s.id, active: s.active, lateCancelFeePct: s.lateCancelFeePct, pricing: p, slotConfig: s.slotConfig })}>
{t('ui.save')}
</button>
)}
<p className="bo-hint" style={{ marginTop: 10 }}>
{t('pr.priceHint')}
</p>
</section>
);
}
/** Включена ли услуга, окно записи, удержание за позднюю отмену */
function SettingsPanel({ service: s, canEdit }: { service: Service; canEdit: boolean }) {
const { t } = useT();
const trpc = useTRPC();
const [f, setF] = useState({ active: s.active, fee: String(s.lateCancelFeePct), from: hhmm(s.slotConfig.dayStartMinute), to: hhmm(s.slotConfig.dayEndMinute), step: String(s.slotConfig.stepMinutes), lead: String(s.slotConfig.leadHours), horizon: String(s.slotConfig.horizonDays) });
const save = useMutation(trpc.bo.products.saveService.mutationOptions(useSaved()));
return (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('pr.settings')}</h2>
</div>
<div className="bo-col" style={{ gap: 12 }}>
<label className="bo-check">
<input type="checkbox" disabled={!canEdit} checked={f.active} onChange={(e) => setF({ ...f, active: e.target.checked })} />
{t('pr.activeLabel')}
</label>
<div className="bo-row2">
<Field label={t('pr.windowFrom')}>
<input className="bo-input" type="time" step={900} disabled={!canEdit} value={f.from} onChange={(e) => setF({ ...f, from: e.target.value })} />
</Field>
<Field label={t('pr.windowTo')}>
<input className="bo-input" type="time" step={900} disabled={!canEdit} value={f.to} onChange={(e) => setF({ ...f, to: e.target.value })} />
</Field>
</div>
<div className="bo-row2">
<Field label={t('pr.step')}>
<input className="bo-input" inputMode="numeric" disabled={!canEdit} value={f.step} onChange={(e) => setF({ ...f, step: num(e.target.value) })} />
</Field>
<Field label={t('pr.lead')}>
<input className="bo-input" inputMode="numeric" disabled={!canEdit} value={f.lead} onChange={(e) => setF({ ...f, lead: num(e.target.value) })} />
</Field>
</div>
<div className="bo-row2">
<Field label={t('pr.horizon')}>
<input className="bo-input" inputMode="numeric" disabled={!canEdit} value={f.horizon} onChange={(e) => setF({ ...f, horizon: num(e.target.value) })} />
</Field>
<Field label={t('pr.lateFee')}>
<input className="bo-input" inputMode="numeric" disabled={!canEdit} value={f.fee} onChange={(e) => setF({ ...f, fee: num(e.target.value) })} />
</Field>
</div>
</div>
{canEdit && (
<button
type="button"
className="bo-btn bo-btn--sm"
style={{ marginTop: 14 }}
disabled={save.isPending}
onClick={() =>
save.mutate({
id: s.id,
active: f.active,
lateCancelFeePct: Math.min(100, Number(f.fee) || 0),
pricing: s.pricing,
slotConfig: { dayStartMinute: toMin(f.from), dayEndMinute: toMin(f.to), stepMinutes: Math.max(15, Number(f.step) || 30), leadHours: Number(f.lead) || 0, horizonDays: Math.max(1, Number(f.horizon) || 14) },
})
}
>
{t('ui.save')}
</button>
)}
</section>
);
}
/** Скидки по уровню подписки — на всё направление */
function CategoryPanel({ category: c, canEdit }: { category: Catalog[number]; canEdit: boolean }) {
const { t, shared } = useT();
const trpc = useTRPC();
const [plus, setPlus] = useState(String(c.barevPlusDiscountPct));
const [elite, setElite] = useState(String(c.eliteDiscountPct));
const save = useMutation(trpc.bo.products.saveCategory.mutationOptions(useSaved()));
return (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('pr.discounts')}</h2>
</div>
<p className="bo-hint" style={{ marginBottom: 10 }}>
{t('pr.discountsHint', { cat: shared(`categories.${c.code}.name`) })}
</p>
<div className="bo-row2">
<Field label="Barev+, %">
<input className="bo-input" inputMode="numeric" disabled={!canEdit} value={plus} onChange={(e) => setPlus(num(e.target.value))} />
</Field>
<Field label="Barev Elite, %">
<input className="bo-input" inputMode="numeric" disabled={!canEdit} value={elite} onChange={(e) => setElite(num(e.target.value))} />
</Field>
</div>
{canEdit && (
<button type="button" className="bo-btn bo-btn--sm" style={{ marginTop: 12 }} disabled={save.isPending} onClick={() => save.mutate({ id: c.id, barevPlusDiscountPct: Math.min(100, Number(plus) || 0), eliteDiscountPct: Math.min(100, Number(elite) || 0) })}>
{t('ui.save')}
</button>
)}
</section>
);
}
/** Чек-лист исполнителя: пункты и где нужно фото */
function ChecklistPanel({ service: s, canEdit }: { service: Service; canEdit: boolean }) {
const { t, shared } = useT();
const trpc = useTRPC();
const [items, setItems] = useState<{ code?: string; label?: string; requiresPhoto: boolean }[]>(s.checklist);
useEffect(() => setItems(s.checklist), [s.checklist]);
const [label, setLabel] = useState('');
const save = useMutation(trpc.bo.products.saveChecklist.mutationOptions(useSaved()));
return (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('pr.checklist')}</h2>
</div>
<ul className="bo-list">
{items.map((x, i) => (
<li key={x.code ?? `new-${i}`}>
<span className="bo-grow">{x.code ? shared(`checklist.${x.code}`) : x.label}</span>
<label className="bo-check bo-small">
<input type="checkbox" disabled={!canEdit} checked={x.requiresPhoto} onChange={(e) => setItems(items.map((y, k) => (k === i ? { ...y, requiresPhoto: e.target.checked } : y)))} />
{t('pr.photo')}
</label>
{canEdit && (
<button type="button" className="bo-iconbtn" onClick={() => setItems(items.filter((_, k) => k !== i))} aria-label={t('chat.tplDelete')}>
<I name="close" size={16} />
</button>
)}
</li>
))}
</ul>
{canEdit && (
<>
<form
style={{ display: 'flex', gap: 8, marginTop: 10 }}
onSubmit={(e) => {
e.preventDefault();
if (!label.trim()) return;
setItems([...items, { label: label.trim(), requiresPhoto: false }]);
setLabel('');
}}
>
<input className="bo-input" value={label} maxLength={120} onChange={(e) => setLabel(e.target.value)} placeholder={t('pr.newItem')} style={{ minHeight: 36, padding: '6px 12px' }} />
<button type="submit" className="bo-btn bo-btn--ghost bo-btn--sm" disabled={!label.trim()}>
<I name="plus" size={16} />
</button>
</form>
<button type="button" className="bo-btn bo-btn--sm" style={{ marginTop: 12 }} disabled={save.isPending} onClick={() => save.mutate({ serviceId: s.id, items })}>
{t('ui.save')}
</button>
</>
)}
<p className="bo-hint" style={{ marginTop: 10 }}>
{t('pr.checklistHint')}
</p>
</section>
);
}
// ───── подписка ─────
const PLAN_KEYS = ['barevPlus.monthlyPrice', 'barevPlus.maxDiscountPct', 'barevPlus.giftDays', 'elite.monthlyPrice', 'elite.maxDiscountPct'] as const;
function Plans({ canEdit }: { canEdit: boolean }) {
const { t, shared } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const settings = useQuery({ ...trpc.bo.settings.get.queryOptions(), enabled: canEdit });
const cat = useCatalog();
const [f, setF] = useState<Record<string, string> | null>(null);
useEffect(() => {
if (settings.data && !f) setF(Object.fromEntries(PLAN_KEYS.map((k) => [k, String(settings.data[k])])));
}, [settings.data, f]);
const save = useMutation(
trpc.bo.settings.save.mutationOptions({
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.bo.settings.get.queryKey() });
toast(t('prv.saved'));
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<div className="bo-client-grid">
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('pr.plans')}</h2>
</div>
{!canEdit ? (
<p className="bo-hint">{t('pr.plansSuper')}</p>
) : !f ? (
<Skeleton h={200} />
) : (
<>
<div className="bo-col" style={{ gap: 12 }}>
{PLAN_KEYS.map((k) => (
<Field key={k} label={shared(`admin.settings.${k.replace('.', '_')}`)}>
<input className="bo-input" inputMode="numeric" value={f[k]} onChange={(e) => setF({ ...f, [k]: num(e.target.value) })} />
</Field>
))}
</div>
<button type="button" className="bo-btn bo-btn--sm" style={{ marginTop: 14 }} disabled={save.isPending} onClick={() => save.mutate(Object.fromEntries(PLAN_KEYS.map((k) => [k, Number(f[k]) || 0])))}>
{t('ui.save')}
</button>
<p className="bo-hint" style={{ marginTop: 10 }}>
{t('pr.plansHint')}
</p>
</>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('pr.discountTable')}</h2>
</div>
{!cat ? (
<Skeleton h={160} />
) : (
<table className="bo-table">
<thead>
<tr>
<th />
<th className="bo-r">Barev+</th>
<th className="bo-r">Elite</th>
</tr>
</thead>
<tbody>
{cat.map((c) => (
<tr key={c.id}>
<td>{shared(`categories.${c.code}.name`)}</td>
<td className="bo-r bo-num">−{c.barevPlusDiscountPct}%</td>
<td className="bo-r bo-num">−{c.eliteDiscountPct}%</td>
</tr>
))}
</tbody>
</table>
)}
<p className="bo-hint" style={{ marginTop: 10 }}>
{t('pr.discountTableHint')}
</p>
</section>
</div>
);
}
// ───── промокоды ─────
type Promo = { id: string; code: string; discountPct: number; maxUses: number | null; usedCount: number; validFrom: Date | null; validUntil: Date | null; categories: string[]; note: string | null; active: boolean; state: string };
function Promos({ canEdit }: { canEdit: boolean }) {
const { t, date, shared } = useT();
const trpc = useTRPC();
const list = useQuery(trpc.bo.products.promos.queryOptions());
const [edit, setEdit] = useState<Promo | 'new' | null>(null);
return (
<section className="bo-panel">
<div className="bo-panel__head">
<p className="bo-hint" style={{ margin: 0, maxWidth: 560 }}>
{t('pr.promoHint')}
</p>
{canEdit && (
<button type="button" className="bo-btn bo-btn--sm" onClick={() => setEdit('new')}>
<I name="plus" size={16} />
{t('pr.newPromo')}
</button>
)}
</div>
{!list.data ? (
<Skeleton h={160} />
) : list.data.length === 0 ? (
<p className="bo-empty">{t('pr.noPromos')}</p>
) : (
<ul className="bo-list">
{list.data.map((p) => (
<li key={p.id}>
<span className="bo-promo">{p.code}</span>
<span className="bo-grow" style={{ minWidth: 0 }}>
<b className="bo-num">−{p.discountPct}%</b>
<span className="bo-small bo-muted">
{' '}
· {t('pr.used', { n: p.usedCount, max: p.maxUses ?? '∞' })}
{p.validUntil && ` · ${t('pr.until', { date: date(p.validUntil) })}`}
{p.categories.length > 0 && ` · ${p.categories.map((c) => shared(`categories.${c}.name`)).join(', ')}`}
{p.note && ` · ${p.note}`}
</span>
</span>
<span className={`bo-chip${p.state === 'live' ? ' bo-chip--ok' : ''}`}>{t(`pr.state.${p.state}`)}</span>
{canEdit && (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setEdit(p)}>
{t('prv.edit')}
</button>
)}
</li>
))}
</ul>
)}
{edit && <PromoDialog promo={edit === 'new' ? undefined : edit} onClose={() => setEdit(null)} />}
</section>
);
}
function PromoDialog({ promo, onClose }: { promo?: Promo; onClose: () => void }) {
const { t, shared } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const day = (d: Date | null) => (d ? new Date(d.getTime() + 4 * 3600_000).toISOString().slice(0, 10) : '');
const [f, setF] = useState({
code: promo?.code ?? '',
pct: String(promo?.discountPct ?? 10),
max: promo?.maxUses != null ? String(promo.maxUses) : '',
from: day(promo?.validFrom ?? null),
until: day(promo?.validUntil ?? null),
categories: (promo?.categories ?? []) as (typeof CATS)[number][],
note: promo?.note ?? '',
active: promo?.active ?? true,
});
const save = useMutation(
trpc.bo.products.savePromo.mutationOptions({
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.bo.products.promos.queryKey() });
toast(t('prv.saved'));
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={promo ? t('pr.promo') : t('pr.newPromo')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button
type="button"
className="bo-btn"
disabled={f.code.trim().length < 3 || !Number(f.pct) || save.isPending}
onClick={() =>
save.mutate({
id: promo?.id,
code: f.code,
discountPct: Math.min(100, Number(f.pct)),
maxUses: f.max ? Number(f.max) : null,
validFrom: f.from ? new Date(`${f.from}T00:00:00+04:00`) : null,
validUntil: f.until ? new Date(`${f.until}T23:59:59+04:00`) : null,
categories: f.categories,
note: f.note || null,
active: f.active,
})
}
>
{t('ui.save')}
</button>
</>
}
>
<div className="bo-row2">
<Field label={t('pr.code')}>
<input className="bo-input" value={f.code} maxLength={40} onChange={(e) => setF({ ...f, code: e.target.value.toUpperCase().replace(/[^A-Z0-9_-]/g, '') })} placeholder="AUTUMN15" />
</Field>
<Field label={t('pr.pct')}>
<input className="bo-input" inputMode="numeric" value={f.pct} onChange={(e) => setF({ ...f, pct: num(e.target.value) })} />
</Field>
</div>
<div className="bo-row2">
<Field label={t('pr.validFrom')}>
<input className="bo-input" type="date" value={f.from} onChange={(e) => setF({ ...f, from: e.target.value })} />
</Field>
<Field label={t('pr.validUntil')}>
<input className="bo-input" type="date" value={f.until} onChange={(e) => setF({ ...f, until: e.target.value })} />
</Field>
</div>
<Field label={t('pr.maxUses')} hint={t('pr.maxUsesHint')}>
<input className="bo-input" inputMode="numeric" value={f.max} onChange={(e) => setF({ ...f, max: num(e.target.value) })} placeholder="∞" />
</Field>
<div>
<span className="bo-label">{t('pr.forCats')}</span>
<div className="bo-pills" style={{ marginTop: 6 }}>
{CATS.map((c) => (
<button key={c} type="button" className="bo-pill" aria-pressed={f.categories.includes(c)} onClick={() => setF({ ...f, categories: f.categories.includes(c) ? f.categories.filter((x) => x !== c) : [...f.categories, c] })}>
{shared(`categories.${c}.name`)}
</button>
))}
</div>
<span className="bo-hint">{t('pr.forCatsHint')}</span>
</div>
<Field label={t('crm.note')}>
<input className="bo-input" value={f.note} maxLength={300} onChange={(e) => setF({ ...f, note: e.target.value })} placeholder={t('pr.promoNotePh')} />
</Field>
<label className="bo-check">
<input type="checkbox" checked={f.active} onChange={(e) => setF({ ...f, active: e.target.checked })} />
{t('pr.promoActive')}
</label>
</Dialog>
);
}
// ───── релокация ─────
type RelItem = { id: string; kind: 'STEP' | 'DOC'; code: string; name: unknown; hint: unknown; active: boolean; inTurnkey: boolean };
function Relocation({ canEdit }: { canEdit: boolean }) {
const { t, locale } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const list = useQuery(trpc.bo.products.relocation.queryOptions());
const move = useMutation(trpc.bo.products.moveRelocation.mutationOptions({ onSuccess: () => qc.invalidateQueries({ queryKey: trpc.bo.products.relocation.queryKey() }), onError: (e) => toast(errText(t, e), 'error') }));
const [edit, setEdit] = useState<{ kind: 'STEP' | 'DOC'; item?: RelItem } | null>(null);
const name = (v: unknown) => {
const o = (v ?? {}) as Record<string, string>;
return o[locale] || o.ru || '';
};
return (
<div className="bo-client-grid">
{(['STEP', 'DOC'] as const).map((kind) => (
<section key={kind} className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t(`pr.rel.${kind}`)}</h2>
{canEdit && (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setEdit({ kind })}>
<I name="plus" size={16} />
</button>
)}
</div>
<ul className="bo-list">
{(list.data ?? [])
.filter((x) => x.kind === kind)
.map((x) => (
<li key={x.id} style={{ opacity: x.active ? 1 : 0.5 }}>
{canEdit && (
<span className="bo-stages__arrows">
<button type="button" onClick={() => move.mutate({ id: x.id, dir: -1 })} aria-label="↑">
<I name="arrowUp" size={14} />
</button>
<button type="button" onClick={() => move.mutate({ id: x.id, dir: 1 })} aria-label="↓">
<I name="arrowDown" size={14} />
</button>
</span>
)}
<span className="bo-grow" style={{ whiteSpace: 'normal' }}>
{name(x.name)}
{kind === 'STEP' && x.inTurnkey && <span className="bo-chip" style={{ marginLeft: 8 }}>{t('pr.turnkey')}</span>}
</span>
{canEdit && (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setEdit({ kind, item: x as RelItem })}>
{t('prv.edit')}
</button>
)}
</li>
))}
</ul>
<p className="bo-hint" style={{ marginTop: 10 }}>
{t(`pr.relHint.${kind}`)}
</p>
</section>
))}
{edit && <RelocationDialog kind={edit.kind} item={edit.item} onClose={() => setEdit(null)} />}
</div>
);
}
function RelocationDialog({ kind, item, onClose }: { kind: 'STEP' | 'DOC'; item?: RelItem; onClose: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const [name, setName] = useState<L5>({ ...EMPTY, ...((item?.name as Partial<L5>) ?? {}) });
const [hint, setHint] = useState<L5>({ ...EMPTY, ...((item?.hint as Partial<L5>) ?? {}) });
const [active, setActive] = useState(item?.active ?? true);
const [turnkey, setTurnkey] = useState(item?.inTurnkey ?? true);
const save = useMutation(
trpc.bo.products.saveRelocation.mutationOptions({
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.bo.products.relocation.queryKey() });
toast(t('prv.saved'));
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
wide
onClose={onClose}
title={t(`pr.rel.${kind}`)}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn" disabled={!name.ru.trim() || save.isPending} onClick={() => save.mutate({ id: item?.id, kind, name, hint: kind === 'DOC' ? hint : null, active, inTurnkey: kind === 'STEP' ? turnkey : false })}>
{t('ui.save')}
</button>
</>
}
>
<L5Editor label={t('pr.name')} value={name} defaults={{}} onChange={setName} />
{kind === 'DOC' && <L5Editor label={t('pr.docHint')} value={hint} defaults={{}} onChange={setHint} />}
{kind === 'STEP' && (
<label className="bo-check">
<input type="checkbox" checked={turnkey} onChange={(e) => setTurnkey(e.target.checked)} />
{t('pr.turnkeyLabel')}
</label>
)}
<label className="bo-check">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
{t('pr.relActive')}
</label>
</Dialog>
);
}