Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-25 22:55:29 +04:00
1 parent 45d7512d2b
commit 3489e0b816
319 files changed
+45483 -121

No files matched your search

+231
View File
@@ -0,0 +1,231 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { pricingSchema, slotConfigSchema, type Pricing, type SlotConfig } from '@barev/core';
import { Badge, Button, Card, Field } from '@barev/ui';
import { errorText, useI18n, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
/** ADM-5: направления и услуги — цены, доступность, окно записи, удержание; скидки Barev+ */
export function Services({ isOwner }: { isOwner: boolean }) {
const { t, amd } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const list = useQuery(trpc.admin.services.list.queryOptions());
const [open, setOpen] = useState<string | null>(null);
const updateCategory = useMutation(
trpc.admin.services.updateCategory.mutationOptions({
onSuccess: () => {
toast(t('admin.saved'));
void qc.invalidateQueries();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
return (
<>
<h1 className="bc-h2">{t('admin.nav.services')}</h1>
{list.data?.map((c) => (
<Card key={c.id} className="bc-stack">
<div className="bc-between">
<h2 className="bc-h3">{t(`categories.${c.code}.name`)}</h2>
{c.code !== 'PERKS' && (
<label className="bc-row bc-small">
{t('admin.services.plusDiscount')}
<input
className="bc-input"
type="number"
min={0}
max={100}
defaultValue={c.barevPlusDiscountPct}
disabled={!isOwner}
style={{ width: 90, minHeight: 40 }}
onBlur={(e) => Number(e.target.value) !== c.barevPlusDiscountPct && updateCategory.mutate({ id: c.id, barevPlusDiscountPct: Number(e.target.value) })}
/>
%
<span style={{ marginLeft: 12 }}>{t('admin.services.eliteDiscount')}</span>
<input
className="bc-input"
type="number"
min={0}
max={100}
defaultValue={c.eliteDiscountPct}
disabled={!isOwner}
style={{ width: 90, minHeight: 40 }}
onBlur={(e) => Number(e.target.value) !== c.eliteDiscountPct && updateCategory.mutate({ id: c.id, eliteDiscountPct: Number(e.target.value) })}
/>
%
</label>
)}
</div>
{c.services.map((s) => {
const pricing = pricingSchema.parse(s.pricing);
const from = pricing.kind === 'fixed' ? pricing.price : Math.min(...Object.values(pricing.tiers).map((x) => x.base));
return (
<div key={s.id} className="bc-stack" style={{ gap: 8, borderTop: '1px solid var(--bc-line)', paddingTop: 10 }}>
<div className="bc-between">
<div className="bc-row">
<b>{t(`services.${s.code}.name`)}</b>
<Badge style={{ background: s.active ? 'var(--bc-home)' : 'var(--bc-bg)' }}>{t(s.active ? 'admin.services.active' : 'admin.services.inactive')}</Badge>
<span className="bc-small bc-muted">{t('common.from', { price: amd(from) })}</span>
</div>
<Button size="sm" variant="light" onClick={() => setOpen(open === s.id ? null : s.id)}>
{t(open === s.id ? 'common.close' : 'admin.edit')}
</Button>
</div>
{open === s.id && <ServiceEditor service={{ ...s, pricing, slotConfig: slotConfigSchema.parse(s.slotConfig) }} onSaved={() => setOpen(null)} />}
</div>
);
})}
</Card>
))}
</>
);
}
function ServiceEditor({
service,
onSaved,
}: {
service: { id: string; code: string; active: boolean; lateCancelFeePct: number; pricing: Pricing; slotConfig: SlotConfig; checklist: { code: string; requiresPhoto: boolean }[] };
onSaved: () => void;
}) {
const { t } = useI18n();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const [active, setActive] = useState(service.active);
const [fee, setFee] = useState(service.lateCancelFeePct);
const [pricing, setPricing] = useState<Pricing>(service.pricing);
const [slot, setSlot] = useState<SlotConfig>(service.slotConfig);
const save = useMutation(
trpc.admin.services.update.mutationOptions({
onSuccess: () => {
toast(t('admin.saved'));
void qc.invalidateQueries();
onSaved();
},
onError: (e) => toast(errorText(t, e), 'error'),
}),
);
const num = (v: string) => Math.max(0, Math.round(Number(v) || 0));
return (
<div className="bc-stack" style={{ background: 'var(--bc-bg)', borderRadius: 20, padding: 16 }}>
<label className="bc-row">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} /> {t('admin.services.availableToOrder')}
</label>
<h4 style={{ margin: 0 }}>{t('admin.services.prices')}</h4>
{pricing.kind === 'rooms' ? (
<table className="grid">
<thead>
<tr>
<th>{t('admin.services.variant')}</th>
<th>{t('admin.services.base')}</th>
<th>{t('admin.services.perRoom')}</th>
<th>{t('admin.services.baseMinutes')}</th>
<th>{t('admin.services.perRoomMinutes')}</th>
<th>{t('admin.services.perBathroom')}</th>
<th>{t('admin.services.perBathroomMinutes')}</th>
</tr>
</thead>
<tbody>
{Object.entries(pricing.tiers).map(([variant, tier]) => (
<tr key={variant}>
<td>{t(`params.${service.code}.${pricing.variantParam}.${variant}`)}</td>
{(['base', 'perRoom', 'baseMinutes', 'perRoomMinutes', 'perBathroom', 'perBathroomMinutes'] as const).map((k) => (
<td key={k}>
<input
className="bc-input"
type="number"
min={0}
value={tier[k]}
style={{ minHeight: 38, padding: '6px 10px' }}
onChange={(e) => setPricing({ ...pricing, tiers: { ...pricing.tiers, [variant]: { ...tier, [k]: num(e.target.value) } } })}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
) : null}
{pricing.kind === 'rooms' && Object.keys(pricing.extras).length > 0 && (
<>
<h4 style={{ margin: 0 }}>{t('admin.services.extras')}</h4>
<table className="grid">
<thead>
<tr>
<th>{t('admin.services.extra')}</th>
<th>{t('admin.services.extraPrice')}</th>
<th>{t('admin.services.extraMinutes')}</th>
</tr>
</thead>
<tbody>
{Object.entries(pricing.extras).map(([code, e]) => (
<tr key={code}>
<td>
{t(`extras.${code}.name`)}
{e.unit !== 'service' && <span className="bc-small bc-muted"> {t(`extras.${code}.per`)}</span>}
</td>
{(['price', 'minutes'] as const).map((k) => (
<td key={k}>
<input
className="bc-input"
type="number"
min={0}
value={e[k]}
style={{ minHeight: 38, padding: '6px 10px' }}
onChange={(ev) => setPricing({ ...pricing, extras: { ...pricing.extras, [code]: { ...e, [k]: num(ev.target.value) } } })}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</>
)}
{pricing.kind === 'fixed' && (
<div className="form-grid">
<Field label={t('admin.services.price')}>
<input className="bc-input" type="number" min={0} value={pricing.price} onChange={(e) => setPricing({ ...pricing, price: num(e.target.value) })} />
</Field>
<Field label={t('admin.services.minutes')}>
<input className="bc-input" type="number" min={1} value={pricing.minutes} onChange={(e) => setPricing({ ...pricing, minutes: Math.max(1, num(e.target.value)) })} />
</Field>
</div>
)}
<div className="form-grid">
<Field label={t('admin.services.lateFee')}>
<input className="bc-input" type="number" min={0} max={100} value={fee} onChange={(e) => setFee(Math.min(100, num(e.target.value)))} />
</Field>
<Field label={t('admin.services.step')}>
<input className="bc-input" type="number" min={15} step={15} value={slot.stepMinutes} onChange={(e) => setSlot({ ...slot, stepMinutes: Math.max(15, num(e.target.value)) })} />
</Field>
<Field label={t('admin.services.dayStart')}>
<input className="bc-input" type="number" min={0} max={24} value={slot.dayStartMinute / 60} onChange={(e) => setSlot({ ...slot, dayStartMinute: Math.min(24, num(e.target.value)) * 60 })} />
</Field>
<Field label={t('admin.services.dayEnd')}>
<input className="bc-input" type="number" min={0} max={24} value={slot.dayEndMinute / 60} onChange={(e) => setSlot({ ...slot, dayEndMinute: Math.min(24, num(e.target.value)) * 60 })} />
</Field>
<Field label={t('admin.services.leadHours')}>
<input className="bc-input" type="number" min={0} value={slot.leadHours} onChange={(e) => setSlot({ ...slot, leadHours: num(e.target.value) })} />
</Field>
<Field label={t('admin.services.horizon')}>
<input className="bc-input" type="number" min={1} value={slot.horizonDays} onChange={(e) => setSlot({ ...slot, horizonDays: Math.max(1, num(e.target.value)) })} />
</Field>
</div>
{service.checklist.length > 0 && (
<div className="bc-small">
<b>{t('admin.order.checklist')}:</b> {service.checklist.map((c) => `${t(`checklist.${c.code}`)}${c.requiresPhoto ? ' 📷' : ''}`).join(' · ')}
</div>
)}
<div>
<Button onClick={() => save.mutate({ id: service.id, active, lateCancelFeePct: fee, pricing, slotConfig: slot })} disabled={save.isPending}>
{t('admin.save')}
</Button>
</div>
</div>
);
}