Products: create services from the admin

- "New service": from scratch (category, who does it — cleaner, driver, career
  consultant or docs agent —, fixed price and duration, online, name and
  description in 5 languages) or as a copy of an existing service (prices,
  questions, checklist, booking window and question texts copied). Created
  switched off; delete only without orders; reorder within a category.
- "Questions in the order form" builder for fixed-price services: choice,
  number, text, yes/no, required flag; labels stored as text overrides.
- The admin itself now loads text overrides, so new service names show right
  away; the mini app hides missing optional texts (description, option hints)
  instead of showing keys.

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:32:17 +04:00
1 parent 0edc6432b7
commit 8866616782
8 files changed
+599 -13

No files matched your search

+296 -3
View File
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { navigate, useLocation, useToast, useTRPC } from '@barev/web';
import { loadTextOverrides, 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';
@@ -36,7 +36,7 @@ export function Products({ me }: { me: Me }) {
))}
</div>
{!canEdit && <p className="bo-hint" style={{ marginBottom: 12 }}>{t('pr.readOnly')}</p>}
{tab === 'services' && <Services />}
{tab === 'services' && <Services canEdit={canEdit} />}
{tab === 'plans' && <Plans canEdit={canEdit} />}
{tab === 'promos' && <Promos canEdit={canEdit} />}
{tab === 'relocation' && <Relocation canEdit={canEdit} />}
@@ -46,13 +46,26 @@ export function Products({ me }: { me: Me }) {
// ───── услуги ─────
function Services() {
function Services({ canEdit }: { canEdit: boolean }) {
const { t, shared, amd } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const cat = useQuery(trpc.bo.products.catalog.queryOptions());
const [creating, setCreating] = useState(false);
const move = useMutation(trpc.bo.products.moveService.mutationOptions({ onSuccess: () => qc.invalidateQueries({ queryKey: trpc.bo.products.catalog.queryKey() }) }));
if (!cat.data) return <Skeleton h={300} />;
return (
<div className="bo-col" style={{ gap: 18 }}>
{canEdit && (
<div style={{ display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
<button type="button" className="bo-btn bo-btn--accent" onClick={() => setCreating(true)}>
<I name="plus" size={16} />
{t('pr.newService')}
</button>
<span className="bo-hint">{t('pr.newServiceHint')}</span>
</div>
)}
{creating && <NewServiceDialog catalog={cat.data} onClose={() => setCreating(false)} />}
{cat.data.map((c) => (
<section key={c.id} className="bo-panel">
<div className="bo-panel__head">
@@ -62,6 +75,16 @@ function Services() {
<ul className="bo-list">
{c.services.map((s) => (
<li key={s.id} style={{ opacity: s.active ? 1 : 0.55 }}>
{canEdit && (
<span className="bo-stages__arrows">
<button type="button" onClick={() => move.mutate({ id: s.id, dir: -1 })} aria-label="↑">
<I name="arrowUp" size={14} />
</button>
<button type="button" onClick={() => move.mutate({ id: s.id, dir: 1 })} aria-label="↓">
<I name="arrowDown" size={14} />
</button>
</span>
)}
<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' }}>
@@ -136,6 +159,7 @@ export function ServiceEditor({ id, me }: { id: string; me: Me }) {
<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) : []} />
{s.pricing.kind === 'fixed' && <ParamsPanel service={s} canEdit={canEdit} />}
</div>
<div className="bo-col">
<SettingsPanel service={s} canEdit={canEdit} />
@@ -146,6 +170,7 @@ export function ServiceEditor({ id, me }: { id: string; me: Me }) {
<div style={{ marginTop: 16 }}>
<PricePanel service={s} canEdit={canEdit} />
</div>
{canEdit && <DeleteService id={s.id} />}
</>
);
}
@@ -159,6 +184,7 @@ function useSaved() {
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.bo.products.catalog.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.products.texts.queryKey() });
void loadTextOverrides();
toast(t('prv.saved'));
},
onError: (e: unknown) => toast(errText(t, e), 'error'),
@@ -835,3 +861,270 @@ function RelocationDialog({ kind, item, onClose }: { kind: 'STEP' | 'DOC'; item?
</Dialog>
);
}
// ───── новая услуга ─────
const NEW_ROLES = ['CLEANER', 'DRIVER', 'CAREER_CONSULTANT', 'DOCS_AGENT'] as const;
function NewServiceDialog({ catalog, onClose }: { catalog: Catalog; onClose: () => void }) {
const { t, shared } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const [categoryId, setCategoryId] = useState(catalog[0]?.id ?? '');
const [mode, setMode] = useState<'new' | 'copy'>('new');
const [copyFromId, setCopyFrom] = useState(catalog[0]?.services[0]?.id ?? '');
const [role, setRole] = useState<(typeof NEW_ROLES)[number]>('CLEANER');
const [isOnline, setOnline] = useState(false);
const [price, setPrice] = useState('10000');
const [minutes, setMinutes] = useState('60');
const [name, setName] = useState<L5>({ ...EMPTY });
const [desc, setDesc] = useState<L5>({ ...EMPTY });
const create = useMutation(
trpc.bo.products.createService.mutationOptions({
onSuccess: async (r) => {
await loadTextOverrides();
qc.invalidateQueries({ queryKey: trpc.bo.products.catalog.queryKey() });
toast(t('pr.created'));
onClose();
navigate(`/products/${r.id}`);
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
const all = catalog.flatMap((c) => c.services);
return (
<Dialog
open
wide
onClose={onClose}
title={t('pr.newService')}
footer={
<>
<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={!name.ru.trim() || !categoryId || create.isPending}
onClick={() =>
create.mutate({
categoryId,
name,
description: desc,
...(mode === 'copy' ? { copyFromId } : { providerRole: role, isOnline, price: Number(price) || 0, minutes: Math.max(15, Number(minutes) || 60) }),
})
}
>
{t('pr.create')}
</button>
</>
}
>
<div className="bo-chtabs">
<button type="button" aria-pressed={mode === 'new'} onClick={() => setMode('new')}>
{t('pr.fromScratch')}
</button>
<button type="button" aria-pressed={mode === 'copy'} onClick={() => setMode('copy')}>
{t('pr.copyOf')}
</button>
</div>
<Field label={t('pr.category')}>
<select className="bo-select" value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
{catalog.map((c) => (
<option key={c.id} value={c.id}>
{shared(`categories.${c.code}.name`)}
</option>
))}
</select>
</Field>
{mode === 'copy' ? (
<Field label={t('pr.copyFrom')} hint={t('pr.copyHint')}>
<select className="bo-select" value={copyFromId} onChange={(e) => setCopyFrom(e.target.value)}>
{all.map((x) => (
<option key={x.id} value={x.id}>
{shared(`services.${x.code}.name`)}
</option>
))}
</select>
</Field>
) : (
<>
<div>
<span className="bo-label">{t('pr.who')}</span>
<div className="bo-roles" style={{ marginTop: 6 }}>
{NEW_ROLES.map((r) => (
<button key={r} type="button" className="bo-role" aria-pressed={role === r} onClick={() => setRole(r)}>
<b>{shared(`roles.${r}`)}</b>
<span className="bo-small">{t(`pr.flowHint.${r}`)}</span>
</button>
))}
</div>
<span className="bo-hint">{t('pr.whoHint')}</span>
</div>
<div className="bo-row2">
<Field label={t('pr.fixedPrice')}>
<input className="bo-input" inputMode="numeric" value={price} onChange={(e) => setPrice(num(e.target.value))} />
</Field>
<Field label={t('pr.minutes')}>
<input className="bo-input" inputMode="numeric" value={minutes} onChange={(e) => setMinutes(num(e.target.value))} />
</Field>
</div>
<label className="bo-check">
<input type="checkbox" checked={isOnline} onChange={(e) => setOnline(e.target.checked)} />
{t('pr.online')}
</label>
</>
)}
<L5Editor label={t('pr.name')} value={name} defaults={{ ru: t('pr.namePh') }} onChange={setName} />
<L5Editor label={t('pr.description')} value={desc} defaults={{}} onChange={setDesc} multiline />
<p className="bo-hint">{t('pr.createHint')}</p>
</Dialog>
);
}
type QField = { key?: string; type: 'select' | 'int' | 'text' | 'bool'; label: string; required: boolean; options: { key?: string; label: string }[]; min: number; max: number };
/** Вопросы клиенту при заказе — для услуг с фиксированной ценой */
function ParamsPanel({ service: s, canEdit }: { service: Service; canEdit: boolean }) {
const { t, shared } = useT();
const trpc = useTRPC();
const label = (k: string) => {
const v = shared(k);
return v === k ? '' : v;
};
const initial = (): QField[] =>
s.paramsSchema.fields
.filter((f) => f.type !== 'extras')
.map((f) => ({
key: f.key,
type: f.type as QField['type'],
label: label(`params.${s.code}.${f.key}.label`) || f.key,
required: 'required' in f ? !!f.required : false,
options: f.type === 'select' ? f.options.map((o) => ({ key: o, label: label(`params.${s.code}.${f.key}.${o}`) || o })) : [],
min: f.type === 'int' ? f.min : 1,
max: f.type === 'int' ? f.max : 10,
}));
const [fields, setFields] = useState<QField[]>(initial);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => setFields(initial()), [s.paramsSchema]);
const save = useMutation(trpc.bo.products.saveParams.mutationOptions(useSaved()));
const set = (i: number, patch: Partial<QField>) => setFields(fields.map((f, k) => (k === i ? { ...f, ...patch } : f)));
return (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('pr.questions')}</h2>
</div>
<p className="bo-hint" style={{ marginBottom: 12 }}>
{t('pr.questionsHint')}
</p>
<div className="bo-col" style={{ gap: 10 }}>
{fields.map((f, i) => (
<div key={f.key ?? `n${i}`} className="bo-qfield">
<div style={{ display: 'flex', gap: 8 }}>
<input className="bo-input" disabled={!canEdit} value={f.label} maxLength={120} onChange={(e) => set(i, { label: e.target.value })} placeholder={t('pr.qLabel')} />
<select
className="bo-select"
style={{ width: 160 }}
disabled={!canEdit}
value={f.type}
onChange={(e) => set(i, { type: e.target.value as QField['type'], options: e.target.value === 'select' && f.options.length < 2 ? [{ label: '' }, { label: '' }] : f.options })}
>
{(['select', 'int', 'text', 'bool'] as const).map((x) => (
<option key={x} value={x}>
{t(`pr.qType.${x}`)}
</option>
))}
</select>
{canEdit && (
<button type="button" className="bo-iconbtn" onClick={() => setFields(fields.filter((_, k) => k !== i))} aria-label={t('chat.tplDelete')}>
<I name="close" size={16} />
</button>
)}
</div>
{f.type === 'select' && (
<div className="bo-col" style={{ gap: 6, marginTop: 8, paddingLeft: 12 }}>
{f.options.map((o, k) => (
<div key={o.key ?? `o${k}`} style={{ display: 'flex', gap: 6 }}>
<input
className="bo-input"
style={{ minHeight: 34, padding: '4px 10px' }}
disabled={!canEdit}
value={o.label}
maxLength={120}
placeholder={t('pr.qOption', { n: k + 1 })}
onChange={(e) => set(i, { options: f.options.map((x, j) => (j === k ? { ...x, label: e.target.value } : x)) })}
/>
{canEdit && f.options.length > 2 && (
<button type="button" className="bo-iconbtn" onClick={() => set(i, { options: f.options.filter((_, j) => j !== k) })} aria-label={t('chat.tplDelete')}>
<I name="close" size={14} />
</button>
)}
</div>
))}
{canEdit && (
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-accent)', padding: 0, alignSelf: 'flex-start' }} onClick={() => set(i, { options: [...f.options, { label: '' }] })}>
+ {t('pr.qAddOption')}
</button>
)}
</div>
)}
{f.type === 'int' && (
<div style={{ display: 'flex', gap: 8, marginTop: 8, alignItems: 'center' }} className="bo-small">
{t('pr.qFrom')}
<input className="bo-input" style={{ width: 80, minHeight: 34 }} disabled={!canEdit} inputMode="numeric" value={String(f.min)} onChange={(e) => set(i, { min: Number(num(e.target.value)) || 0 })} />
{t('pr.qTo')}
<input className="bo-input" style={{ width: 80, minHeight: 34 }} disabled={!canEdit} inputMode="numeric" value={String(f.max)} onChange={(e) => set(i, { max: Number(num(e.target.value)) || 1 })} />
</div>
)}
{f.type !== 'bool' && (
<label className="bo-check bo-small" style={{ marginTop: 6 }}>
<input type="checkbox" disabled={!canEdit} checked={f.required} onChange={(e) => set(i, { required: e.target.checked })} />
{t('pr.qRequired')}
</label>
)}
</div>
))}
{fields.length === 0 && <p className="bo-hint">{t('pr.qNone')}</p>}
</div>
{canEdit && (
<div style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setFields([...fields, { type: 'select', label: '', required: true, options: [{ label: '' }, { label: '' }], min: 1, max: 10 }])}>
<I name="plus" size={16} />
{t('pr.qAdd')}
</button>
<button
type="button"
className="bo-btn bo-btn--sm"
disabled={save.isPending || fields.some((f) => !f.label.trim() || (f.type === 'select' && f.options.filter((o) => o.label.trim()).length < 2))}
onClick={() => save.mutate({ serviceId: s.id, fields: fields.map((f) => ({ ...f, options: f.type === 'select' ? f.options.filter((o) => o.label.trim()) : [] })) })}
>
{t('ui.save')}
</button>
</div>
)}
</section>
);
}
function DeleteService({ id }: { id: string }) {
const { t } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const del = useMutation(
trpc.bo.products.deleteService.mutationOptions({
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.bo.products.catalog.queryKey() });
navigate('/products');
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-danger)', marginTop: 16 }} onClick={() => confirm(t('pr.deleteConfirm')) && del.mutate({ id })}>
{t('pr.delete')}
</button>
);
}