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:
1 parent
45d7512d2b
commit
3489e0b816
319 files changed
+45483
-121
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user