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:
zabudko-ruandClaude Opus 5.5 committed 2026-10-25 22:57:03 +04:00
1 parent dda917fc71
commit 45477fb018
94 files changed
+10955 -650

No files matched your search

+5 -2
View File
@@ -261,8 +261,11 @@ export function Journal() {
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: 'subs', keys: ['barevPlus.monthlyPrice', 'barevPlus.maxDiscountPct', 'elite.monthlyPrice', 'elite.maxDiscountPct'] },
// подарок новым клиентам задаётся у каждого агентства; giftDays — только число в текстах лендинга
{ key: 'agencies', keys: ['agency.attributionMonths', 'agency.defaultCommissionPct', 'barevPlus.giftDays'] },
{ key: 'points', keys: ['points.pendingHours', 'points.maxSharePct', 'points.minRedeem', 'points.burnDays', 'points.warnDays1', 'points.warnDays2', 'points.requestTtlHours', 'points.streakGapDays', 'points.diffHighlightPct', 'points.supportCanManage', 'points.opMaxPerOperation', 'points.opMaxPerDay'] },
{ key: 'partners', keys: ['partner.fotPct', 'partner.taxPct', 'partner.minTravelGapMinutes', 'partner.acceptTimeoutMinutes'] },
{ 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'] },
+44 -3
View File
@@ -26,6 +26,15 @@ function CopyLink({ url }: { url: string }) {
}
export { CopyLink };
const GIFT_MONTHS = [1, 2, 3, 6, 12] as const;
type GiftMonths = (typeof GIFT_MONTHS)[number];
const PLAN_NAME = { BAREV_PLUS: 'Barev+', ELITE: 'Barev Elite' } as const;
/** «Barev+ на 3 мес.» или «без подарка» */
function giftText(t: ReturnType<typeof useT>['t'], a: { giftPlan: 'BAREV_PLUS' | 'ELITE' | null; giftMonths: number }) {
return a.giftPlan ? t('ag.giftValue', { plan: PLAN_NAME[a.giftPlan], n: a.giftMonths }) : t('ag.noGift');
}
export function Agencies() {
const { t, amd } = useT();
const trpc = useTRPC();
@@ -64,7 +73,7 @@ export function Agencies() {
<Avatar name={a.name} id={a.id} size={38} />
<span style={{ minWidth: 0 }}>
<span className="bo-who__name">{a.name}</span>
<span className="bo-who__meta">{[a.code, `${a.commissionPct}%`, a.sales?.name].filter(Boolean).join(' · ')}</span>
<span className="bo-who__meta">{[a.code, `${a.commissionPct}%`, giftText(t, a), a.sales?.name].filter(Boolean).join(' · ')}</span>
{!a.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
</span>
</a>
@@ -125,6 +134,8 @@ export function AgencyCard({ id }: { id: string }) {
<div className="bo-muted" style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: '4px 12px' }}>
<span className="bo-num">{d.code}</span>
<span>{t('ag.pct', { n: d.commissionPct })}</span>
<span>🎁 {giftText(t, d)}</span>
{d.housewarmingPoints > 0 && <span>🪙 {t('ag.hwValue', { n: new Intl.NumberFormat('ru-RU').format(d.housewarmingPoints), days: d.housewarmingDays })}</span>}
{d.sales && <span>{t('ag.byS', { name: d.sales.name })}</span>}
<span>{t('prv.since', { date: date(d.createdAt) })}</span>
{!d.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
@@ -227,7 +238,7 @@ export function AgencyCard({ id }: { id: string }) {
);
}
function AgencyDialog({ agency, onClose, onDone }: { agency?: { id: string; code: string; name: string; commissionPct: number; contact: string | null; note: string | null; active: boolean; sales: { id: string } | null }; onClose: () => void; onDone?: () => void }) {
function AgencyDialog({ agency, onClose, onDone }: { agency?: { id: string; code: string; name: string; commissionPct: number; giftPlan: 'BAREV_PLUS' | 'ELITE' | null; giftMonths: number; housewarmingPoints: number; housewarmingDays: number; contact: string | null; note: string | null; active: boolean; sales: { id: string } | null }; onClose: () => void; onDone?: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
@@ -237,6 +248,10 @@ function AgencyDialog({ agency, onClose, onDone }: { agency?: { id: string; code
code: agency?.code ?? '',
name: agency?.name ?? '',
commissionPct: String(agency?.commissionPct ?? 10),
giftPlan: (agency ? (agency.giftPlan ?? 'NONE') : 'BAREV_PLUS') as 'BAREV_PLUS' | 'ELITE' | 'NONE',
giftMonths: (GIFT_MONTHS.includes(agency?.giftMonths as GiftMonths) ? agency!.giftMonths : 1) as GiftMonths,
hwPoints: String(agency?.housewarmingPoints ?? 0),
hwDays: String(agency?.housewarmingDays ?? 60),
contact: agency?.contact ?? '',
note: agency?.note ?? '',
active: agency?.active ?? true,
@@ -267,7 +282,7 @@ function AgencyDialog({ agency, onClose, onDone }: { agency?: { id: string; code
type="button"
className="bo-btn"
disabled={!f.name.trim() || !f.code.trim() || save.isPending}
onClick={() => save.mutate({ id: agency?.id, code: f.code, name: f.name, commissionPct: Math.min(100, Number(f.commissionPct) || 0), contact: f.contact || null, note: f.note || null, active: f.active, salesId: f.salesId || null })}
onClick={() => save.mutate({ id: agency?.id, code: f.code, name: f.name, commissionPct: Math.min(100, Number(f.commissionPct) || 0), giftPlan: f.giftPlan === 'NONE' ? null : f.giftPlan, giftMonths: f.giftMonths, housewarmingPoints: Number(f.hwPoints) || 0, housewarmingDays: Number(f.hwDays) || 60, contact: f.contact || null, note: f.note || null, active: f.active, salesId: f.salesId || null })}
>
{t('ui.save')}
</button>
@@ -297,6 +312,32 @@ function AgencyDialog({ agency, onClose, onDone }: { agency?: { id: string; code
</select>
</Field>
</div>
<div className="bo-row2">
<Field label={t('ag.giftPlan')} hint={t('ag.giftHint')}>
<select className="bo-select" value={f.giftPlan} onChange={(e) => setF({ ...f, giftPlan: e.target.value as typeof f.giftPlan })}>
<option value="BAREV_PLUS">Barev+</option>
<option value="ELITE">Barev Elite</option>
<option value="NONE">{t('ag.noGift')}</option>
</select>
</Field>
<Field label={t('ag.giftMonths')}>
<select className="bo-select" value={f.giftMonths} disabled={f.giftPlan === 'NONE'} onChange={(e) => setF({ ...f, giftMonths: Number(e.target.value) as GiftMonths })}>
{GIFT_MONTHS.map((m) => (
<option key={m} value={m}>
{t('ag.monthsN', { n: m })}
</option>
))}
</select>
</Field>
</div>
<div className="bo-row2">
<Field label={t('ag.hwPoints')} hint={t('ag.hwHint')}>
<input className="bo-input" inputMode="numeric" value={f.hwPoints} onChange={(e) => setF({ ...f, hwPoints: e.target.value.replace(/\D/g, '').slice(0, 7) })} />
</Field>
<Field label={t('ag.hwDays')}>
<input className="bo-input" inputMode="numeric" value={f.hwDays} disabled={!Number(f.hwPoints)} onChange={(e) => setF({ ...f, hwDays: e.target.value.replace(/\D/g, '').slice(0, 4) })} />
</Field>
</div>
<Field label={t('crm.contacts')}>
<input className="bo-input" value={f.contact} maxLength={200} onChange={(e) => setF({ ...f, contact: e.target.value })} placeholder={t('ag.contactPh')} />
</Field>
+2
View File
@@ -1,3 +1,4 @@
import { ClientPoints } from './Points.tsx';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { navigate, useToast, useTRPC } from '@barev/web';
import { myChannels } from './Chats.tsx';
@@ -234,6 +235,7 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
</div>
<div className="bo-col">
{isSuper && <ClientPoints id={d.id} compact />}
{d.money && (
<section className="bo-panel bo-money-card">
<span className="bo-label" style={{ color: 'inherit', opacity: 0.75 }}>
+6 -3
View File
@@ -519,14 +519,17 @@ export function Requests({ me }: { me: Me }) {
<b>
<span className="bo-num bo-faint">№{r.number}</span> {shared(`services.${r.service.code}.name`)}
</b>
<span className="bo-chip">{shared(`admin.webRequests.status_${r.status}`)}</span>
<span className="bo-tags">
{r.status !== 'ORDERED' && r.status !== 'CANCELLED' && (!r.preferredAt || !r.district || !r.addressText) && <span className="bo-chip bo-chip--warn">{t('rq.missing')}</span>}
<span className="bo-chip">{shared(`admin.webRequests.status_${r.status}`)}</span>
</span>
</div>
<div className="bo-small">{paramsSummary(sharedT, r.service.code, r.params as Record<string, unknown>)}</div>
<div className="bo-small">
{t('rq.wish')}: <b>{dateTime(r.preferredAt)}</b> · {t('rq.price')}: <b className="bo-num">{amd(r.totalPrice)}</b>
{t('rq.wish')}: <b>{r.preferredAt ? dateTime(r.preferredAt) : t('rq.noTime')}</b> · {t('rq.price')}: <b className="bo-num">{amd(r.totalPrice)}</b>
</div>
<div className="bo-small bo-faint">
{shared(`districts.${r.district.code}`)}, {r.addressText}
{[r.district ? shared(`districts.${r.district.code}`) : t('rq.noDistrict'), r.addressText ?? t('rq.noAddress')].join(', ')}
</div>
<div className="bo-rq__who">
<span>
+2 -2
View File
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
import { errText, useT } from '../i18n.tsx';
import { Avatar, Dialog, Field, I, PlanBadge } from '../components/kit.tsx';
type ParamField =
export type ParamField =
| { key: string; type: 'select'; options: string[]; default?: string }
| { key: string; type: 'int'; min: number; max: number; default?: number }
| { key: string; type: 'text'; maxLength: number; required?: boolean }
@@ -262,7 +262,7 @@ export function NewOrderDialog({ clientId: fixedClient, onClose }: { clientId?:
);
}
function ParamsForm({ service, fields, value, onChange }: { service: string; fields: ParamField[]; value: Params; onChange: (v: Params) => void }) {
export function ParamsForm({ service, fields, value, onChange }: { service: string; fields: ParamField[]; value: Params; onChange: (v: Params) => void }) {
const { t, shared } = useT();
const label = (k: string) => {
const key = `params.${service}.${k}.label`;
+170 -9
View File
@@ -5,7 +5,7 @@ import type { Me } from '../App.tsx';
import { errText, useT } from '../i18n.tsx';
import { Avatar, Dialog, Field, I, PlanBadge, Skeleton } from '../components/kit.tsx';
import { GROUP_DOT, statusLabel } from './Orders.tsx';
import { SlotPicker } from './NewOrder.tsx';
import { ParamsForm, SlotPicker, type ParamField } from './NewOrder.tsx';
const MIN = 60_000;
@@ -15,7 +15,8 @@ export function OrderCard({ id, me }: { id: string; me: Me }) {
const qc = useQueryClient();
const toast = useToast();
const o = useQuery({ ...trpc.bo.orders.get.queryOptions({ id }), refetchInterval: 15_000 });
const [dlg, setDlg] = useState<'reschedule' | 'cancel' | null>(null);
const [dlg, setDlg] = useState<'reschedule' | 'cancel' | 'edit' | null>(null);
const [check, setCheck] = useState<{ code: string; requiresPhoto: boolean } | null>(null);
const refresh = () => {
qc.invalidateQueries({ queryKey: trpc.bo.orders.get.queryKey({ id }) });
qc.invalidateQueries({ queryKey: trpc.bo.orders.candidates.queryKey({ id }) });
@@ -26,6 +27,7 @@ export function OrderCard({ id, me }: { id: string; me: Me }) {
const onError = (e: unknown) => toast(errText(t, e), 'error');
const transition = useMutation(trpc.bo.orders.transition.mutationOptions({ onSuccess: () => (refresh(), toast(t('ord.statusChanged'))), onError }));
const markPaid = useMutation(trpc.bo.orders.markPaid.mutationOptions({ onSuccess: () => (refresh(), toast(t('ord.paidDone'))), onError }));
const setChecklist = useMutation(trpc.bo.orders.checklist.mutationOptions({ onSuccess: refresh, onError }));
const chat = useMutation(trpc.bo.chats.openFor.mutationOptions({ onSuccess: (r) => navigate(`/chats/${r.id}`), onError }));
const canChat = me.roles.some((r) => r === 'SUPERADMIN' || r === 'SUPPORT' || r === 'CONCIERGE');
@@ -92,6 +94,11 @@ export function OrderCard({ id, me }: { id: string; me: Me }) {
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.details')}</h2>
{d.status !== 'CANCELLED' && (
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => setDlg('edit')}>
{t('ord.editComposition')}
</button>
)}
</div>
<dl className="bo-kv">
<dt>{t('ord.when')}</dt>
@@ -143,9 +150,19 @@ export function OrderCard({ id, me }: { id: string; me: Me }) {
<ul className="bo-checks">
{d.checklist.map((c) => (
<li key={c.code} data-done={c.done || undefined}>
<span className="bo-checks__box">{c.done && <I name="check" size={14} stroke={3} />}</span>
{shared(`checklist.${c.code}`)}
{c.requiresPhoto && !c.fileId && <span className="bo-faint bo-small"> · {t('ord.needsPhoto')}</span>}
<button
type="button"
className="bo-checks__toggle"
disabled={setChecklist.isPending || d.status === 'CANCELLED'}
title={c.done ? t('ord.uncheck') : t('ord.checkForProvider')}
onClick={() => (c.done ? setChecklist.mutate({ id: d.id, code: c.code, checked: false }) : c.requiresPhoto && !c.fileId ? setCheck(c) : setChecklist.mutate({ id: d.id, code: c.code, checked: true }))}
>
<span className="bo-checks__box">{c.done && <I name="check" size={14} stroke={3} />}</span>
{shared(`checklist.${c.code}`)}
</button>
{c.requiresPhoto && !c.fileId && !c.waived && <span className="bo-faint bo-small"> · {t('ord.needsPhoto')}</span>}
{c.waived && <span className="bo-faint bo-small"> · {t('ord.noPhotoReason', { reason: c.waived })}</span>}
{c.byStaff && <span className="bo-faint bo-small"> · {t('ord.byStaff')}</span>}
</li>
))}
</ul>
@@ -332,6 +349,8 @@ export function OrderCard({ id, me }: { id: string; me: Me }) {
</div>
{dlg === 'reschedule' && <RescheduleDialog order={d} onClose={() => setDlg(null)} onDone={refresh} />}
{dlg === 'edit' && <EditDialog order={d} onClose={() => setDlg(null)} onDone={refresh} />}
{check && <ChecklistPhotoDialog orderId={d.id} code={check.code} onClose={() => setCheck(null)} onDone={refresh} />}
{dlg === 'cancel' && <CancelDialog id={id} showFee={!!d.money} onClose={() => setDlg(null)} onDone={refresh} />}
</>
);
@@ -379,7 +398,7 @@ function useOrderData(id: string) {
}
function ProviderPanel({ order: d, onChanged, canChat }: { order: OrderData; onChanged: () => void; canChat: boolean }) {
const { t, shared } = useT();
const { t, shared, amd } = useT();
const trpc = useTRPC();
const toast = useToast();
const [showBusy, setShowBusy] = useState(false);
@@ -464,15 +483,28 @@ function ProviderPanel({ order: d, onChanged, canChat }: { order: OrderData; onC
) : (
<ul className="bo-cands">
{cands.data.free.map((c, i) => (
<li key={`${c.kind}:${c.id}`}>
<li key={`${c.kind}:${c.id}`} data-best={c.best || undefined}>
<span className="bo-cands__rank">{i + 1}</span>
<span className="bo-grow" style={{ minWidth: 0 }}>
<b>{c.name}</b>
{c.kind === 'company' && <span className="bo-chip" style={{ marginLeft: 6 }}>{t('ord.company')}</span>}
{c.kind === 'company' && <span className="bo-chip" style={{ marginLeft: 6 }}>{t('ord.partner')}</span>}
{c.best && <span className="bo-chip bo-chip--ok" style={{ marginLeft: 6 }}>{t('ord.best')}</span>}
{c.tried && <span className="bo-chip bo-chip--warn" style={{ marginLeft: 6 }}>{t('ord.tried')}</span>}
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{[c.rating != null ? `★ ${c.rating}` : null, c.languages.join(', ').toUpperCase(), ...c.reasons.map((r) => shared(`admin.reasons.${r}`)), t('ord.load', { n: c.load })].filter(Boolean).join(' · ')}
{[
c.rating != null ? `★ ${c.rating}` : null,
c.languages.join(', ').toUpperCase(),
...c.reasons.map((r) => shared(`admin.reasons.${r}`)),
c.kind === 'company' && c.partnerFree != null ? t('ord.partnerFree', { n: c.partnerFree, of: c.partnerStaff ?? 0 }) : t('ord.load', { n: c.load }),
]
.filter(Boolean)
.join(' · ')}
</span>
{c.margin != null && (
<span className="bo-small" style={{ display: 'block' }}>
{t('ord.candMoney', { cost: amd(c.cost ?? 0), margin: amd(c.margin) })}
</span>
)}
</span>
<button type="button" className="bo-btn bo-btn--sm" disabled={assign.isPending} onClick={() => assign.mutate({ id: d.id, target: { kind: c.kind, id: c.id } })}>
{t('ord.offer')}
@@ -610,3 +642,132 @@ function CancelDialog({ id, showFee, onClose, onDone }: { id: string; showFee: b
</Dialog>
);
}
/** Изменить состав заказа: пересчёт по прайсу с прежней скидкой или своя цена с причиной */
function EditDialog({ order, onClose, onDone }: { order: { id: string; number: number; service: string; params: Record<string, unknown> }; onClose: () => void; onDone: () => void }) {
const { t, amd } = useT();
const trpc = useTRPC();
const toast = useToast();
const services = useQuery(trpc.bo.orders.services.queryOptions());
const fields = (services.data?.find((s) => s.code === order.service)?.paramsSchema.fields ?? []) as ParamField[];
const [params, setParams] = useState<Record<string, unknown>>(() => ({ ...order.params }));
const [price, setPrice] = useState('');
const [reason, setReason] = useState('');
const [notify, setNotify] = useState(true);
const p = useQuery({ ...trpc.bo.orders.editPreview.queryOptions({ id: order.id, params }), placeholderData: (prev) => prev });
const manual = price !== '' && p.data && Number(price) !== p.data.totalPrice;
const save = useMutation(
trpc.bo.orders.edit.mutationOptions({
onSuccess: (r) => {
toast(r.providerBusy ? t('ord.editedBusy') : t('ord.edited'));
onDone();
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
wide
onClose={onClose}
title={t('ord.editTitle', { n: order.number })}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn" disabled={!p.data || save.isPending || (!!manual && !reason.trim())} onClick={() => save.mutate({ id: order.id, params, totalPrice: price === '' ? null : Number(price), reason: reason.trim() || null, notify })}>
{t('ui.save')}
</button>
</>
}
>
{fields.length > 0 && <ParamsForm service={order.service} fields={fields} value={params} onChange={setParams} />}
{p.isError && <p className="bo-hint" style={{ color: 'var(--bc-danger)' }}>{errText(t, p.error)}</p>}
{p.data && (
<div className="bo-side-panel__note" style={{ margin: '14px 0' }}>
{t('ord.editPrice', { was: amd(p.data.was.totalPrice), now: amd(p.data.totalPrice), pct: p.data.discountPct })}
<br />
{t('ord.editDuration', { was: p.data.was.durationMinutes, now: p.data.durationMinutes })}
{p.data.providerBusy && (
<>
<br />⚠️ {t('ord.editBusy')}
</>
)}
{p.data.paid && (
<>
<br />💳 {t('ord.editPaid')}
</>
)}
</div>
)}
<div className="bo-row2">
<Field label={t('ord.editOwnPrice')} hint={p.data ? t('ord.editOwnPriceHint', { n: amd(p.data.totalPrice) }) : undefined}>
<input className="bo-input" inputMode="numeric" value={price} placeholder={p.data ? String(p.data.totalPrice) : ''} onChange={(e) => setPrice(e.target.value.replace(/\D/g, '').slice(0, 8))} />
</Field>
<Field label={manual ? t('ord.editReasonRequired') : t('ord.editReason')}>
<input className="bo-input" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} placeholder={t('ord.editReasonPh')} />
</Field>
</div>
<label className="bo-check">
<input type="checkbox" checked={notify} onChange={(e) => setNotify(e.target.checked)} />
{t('ord.editNotify')}
</label>
</Dialog>
);
}
/** Пункт с фото: загрузить фото или отметить без фото с причиной */
function ChecklistPhotoDialog({ orderId, code, onClose, onDone }: { orderId: string; code: string; onClose: () => void; onDone: () => void }) {
const { t, shared } = useT();
const trpc = useTRPC();
const toast = useToast();
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const mark = useMutation(trpc.bo.orders.checklist.mutationOptions({ onSuccess: () => (onDone(), onClose()), onError: (e) => toast(errText(t, e), 'error') }));
const upload = async (file: File) => {
setBusy(true);
const fd = new FormData();
fd.append('code', code);
fd.append('file', file);
const res = await fetch(`/files/bo/checklist-photo/${orderId}`, { method: 'POST', body: fd, credentials: 'include' }).catch(() => null);
setBusy(false);
if (!res?.ok) {
const err = (await res?.json().catch(() => null)) as { error?: string } | null;
toast(errText(t, { message: err?.error ?? 'NETWORK' }), 'error');
return;
}
onDone();
onClose();
};
return (
<Dialog
open
onClose={onClose}
title={shared(`checklist.${code}`)}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn" disabled={!reason.trim() || mark.isPending} onClick={() => mark.mutate({ id: orderId, code, checked: true, reason })}>
{t('ord.checkNoPhoto')}
</button>
</>
}
>
<p className="bo-hint" style={{ marginTop: 0 }}>
{t('ord.photoHint')}
</p>
<label className="bo-btn bo-btn--accent" style={{ display: 'inline-flex', marginBottom: 14, opacity: busy ? 0.6 : 1 }}>
<I name="clip" size={16} />
{busy ? t('ui.loading') : t('ord.uploadPhoto')}
<input type="file" accept="image/*" hidden disabled={busy} onChange={(e) => e.target.files?.[0] && void upload(e.target.files[0])} />
</label>
<Field label={t('ord.noPhotoField')}>
<input className="bo-input" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} placeholder={t('ord.noPhotoPh')} />
</Field>
</Dialog>
);
}
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -299,8 +299,10 @@ export function ProviderCard({ id, me }: { id: string; me: Me }) {
);
}
function rateText(t: ReturnType<typeof useT>['t'], r: { kind: 'company' | 'preset' | 'own'; type?: string; value?: number; name?: string }) {
function rateText(t: ReturnType<typeof useT>['t'], r: { kind: 'company' | 'preset' | 'own'; type?: string; value?: number | null; name?: string }) {
if (r.kind === 'preset') return t('prv.ratePreset', { name: r.name ?? '' });
// сотрудник партнёра «как Uber» — зарплату ему платит партнёр
if (r.kind === 'company' && r.value == null) return t('prv.ratePartnerPays');
const v = r.type === 'PERCENT' ? `${r.value}%` : `${r.value} ֏`;
return r.kind === 'company' ? t('prv.rateCompany', { v }) : t('prv.rateOwn', { v });
}
@@ -0,0 +1,485 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { formatDateTime, type Locale } from '@barev/i18n';
import { navigate, useToast, useTRPC } from '@barev/web';
import { useState } from 'react';
import { errText, useT } from '../i18n.tsx';
import { Avatar, Dialog, Field, I, Skeleton } from '../components/kit.tsx';
import { CopyLink } from './Agencies.tsx';
type Deal = 'PROFIT_SHARE' | 'COMMISSION';
type Terms = { deal: Deal; commissionPct: number; profitSharePct: number; fotPct: number; taxPct: number };
function useTermsText() {
const { t } = useT();
return (x: Terms) => (x.deal === 'COMMISSION' ? t('sp.terms.COMMISSION', { pct: x.commissionPct }) : t('sp.terms.PROFIT_SHARE', { pct: x.profitSharePct, fot: x.fotPct, tax: x.taxPct }));
}
/** Партнёры услуг: клининговые компании со своими сотрудниками и менеджером */
export function ServicePartners() {
const { t, amd } = useT();
const trpc = useTRPC();
const terms = useTermsText();
const list = useQuery(trpc.bo.partners.servicePartners.queryOptions());
const [creating, setCreating] = useState(false);
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('sp.title')}</h1>
<button type="button" className="bo-btn bo-btn--accent" onClick={() => setCreating(true)}>
<I name="plus" size={16} />
{t('sp.add')}
</button>
</div>
<p className="bo-hint" style={{ marginTop: -6, marginBottom: 14 }}>
{t('sp.lead')}
</p>
{!list.data ? (
<Skeleton h={240} />
) : list.data.length === 0 ? (
<div className="bo-panel bo-empty">{t('sp.empty')}</div>
) : (
<div className="bo-panel" style={{ padding: 8 }}>
<table className="bo-table bo-ptable">
<thead>
<tr>
<th>{t('sp.col.name')}</th>
<th className="bo-r">{t('sp.col.staff')}</th>
<th className="bo-r">{t('sp.col.active')}</th>
<th className="bo-r">{t('sp.col.month')}</th>
<th className="bo-r">{t('sp.col.balance')}</th>
</tr>
</thead>
<tbody>
{list.data.map((c) => (
<tr key={c.id} onClick={() => navigate(`/service-partners/${c.id}`)} data-off={!c.active || undefined}>
<td>
<a href={`#/service-partners/${c.id}`} className="bo-who" onClick={(e) => e.stopPropagation()}>
<Avatar name={c.name} id={c.id} size={38} />
<span style={{ minWidth: 0 }}>
<span className="bo-who__name">{c.name}</span>
<span className="bo-who__meta">{terms(c.terms)}</span>
<span className="bo-tags" style={{ marginTop: 4 }}>
{!c.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
{c.managers === 0 && <span className="bo-chip bo-chip--warn">{t('sp.noManager')}</span>}
{c.autoAssign && <span className="bo-chip">{t('sp.auto')}</span>}
</span>
</span>
</a>
</td>
<td className="bo-r bo-num">{c.staff}</td>
<td className="bo-r bo-num">{c.activeOrders}</td>
<td className="bo-r bo-num">{amd(c.thisMonth)}</td>
<td className="bo-r">
<b className="bo-num">{amd(c.balance)}</b>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{creating && <PartnerDialog onClose={() => setCreating(false)} />}
</>
);
}
export function ServicePartnerCard({ id }: { id: string }) {
const { t, amd, date, dateTime, locale, shared } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const terms = useTermsText();
const c = useQuery(trpc.bo.partners.servicePartner.queryOptions({ id }));
const [dlg, setDlg] = useState<'edit' | 'payout' | null>(null);
const refresh = () => {
void qc.invalidateQueries({ queryKey: trpc.bo.partners.servicePartner.queryKey({ id }) });
void qc.invalidateQueries({ queryKey: trpc.bo.partners.servicePartners.queryKey() });
};
const reset = useMutation(trpc.bo.partners.resetManagerLink.mutationOptions({ onSuccess: () => (refresh(), toast(t('sp.linkReset'))), onError: (e) => toast(errText(t, e), 'error') }));
const removeManager = useMutation(trpc.bo.partners.removeManager.mutationOptions({ onSuccess: refresh, onError: (e) => toast(errText(t, e), 'error') }));
const back = (
<a className="bo-back" href="#/service-partners">
<I name="back" size={16} />
{t('sp.back')}
</a>
);
if (!c.data)
return (
<>
{back}
{c.isError ? <div className="bo-panel bo-empty">{errText(t, c.error)}</div> : <Skeleton h={300} />}
</>
);
const d = c.data;
return (
<>
{back}
<header className="bo-profile">
<Avatar name={d.name} id={d.id} size={64} />
<div style={{ flex: 1, minWidth: 220 }}>
<h1 className="bo-title" style={{ fontSize: 'clamp(26px, 3.4vw, 38px)' }}>
{d.name}
</h1>
<div className="bo-muted" style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: '4px 12px' }}>
<span>{terms(d.terms)}</span>
{d.contactPhone && <span>{d.contactPhone}</span>}
<span>{t('prv.since', { date: date(d.createdAt) })}</span>
{d.autoAssign && <span className="bo-chip">{t('sp.auto')}</span>}
{!d.active && <span className="bo-chip bo-chip--danger">{t('ag.inactive')}</span>}
</div>
</div>
<button type="button" className="bo-btn" onClick={() => setDlg('edit')}>
{t('prv.edit')}
</button>
</header>
<div className="bo-client-grid">
<div className="bo-col">
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('sp.managerLink')}</h2>
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" disabled={reset.isPending} onClick={() => confirm(t('sp.resetConfirm')) && reset.mutate({ id })}>
{t('sp.resetLink')}
</button>
</div>
<p className="bo-hint" style={{ marginBottom: 10 }}>
{t('sp.managerLinkHint')}
</p>
<CopyLink url={d.managerLink} />
<h3 className="bo-label" style={{ marginTop: 16 }}>
{t('sp.managers')}
</h3>
{d.managers.length === 0 ? (
<p className="bo-hint">{t('sp.noManagers')}</p>
) : (
<ul className="bo-list">
{d.managers.map((m) => (
<li key={m.id}>
<span className="bo-grow">
<b>{m.name}</b>
<span className="bo-small bo-faint" style={{ display: 'block' }}>
{t('sp.managerSince', { date: date(m.since) })}
</span>
</span>
<button type="button" className="bo-btn bo-btn--sm bo-btn--ghost" onClick={() => confirm(t('sp.removeManagerConfirm', { name: m.name })) && removeManager.mutate({ id: m.id })}>
{t('sp.remove')}
</button>
</li>
))}
</ul>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('sp.staff')}</h2>
<span className="bo-faint bo-small">{t('sp.staffHint')}</span>
</div>
{d.staff.length === 0 ? (
<p className="bo-hint">{t('sp.noStaff')}</p>
) : (
<ul className="bo-list">
{d.staff.map((s) => (
<li key={s.id} style={{ opacity: s.active ? 1 : 0.5 }}>
<a href={`#/providers/${s.id}`} className="bo-grow" style={{ color: 'inherit', textDecoration: 'none' }}>
<b>{s.name}</b>
<span className="bo-small bo-faint" style={{ display: 'block' }}>
{[s.phone, s.maxOrdersPerDay ? t('sp.limitOrders', { n: s.maxOrdersPerDay }) : null, s.maxHoursPerDay ? t('sp.limitHours', { n: s.maxHoursPerDay }) : null].filter(Boolean).join(' · ') || '—'}
</span>
</a>
{!s.linked && <span className="bo-chip bo-chip--warn">{t('sp.notLinked')}</span>}
{!s.active && <span className="bo-chip">{t('sp.off')}</span>}
</li>
))}
</ul>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('sp.orders')}</h2>
</div>
{d.orders.length === 0 ? (
<p className="bo-hint">{t('sp.noOrders')}</p>
) : (
<ul className="bo-list">
{d.orders.map((o) => (
<li key={o.id}>
<a href={`#/orders/${o.id}`} className="bo-grow" style={{ color: 'inherit', textDecoration: 'none' }}>
<b>
#{o.number} · {shared(`services.${o.service}.name`)}
</b>
<span className="bo-small bo-faint" style={{ display: 'block' }}>
{dateTime(o.scheduledAt)} · {o.staff ?? t('sp.waitingStaff')}
</span>
</a>
</li>
))}
</ul>
)}
</section>
</div>
<div className="bo-col">
<section className="bo-panel bo-money-card">
<span className="bo-label" style={{ color: 'inherit', opacity: 0.75 }}>
{t('sp.toPay')}
</span>
<div className="bo-bignum" style={{ margin: '6px 0 8px' }}>
{amd(d.money.balance)}
</div>
<div className="bo-small" style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginBottom: 12 }}>
<span>
{t('ag.accrued')} <b className="bo-num">{amd(d.money.accrued)}</b>
</span>
<span>
{t('prv.paidTotal')} <b className="bo-num">{amd(d.money.paid)}</b>
</span>
</div>
<button type="button" className="bo-btn bo-btn--sm" disabled={d.money.balance <= 0} onClick={() => setDlg('payout')}>
{t('prv.payout')}
</button>
{d.money.months.length > 0 && (
<ul className="bo-list" style={{ marginTop: 12 }}>
{d.money.months.map((m) => (
<li key={m.period}>
<span className="bo-grow" style={{ textTransform: 'capitalize' }}>
{formatDateTime(locale as Locale, new Date(`${m.period}-15T12:00:00+04:00`), { month: 'long', year: 'numeric' })}
</span>
<span className="bo-small" style={{ opacity: 0.75 }}>
{t('ag.orders', { n: m.orders })} · {amd(m.revenue)}
</span>
<b className="bo-num">{amd(m.amount)}</b>
</li>
))}
</ul>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('sp.byOrder')}</h2>
</div>
{d.money.recent.length === 0 ? (
<p className="bo-hint">{t('sp.noIncome')}</p>
) : (
<ul className="bo-list">
{d.money.recent.slice(0, 20).map((e) => (
<li key={e.orderId}>
<a href={`#/orders/${e.orderId}`} className="bo-grow" style={{ color: 'inherit', textDecoration: 'none' }}>
<b>#{e.number}</b> <span className="bo-small bo-faint">{date(e.at)}</span>
<span className="bo-small bo-faint" style={{ display: 'block' }}>
{t('sp.paid', { sum: amd(e.paid) })}
{e.deal === 'PROFIT_SHARE' ? ` · ${t('sp.fotTax', { fot: amd(e.fot), tax: amd(e.tax) })}` : ` · −${e.pct}%`}
</span>
</a>
<b className="bo-num">{amd(e.amount)}</b>
</li>
))}
</ul>
)}
</section>
{d.note && (
<section className="bo-panel">
<p className="bo-side-panel__note" style={{ margin: 0 }}>
{d.note}
</p>
</section>
)}
</div>
</div>
{dlg === 'edit' && <PartnerDialog partner={d} onClose={() => setDlg(null)} onDone={refresh} />}
{dlg === 'payout' && <PartnerPayout id={d.id} balance={d.money.balance} onClose={() => setDlg(null)} onDone={refresh} />}
</>
);
}
type PartnerRow = {
id: string;
name: string;
contactPhone: string | null;
note: string | null;
active: boolean;
deal: Deal;
commissionPct: number;
profitSharePct: number;
fotPct: number | null;
taxPct: number | null;
defaults: { fotPct: number; taxPct: number };
};
function PartnerDialog({ partner, onClose, onDone }: { partner?: PartnerRow; onClose: () => void; onDone?: () => void }) {
const { t, amd } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const settings = useQuery({ ...trpc.bo.settings.get.queryOptions(), enabled: !partner });
const defaults = partner?.defaults ?? { fotPct: settings.data?.['partner.fotPct'] ?? 50, taxPct: settings.data?.['partner.taxPct'] ?? 10 };
const [f, setF] = useState({
name: partner?.name ?? '',
contactPhone: partner?.contactPhone ?? '',
note: partner?.note ?? '',
active: partner?.active ?? true,
deal: partner?.deal ?? ('COMMISSION' as Deal),
commissionPct: String(partner?.commissionPct ?? 30),
profitSharePct: String(partner?.profitSharePct ?? 50),
fotPct: partner?.fotPct == null ? '' : String(partner.fotPct),
taxPct: partner?.taxPct == null ? '' : String(partner.taxPct),
});
const num = (s: string) => Math.min(100, Number(s) || 0);
const opt = (s: string) => (s === '' ? null : num(s));
// пример на 20 000 ֏, чтобы было видно, как считается
const ex = 20_000;
const fot = opt(f.fotPct) ?? defaults.fotPct;
const tax = opt(f.taxPct) ?? defaults.taxPct;
const example =
f.deal === 'COMMISSION'
? t('sp.exampleCOMMISSION', { sum: amd(ex), ours: amd(Math.floor((ex * num(f.commissionPct)) / 100)), partner: amd(ex - Math.floor((ex * num(f.commissionPct)) / 100)) })
: (() => {
const fotS = Math.floor((ex * fot) / 100);
const taxS = Math.floor((ex * tax) / 100);
const profit = Math.max(0, ex - fotS - taxS);
const p = Math.floor((profit * num(f.profitSharePct)) / 100);
return t('sp.examplePROFIT_SHARE', { sum: amd(ex), fot: amd(fotS), tax: amd(taxS), profit: amd(profit), partner: amd(p), ours: amd(profit - p) });
})();
const save = useMutation(
trpc.bo.partners.saveServicePartner.mutationOptions({
onSuccess: (r) => {
void qc.invalidateQueries({ queryKey: trpc.bo.partners.servicePartners.queryKey() });
onDone?.();
onClose();
if (!partner) navigate(`/service-partners/${r.id}`);
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={partner ? t('sp.editTitle') : t('sp.add')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button
type="button"
className="bo-btn"
disabled={!f.name.trim() || save.isPending}
onClick={() =>
save.mutate({
id: partner?.id,
name: f.name,
contactPhone: f.contactPhone || null,
note: f.note || null,
active: f.active,
deal: f.deal,
commissionPct: num(f.commissionPct),
profitSharePct: num(f.profitSharePct),
fotPct: opt(f.fotPct),
taxPct: opt(f.taxPct),
})
}
>
{t('ui.save')}
</button>
</>
}
>
<div className="bo-row2">
<Field label={t('crm.name')}>
<input className="bo-input" value={f.name} maxLength={100} onChange={(e) => setF({ ...f, name: e.target.value })} />
</Field>
<Field label={t('sp.phone')}>
<input className="bo-input" value={f.contactPhone} maxLength={30} onChange={(e) => setF({ ...f, contactPhone: e.target.value })} />
</Field>
</div>
<Field label={t('sp.service')}>
<input className="bo-input" value={t('sp.cleaningOnly')} disabled />
</Field>
<Field label={t('sp.deal')}>
<div className="bo-chtabs">
{(['COMMISSION', 'PROFIT_SHARE'] as const).map((d) => (
<button key={d} type="button" aria-pressed={f.deal === d} onClick={() => setF({ ...f, deal: d })}>
{t(`sp.deal.${d}`)}
</button>
))}
</div>
</Field>
<p className="bo-hint" style={{ marginTop: -4 }}>
{t(`sp.dealHint.${f.deal}`)}
</p>
{f.deal === 'COMMISSION' ? (
<Field label={t('sp.commissionPct')}>
<input className="bo-input" inputMode="numeric" value={f.commissionPct} onChange={(e) => setF({ ...f, commissionPct: e.target.value.replace(/\D/g, '').slice(0, 3) })} />
</Field>
) : (
<>
<Field label={t('sp.profitSharePct')}>
<input className="bo-input" inputMode="numeric" value={f.profitSharePct} onChange={(e) => setF({ ...f, profitSharePct: e.target.value.replace(/\D/g, '').slice(0, 3) })} />
</Field>
<div className="bo-row2">
<Field label={t('sp.fotPct')} hint={t('sp.defaultHint', { n: defaults.fotPct })}>
<input className="bo-input" inputMode="numeric" placeholder={String(defaults.fotPct)} value={f.fotPct} onChange={(e) => setF({ ...f, fotPct: e.target.value.replace(/\D/g, '').slice(0, 3) })} />
</Field>
<Field label={t('sp.taxPct')} hint={t('sp.defaultHint', { n: defaults.taxPct })}>
<input className="bo-input" inputMode="numeric" placeholder={String(defaults.taxPct)} value={f.taxPct} onChange={(e) => setF({ ...f, taxPct: e.target.value.replace(/\D/g, '').slice(0, 3) })} />
</Field>
</div>
</>
)}
<p className="bo-side-panel__note" style={{ margin: '0 0 12px' }}>
{example}
</p>
<Field label={t('crm.note')}>
<textarea className="bo-textarea" style={{ minHeight: 56 }} value={f.note} maxLength={2000} onChange={(e) => setF({ ...f, note: e.target.value })} />
</Field>
<label className="bo-check">
<input type="checkbox" checked={f.active} onChange={(e) => setF({ ...f, active: e.target.checked })} />
{t('sp.activeLabel')}
</label>
</Dialog>
);
}
function PartnerPayout({ id, balance, onClose, onDone }: { id: string; balance: number; onClose: () => void; onDone: () => void }) {
const { t, amd } = useT();
const trpc = useTRPC();
const toast = useToast();
const [amount, setAmount] = useState(String(balance));
const [note, setNote] = useState('');
const save = useMutation(
trpc.bo.partners.servicePartnerPayout.mutationOptions({
onSuccess: () => {
onDone();
toast(t('prv.payoutDone'));
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={t('sp.payoutTitle')}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn" disabled={!Number(amount) || save.isPending} onClick={() => save.mutate({ id, amount: Number(amount), note: note || undefined })}>
{t('prv.payoutConfirm')}
</button>
</>
}
>
<Field label={t('prv.amount')} hint={t('ag.balanceHint', { sum: amd(balance) })}>
<input className="bo-input" inputMode="numeric" value={amount} onChange={(e) => setAmount(e.target.value.replace(/\D/g, ''))} />
</Field>
<Field label={t('prv.payoutNote')}>
<input className="bo-input" value={note} maxLength={300} onChange={(e) => setNote(e.target.value)} placeholder={t('prv.payoutNotePh')} />
</Field>
</Dialog>
);
}