Backoffice stage 3: orders and providers, auto-assignment

- Auto-assignment: a new order is offered to the best free provider (same ranking as
  the dispatcher hint); a decline or timeout passes it to the next one; nobody is
  offered the same order twice; after N tries it waits for a dispatcher and the team
  is notified once. The worker retries waiting orders every minute. Settings
  assign.auto / assign.maxAutoOffers (toggle on the Orders page, checkbox in old admin).
- Orders: list by day with groups (need a provider, offered, assigned, in progress,
  done, cancelled), periods and search; order card with progress, provider or ranked
  candidates ("offer" / "pick automatically"), details, checklist and photos,
  history in plain words, client, money (superadmin), payment, status change,
  reschedule (slots or custom time), cancel with fee.
- New order for a client (from the client card or the Orders page): params form,
  address, free slots for 14 days, price with the client's discount.
- Providers: list with rating, orders, declines, low ratings, next order; card with
  quality, ratings, upcoming orders, schedule and districts, admission documents,
  block/unblock with reason, new bot link, money and payouts (superadmin).
- Payout gets paidAt/note/createdById; Provider gets blockedReason/blockedAt/adminNote
  and documents (ProviderDoc).
- Tests for auto-assignment.

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 02:25:41 +04:00
1 parent 2772837e29
commit e7de4f5893
33 files changed
+4521 -45

No files matched your search

+10 -6
View File
@@ -34,12 +34,16 @@ export function Settings() {
<div className="form-grid"> <div className="form-grid">
{SETTING_KEYS.map((k) => ( {SETTING_KEYS.map((k) => (
<Field key={k} label={t(`admin.settings.${k.replace('.', '_')}`)}> <Field key={k} label={t(`admin.settings.${k.replace('.', '_')}`)}>
<input {typeof draft[k] === 'boolean' ? (
className="bc-input" <input type="checkbox" checked={draft[k] as boolean} onChange={(e) => setDraft({ ...draft, [k]: e.target.checked })} style={{ width: 22, height: 22 }} />
type={typeof draft[k] === 'number' ? 'number' : 'text'} ) : (
value={String(draft[k])} <input
onChange={(e) => setDraft({ ...draft, [k]: typeof draft[k] === 'number' ? Number(e.target.value) : e.target.value })} className="bc-input"
/> type={typeof draft[k] === 'number' ? 'number' : 'text'}
value={String(draft[k])}
onChange={(e) => setDraft({ ...draft, [k]: typeof draft[k] === 'number' ? Number(e.target.value) : e.target.value })}
/>
)}
</Field> </Field>
))} ))}
</div> </div>
+5 -3
View File
@@ -82,9 +82,11 @@ export const boChatsRouter = router({
}), }),
/** Написать клиенту из карточки: открывает диалог в канале, куда он пишет (или в указанном) */ /** Написать клиенту из карточки: открывает диалог в канале, куда он пишет (или в указанном) */
openFor: chatProcedure.input(z.object({ clientId: z.string(), channel: z.enum(CHANNELS).optional() })).mutation(async ({ ctx, input }) => { openFor: chatProcedure.input(z.object({ clientId: z.string().optional(), providerId: z.string().optional(), channel: z.enum(CHANNELS).optional() })).mutation(async ({ ctx, input }) => {
const channel = input.channel ?? (ctx.channels.length === 1 ? ctx.channels[0] : undefined); if (!input.clientId && !input.providerId) throw new TRPCError({ code: 'BAD_REQUEST' });
const t = await openThreadFor(ctx.svc, { clientId: input.clientId }, channel); // исполнитель пишет только в поддержку
const channel = input.providerId ? 'SUPPORT' : (input.channel ?? (ctx.channels.length === 1 ? ctx.channels[0] : undefined));
const t = await openThreadFor(ctx.svc, input.providerId ? { providerId: input.providerId } : { clientId: input.clientId! }, channel);
if (!ctx.channels.includes(t.channel)) throw new TRPCError({ code: 'FORBIDDEN', message: 'ROLE_FORBIDDEN' }); if (!ctx.channels.includes(t.channel)) throw new TRPCError({ code: 'FORBIDDEN', message: 'ROLE_FORBIDDEN' });
return { id: t.id }; return { id: t.id };
}), }),
+4
View File
@@ -3,6 +3,8 @@ import { boAuthRouter, boTeamRouter } from './auth.ts';
import { boClientsRouter, boSegmentsRouter } from './clients.ts'; import { boClientsRouter, boSegmentsRouter } from './clients.ts';
import { boDashboardRouter } from './dashboard.ts'; import { boDashboardRouter } from './dashboard.ts';
import { boChatsRouter } from './chats.ts'; import { boChatsRouter } from './chats.ts';
import { boOrdersRouter } from './orders.ts';
import { boProvidersRouter } from './providers.ts';
/** API новой админки (apps/backoffice) */ /** API новой админки (apps/backoffice) */
export const backofficeRouter = router({ export const backofficeRouter = router({
@@ -12,4 +14,6 @@ export const backofficeRouter = router({
clients: boClientsRouter, clients: boClientsRouter,
segments: boSegmentsRouter, segments: boSegmentsRouter,
chats: boChatsRouter, chats: boChatsRouter,
orders: boOrdersRouter,
providers: boProvidersRouter,
}); });
+397
View File
@@ -0,0 +1,397 @@
import { DAY, getFlow, kanbanColumn, ORDER_STATUSES, startOfYerevanDay, type ServiceFlow } from '@barev/core';
import type { Db, Prisma } from '@barev/db';
import {
assignmentCandidates,
autoAssign,
availableSlots,
breakdownOf,
createOrder,
getServiceByCode,
getSettings,
markPaid,
offerOrder,
orderPayout,
quoteForClient,
rescheduleByAdmin,
transitionOrder,
unassignOrder,
} from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { rolesProcedure, router, superProcedure } from '../../trpc.ts';
/** Заказы ведёт поддержка (диспетчер); суперадмину — всё */
const ordersProcedure = rolesProcedure('SUPPORT');
/** Группы списка: ждёт исполнителя · предложен · назначен · в работе · готово · отменён */
export const ORDER_GROUPS = ['new', 'pending', 'assigned', 'in_progress', 'done', 'cancelled'] as const;
type Group = (typeof ORDER_GROUPS)[number];
const name = (u: { firstName: string | null; lastName: string | null; username: string | null }) =>
[u.firstName, u.lastName].filter(Boolean).join(' ') || (u.username ? `@${u.username}` : '—');
const listInclude = {
service: { select: { code: true, flow: true, category: { select: { code: true } } } },
district: { select: { code: true } },
provider: { select: { id: true, name: true } },
company: { select: { id: true, name: true } },
client: { include: { user: { select: { firstName: true, lastName: true, username: true } } } },
offers: { where: { status: 'OFFERED' as const }, include: { provider: { select: { name: true } }, company: { select: { name: true } } } },
rating: { select: { score: true } },
history: { where: { event: { in: ['autoNoCandidate', 'autoGaveUp', 'autoOffered', 'offered', 'assignedToCompany'] } }, orderBy: { createdAt: 'desc' as const }, take: 1 },
} satisfies Prisma.OrderInclude;
type ListRow = Prisma.OrderGetPayload<{ include: typeof listInclude }>;
function groupOf(o: ListRow): Group {
const col = kanbanColumn(o.service.flow as ServiceFlow, o.status, { hasProvider: !!o.providerId, hasPendingOffer: o.offers.length > 0, hasCompany: !!o.companyId });
if (col === 'assigned' && !o.providerId) return 'pending';
return col === 'new' ? 'new' : col;
}
function rowView(o: ListRow, showMoney: boolean) {
const offer = o.offers[0];
const lastAuto = o.history[0]?.event ?? null;
return {
id: o.id,
number: o.number,
service: o.service.code,
category: o.service.category.code,
flow: o.service.flow,
status: o.status,
group: groupOf(o),
scheduledAt: o.scheduledAt,
durationMinutes: o.durationMinutes,
district: o.district?.code ?? null,
address: o.addressText,
isOnline: o.isOnline,
client: { id: o.clientId, name: name(o.client.user) },
provider: o.provider,
company: o.company,
/** ждёт ответа: кому предложено и до какого времени; auto — предложила система */
pending: offer ? { name: offer.provider?.name ?? offer.company?.name ?? '', expiresAt: offer.expiresAt, auto: !offer.createdById, company: !offer.providerId } : null,
/** автораспределение не нашло исполнителя — нужен человек */
needsDispatcher: !o.providerId && !offer && (lastAuto === 'autoNoCandidate' || lastAuto === 'autoGaveUp'),
total: showMoney ? o.totalPrice : null,
paymentStatus: o.paymentStatus,
rating: o.rating?.score ?? null,
};
}
const periodInput = z.enum(['today', 'tomorrow', 'week', 'past', 'all']);
function periodWhere(period: z.infer<typeof periodInput>, now: Date): Prisma.OrderWhereInput {
const today = startOfYerevanDay(now);
switch (period) {
case 'today':
return { scheduledAt: { gte: today, lt: new Date(today.getTime() + DAY) } };
case 'tomorrow':
return { scheduledAt: { gte: new Date(today.getTime() + DAY), lt: new Date(today.getTime() + 2 * DAY) } };
case 'week':
return { scheduledAt: { gte: today, lt: new Date(today.getTime() + 7 * DAY) } };
case 'past':
return { scheduledAt: { lt: today } };
default:
return {};
}
}
function searchWhere(q: string | undefined): Prisma.OrderWhereInput {
const s = q?.trim();
if (!s) return {};
const n = Number(s.replace(/^[#№]/, ''));
return {
OR: [
...(Number.isInteger(n) && n > 0 ? [{ number: n }] : []),
{ addressText: { contains: s, mode: 'insensitive' as const } },
{ client: { user: { OR: [{ firstName: { contains: s, mode: 'insensitive' as const } }, { lastName: { contains: s, mode: 'insensitive' as const } }, { username: { contains: s.replace(/^@/, ''), mode: 'insensitive' as const } }, { phone: { contains: s } }] } } },
{ provider: { name: { contains: s, mode: 'insensitive' as const } } },
],
};
}
/** Активные заказы и закрытые за последние 7 дней — из них считаем группы */
async function loadRows(db: Db, where: Prisma.OrderWhereInput, now: Date) {
const weekAgo = new Date(now.getTime() - 7 * DAY);
return db.order.findMany({
where: { AND: [where, { OR: [{ status: { notIn: ['DONE', 'COMPLETED', 'PLAN_SENT', 'CANCELLED', 'LESSON_DONE'] } }, { updatedAt: { gte: weekAgo } }, { scheduledAt: { gte: weekAgo } }] }] },
include: listInclude,
orderBy: { scheduledAt: 'asc' },
take: 600,
});
}
/** История заказа понятными словами: кто (имя сотрудника / исполнителя / клиента / система) */
async function historyView(db: Db, o: { client: { user: { firstName: string | null; lastName: string | null; username: string | null } }; history: { id: string; fromStatus: string | null; toStatus: string; event: string | null; actorType: string; actorId: string | null; comment: string | null; createdAt: Date }[] }) {
const ids = (t: string) => [...new Set(o.history.filter((h) => h.actorType === t && h.actorId).map((h) => h.actorId!))];
const [admins, providers, dispatchers] = await Promise.all([
db.adminUser.findMany({ where: { id: { in: ids('ADMIN') } }, select: { id: true, name: true } }),
db.provider.findMany({ where: { id: { in: [...ids('PROVIDER'), ...ids('SYSTEM')] } }, select: { id: true, name: true } }),
db.user.findMany({ where: { id: { in: ids('DISPATCHER') } }, select: { id: true, firstName: true, lastName: true, username: true } }),
]);
const who = new Map<string, string>([...admins.map((a) => [a.id, a.name] as const), ...providers.map((p) => [p.id, p.name] as const), ...dispatchers.map((u) => [u.id, name(u)] as const)]);
return o.history.map((h) => ({
id: h.id,
from: h.fromStatus,
to: h.toStatus,
event: h.event,
actor: h.actorType,
actorName: h.actorType === 'CLIENT' ? name(o.client.user) : h.actorId ? (who.get(h.actorId) ?? null) : null,
comment: h.comment,
at: h.createdAt,
}));
}
const idInput = z.object({ id: z.string() });
export const boOrdersRouter = router({
/** Счётчики групп (без учёта фильтра группы) */
counts: ordersProcedure.input(z.object({ period: periodInput.default('all'), search: z.string().max(100).optional() }).optional()).query(async ({ ctx, input }) => {
const now = ctx.svc.now();
const rows = await loadRows(ctx.db, { ...periodWhere(input?.period ?? 'all', now), ...searchWhere(input?.search) }, now);
const counts = Object.fromEntries(ORDER_GROUPS.map((g) => [g, 0])) as Record<Group, number>;
let needsDispatcher = 0;
for (const r of rows) {
const v = rowView(r, false);
counts[v.group]++;
if (v.needsDispatcher) needsDispatcher++;
}
return { ...counts, needsDispatcher };
}),
list: ordersProcedure
.input(
z.object({
group: z.enum([...ORDER_GROUPS, 'active', 'all']).default('active'),
period: periodInput.default('all'),
search: z.string().max(100).optional(),
service: z.string().max(40).optional(),
providerId: z.string().optional(),
clientId: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const now = ctx.svc.now();
const where: Prisma.OrderWhereInput = {
...periodWhere(input.period, now),
...searchWhere(input.search),
...(input.service ? { service: { code: input.service } } : {}),
...(input.providerId ? { providerId: input.providerId } : {}),
...(input.clientId ? { clientId: input.clientId } : {}),
};
const rows = (await loadRows(ctx.db, where, now)).map((r) => rowView(r, ctx.isSuper));
const filtered = rows.filter((r) => (input.group === 'all' ? true : input.group === 'active' ? !['done', 'cancelled'].includes(r.group) : r.group === input.group));
// закрытые — свежие сверху; активные — ближайшие сверху
if (input.group === 'done' || input.group === 'cancelled') filtered.reverse();
return filtered;
}),
get: ordersProcedure.input(idInput).query(async ({ ctx, input }) => {
const o = await ctx.db.order.findUnique({
where: { id: input.id },
include: {
service: { include: { category: true } },
district: true,
provider: { include: { company: true, payoutPreset: true, user: { select: { username: true } } } },
company: true,
client: { include: { user: true, subscriptions: { where: { status: 'ACTIVE' } } } },
history: { orderBy: { createdAt: 'asc' } },
checklist: { orderBy: { sortOrder: 'asc' } },
offers: { orderBy: { createdAt: 'desc' }, include: { provider: { select: { name: true } }, company: { select: { name: true } } } },
files: { where: { deletedAt: null }, select: { id: true, kind: true, mime: true, createdAt: true } },
paymentMethod: true,
rating: true,
},
});
if (!o) throw new TRPCError({ code: 'NOT_FOUND' });
const flow = getFlow(o.service.flow);
const now = ctx.svc.now();
// «Назначен» — только через предложение исполнителю; «Новый» — через «Снять исполнителя»
const adminTransitions = [...new Set(flow.transitions.filter((t) => t.from === o.status && t.by.includes('ADMIN') && t.to !== flow.initial && t.to !== flow.assigned).map((t) => t.to))];
const pending = o.offers.find((x) => x.status === 'OFFERED');
const adminIds = [...new Set(o.offers.map((x) => x.createdById).filter((x): x is string => !!x))];
const admins = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: adminIds } }, select: { id: true, name: true } })).map((a) => [a.id, a.name]));
const payout = ctx.isSuper && o.provider ? orderPayout(o, o.provider) : null;
const sub = o.client.subscriptions.find((s) => s.startsAt <= now && s.endsAt > now);
return {
id: o.id,
number: o.number,
service: o.service.code,
category: o.service.category.code,
flow: o.service.flow,
status: o.status,
steps: flow.steps,
group: kanbanColumn(o.service.flow as ServiceFlow, o.status, { hasProvider: !!o.providerId, hasPendingOffer: !!pending, hasCompany: !!o.companyId }),
scheduledAt: o.scheduledAt,
durationMinutes: o.durationMinutes,
params: o.params as Record<string, unknown>,
isOnline: o.isOnline,
district: o.district?.code ?? null,
address: o.addressText,
note: o.note,
locale: o.locale,
createdAt: o.createdAt,
client: { id: o.clientId, name: name(o.client.user), username: o.client.user.username, phone: o.client.user.phone, plan: sub?.plan ?? null },
provider: o.provider ? { id: o.provider.id, name: o.provider.name, phone: o.provider.phone, username: o.provider.user?.username ?? null } : null,
company: o.company ? { id: o.company.id, name: o.company.name } : null,
pending: pending ? { id: pending.id, name: pending.provider?.name ?? pending.company?.name ?? '', expiresAt: pending.expiresAt, auto: !pending.createdById, company: !pending.providerId } : null,
offers: o.offers.map((x) => ({
id: x.id,
name: x.provider?.name ?? x.company?.name ?? '',
company: !x.providerId,
status: x.status,
by: x.createdById ? (admins.get(x.createdById) ?? null) : null,
createdAt: x.createdAt,
respondedAt: x.respondedAt,
})),
history: await historyView(ctx.db, o),
checklist: o.checklist.map((c) => ({ code: c.code, done: !!c.checkedAt, requiresPhoto: c.requiresPhoto, fileId: c.fileId })),
photos: o.files.filter((f) => f.kind === 'PHOTO_REPORT').map((f) => f.id),
rating: o.rating ? { score: o.rating.score, comment: o.rating.comment } : null,
// деньги — только суперадмину; статус оплаты видят все (поддержка отмечает оплату наличными)
money: ctx.isSuper
? { base: o.basePrice, discountPct: o.discountPct, discount: o.discountAmount, total: o.totalPrice, cancelFee: o.cancelFee, payout, breakdown: breakdownOf(o) }
: null,
paymentStatus: o.paymentStatus,
paidAt: o.paidAt,
paymentMethod: o.paymentMethod ? (o.paymentMethod.name as Record<string, string>) : null,
cancelReason: o.cancelReason,
adminTransitions,
canAssign: o.status === flow.initial && !o.providerId,
canUnassign: (o.status === flow.initial && (!!pending || !!o.companyId)) || (o.status === flow.assigned && !!o.providerId),
canReschedule: o.status === flow.initial || o.status === flow.assigned,
canCancel: o.status !== 'CANCELLED' && !flow.done.includes(o.status),
};
}),
/** Подсказка: свободные кандидаты по рейтингу подбора и занятые */
candidates: ordersProcedure.input(idInput).query(async ({ ctx, input }) => {
const { ranked, busy } = await assignmentCandidates(ctx.svc, input.id);
const tried = new Set((await ctx.db.orderOffer.findMany({ where: { orderId: input.id }, select: { providerId: true, companyId: true, status: true } })).filter((o) => o.status !== 'OFFERED').map((o) => o.providerId ?? o.companyId));
const view = (c: (typeof ranked)[number] | (typeof busy)[number]) => ({
kind: c.kind,
id: c.id,
name: c.name,
languages: c.languages,
rating: c.rating,
load: c.load,
onShift: c.onShift,
reasons: 'reasons' in c ? c.reasons : [],
tried: tried.has(c.id),
});
return { free: ranked.map(view), busy: busy.map(view) };
}),
/** Предложить конкретному исполнителю или компании (текущее предложение отзывается) */
assign: ordersProcedure
.input(z.object({ id: z.string(), target: z.object({ kind: z.enum(['provider', 'company']), id: z.string() }) }))
.mutation(({ ctx, input }) => offerOrder(ctx.svc, { orderId: input.id, adminId: ctx.admin.id, target: input.target }).then((o) => ({ offerId: o.id }))),
/** «Подобрать автоматически» — следующий лучший, даже если автораспределение выключено */
auto: ordersProcedure.input(idInput).mutation(({ ctx, input }) => autoAssign(ctx.svc, input.id, { force: true })),
/** Снять исполнителя / отозвать предложение — заказ снова ждёт исполнителя */
unassign: ordersProcedure.input(idInput).mutation(({ ctx, input }) => unassignOrder(ctx.svc, { orderId: input.id, adminId: ctx.admin.id })),
/** Сменить статус вручную — те же правила, что у исполнителя */
transition: ordersProcedure
.input(z.object({ id: z.string(), to: z.enum(ORDER_STATUSES), comment: z.string().max(500).optional() }))
.mutation(async ({ ctx, input }) => {
await transitionOrder(ctx.svc, { orderId: input.id, to: input.to, actor: 'ADMIN', actorId: ctx.admin.id, comment: input.comment });
}),
/** Отмена командой; удержание — вручную (по умолчанию 0) */
cancel: ordersProcedure
.input(z.object({ id: z.string(), reason: z.string().trim().min(1).max(500), fee: z.number().int().min(0).default(0) }))
.mutation(async ({ ctx, input }) => {
await transitionOrder(ctx.svc, { orderId: input.id, to: 'CANCELLED', actor: 'ADMIN', actorId: ctx.admin.id, comment: input.reason, data: { cancelFee: input.fee, cancelReason: input.reason } });
await ctx.db.orderOffer.updateMany({ where: { orderId: input.id, status: 'OFFERED' }, data: { status: 'REVOKED', respondedAt: new Date() } });
}),
reschedule: ordersProcedure
.input(z.object({ id: z.string(), scheduledAt: z.date(), allowBusySlot: z.boolean().default(false), comment: z.string().max(500).optional() }))
.mutation(({ ctx, input }) => rescheduleByAdmin(ctx.svc, { orderId: input.id, adminId: ctx.admin.id, scheduledAt: input.scheduledAt, allowBusySlot: input.allowBusySlot, comment: input.comment })),
markPaid: ordersProcedure
.input(z.object({ id: z.string(), paymentMethodId: z.string().optional() }))
.mutation(({ ctx, input }) => markPaid(ctx.svc, { orderId: input.id, by: { type: 'ADMIN', id: ctx.admin.id }, paymentMethodId: input.paymentMethodId })),
// ───── новый заказ за клиента ─────
/** Услуги, которые можно заказать сейчас, с формой параметров */
services: ordersProcedure.query(async ({ ctx }) => {
const list = await ctx.db.service.findMany({ where: { active: true }, orderBy: { sortOrder: 'asc' }, select: { code: true } });
return Promise.all(
list.map(async ({ code }) => {
const s = await getServiceByCode(ctx.db, code);
return { code: s.code, category: s.category, isOnline: s.isOnline, paramsSchema: s.paramsSchema };
}),
);
}),
dictionaries: ordersProcedure.query(async ({ ctx }) => ({
districts: (await ctx.db.district.findMany({ orderBy: { sortOrder: 'asc' } })).map((d) => ({ id: d.id, code: d.code })),
paymentMethods: (await ctx.db.paymentMethod.findMany({ where: { active: true }, orderBy: { sortOrder: 'asc' } })).map((m) => ({ id: m.id, code: m.code, name: m.name as Record<string, string> })),
})),
/** Цена и длительность для клиента (с его скидкой по подписке) */
quote: ordersProcedure
.input(z.object({ clientId: z.string(), service: z.string(), params: z.record(z.string(), z.unknown()) }))
.query(async ({ ctx, input }) => {
const { quote: q, membership } = await quoteForClient(ctx.svc, input.clientId, input.service, input.params);
return { durationMinutes: q.durationMinutes, total: q.totalPrice, base: q.basePrice, discountPct: q.discountPct, level: membership.level };
}),
slots: ordersProcedure
.input(z.object({ service: z.string(), params: z.record(z.string(), z.unknown()), dateKey: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), excludeOrderId: z.string().optional() }))
.query(async ({ ctx, input }) => (await availableSlots(ctx.svc, { serviceCode: input.service, params: input.params, dateKey: input.dateKey, excludeOrderId: input.excludeOrderId })).map((s) => ({ start: s.start, capacity: s.capacity }))),
create: ordersProcedure
.input(
z.object({
clientId: z.string(),
service: z.string(),
params: z.record(z.string(), z.unknown()),
scheduledAt: z.date(),
addressText: z.string().trim().max(300).optional(),
districtId: z.string().optional(),
note: z.string().trim().max(1000).optional(),
paymentMethodId: z.string().optional(),
allowBusySlot: z.boolean().default(false),
}),
)
.mutation(async ({ ctx, input }) => {
const o = await createOrder(ctx.svc, {
clientId: input.clientId,
serviceCode: input.service,
params: input.params,
scheduledAt: input.scheduledAt,
addressText: input.addressText,
districtId: input.districtId,
note: input.note,
paymentMethodId: input.paymentMethodId,
allowBusySlot: input.allowBusySlot,
createdBy: { type: 'ADMIN', id: ctx.admin.id },
});
return { id: o.id, number: o.number };
}),
// ───── автораспределение ─────
autoSetting: ordersProcedure.query(async ({ ctx }) => {
const s = await getSettings(ctx.db);
return { auto: s['assign.auto'], maxTries: s['assign.maxAutoOffers'], timeoutMinutes: s['offer.acceptTimeoutMinutes'] };
}),
setAutoSetting: superProcedure
.input(z.object({ auto: z.boolean().optional(), maxTries: z.number().int().min(1).max(20).optional(), timeoutMinutes: z.number().int().min(1).max(1440).optional() }))
.mutation(async ({ ctx, input }) => {
const pairs: [string, unknown][] = [
['assign.auto', input.auto],
['assign.maxAutoOffers', input.maxTries],
['offer.acceptTimeoutMinutes', input.timeoutMinutes],
];
for (const [key, value] of pairs) {
if (value === undefined) continue;
await ctx.db.setting.upsert({ where: { key }, create: { key, value: value as never }, update: { value: value as never } });
}
}),
});
@@ -0,0 +1,258 @@
import { randomBytes } from 'node:crypto';
import { averageRating, DAY, LOCALES, PROVIDER_ROLES, startOfYerevanDay } from '@barev/core';
import type { Prisma } from '@barev/db';
import { orderPayout, rateLabel } from '@barev/services';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { env } from '../../env.ts';
import { rolesProcedure, router, superProcedure } from '../../trpc.ts';
/** Исполнителей ведёт поддержка; ставки и деньги — только суперадмин */
const providersProcedure = rolesProcedure('SUPPORT');
const invite = () => `PRO-${randomBytes(5).toString('hex').toUpperCase()}`;
const inviteUrl = (code: string) => `https://t.me/${env.PRO_BOT_USERNAME}?start=${code}`;
const DONE = { completedAt: { not: null }, status: { not: 'CANCELLED' as const } } satisfies Prisma.OrderWhereInput;
const weekSchedule = z.array(z.object({ weekday: z.number().int().min(0).max(6), startMinute: z.number().int().min(0).max(1440), endMinute: z.number().int().min(0).max(1440) })).max(21);
const profileInput = z.object({
name: z.string().trim().min(1).max(100),
phone: z.string().trim().max(30).nullable().optional(),
role: z.enum(PROVIDER_ROLES),
companyId: z.string().nullable().optional(),
languages: z.array(z.enum(LOCALES)).min(1),
districtIds: z.array(z.string()).max(30),
schedule: weekSchedule,
adminNote: z.string().max(4000).nullable().optional(),
/** ставка — меняет только суперадмин */
rate: z.object({ rateType: z.enum(['FIXED', 'PERCENT']), rateValue: z.number().int().min(0), payoutPresetId: z.string().nullable() }).optional(),
});
const payoutInclude = { company: { select: { rateType: true, rateValue: true } }, payoutPreset: { select: { name: true, rules: true } } } as const;
export const boProvidersRouter = router({
list: providersProcedure
.input(z.object({ role: z.enum(PROVIDER_ROLES).optional(), status: z.enum(['active', 'blocked', 'all']).default('active'), search: z.string().trim().max(100).optional() }))
.query(async ({ ctx, input }) => {
const now = ctx.svc.now();
const from30 = new Date(now.getTime() - 30 * DAY);
const today = startOfYerevanDay(now);
const providers = await ctx.db.provider.findMany({
where: {
...(input.role ? { role: input.role } : {}),
...(input.status === 'active' ? { active: true } : input.status === 'blocked' ? { active: false } : {}),
...(input.search ? { OR: [{ name: { contains: input.search, mode: 'insensitive' } }, { phone: { contains: input.search } }] } : {}),
},
orderBy: [{ active: 'desc' }, { name: 'asc' }],
include: { company: { select: { id: true, name: true } }, districts: { include: { district: { select: { code: true } } } }, _count: { select: { docs: true } } },
});
const ids = providers.map((p) => p.id);
const [done30, refused30, low30, upcoming] = await Promise.all([
ctx.db.order.groupBy({ by: ['providerId'], where: { providerId: { in: ids }, ...DONE, completedAt: { gte: from30 } }, _count: true }),
ctx.db.orderOffer.groupBy({ by: ['providerId'], where: { providerId: { in: ids }, status: { in: ['DECLINED', 'EXPIRED'] }, createdAt: { gte: from30 } }, _count: true }),
ctx.db.rating.groupBy({ by: ['providerId'], where: { providerId: { in: ids }, score: { lte: 3 }, createdAt: { gte: from30 } }, _count: true }),
ctx.db.order.findMany({
where: { providerId: { in: ids }, scheduledAt: { gte: today }, status: { notIn: ['CANCELLED', 'DONE', 'COMPLETED', 'LESSON_DONE'] } },
orderBy: { scheduledAt: 'asc' },
select: { providerId: true, scheduledAt: true, number: true, id: true },
}),
]);
const by = <T extends { providerId: string | null; _count: number }>(rows: T[]) => new Map(rows.map((r) => [r.providerId, r._count]));
const done = by(done30);
const refused = by(refused30);
const low = by(low30);
const tomorrow = new Date(today.getTime() + DAY);
return providers.map((p) => {
const mine = upcoming.filter((o) => o.providerId === p.id);
return {
id: p.id,
name: p.name,
phone: p.phone,
role: p.role,
company: p.company,
active: p.active,
blockedReason: p.blockedReason,
onShift: p.onShift,
linked: !!p.userId,
languages: p.languages,
districts: p.districts.map((d) => d.district.code),
rating: averageRating(p.ratingSum, p.ratingCount),
ratingCount: p.ratingCount,
done30: done.get(p.id) ?? 0,
refused30: refused.get(p.id) ?? 0,
low30: low.get(p.id) ?? 0,
today: mine.filter((o) => o.scheduledAt < tomorrow).length,
next: mine[0] ? { id: mine[0].id, number: mine[0].number, scheduledAt: mine[0].scheduledAt } : null,
docs: p._count.docs,
};
});
}),
get: providersProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const now = ctx.svc.now();
const from30 = new Date(now.getTime() - 30 * DAY);
const p = await ctx.db.provider.findUnique({
where: { id: input.id },
include: {
...payoutInclude,
company: { select: { id: true, name: true, rateType: true, rateValue: true } },
user: { select: { username: true, firstName: true } },
districts: true,
schedule: { orderBy: [{ weekday: 'asc' }, { startMinute: 'asc' }] },
docs: { orderBy: { createdAt: 'desc' } },
},
});
if (!p) throw new TRPCError({ code: 'NOT_FOUND' });
const [ratings, offers30, cancelled30, upcoming, recent] = await Promise.all([
ctx.db.rating.findMany({ where: { providerId: p.id }, orderBy: { createdAt: 'desc' }, take: 20, include: { order: { select: { id: true, number: true } }, client: { include: { user: { select: { firstName: true } } } } } }),
ctx.db.orderOffer.groupBy({ by: ['status'], where: { providerId: p.id, createdAt: { gte: from30 } }, _count: true }),
ctx.db.orderStatusHistory.count({ where: { event: 'unassigned', createdAt: { gte: from30 }, order: { offers: { some: { providerId: p.id, status: 'ACCEPTED' } } } } }),
ctx.db.order.findMany({
where: { providerId: p.id, scheduledAt: { gte: startOfYerevanDay(now) }, status: { notIn: ['CANCELLED', 'DONE', 'COMPLETED', 'LESSON_DONE'] } },
orderBy: { scheduledAt: 'asc' },
take: 10,
include: { service: { select: { code: true, flow: true } }, district: { select: { code: true } } },
}),
ctx.db.order.findMany({
where: { providerId: p.id, ...DONE },
orderBy: { completedAt: 'desc' },
take: 15,
include: { service: { select: { code: true, pricing: true, paramsSchema: true } }, rating: { select: { score: true } } },
}),
]);
const offerCount = (s: string) => offers30.find((o) => o.status === s)?._count ?? 0;
// деньги: заработано (по ставке исполнителя) минус выплачено = к выплате
let money = null;
if (ctx.isSuper) {
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const prevMonthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
const [doneAll, payouts] = await Promise.all([
ctx.db.order.findMany({ where: { providerId: p.id, ...DONE }, select: { completedAt: true, totalPrice: true, basePrice: true, params: true, priceBreakdown: true, service: { select: { pricing: true, paramsSchema: true } } } }),
ctx.db.payout.findMany({ where: { providerId: p.id }, orderBy: { createdAt: 'desc' } }),
]);
const earn = doneAll.map((o) => ({ at: o.completedAt!, sum: orderPayout(o, p) }));
const sum = (f: (x: (typeof earn)[number]) => boolean) => earn.filter(f).reduce((s, x) => s + x.sum, 0);
const earnedTotal = sum(() => true);
const paidTotal = payouts.filter((x) => x.status === 'PAID').reduce((s, x) => s + x.amount, 0);
const admins = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: payouts.map((x) => x.createdById).filter((x): x is string => !!x) } }, select: { id: true, name: true } })).map((a) => [a.id, a.name]));
money = {
rate: rateLabel(p),
thisMonth: sum((x) => x.at >= monthStart),
prevMonth: sum((x) => x.at >= prevMonthStart && x.at < monthStart),
earnedTotal,
paidTotal,
balance: earnedTotal - paidTotal,
payouts: payouts.map((x) => ({ id: x.id, amount: x.amount, status: x.status, paidAt: x.paidAt, note: x.note, by: x.createdById ? (admins.get(x.createdById) ?? null) : null, periodStart: x.periodStart, periodEnd: x.periodEnd })),
presets: (await ctx.db.payoutPreset.findMany({ where: { active: true, role: p.role }, orderBy: { sortOrder: 'asc' }, select: { id: true, name: true } })),
rateType: p.rateType,
rateValue: p.rateValue,
payoutPresetId: p.payoutPresetId,
};
}
return {
id: p.id,
name: p.name,
phone: p.phone,
role: p.role,
company: p.company ? { id: p.company.id, name: p.company.name } : null,
languages: p.languages,
districtIds: p.districts.map((d) => d.districtId),
schedule: p.schedule.map((s) => ({ weekday: s.weekday, startMinute: s.startMinute, endMinute: s.endMinute })),
active: p.active,
blockedReason: p.blockedReason,
blockedAt: p.blockedAt,
adminNote: p.adminNote,
onShift: p.onShift,
linked: !!p.userId,
username: p.user?.username ?? null,
inviteUrl: p.userId ? null : inviteUrl(p.inviteCode),
createdAt: p.createdAt,
docs: p.docs.map((d) => ({ id: d.id, title: d.title, fileName: d.fileName, mime: d.mime, size: d.size, createdAt: d.createdAt })),
quality: {
rating: averageRating(p.ratingSum, p.ratingCount),
ratingCount: p.ratingCount,
accepted30: offerCount('ACCEPTED'),
declined30: offerCount('DECLINED'),
expired30: offerCount('EXPIRED'),
unassigned30: cancelled30,
low30: ratings.filter((r) => r.score <= 3 && r.createdAt >= from30).length,
},
ratings: ratings.map((r) => ({ id: r.id, score: r.score, comment: r.comment, orderId: r.order.id, orderNumber: r.order.number, client: r.client.user.firstName, at: r.createdAt })),
upcoming: upcoming.map((o) => ({ id: o.id, number: o.number, service: o.service.code, flow: o.service.flow, scheduledAt: o.scheduledAt, status: o.status, district: o.district?.code ?? null })),
recent: recent.map((o) => ({ id: o.id, number: o.number, completedAt: o.completedAt, rating: o.rating?.score ?? null })),
money,
};
}),
/** Справочники для формы */
dictionaries: providersProcedure.query(async ({ ctx }) => ({
districts: (await ctx.db.district.findMany({ orderBy: { sortOrder: 'asc' } })).map((d) => ({ id: d.id, code: d.code })),
companies: await ctx.db.providerCompany.findMany({ where: { active: true }, orderBy: { name: 'asc' }, select: { id: true, name: true, roles: true } }),
})),
create: providersProcedure.input(profileInput).mutation(async ({ ctx, input }) => {
const { districtIds, schedule, rate, ...data } = input;
const p = await ctx.db.provider.create({
data: {
...data,
...(ctx.isSuper && rate ? rate : {}),
inviteCode: invite(),
districts: { create: districtIds.map((districtId) => ({ districtId })) },
schedule: { create: schedule.filter((s) => s.endMinute > s.startMinute) },
},
});
return { id: p.id, inviteUrl: inviteUrl(p.inviteCode) };
}),
update: providersProcedure.input(profileInput.extend({ id: z.string() })).mutation(async ({ ctx, input }) => {
const { id, districtIds, schedule, rate, ...data } = input;
await ctx.db.provider.update({
where: { id },
data: {
...data,
...(ctx.isSuper && rate ? rate : {}),
districts: { deleteMany: {}, create: districtIds.map((districtId) => ({ districtId })) },
schedule: { deleteMany: {}, create: schedule.filter((s) => s.endMinute > s.startMinute) },
},
});
}),
/** Допуск: заблокировать (с причиной) или вернуть к работе. Заблокированному заказы не предлагаются */
setActive: providersProcedure
.input(z.object({ id: z.string(), active: z.boolean(), reason: z.string().trim().max(500).optional() }))
.mutation(async ({ ctx, input }) => {
if (!input.active && !input.reason) throw new TRPCError({ code: 'BAD_REQUEST', message: 'REASON_REQUIRED' });
await ctx.db.provider.update({
where: { id: input.id },
data: input.active ? { active: true, blockedReason: null, blockedAt: null } : { active: false, onShift: false, blockedReason: input.reason, blockedAt: ctx.svc.now() },
});
// заблокирован — действующие предложения ему отзываем
if (!input.active) await ctx.db.orderOffer.updateMany({ where: { providerId: input.id, status: 'OFFERED' }, data: { status: 'REVOKED', respondedAt: ctx.svc.now() } });
}),
/** Новая ссылка для входа в рабочий бот — старая привязка снимается */
resetInvite: providersProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
const p = await ctx.db.provider.update({ where: { id: input.id }, data: { inviteCode: invite(), userId: null, onShift: false } });
return { inviteUrl: inviteUrl(p.inviteCode) };
}),
removeDoc: providersProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await ctx.db.providerDoc.delete({ where: { id: input.id } });
}),
/** Отметить выплату (наличные / перевод). По умолчанию — весь долг */
payout: superProcedure
.input(z.object({ id: z.string(), amount: z.number().int().min(1), note: z.string().trim().max(300).optional() }))
.mutation(async ({ ctx, input }) => {
const now = ctx.svc.now();
const last = await ctx.db.payout.findFirst({ where: { providerId: input.id }, orderBy: { periodEnd: 'desc' } });
const first = await ctx.db.order.findFirst({ where: { providerId: input.id, ...DONE }, orderBy: { completedAt: 'asc' }, select: { completedAt: true } });
await ctx.db.payout.create({
data: { providerId: input.id, amount: input.amount, status: 'PAID', paidAt: now, note: input.note, createdById: ctx.admin.id, periodStart: last?.periodEnd ?? first?.completedAt ?? now, periodEnd: now },
});
}),
});
+42
View File
@@ -163,6 +163,48 @@ app.get<{ Params: { messageId: string } }>('/files/bo/chat-file/:messageId', asy
return sendChatFile(reply, m); return sendChatFile(reply, m);
}); });
// ───── исполнители и заказы (этап 3) ─────
/** Заказы и исполнителей ведёт поддержка; суперадмину — всё */
const canDispatch = (staff: NonNullable<Awaited<ReturnType<typeof staffFromCookie>>>) => staff.admin.roles.includes('SUPERADMIN') || staff.admin.roles.includes('SUPPORT');
const DOC_TYPES = /^(image\/(jpeg|png|webp|heic)|application\/pdf)$/;
/** Документ для допуска исполнителя: паспорт, договор… (фото или PDF) */
app.post<{ Params: { providerId: string } }>('/files/bo/provider-doc/:providerId', async (req, reply) => {
const staff = await staffFromCookie(req);
if (!staff || !canDispatch(staff)) return reply.code(staff ? 403 : 401).send({ error: staff ? 'ROLE_FORBIDDEN' : 'ADMIN_LOGIN_REQUIRED' });
const provider = await db.provider.findUnique({ where: { id: req.params.providerId } });
if (!provider) return reply.code(404).send({ error: 'NOT_FOUND' });
const part = await req.file();
if (!part) return reply.code(400).send({ error: 'NO_FILE' });
if (!DOC_TYPES.test(part.mimetype)) return reply.code(400).send({ error: 'BAD_FILE_TYPE' });
const body = await part.toBuffer();
const fileName = safeName(part.filename);
const fileKey = await putFile(`providers/${provider.id}`, body, part.mimetype, fileName);
const title = (multipartField(part, 'title') ?? '').trim().slice(0, 120) || fileName;
const doc = await db.providerDoc.create({ data: { providerId: provider.id, title, fileKey, fileName, mime: part.mimetype, size: body.length, uploadedById: staff.admin.id } });
await db.auditLog.create({ data: { adminUserId: staff.admin.id, action: 'bo.providers.addDoc', entity: 'providers', entityId: provider.id, data: { title } } });
return { id: doc.id };
});
app.get<{ Params: { docId: string } }>('/files/bo/provider-doc-file/:docId', async (req, reply) => {
const staff = await staffFromCookie(req);
if (!staff || !canDispatch(staff)) return reply.code(404).send({ error: 'NOT_FOUND' });
const d = await db.providerDoc.findUnique({ where: { id: req.params.docId } });
if (!d) return reply.code(404).send({ error: 'NOT_FOUND' });
return sendChatFile(reply, { fileKey: d.fileKey, tgFileId: null, tgBot: null, mime: d.mime, fileName: d.fileName });
});
/** Фотоотчёт по заказу; каждый просмотр — в журнал доступа */
app.get<{ Params: { fileId: string } }>('/files/bo/order-file/:fileId', async (req, reply) => {
const staff = await staffFromCookie(req);
if (!staff || !canDispatch(staff)) return reply.code(404).send({ error: 'NOT_FOUND' });
const f = await db.orderFile.findUnique({ where: { id: req.params.fileId } });
if (!f || f.deletedAt) return reply.code(404).send({ error: 'NOT_FOUND' });
await db.fileAccessLog.create({ data: { fileId: f.id, actorType: 'ADMIN', actorId: staff.admin.id } });
return sendChatFile(reply, { fileKey: f.storageKey, tgFileId: null, tgBot: null, mime: f.mime, fileName: `order-photo.${f.mime.split('/')[1] ?? 'jpg'}` });
});
/** Мини-апп клиента: клиент видит только файлы своей переписки (без заметок команды) */ /** Мини-апп клиента: клиент видит только файлы своей переписки (без заметок команды) */
function clientFromInit(req: FastifyRequest) { function clientFromInit(req: FastifyRequest) {
const auth = req.headers.authorization?.startsWith('tma ') ? req.headers.authorization.slice(4) : undefined; const auth = req.headers.authorization?.startsWith('tma ') ? req.headers.authorization.slice(4) : undefined;
+13 -2
View File
@@ -11,6 +11,10 @@ import { ClientCard } from './pages/ClientCard.tsx';
import { Team } from './pages/Team.tsx'; import { Team } from './pages/Team.tsx';
import { Soon } from './pages/Soon.tsx'; import { Soon } from './pages/Soon.tsx';
import { Chats } from './pages/Chats.tsx'; import { Chats } from './pages/Chats.tsx';
import { Orders } from './pages/Orders.tsx';
import { OrderCard } from './pages/OrderCard.tsx';
import { Providers } from './pages/Providers.tsx';
import { ProviderCard } from './pages/ProviderCard.tsx';
export type Role = 'SUPERADMIN' | 'SUPPORT' | 'CONCIERGE' | 'SALES'; export type Role = 'SUPERADMIN' | 'SUPPORT' | 'CONCIERGE' | 'SALES';
export interface Me { export interface Me {
@@ -44,8 +48,8 @@ const NAV: NavItem[] = [
{ path: '/', key: 'nav.dashboard', icon: 'dashboard', roles: 'all' }, { path: '/', key: 'nav.dashboard', icon: 'dashboard', roles: 'all' },
{ path: '/clients', key: 'nav.clients', icon: 'clients', roles: ['SUPPORT', 'CONCIERGE'] }, { path: '/clients', key: 'nav.clients', icon: 'clients', roles: ['SUPPORT', 'CONCIERGE'] },
{ path: '/chats', key: 'nav.chats', icon: 'chats', roles: ['SUPPORT', 'CONCIERGE'], count: 'chats' }, { path: '/chats', key: 'nav.chats', icon: 'chats', roles: ['SUPPORT', 'CONCIERGE'], count: 'chats' },
{ path: '/orders', key: 'nav.orders', icon: 'orders', stage: 3, roles: ['SUPPORT'], count: 'orders' }, { path: '/orders', key: 'nav.orders', icon: 'orders', roles: ['SUPPORT'], count: 'orders' },
{ path: '/providers', key: 'nav.providers', icon: 'providers', stage: 3, roles: ['SUPPORT'], group: 'work' }, { path: '/providers', key: 'nav.providers', icon: 'providers', roles: ['SUPPORT'], group: 'work' },
{ path: '/agencies', key: 'nav.agencies', icon: 'agencies', stage: 4, roles: [], group: 'partners' }, { path: '/agencies', key: 'nav.agencies', icon: 'agencies', stage: 4, roles: [], group: 'partners' },
{ path: '/venues', key: 'nav.venues', icon: 'venues', stage: 4, roles: [], group: 'partners' }, { path: '/venues', key: 'nav.venues', icon: 'venues', stage: 4, roles: [], group: 'partners' },
{ path: '/crm', key: 'nav.crm', icon: 'crm', stage: 4, roles: ['SALES'], group: 'partners' }, { path: '/crm', key: 'nav.crm', icon: 'crm', stage: 4, roles: ['SALES'], group: 'partners' },
@@ -74,8 +78,15 @@ function Shell({ me }: { me: Me }) {
const clientId = match('/clients/:id', path)?.id; const clientId = match('/clients/:id', path)?.id;
const threadId = match('/chats/:id', path)?.id; const threadId = match('/chats/:id', path)?.id;
const canChat = items.some((n) => n.path === '/chats'); const canChat = items.some((n) => n.path === '/chats');
const orderId = match('/orders/:id', path)?.id;
const providerId = match('/providers/:id', path)?.id;
const canOrders = items.some((n) => n.path === '/orders');
if (path === '/') page = <Dashboard me={me} />; if (path === '/') page = <Dashboard me={me} />;
else if ((path === '/chats' || threadId) && canChat) page = <Chats me={me} threadId={threadId} />; else if ((path === '/chats' || threadId) && canChat) page = <Chats me={me} threadId={threadId} />;
else if (path === '/orders' && canOrders) page = <Orders me={me} />;
else if (orderId && canOrders) page = <OrderCard id={orderId} me={me} />;
else if (path === '/providers' && canOrders) page = <Providers me={me} />;
else if (providerId && canOrders) page = <ProviderCard id={providerId} me={me} />;
else if (path === '/clients' && current) page = <Clients me={me} />; else if (path === '/clients' && current) page = <Clients me={me} />;
else if (clientId && items.some((n) => n.path === '/clients')) page = <ClientCard id={clientId} me={me} />; else if (clientId && items.some((n) => n.path === '/clients')) page = <ClientCard id={clientId} me={me} />;
else if (path === '/team' && isSuper) page = <Team me={me} />; else if (path === '/team' && isSuper) page = <Team me={me} />;
+2 -2
View File
@@ -95,7 +95,7 @@ export function sourceKeyLabel(t: T, key: string) {
} }
/** Модальное окно на <dialog>: фокус, Esc и затемнение — силами браузера */ /** Модальное окно на <dialog>: фокус, Esc и затемнение — силами браузера */
export function Dialog({ open, onClose, title, children, footer }: { open: boolean; onClose: () => void; title: string; children: ReactNode; footer: ReactNode }) { export function Dialog({ open, onClose, title, children, footer, wide }: { open: boolean; onClose: () => void; title: string; children: ReactNode; footer: ReactNode; wide?: boolean }) {
const ref = useRef<HTMLDialogElement>(null); const ref = useRef<HTMLDialogElement>(null);
useEffect(() => { useEffect(() => {
const d = ref.current; const d = ref.current;
@@ -106,7 +106,7 @@ export function Dialog({ open, onClose, title, children, footer }: { open: boole
return ( return (
<dialog <dialog
ref={ref} ref={ref}
className="bo-dialog" className={`bo-dialog${wide ? ' bo-dialog--wide' : ''}`}
onClose={onClose} onClose={onClose}
onClick={(e) => { onClick={(e) => {
if (e.target === ref.current) onClose(); if (e.target === ref.current) onClose();
+716 -2
View File
@@ -386,6 +386,242 @@ const ru = {
'chat.notLinked': 'Рабочий бот не открыт', 'chat.notLinked': 'Рабочий бот не открыт',
'chat.details': 'Подробнее', 'chat.details': 'Подробнее',
'chat.more': 'Ещё действия', 'chat.more': 'Ещё действия',
// ───── заказы и исполнители (этап 3) ─────
'ord.title': 'Заказы',
'ord.new': 'Новый заказ',
'ord.search': '№, клиент, телефон, адрес или исполнитель',
'ord.period': 'Период',
'ord.period.all': 'Все даты',
'ord.period.today': 'Сегодня',
'ord.period.tomorrow': 'Завтра',
'ord.period.week': '7 дней',
'ord.period.past': 'Прошедшие',
'ord.group.active': 'В работе у команды',
'ord.group.new': 'Ждут исполнителя',
'ord.group.pending': 'Предложены',
'ord.group.assigned': 'Назначены',
'ord.group.in_progress': 'Выполняются',
'ord.group.done': 'Готово',
'ord.group.cancelled': 'Отменены',
'ord.today': 'Сегодня',
'ord.tomorrow': 'Завтра',
'ord.empty': 'Заказов с такими условиями нет.',
'ord.waitsAnswer': 'ждём ответа: {name}',
'ord.needsHuman': 'нужен диспетчер',
'ord.searching': 'система подбирает…',
'ord.overdue': 'время прошло, исполнителя нет',
'ord.needsDispatcher': 'Автораспределение не нашло исполнителя для заказов: {n}. Назначьте вручную.',
'ord.autoOn': 'Автораспределение включено',
'ord.autoOff': 'Автораспределение выключено',
'ord.autoHint': 'Новый заказ сам предлагается лучшему свободному исполнителю (язык клиента, район, загрузка, рейтинг). Нет ответа {minutes} мин или отказ — следующему; после {tries} попыток — диспетчеру.',
'ord.back': 'Все заказы',
'ord.progress': 'Ход заказа',
'ord.details': 'Детали',
'ord.when': 'Когда',
'ord.where': 'Где',
'ord.minutes': '{n} мин',
'ord.params': 'Параметры',
'ord.extras': 'Доп. услуги',
'ord.note': 'Комментарий клиента',
'ord.lang': 'Язык клиента',
'ord.created': 'Создан',
'ord.checklist': 'Чек-лист',
'ord.needsPhoto': 'нужно фото',
'ord.photo': 'Фото',
'ord.history': 'История',
'ord.client': 'Клиент',
'ord.writeClient': 'Написать клиенту',
'ord.writeProvider': 'Написать исполнителю',
'ord.money': 'Деньги',
'ord.base': 'Без скидки',
'ord.discount': 'Скидка {pct}%',
'ord.payout': 'Исполнителю',
'ord.cancelFee': 'Удержание за отмену',
'ord.payment': 'Оплата',
'ord.markPaid': 'Отметить оплату',
'ord.confirmPaid': 'Отметить, что клиент оплатил заказ?',
'ord.paidDone': 'Оплата отмечена',
'ord.actions': 'Действия',
'ord.setStatus': 'Статус: {status}',
'ord.confirmStatus': 'Перевести заказ в «{status}»? Клиент получит уведомление.',
'ord.statusChanged': 'Статус изменён',
'ord.reschedule': 'Перенести',
'ord.rescheduleTitle': 'Перенос заказа №{n}',
'ord.rescheduleHint': 'Если исполнитель занят в новое время, заказ вернётся в «Ждут исполнителя» и уйдёт в автораспределение.',
'ord.rescheduled': 'Заказ перенесён, исполнитель тот же',
'ord.rescheduledFree': 'Заказ перенесён; исполнитель занят — ищем нового',
'ord.comment': 'Комментарий',
'ord.commentPh': 'Например: клиент попросил по телефону',
'ord.cancel': 'Отменить заказ',
'ord.cancelTitle': 'Отмена заказа',
'ord.cancelReason': 'Причина отмены',
'ord.cancelReasonPh': 'Например: клиент передумал',
'ord.cancelFeeHint': 'Сколько удержать с клиента, ֏. Обычно 0.',
'ord.cancelHint': 'Клиент и исполнитель получат уведомление.',
'ord.cancelled': 'Заказ отменён',
'ord.rating': 'Оценка клиента',
'ord.noComment': 'Без комментария',
'ord.offers': 'Кому предлагали',
'ord.bySystem': 'система',
'ord.provider': 'Исполнитель',
'ord.unassign': 'Снять исполнителя',
'ord.revoke': 'Отозвать предложение',
'ord.confirmUnassign': 'Снять исполнителя? Заказ вернётся в «Ждут исполнителя».',
'ord.confirmRevoke': 'Отозвать предложение? Заказ снова будет ждать исполнителя.',
'ord.unassigned': 'Готово — заказ снова ждёт исполнителя',
'ord.pendingProvider': 'Предложен: {name}',
'ord.pendingCompany': 'Передан компании «{name}» — ждём, кого назначит диспетчер',
'ord.byAuto': 'предложила система',
'ord.minLeft': 'на ответ ≈ {n} мин',
'ord.pickHint': 'Свободные на это время — по порядку подбора. Или поручите выбор системе.',
'ord.autoPick': 'Подобрать автоматически',
'ord.autoOffered': 'Предложили: {name}',
'ord.autoNone': 'Свободных исполнителей не нашлось',
'ord.noFree': 'На это время все заняты. Перенесите заказ или договоритесь с исполнителем.',
'ord.busy': 'Заняты в это время: {n}',
'ord.company': 'компания',
'ord.tried': 'уже предлагали',
'ord.load': 'заказов в этот день: {n}',
'ord.offer': 'Предложить',
'ord.offered': 'Заказ отправлен исполнителю',
'ord.noProvider': 'Исполнитель не назначался.',
'ord.ev.created': 'Заказ создан',
'ord.ev.createdByStaff': 'Заказ оформила команда',
'ord.ev.offered': 'Предложен исполнителю',
'ord.ev.autoOffered': 'Автораспределение: предложен',
'ord.ev.autoNoCandidate': 'Автораспределение: свободных нет',
'ord.ev.autoGaveUp': 'Автораспределение остановлено — нужен диспетчер',
'no.title': 'Новый заказ',
'no.client': 'Клиент',
'no.clientPh': 'Имя, @username или телефон',
'no.noClients': 'Никого не нашли',
'no.changeClient': 'Другой клиент',
'no.service': 'Услуга',
'no.extras': 'Доп. услуги',
'no.address': 'Адрес',
'no.addressPh': 'Улица, дом, квартира',
'no.district': 'Район',
'no.when': 'Дата и время',
'no.day': 'День',
'no.noSlots': 'На этот день свободного времени нет — выберите другой день или «другое время».',
'no.otherTime': 'Другое время (договорились с исполнителем сами)',
'no.customTime': 'Время',
'no.customHint': 'По Еревану. Свободных исполнителей система не гарантирует.',
'no.note': 'Комментарий для исполнителя',
'no.notePh': 'Код домофона, животные…',
'no.payment': 'Оплата',
'no.hint': 'Цена — с учётом подписки клиента. Клиент получит уведомление в бота, заказ сразу уйдёт в автораспределение.',
'no.create': 'Оформить заказ',
'no.created': 'Заказ №{n} оформлен',
'prv.title': 'Исполнители',
'prv.add': 'Добавить',
'prv.search': 'Имя или телефон',
'prv.status.active': 'Работают',
'prv.status.blocked': 'Заблокированы',
'prv.status.all': 'Все',
'prv.empty': 'Никого не нашли.',
'prv.col.name': 'Исполнитель',
'prv.col.rating': 'Рейтинг',
'prv.col.done': 'Заказов',
'prv.col.refused': 'Отказы',
'prv.col.next': 'Ближайший заказ',
'prv.period30': 'Заказы, отказы и низкие оценки — за 30 дней.',
'prv.blocked': 'заблокирован',
'prv.onShift': 'на смене',
'prv.offShift': 'не на смене',
'prv.notLinked': 'не открыл бота',
'prv.noDocs': 'нет документов',
'prv.ratings': 'оценок: {n}',
'prv.low': 'низких: {n}',
'prv.today': 'сегодня: {n}',
'prv.back': 'Все исполнители',
'prv.write': 'Написать',
'prv.edit': 'Редактировать',
'prv.blockedSince': 'Заблокирован с {date}.',
'prv.unblock': 'Вернуть к работе',
'prv.inviteHint': 'Исполнитель ещё не открыл рабочий бот — отправьте ему ссылку.',
'prv.copyInvite': 'Скопировать ссылку',
'prv.quality': 'Качество',
'prv.days30': 'за 30 дней',
'prv.acceptRate': 'принял {a} из {n} предложений',
'prv.refusals': 'отказы: {d}, молчал: {e}',
'prv.lowRatings': 'оценок 3 и ниже',
'prv.upcoming': 'Ближайшие заказы',
'prv.noUpcoming': 'Назначенных заказов нет.',
'prv.recent': 'Последние выполненные',
'prv.balance': 'К выплате',
'prv.thisMonth': 'Этот месяц',
'prv.prevMonth': 'Прошлый',
'prv.paidTotal': 'Выплачено',
'prv.payout': 'Отметить выплату',
'prv.payoutTitle': 'Выплата исполнителю',
'prv.amount': 'Сумма, ֏',
'prv.balanceHint': 'Долг перед исполнителем: {sum}',
'prv.payoutNote': 'Как выплатили',
'prv.payoutNotePh': 'Наличные / перевод на карту…',
'prv.payoutConfirm': 'Выплачено',
'prv.payoutDone': 'Выплата отмечена',
'prv.rateCompany': 'ставка компании {v}',
'prv.rateOwn': 'ставка {v}',
'prv.ratePreset': 'по пресету «{name}»',
'prv.work': 'Работа',
'prv.languages': 'Языки',
'prv.districts': 'Районы',
'prv.anyDistrict': 'любые',
'prv.districtsHint': 'Подбор ставит выше тех, кто работает в районе заказа.',
'prv.docs': 'Документы для допуска',
'prv.noDocsHint': 'Паспорт, договор, справка — всё хранится в закрытом хранилище.',
'prv.docTitlePh': 'Название: паспорт, договор…',
'prv.upload': 'Загрузить',
'prv.docAdded': 'Документ добавлен',
'prv.removeDocConfirm': 'Удалить документ?',
'prv.access': 'Допуск',
'prv.block': 'Заблокировать',
'prv.blockTitle': 'Заблокировать исполнителя',
'prv.blockReason': 'Причина',
'prv.blockReasonPh': 'Например: жалобы клиентов, нет документов',
'prv.blockHint': 'Заказы ему больше не предлагаются, действующие предложения отзываются. Уже назначенные заказы остаются — переназначьте их вручную.',
'prv.resetInvite': 'Новая ссылка для входа в бот',
'prv.resetConfirm': 'Сделать новую ссылку? Текущая привязка к Telegram снимется — исполнителю нужно будет войти заново.',
'prv.since': 'С нами с {date}',
'prv.saved': 'Сохранено',
'prv.addTitle': 'Новый исполнитель',
'prv.editTitle': 'Профиль исполнителя',
'prv.name': 'Имя',
'prv.role': 'Роль',
'prv.company': 'Компания',
'prv.freelance': 'Частный исполнитель',
'prv.schedule': 'График',
'prv.rate': 'Ставка',
'prv.note': 'Заметка команды',
'err.REASON_REQUIRED': 'Укажите причину.',
'err.SLOT_UNAVAILABLE': 'Это время уже занято или прошло. Выберите другое или «другое время».',
'err.ALREADY_ASSIGNED': 'Заказ уже назначен или предложен — обновите страницу.',
'err.PROVIDER_BUSY': 'Исполнитель занят в это время.',
'err.TRANSITION_NOT_ALLOWED': 'Сейчас этот шаг для заказа недоступен.',
'err.CHECKLIST_INCOMPLETE': 'Сначала исполнитель должен закрыть чек-лист и приложить фото.',
'err.CONSENT_REQUIRED': 'Клиент ещё не дал согласие на обработку данных в боте — заказ оформить нельзя.',
'err.ADDRESS_REQUIRED': 'Укажите адрес и район.',
'err.INVALID_PARAMS': 'Проверьте параметры заказа.',
'err.OFFER_NOT_ACTIVE': 'Предложение уже неактуально.',
'err.BAD_FILE_TYPE_DOC': 'Можно фото или PDF.',
'audit.bo.orders.assign': 'предложил(а) заказ исполнителю',
'audit.bo.orders.auto': 'запустил(а) подбор исполнителя',
'audit.bo.orders.unassign': 'снял(а) исполнителя с заказа',
'audit.bo.orders.transition': 'сменил(а) статус заказа',
'audit.bo.orders.cancel': 'отменил(а) заказ',
'audit.bo.orders.reschedule': 'перенёс(ла) заказ',
'audit.bo.orders.markPaid': 'отметил(а) оплату заказа',
'audit.bo.orders.create': 'оформил(а) заказ за клиента',
'audit.bo.orders.setAutoSetting': 'изменил(а) автораспределение',
'audit.bo.providers.create': 'добавил(а) исполнителя',
'audit.bo.providers.update': 'изменил(а) профиль исполнителя',
'audit.bo.providers.setActive': 'изменил(а) допуск исполнителя',
'audit.bo.providers.resetInvite': 'выпустил(а) новую ссылку исполнителю',
'audit.bo.providers.removeDoc': 'удалил(а) документ исполнителя',
'audit.bo.providers.addDoc': 'загрузил(а) документ исполнителя',
'audit.bo.providers.payout': 'отметил(а) выплату исполнителю',
'chat.order': 'Заказ №{n}', 'chat.order': 'Заказ №{n}',
'chat.noOrder': 'Без заказа', 'chat.noOrder': 'Без заказа',
'chat.noOrderNow': 'Сейчас без заказа', 'chat.noOrderNow': 'Сейчас без заказа',
@@ -792,6 +1028,242 @@ const en: Dict = {
'chat.notLinked': 'Work bot not opened', 'chat.notLinked': 'Work bot not opened',
'chat.details': 'Details', 'chat.details': 'Details',
'chat.more': 'More actions', 'chat.more': 'More actions',
// ───── заказы и исполнители (этап 3) ─────
'ord.title': 'Orders',
'ord.new': 'New order',
'ord.search': 'No., client, phone, address or provider',
'ord.period': 'Period',
'ord.period.all': 'All dates',
'ord.period.today': 'Today',
'ord.period.tomorrow': 'Tomorrow',
'ord.period.week': '7 days',
'ord.period.past': 'Past',
'ord.group.active': 'Active',
'ord.group.new': 'Need a provider',
'ord.group.pending': 'Offered',
'ord.group.assigned': 'Assigned',
'ord.group.in_progress': 'In progress',
'ord.group.done': 'Done',
'ord.group.cancelled': 'Cancelled',
'ord.today': 'Today',
'ord.tomorrow': 'Tomorrow',
'ord.empty': 'No orders match these filters.',
'ord.waitsAnswer': 'awaiting: {name}',
'ord.needsHuman': 'needs a dispatcher',
'ord.searching': 'auto-assigning…',
'ord.overdue': 'time passed, no provider',
'ord.needsDispatcher': 'Auto-assignment found no provider for {n} order(s). Assign manually.',
'ord.autoOn': 'Auto-assign on',
'ord.autoOff': 'Auto-assign off',
'ord.autoHint': 'A new order is offered to the best free provider (client language, district, load, rating). No answer in {minutes} min or a decline — the next one; after {tries} tries — to a dispatcher.',
'ord.back': 'All orders',
'ord.progress': 'Progress',
'ord.details': 'Details',
'ord.when': 'When',
'ord.where': 'Where',
'ord.minutes': '{n} min',
'ord.params': 'Details',
'ord.extras': 'Extras',
'ord.note': 'Client comment',
'ord.lang': 'Client language',
'ord.created': 'Created',
'ord.checklist': 'Checklist',
'ord.needsPhoto': 'photo needed',
'ord.photo': 'Photo',
'ord.history': 'History',
'ord.client': 'Client',
'ord.writeClient': 'Message the client',
'ord.writeProvider': 'Message the provider',
'ord.money': 'Money',
'ord.base': 'Before discount',
'ord.discount': 'Discount {pct}%',
'ord.payout': 'To the provider',
'ord.cancelFee': 'Cancellation fee',
'ord.payment': 'Payment',
'ord.markPaid': 'Mark as paid',
'ord.confirmPaid': 'Mark the order as paid?',
'ord.paidDone': 'Marked as paid',
'ord.actions': 'Actions',
'ord.setStatus': 'Status: {status}',
'ord.confirmStatus': 'Move the order to “{status}”? The client will be notified.',
'ord.statusChanged': 'Status changed',
'ord.reschedule': 'Reschedule',
'ord.rescheduleTitle': 'Reschedule order #{n}',
'ord.rescheduleHint': 'If the provider is busy at the new time, the order goes back to “Need a provider” and to auto-assignment.',
'ord.rescheduled': 'Rescheduled, same provider',
'ord.rescheduledFree': 'Rescheduled; the provider is busy — looking for a new one',
'ord.comment': 'Comment',
'ord.commentPh': 'E.g. the client asked by phone',
'ord.cancel': 'Cancel order',
'ord.cancelTitle': 'Cancel the order',
'ord.cancelReason': 'Reason',
'ord.cancelReasonPh': 'E.g. the client changed their mind',
'ord.cancelFeeHint': 'How much to charge the client, ֏. Usually 0.',
'ord.cancelHint': 'The client and the provider will be notified.',
'ord.cancelled': 'Order cancelled',
'ord.rating': 'Client rating',
'ord.noComment': 'No comment',
'ord.offers': 'Offers',
'ord.bySystem': 'system',
'ord.provider': 'Provider',
'ord.unassign': 'Remove provider',
'ord.revoke': 'Withdraw offer',
'ord.confirmUnassign': 'Remove the provider? The order goes back to “Need a provider”.',
'ord.confirmRevoke': 'Withdraw the offer? The order will wait for a provider again.',
'ord.unassigned': 'Done — the order needs a provider again',
'ord.pendingProvider': 'Offered to {name}',
'ord.pendingCompany': 'Passed to {name} — waiting for their dispatcher',
'ord.byAuto': 'offered automatically',
'ord.minLeft': '≈ {n} min to answer',
'ord.pickHint': 'Free at this time, best match first. Or let the system pick.',
'ord.autoPick': 'Pick automatically',
'ord.autoOffered': 'Offered to {name}',
'ord.autoNone': 'No free provider found',
'ord.noFree': 'Everyone is busy at this time. Reschedule or arrange with a provider.',
'ord.busy': 'Busy at this time: {n}',
'ord.company': 'company',
'ord.tried': 'already offered',
'ord.load': '{n} orders that day',
'ord.offer': 'Offer',
'ord.offered': 'Offer sent',
'ord.noProvider': 'No provider was assigned.',
'ord.ev.created': 'Order created',
'ord.ev.createdByStaff': 'Created by the team',
'ord.ev.offered': 'Offered to a provider',
'ord.ev.autoOffered': 'Auto-assign: offered',
'ord.ev.autoNoCandidate': 'Auto-assign: nobody free',
'ord.ev.autoGaveUp': 'Auto-assign stopped — needs a dispatcher',
'no.title': 'New order',
'no.client': 'Client',
'no.clientPh': 'Name, @username or phone',
'no.noClients': 'Nobody found',
'no.changeClient': 'Another client',
'no.service': 'Service',
'no.extras': 'Extras',
'no.address': 'Address',
'no.addressPh': 'Street, building, apartment',
'no.district': 'District',
'no.when': 'Date and time',
'no.day': 'Day',
'no.noSlots': 'No free time that day — pick another day or “other time”.',
'no.otherTime': 'Other time (arranged with a provider directly)',
'no.customTime': 'Time',
'no.customHint': 'Yerevan time. A free provider is not guaranteed.',
'no.note': 'Comment for the provider',
'no.notePh': 'Door code, pets…',
'no.payment': 'Payment',
'no.hint': 'The price includes the client’s subscription discount. The client is notified and the order goes to auto-assignment.',
'no.create': 'Create order',
'no.created': 'Order #{n} created',
'prv.title': 'Providers',
'prv.add': 'Add',
'prv.search': 'Name or phone',
'prv.status.active': 'Active',
'prv.status.blocked': 'Blocked',
'prv.status.all': 'All',
'prv.empty': 'Nobody found.',
'prv.col.name': 'Provider',
'prv.col.rating': 'Rating',
'prv.col.done': 'Orders',
'prv.col.refused': 'Declines',
'prv.col.next': 'Next order',
'prv.period30': 'Orders, declines and low ratings — last 30 days.',
'prv.blocked': 'blocked',
'prv.onShift': 'on shift',
'prv.offShift': 'off shift',
'prv.notLinked': 'bot not opened',
'prv.noDocs': 'no documents',
'prv.ratings': '{n} ratings',
'prv.low': '{n} low',
'prv.today': 'today: {n}',
'prv.back': 'All providers',
'prv.write': 'Message',
'prv.edit': 'Edit',
'prv.blockedSince': 'Blocked since {date}.',
'prv.unblock': 'Unblock',
'prv.inviteHint': 'The provider hasn’t opened the work bot yet — send them the link.',
'prv.copyInvite': 'Copy link',
'prv.quality': 'Quality',
'prv.days30': 'last 30 days',
'prv.acceptRate': 'accepted {a} of {n} offers',
'prv.refusals': 'declined: {d}, no answer: {e}',
'prv.lowRatings': 'ratings of 3 or less',
'prv.upcoming': 'Upcoming orders',
'prv.noUpcoming': 'No assigned orders.',
'prv.recent': 'Recently done',
'prv.balance': 'To pay',
'prv.thisMonth': 'This month',
'prv.prevMonth': 'Last month',
'prv.paidTotal': 'Paid',
'prv.payout': 'Record a payout',
'prv.payoutTitle': 'Payout to provider',
'prv.amount': 'Amount, ֏',
'prv.balanceHint': 'Owed to the provider: {sum}',
'prv.payoutNote': 'How it was paid',
'prv.payoutNotePh': 'Cash / card transfer…',
'prv.payoutConfirm': 'Paid',
'prv.payoutDone': 'Payout recorded',
'prv.rateCompany': 'company rate {v}',
'prv.rateOwn': 'rate {v}',
'prv.ratePreset': 'preset “{name}”',
'prv.work': 'Work',
'prv.languages': 'Languages',
'prv.districts': 'Districts',
'prv.anyDistrict': 'any',
'prv.districtsHint': 'Assignment favors providers working in the order’s district.',
'prv.docs': 'Admission documents',
'prv.noDocsHint': 'Passport, contract, certificate — kept in private storage.',
'prv.docTitlePh': 'Title: passport, contract…',
'prv.upload': 'Upload',
'prv.docAdded': 'Document added',
'prv.removeDocConfirm': 'Delete the document?',
'prv.access': 'Access',
'prv.block': 'Block',
'prv.blockTitle': 'Block the provider',
'prv.blockReason': 'Reason',
'prv.blockReasonPh': 'E.g. client complaints, missing documents',
'prv.blockHint': 'No new offers; pending offers are withdrawn. Already assigned orders stay — reassign them manually.',
'prv.resetInvite': 'New bot link',
'prv.resetConfirm': 'Create a new link? The current Telegram link is removed — the provider will need to log in again.',
'prv.since': 'With us since {date}',
'prv.saved': 'Saved',
'prv.addTitle': 'New provider',
'prv.editTitle': 'Provider profile',
'prv.name': 'Name',
'prv.role': 'Role',
'prv.company': 'Company',
'prv.freelance': 'Freelancer',
'prv.schedule': 'Schedule',
'prv.rate': 'Rate',
'prv.note': 'Team note',
'err.REASON_REQUIRED': 'Please give a reason.',
'err.SLOT_UNAVAILABLE': 'This time is taken or has passed. Pick another one.',
'err.ALREADY_ASSIGNED': 'The order is already assigned or offered — refresh the page.',
'err.PROVIDER_BUSY': 'The provider is busy at this time.',
'err.TRANSITION_NOT_ALLOWED': 'This step isn’t available for the order now.',
'err.CHECKLIST_INCOMPLETE': 'The provider must finish the checklist and add photos first.',
'err.CONSENT_REQUIRED': 'The client hasn’t given data-processing consent in the bot yet.',
'err.ADDRESS_REQUIRED': 'Please enter the address and district.',
'err.INVALID_PARAMS': 'Check the order details.',
'err.OFFER_NOT_ACTIVE': 'The offer is no longer active.',
'err.BAD_FILE_TYPE_DOC': 'Photo or PDF only.',
'audit.bo.orders.assign': 'offered an order',
'audit.bo.orders.auto': 'ran auto-assignment',
'audit.bo.orders.unassign': 'removed a provider from an order',
'audit.bo.orders.transition': 'changed an order status',
'audit.bo.orders.cancel': 'cancelled an order',
'audit.bo.orders.reschedule': 'rescheduled an order',
'audit.bo.orders.markPaid': 'marked an order as paid',
'audit.bo.orders.create': 'created an order for a client',
'audit.bo.orders.setAutoSetting': 'changed auto-assignment',
'audit.bo.providers.create': 'added a provider',
'audit.bo.providers.update': 'edited a provider',
'audit.bo.providers.setActive': 'changed provider access',
'audit.bo.providers.resetInvite': 'reissued a provider link',
'audit.bo.providers.removeDoc': 'deleted a provider document',
'audit.bo.providers.addDoc': 'uploaded a provider document',
'audit.bo.providers.payout': 'recorded a payout',
'chat.order': 'Order #{n}', 'chat.order': 'Order #{n}',
'chat.noOrder': 'No order', 'chat.noOrder': 'No order',
'chat.noOrderNow': 'No current order', 'chat.noOrderNow': 'No current order',
@@ -1195,6 +1667,242 @@ const hy: Dict = {
'chat.notLinked': 'Աշխատանքային բոտը բացված չէ', 'chat.notLinked': 'Աշխատանքային բոտը բացված չէ',
'chat.details': 'Մանրամասներ', 'chat.details': 'Մանրամասներ',
'chat.more': 'Այլ գործողություններ', 'chat.more': 'Այլ գործողություններ',
// ───── заказы и исполнители (этап 3) ─────
'ord.title': 'Պատվերներ',
'ord.new': 'Նոր պատվեր',
'ord.search': '№, հաճախորդ, հեռախոս, հասցե կամ կատարող',
'ord.period': 'Ժամանակահատված',
'ord.period.all': 'Բոլոր օրերը',
'ord.period.today': 'Այսօր',
'ord.period.tomorrow': 'Վաղը',
'ord.period.week': '7 օր',
'ord.period.past': 'Անցածները',
'ord.group.active': 'Ակտիվ',
'ord.group.new': 'Սպասում են կատարողի',
'ord.group.pending': 'Առաջարկված',
'ord.group.assigned': 'Նշանակված',
'ord.group.in_progress': 'Կատարվում են',
'ord.group.done': 'Պատրաստ',
'ord.group.cancelled': 'Չեղարկված',
'ord.today': 'Այսօր',
'ord.tomorrow': 'Վաղը',
'ord.empty': 'Այս պայմաններով պատվերներ չկան։',
'ord.waitsAnswer': 'սպասում ենք՝ {name}',
'ord.needsHuman': 'պետք է դիսպետչեր',
'ord.searching': 'համակարգը ընտրում է…',
'ord.overdue': 'ժամն անցել է, կատարող չկա',
'ord.needsDispatcher': 'Ավտոմատ բաշխումը կատարող չգտավ {n} պատվերի համար։ Նշանակեք ձեռքով։',
'ord.autoOn': 'Ավտոմատ բաշխումը միացված է',
'ord.autoOff': 'Ավտոմատ բաշխումն անջատված է',
'ord.autoHint': 'Նոր պատվերն ինքնաբերաբար առաջարկվում է լավագույն ազատ կատարողին։ {minutes} րոպեում պատասխան չկա կամ հրաժարվեց՝ հաջորդին, {tries} փորձից հետո՝ դիսպետչերին։',
'ord.back': 'Բոլոր պատվերները',
'ord.progress': 'Ընթացք',
'ord.details': 'Մանրամասներ',
'ord.when': 'Երբ',
'ord.where': 'Որտեղ',
'ord.minutes': '{n} ր',
'ord.params': 'Պարամետրեր',
'ord.extras': 'Լրացուցիչ',
'ord.note': 'Հաճախորդի մեկնաբանություն',
'ord.lang': 'Հաճախորդի լեզուն',
'ord.created': 'Ստեղծված',
'ord.checklist': 'Ստուգացանկ',
'ord.needsPhoto': 'լուսանկար է պետք',
'ord.photo': 'Լուսանկար',
'ord.history': 'Պատմություն',
'ord.client': 'Հաճախորդ',
'ord.writeClient': 'Գրել հաճախորդին',
'ord.writeProvider': 'Գրել կատարողին',
'ord.money': 'Գումար',
'ord.base': 'Առանց զեղչի',
'ord.discount': 'Զեղչ {pct}%',
'ord.payout': 'Կատարողին',
'ord.cancelFee': 'Չեղարկման պահում',
'ord.payment': 'Վճարում',
'ord.markPaid': 'Նշել վճարումը',
'ord.confirmPaid': 'Նշե՞լ, որ հաճախորդը վճարել է։',
'ord.paidDone': 'Վճարումը նշված է',
'ord.actions': 'Գործողություններ',
'ord.setStatus': 'Կարգավիճակ՝ {status}',
'ord.confirmStatus': 'Տեղափոխե՞լ «{status}»։ Հաճախորդը ծանուցում կստանա։',
'ord.statusChanged': 'Կարգավիճակը փոխված է',
'ord.reschedule': 'Տեղափոխել',
'ord.rescheduleTitle': 'Պատվեր №{n}-ի տեղափոխում',
'ord.rescheduleHint': 'Եթե կատարողը զբաղված է նոր ժամին, պատվերը կվերադառնա «Սպասում են կատարողի»։',
'ord.rescheduled': 'Տեղափոխված է, կատարողը նույնն է',
'ord.rescheduledFree': 'Տեղափոխված է, կատարողը զբաղված է՝ փնտրում ենք նորը',
'ord.comment': 'Մեկնաբանություն',
'ord.commentPh': 'Օրինակ՝ հաճախորդը խնդրեց հեռախոսով',
'ord.cancel': 'Չեղարկել պատվերը',
'ord.cancelTitle': 'Պատվերի չեղարկում',
'ord.cancelReason': 'Չեղարկման պատճառը',
'ord.cancelReasonPh': 'Օրինակ՝ հաճախորդը փոխեց միտքը',
'ord.cancelFeeHint': 'Որքան պահել հաճախորդից, ֏։ Սովորաբար 0։',
'ord.cancelHint': 'Հաճախորդը և կատարողը ծանուցում կստանան։',
'ord.cancelled': 'Պատվերը չեղարկված է',
'ord.rating': 'Հաճախորդի գնահատականը',
'ord.noComment': 'Առանց մեկնաբանության',
'ord.offers': 'Ում է առաջարկվել',
'ord.bySystem': 'համակարգ',
'ord.provider': 'Կատարող',
'ord.unassign': 'Հանել կատարողին',
'ord.revoke': 'Հետ կանչել առաջարկը',
'ord.confirmUnassign': 'Հանե՞լ կատարողին։ Պատվերը կվերադառնա։',
'ord.confirmRevoke': 'Հետ կանչե՞լ առաջարկը։',
'ord.unassigned': 'Պատվերը կրկին սպասում է կատարողի',
'ord.pendingProvider': 'Առաջարկված է՝ {name}',
'ord.pendingCompany': 'Հանձնված է «{name}» ընկերությանը',
'ord.byAuto': 'առաջարկել է համակարգը',
'ord.minLeft': 'պատասխանի համար ≈ {n} ր',
'ord.pickHint': 'Այս ժամին ազատներ՝ ըստ համապատասխանության։',
'ord.autoPick': 'Ընտրել ավտոմատ',
'ord.autoOffered': 'Առաջարկվեց՝ {name}',
'ord.autoNone': 'Ազատ կատարող չգտնվեց',
'ord.noFree': 'Այս ժամին բոլորը զբաղված են։',
'ord.busy': 'Զբաղված են՝ {n}',
'ord.company': 'ընկերություն',
'ord.tried': 'արդեն առաջարկվել է',
'ord.load': 'այդ օրը պատվերներ՝ {n}',
'ord.offer': 'Առաջարկել',
'ord.offered': 'Առաջարկն ուղարկված է',
'ord.noProvider': 'Կատարող չի նշանակվել։',
'ord.ev.created': 'Պատվերը ստեղծված է',
'ord.ev.createdByStaff': 'Ստեղծել է թիմը',
'ord.ev.offered': 'Առաջարկվել է կատարողին',
'ord.ev.autoOffered': 'Ավտոմատ բաշխում՝ առաջարկված',
'ord.ev.autoNoCandidate': 'Ավտոմատ բաշխում՝ ազատ չկա',
'ord.ev.autoGaveUp': 'Ավտոմատ բաշխումը կանգնեց',
'no.title': 'Նոր պատվեր',
'no.client': 'Հաճախորդ',
'no.clientPh': 'Անուն, @username կամ հեռախոս',
'no.noClients': 'Ոչ ոք չգտնվեց',
'no.changeClient': 'Այլ հաճախորդ',
'no.service': 'Ծառայություն',
'no.extras': 'Լրացուցիչ',
'no.address': 'Հասցե',
'no.addressPh': 'Փողոց, շենք, բնակարան',
'no.district': 'Վարչական շրջան',
'no.when': 'Օր և ժամ',
'no.day': 'Օր',
'no.noSlots': 'Այս օրը ազատ ժամ չկա։',
'no.otherTime': 'Այլ ժամ (պայմանավորվել ենք կատարողի հետ)',
'no.customTime': 'Ժամ',
'no.customHint': 'Երևանի ժամով։',
'no.note': 'Մեկնաբանություն կատարողի համար',
'no.notePh': 'Դոմոֆոնի կոդ, կենդանիներ…',
'no.payment': 'Վճարում',
'no.hint': 'Գինը՝ հաշվի առնելով բաժանորդագրությունը։ Հաճախորդը ծանուցում կստանա։',
'no.create': 'Ձևակերպել պատվերը',
'no.created': 'Պատվեր №{n}-ը ձևակերպված է',
'prv.title': 'Կատարողներ',
'prv.add': 'Ավելացնել',
'prv.search': 'Անուն կամ հեռախոս',
'prv.status.active': 'Աշխատում են',
'prv.status.blocked': 'Արգելափակված',
'prv.status.all': 'Բոլորը',
'prv.empty': 'Ոչ ոք չգտնվեց։',
'prv.col.name': 'Կատարող',
'prv.col.rating': 'Վարկանիշ',
'prv.col.done': 'Պատվերներ',
'prv.col.refused': 'Հրաժարումներ',
'prv.col.next': 'Մոտակա պատվերը',
'prv.period30': 'Պատվերներ, հրաժարումներ, ցածր գնահատականներ՝ 30 օրում։',
'prv.blocked': 'արգելափակված',
'prv.onShift': 'հերթափոխում',
'prv.offShift': 'հերթափոխում չէ',
'prv.notLinked': 'բոտը բացված չէ',
'prv.noDocs': 'փաստաթղթեր չկան',
'prv.ratings': 'գնահատականներ՝ {n}',
'prv.low': 'ցածր՝ {n}',
'prv.today': 'այսօր՝ {n}',
'prv.back': 'Բոլոր կատարողները',
'prv.write': 'Գրել',
'prv.edit': 'Խմբագրել',
'prv.blockedSince': 'Արգելափակված է {date}-ից։',
'prv.unblock': 'Վերադարձնել աշխատանքի',
'prv.inviteHint': 'Կատարողը դեռ չի բացել աշխատանքային բոտը՝ ուղարկեք հղումը։',
'prv.copyInvite': 'Պատճենել հղումը',
'prv.quality': 'Որակ',
'prv.days30': '30 օրում',
'prv.acceptRate': 'ընդունել է {a}-ը {n}-ից',
'prv.refusals': 'հրաժարվել՝ {d}, լռել՝ {e}',
'prv.lowRatings': 'գնահատական 3 և ցածր',
'prv.upcoming': 'Մոտակա պատվերներ',
'prv.noUpcoming': 'Նշանակված պատվերներ չկան։',
'prv.recent': 'Վերջին կատարվածները',
'prv.balance': 'Վճարման ենթակա',
'prv.thisMonth': 'Այս ամիս',
'prv.prevMonth': 'Անցյալ',
'prv.paidTotal': 'Վճարված',
'prv.payout': 'Նշել վճարումը',
'prv.payoutTitle': 'Վճարում կատարողին',
'prv.amount': 'Գումար, ֏',
'prv.balanceHint': 'Պարտք կատարողին՝ {sum}',
'prv.payoutNote': 'Ինչպես է վճարվել',
'prv.payoutNotePh': 'Կանխիկ / փոխանցում…',
'prv.payoutConfirm': 'Վճարված է',
'prv.payoutDone': 'Վճարումը նշված է',
'prv.rateCompany': 'ընկերության դրույք {v}',
'prv.rateOwn': 'դրույք {v}',
'prv.ratePreset': '«{name}» նախադրույքով',
'prv.work': 'Աշխատանք',
'prv.languages': 'Լեզուներ',
'prv.districts': 'Շրջաններ',
'prv.anyDistrict': 'ցանկացած',
'prv.districtsHint': 'Ընտրությունը նախապատվություն է տալիս շրջանում աշխատողներին։',
'prv.docs': 'Թույլտվության փաստաթղթեր',
'prv.noDocsHint': 'Անձնագիր, պայմանագիր, տեղեկանք՝ փակ պահոցում։',
'prv.docTitlePh': 'Անվանում՝ անձնագիր, պայմանագիր…',
'prv.upload': 'Վերբեռնել',
'prv.docAdded': 'Փաստաթուղթն ավելացված է',
'prv.removeDocConfirm': 'Ջնջե՞լ փաստաթուղթը։',
'prv.access': 'Թույլտվություն',
'prv.block': 'Արգելափակել',
'prv.blockTitle': 'Արգելափակել կատարողին',
'prv.blockReason': 'Պատճառը',
'prv.blockReasonPh': 'Օրինակ՝ բողոքներ, փաստաթղթեր չկան',
'prv.blockHint': 'Նոր առաջարկներ չեն լինի։ Նշանակված պատվերները վերանշանակեք ձեռքով։',
'prv.resetInvite': 'Նոր հղում բոտի համար',
'prv.resetConfirm': 'Ստեղծե՞լ նոր հղում։ Ընթացիկ կապը կհանվի։',
'prv.since': 'Մեզ հետ է {date}-ից',
'prv.saved': 'Պահպանված է',
'prv.addTitle': 'Նոր կատարող',
'prv.editTitle': 'Կատարողի պրոֆիլ',
'prv.name': 'Անուն',
'prv.role': 'Դեր',
'prv.company': 'Ընկերություն',
'prv.freelance': 'Անհատ կատարող',
'prv.schedule': 'Ժամանակացույց',
'prv.rate': 'Դրույք',
'prv.note': 'Թիմի նշում',
'err.REASON_REQUIRED': 'Նշեք պատճառը։',
'err.SLOT_UNAVAILABLE': 'Այս ժամը զբաղված է կամ անցել է։',
'err.ALREADY_ASSIGNED': 'Պատվերն արդեն նշանակված է։',
'err.PROVIDER_BUSY': 'Կատարողը զբաղված է այդ ժամին։',
'err.TRANSITION_NOT_ALLOWED': 'Այս քայլը հիմա հասանելի չէ։',
'err.CHECKLIST_INCOMPLETE': 'Կատարողը նախ պետք է ավարտի ստուգացանկը։',
'err.CONSENT_REQUIRED': 'Հաճախորդը դեռ համաձայնություն չի տվել։',
'err.ADDRESS_REQUIRED': 'Նշեք հասցեն և շրջանը։',
'err.INVALID_PARAMS': 'Ստուգեք պատվերի պարամետրերը։',
'err.OFFER_NOT_ACTIVE': 'Առաջարկն այլևս ակտիվ չէ։',
'err.BAD_FILE_TYPE_DOC': 'Միայն լուսանկար կամ PDF։',
'audit.bo.orders.assign': 'առաջարկեց պատվերը',
'audit.bo.orders.auto': 'գործարկեց ավտոմատ ընտրությունը',
'audit.bo.orders.unassign': 'հանեց կատարողին',
'audit.bo.orders.transition': 'փոխեց պատվերի կարգավիճակը',
'audit.bo.orders.cancel': 'չեղարկեց պատվերը',
'audit.bo.orders.reschedule': 'տեղափոխեց պատվերը',
'audit.bo.orders.markPaid': 'նշեց վճարումը',
'audit.bo.orders.create': 'ձևակերպեց պատվեր',
'audit.bo.orders.setAutoSetting': 'փոխեց ավտոմատ բաշխումը',
'audit.bo.providers.create': 'ավելացրեց կատարող',
'audit.bo.providers.update': 'փոխեց կատարողի պրոֆիլը',
'audit.bo.providers.setActive': 'փոխեց կատարողի թույլտվությունը',
'audit.bo.providers.resetInvite': 'նոր հղում տվեց կատարողին',
'audit.bo.providers.removeDoc': 'ջնջեց կատարողի փաստաթուղթը',
'audit.bo.providers.addDoc': 'վերբեռնեց կատարողի փաստաթուղթը',
'audit.bo.providers.payout': 'նշեց վճարում կատարողին',
'chat.order': 'Պատվեր №{n}', 'chat.order': 'Պատվեր №{n}',
'chat.noOrder': 'Առանց պատվերի', 'chat.noOrder': 'Առանց պատվերի',
'chat.noOrderNow': 'Այժմ պատվեր չկա', 'chat.noOrderNow': 'Այժմ պատվեր չկա',
@@ -1235,8 +1943,12 @@ interface Ctx {
locale: BoLocale; locale: BoLocale;
setLocale: (l: BoLocale) => void; setLocale: (l: BoLocale) => void;
t: T; t: T;
/** общие переводы проекта: services.<code>.name и т.п. */ /** общие переводы проекта: services.<code>.name, roles.<ROLE>, params.* и т.п. */
shared: (key: string) => string; shared: (key: string, params?: Record<string, string | number>) => string;
/** время по Еревану: 14:00 */
time: (d: Date) => string;
/** общий t проекта целиком — для хелперов @barev/web (paramsSummary и т.п.) */
sharedT: ReturnType<typeof createSharedT>;
amd: (n: number) => string; amd: (n: number) => string;
date: (d: Date) => string; date: (d: Date) => string;
dateTime: (d: Date) => string; dateTime: (d: Date) => string;
@@ -1279,10 +1991,12 @@ export function BoI18nProvider({ children }: { children: ReactNode }) {
}, },
t: makeT(locale), t: makeT(locale),
shared: sharedT, shared: sharedT,
sharedT,
amd: (n) => formatAmd(locale as SharedLocale, n), amd: (n) => formatAmd(locale as SharedLocale, n),
amdShort: (n) => `${compact.format(n)} ֏`, amdShort: (n) => `${compact.format(n)} ֏`,
date: (d) => formatDateTime(locale as SharedLocale, d, { day: 'numeric', month: 'short', year: 'numeric' }), date: (d) => formatDateTime(locale as SharedLocale, d, { day: 'numeric', month: 'short', year: 'numeric' }),
dateTime: (d) => formatDateTime(locale as SharedLocale, d, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }), dateTime: (d) => formatDateTime(locale as SharedLocale, d, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }),
time: (d) => formatDateTime(locale as SharedLocale, d, { hour: '2-digit', minute: '2-digit' }),
}; };
}, [locale]); }, [locale]);
return <I18nCtx.Provider value={value}>{children}</I18nCtx.Provider>; return <I18nCtx.Provider value={value}>{children}</I18nCtx.Provider>;
+1
View File
@@ -1,6 +1,7 @@
import '@barev/ui/styles.css'; import '@barev/ui/styles.css';
import './styles.css'; import './styles.css';
import './chats.css'; import './chats.css';
import './orders.css';
import { ApiProvider, ToastProvider } from '@barev/web'; import { ApiProvider, ToastProvider } from '@barev/web';
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
+694
View File
@@ -0,0 +1,694 @@
/*
* Этап 3: заказы и исполнители.
* Список заказов — лента по дням: время слева, услуга и клиент, кто исполняет, статус.
*/
/* ───── общие мелочи ───── */
.bo-chip--ok {
background: var(--bc-home);
color: var(--bc-home-ink);
}
.bo-chip--warn {
background: var(--bc-perks);
color: var(--bc-perks-ink);
}
.bo-chip--danger {
background: var(--bc-pink);
color: var(--bc-danger);
}
.bo-alert {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
padding: 12px 18px;
border: 0;
border-radius: var(--bo-r-row);
background: var(--bc-accent);
color: #fff;
font: inherit;
font-weight: 500;
text-align: left;
cursor: pointer;
}
.bo-person {
display: flex;
align-items: center;
gap: 12px;
color: inherit;
text-decoration: none;
}
.bo-person b {
font-weight: 600;
}
.bo-stars {
color: var(--bc-perks-ink);
letter-spacing: 1px;
white-space: nowrap;
}
.bo-stars[data-low] {
color: var(--bc-danger);
}
.bo-kv--plain dt {
color: inherit;
opacity: 0.75;
}
/* переключатель автораспределения */
.bo-autosw {
display: inline-flex;
align-items: center;
gap: 10px;
height: 42px;
padding: 0 16px 0 6px;
border-radius: var(--bc-pill);
border: 1.5px solid var(--bc-line);
background: var(--bo-panel);
font: inherit;
font-size: 14px;
font-weight: 600;
color: var(--bc-ink-2);
cursor: pointer;
}
.bo-autosw:disabled {
cursor: default;
}
.bo-autosw__knob {
width: 40px;
height: 26px;
border-radius: var(--bc-pill);
background: var(--bc-line);
position: relative;
transition: background 0.2s;
}
.bo-autosw__knob::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: transform 0.2s;
}
.bo-autosw.is-on {
color: var(--bc-ok);
}
.bo-autosw.is-on .bo-autosw__knob {
background: var(--bc-ok);
}
.bo-autosw.is-on .bo-autosw__knob::after {
transform: translateX(14px);
}
/* ───── список заказов ───── */
.bo-ogroups {
display: flex;
gap: 4px;
overflow-x: auto;
scrollbar-width: none;
margin-bottom: 18px;
border-bottom: 1.5px solid var(--bc-line);
}
.bo-ogroup {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px 12px;
border: 0;
border-bottom: 3px solid transparent;
margin-bottom: -1.5px;
background: none;
font: inherit;
font-size: 14px;
font-weight: 600;
color: var(--bc-ink-2);
white-space: nowrap;
cursor: pointer;
}
.bo-ogroup[aria-selected='true'] {
color: var(--bc-ink);
border-bottom-color: var(--bc-accent);
}
.bo-ogroup__dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.bo-ogroup__n {
font-family: var(--bc-font-head);
font-variant-numeric: tabular-nums;
color: var(--bc-ink-3);
}
.bo-oday {
display: flex;
align-items: baseline;
gap: 10px;
margin: 0 4px 8px;
font-family: var(--bc-font-head);
font-size: 15px;
font-weight: 700;
}
.bo-orows {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 6px;
}
.bo-orow {
display: grid;
grid-template-columns: 64px minmax(0, 1.6fr) minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
padding: 12px 18px 12px 14px;
border-radius: var(--bo-r-row);
background: var(--bo-panel);
color: inherit;
text-decoration: none;
box-shadow: inset 4px 0 0 transparent;
}
.bo-orow:hover {
box-shadow: 0 6px 20px rgba(30, 27, 26, 0.06);
}
.bo-orow[data-group='new'] {
box-shadow: inset 4px 0 0 var(--bc-accent);
}
.bo-orow[data-group='cancelled'] {
opacity: 0.6;
}
.bo-orow__time {
display: flex;
flex-direction: column;
font-family: var(--bc-font-head);
font-variant-numeric: tabular-nums;
line-height: 1.2;
}
.bo-orow__time b {
font-size: 18px;
}
.bo-orow__time span {
font-size: 12px;
color: var(--bc-ink-3);
}
.bo-orow__main,
.bo-orow__who {
min-width: 0;
}
.bo-orow__title {
display: block;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bo-orow__sub {
display: block;
font-size: 13px;
color: var(--bc-ink-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bo-orow__who {
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bo-orow__pending {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--bc-perks-ink);
}
.bo-orow__status {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 3px;
font-size: 13px;
}
/* ───── карточка заказа ───── */
.bo-ohead {
display: flex;
gap: 16px;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 18px;
}
.bo-ohead__kicker {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
font-family: var(--bc-font-head);
font-weight: 700;
color: var(--bc-ink-2);
}
.bo-steps {
list-style: none;
margin: 0 0 22px;
padding: 0;
display: flex;
gap: 4px;
overflow-x: auto;
scrollbar-width: none;
}
.bo-steps li {
flex: 1 0 auto;
min-width: 96px;
padding: 10px 12px 9px;
border-radius: 12px;
background: var(--bo-panel);
font-size: 13px;
font-weight: 600;
color: var(--bc-ink-3);
border-top: 4px solid var(--bc-line);
}
.bo-steps li[data-state='done'] {
color: var(--bc-ink-2);
border-top-color: var(--bc-ok);
}
.bo-steps li[data-state='now'] {
color: var(--bc-ink);
border-top-color: var(--bc-accent);
background: #fff;
box-shadow: 0 6px 18px rgba(30, 27, 26, 0.07);
}
.bo-checks {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
}
.bo-checks li {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
}
.bo-checks__box {
width: 22px;
height: 22px;
border-radius: 7px;
border: 1.5px solid var(--bc-line);
display: grid;
place-items: center;
flex: none;
}
.bo-checks li[data-done] .bo-checks__box {
background: var(--bc-ok);
border-color: var(--bc-ok);
color: #fff;
}
.bo-photos {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 8px;
margin-top: 14px;
}
.bo-photos img {
display: block;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 14px;
}
.bo-timeline {
list-style: none;
margin: 0;
padding: 0 0 0 14px;
border-left: 2px solid var(--bc-line);
display: grid;
gap: 12px;
}
.bo-timeline li {
position: relative;
display: flex;
flex-direction: column;
gap: 1px;
font-size: 14px;
}
.bo-timeline li::before {
content: '';
position: absolute;
left: -20px;
top: 5px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--bc-bg);
border: 2px solid var(--bc-ink-3);
}
.bo-timeline li:last-child::before {
border-color: var(--bc-accent);
background: var(--bc-accent);
}
/* подбор исполнителя */
.bo-pendingbox {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 18px;
background: var(--bc-perks);
color: var(--bc-perks-ink);
}
.bo-assignbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.bo-cands {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 6px;
}
.bo-cands li {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 16px;
background: var(--bo-soft);
}
.bo-cands li:first-child {
background: var(--bc-home);
color: var(--bc-home-ink);
}
.bo-cands__rank {
width: 26px;
height: 26px;
border-radius: 50%;
display: grid;
place-items: center;
background: #fff;
font-family: var(--bc-font-head);
font-weight: 700;
font-size: 13px;
flex: none;
}
/* время: дни и слоты */
.bo-days {
display: flex;
gap: 6px;
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 2px;
}
.bo-day {
flex: none;
width: 54px;
padding: 6px 0;
border-radius: 14px;
border: 1.5px solid var(--bc-line);
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
font: inherit;
font-size: 12px;
color: var(--bc-ink-2);
cursor: pointer;
}
.bo-day b {
font-family: var(--bc-font-head);
font-size: 18px;
color: var(--bc-ink);
}
.bo-day[aria-pressed='true'] {
background: var(--bc-ink);
border-color: var(--bc-ink);
color: rgba(255, 255, 255, 0.75);
}
.bo-day[aria-pressed='true'] b {
color: #fff;
}
.bo-slots {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(76px, 1fr));
gap: 6px;
}
.bo-slot {
position: relative;
height: 40px;
border-radius: 12px;
border: 1.5px solid var(--bc-line);
background: #fff;
font: inherit;
font-family: var(--bc-font-head);
font-weight: 600;
font-variant-numeric: tabular-nums;
cursor: pointer;
}
.bo-slot span {
position: absolute;
top: 3px;
right: 6px;
font-size: 10px;
color: var(--bc-ink-3);
}
.bo-slot[aria-pressed='true'] {
background: var(--bc-accent);
border-color: var(--bc-accent);
color: #fff;
}
.bo-slot[aria-pressed='true'] span {
color: rgba(255, 255, 255, 0.7);
}
/* новый заказ */
.bo-dialog--wide {
width: min(680px, calc(100vw - 32px));
}
.bo-quote {
align-self: center;
}
.bo-quote b {
font-size: 20px;
}
.bo-stepper {
display: inline-flex;
align-items: center;
gap: 4px;
height: 42px;
padding: 3px;
border-radius: var(--bc-pill);
border: 1.5px solid var(--bc-line);
background: #fff;
align-self: flex-start;
}
.bo-stepper button {
width: 34px;
height: 34px;
border-radius: 50%;
border: 0;
background: var(--bo-soft);
font-size: 18px;
cursor: pointer;
}
.bo-stepper button:disabled {
opacity: 0.35;
cursor: default;
}
.bo-stepper b {
min-width: 26px;
text-align: center;
font-size: 16px;
}
.bo-stepper--sm {
height: 34px;
}
.bo-stepper--sm button {
width: 26px;
height: 26px;
font-size: 15px;
}
.bo-extras {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 6px;
margin-top: 10px;
}
.bo-extra {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 6px 6px 12px;
border-radius: 14px;
background: #fff;
font-size: 14px;
min-height: 46px;
}
.bo-extra[data-on] {
background: var(--bc-pink);
color: var(--bc-plum);
}
.bo-extra input[type='checkbox'] {
width: 20px;
height: 20px;
margin-right: 8px;
accent-color: var(--bc-accent);
}
.bo-picklist {
list-style: none;
margin: 6px 0 0;
padding: 4px;
border-radius: 16px;
background: #fff;
box-shadow: 0 0 0 1px var(--bc-line);
max-height: 260px;
overflow-y: auto;
}
.bo-picklist button {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
border: 0;
border-radius: 12px;
background: none;
font: inherit;
text-align: left;
cursor: pointer;
}
.bo-picklist button:hover {
background: var(--bo-soft);
}
/* ───── исполнители ───── */
.bo-ptable tbody tr {
cursor: pointer;
}
.bo-ptable .bo-who {
color: inherit;
text-decoration: none;
align-items: flex-start;
}
.bo-ptable .bo-who__name,
.bo-ptable .bo-who__meta {
display: block;
}
.bo-ptable .bo-who__name {
font-weight: 600;
}
.bo-ptable tbody tr[data-off] td {
opacity: 0.6;
}
.bo-ptable .bo-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.bo-pavatar {
position: relative;
display: inline-flex;
}
.bo-pavatar[data-shift]::after {
content: '';
position: absolute;
right: -1px;
bottom: -1px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--bc-ok);
border: 2px solid #fff;
}
.bo-quality {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.bo-quality > div {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px;
border-radius: 16px;
background: var(--bo-soft);
}
.bo-quality .bo-bignum {
font-size: 26px;
}
.bo-week {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 4px;
margin-top: 14px;
}
.bo-week__day {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 8px 2px;
border-radius: 12px;
background: var(--bc-home);
color: var(--bc-home-ink);
font-size: 11px;
text-align: center;
}
.bo-week__day[data-off] {
background: var(--bo-soft);
color: var(--bc-ink-3);
}
.bo-sched {
display: grid;
gap: 6px;
margin-top: 6px;
}
.bo-sched__row {
display: flex;
align-items: center;
gap: 8px;
min-height: 40px;
}
.bo-sched__row .bo-input {
width: 110px;
min-height: 36px;
padding: 4px 10px;
}
.bo-sched__row[data-off] {
color: var(--bc-ink-3);
}
@media (max-width: 860px) {
.bo-orow {
grid-template-columns: 54px minmax(0, 1fr);
gap: 4px 12px;
}
.bo-orow__who {
grid-column: 2;
}
.bo-orow__status {
grid-column: 2;
flex-direction: row;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
}
.bo-quality {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.bo-ptable th:nth-child(4),
.bo-ptable td:nth-child(4),
.bo-ptable th:nth-child(5),
.bo-ptable td:nth-child(5) {
display: none;
}
}
+2 -2
View File
@@ -528,7 +528,7 @@ function OrderTag({ order }: { order: OrderBrief | null }) {
</div> </div>
); );
return ( return (
<a className="bo-ordertag" href={`#/clients/${order.clientId}`} title={t('chat.orderClient', { name: order.clientName })}> <a className="bo-ordertag" href={`#/orders/${order.id}`} title={t('chat.orderClient', { name: order.clientName })}>
<I name="orders" size={13} /> <I name="orders" size={13} />
{text(order)} {text(order)}
</a> </a>
@@ -545,7 +545,7 @@ function ProviderOrders({ current, next }: { current: OrderBrief | null; next: O
<span className="bo-grow" style={{ minWidth: 0 }}> <span className="bo-grow" style={{ minWidth: 0 }}>
{current ? ( {current ? (
<> <>
<b>{t('chat.orderNow')}:</b> {text(current, true)} <b>{t('chat.orderNow')}:</b> <a href={`#/orders/${current.id}`} style={{ color: 'inherit' }}>{text(current, true)}</a>
</> </>
) : ( ) : (
<b>{t('chat.noOrderNow')}</b> <b>{t('chat.noOrderNow')}</b>
+11 -6
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { navigate, useToast, useTRPC } from '@barev/web'; import { navigate, useToast, useTRPC } from '@barev/web';
import { myChannels } from './Chats.tsx'; import { myChannels } from './Chats.tsx';
import { NewOrderDialog } from './NewOrder.tsx';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { Me } from '../App.tsx'; import type { Me } from '../App.tsx';
import { errText, useT, type BoKey } from '../i18n.tsx'; import { errText, useT, type BoKey } from '../i18n.tsx';
@@ -16,6 +17,7 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
const trpc = useTRPC(); const trpc = useTRPC();
const c = useQuery(trpc.bo.clients.get.queryOptions({ id })); const c = useQuery(trpc.bo.clients.get.queryOptions({ id }));
const [dlg, setDlg] = useState<{ mode: SubMode; sub?: SubTarget } | null>(null); const [dlg, setDlg] = useState<{ mode: SubMode; sub?: SubTarget } | null>(null);
const [newOrder, setNewOrder] = useState(false);
const isSuper = me.roles.includes('SUPERADMIN'); const isSuper = me.roles.includes('SUPERADMIN');
const canManage = isSuper || me.roles.includes('SUPPORT'); const canManage = isSuper || me.roles.includes('SUPPORT');
const canChat = myChannels(me).length > 0; const canChat = myChannels(me).length > 0;
@@ -189,10 +191,12 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
<section className="bo-panel"> <section className="bo-panel">
<div className="bo-panel__head"> <div className="bo-panel__head">
<h2 className="bo-h2">{t('card.orders')}</h2> <h2 className="bo-h2">{t('card.orders')}</h2>
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" disabled title={t('card.newOrderSoon')}> {canManage && (
<I name="plus" size={16} /> <button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setNewOrder(true)}>
{t('card.newOrder')} <I name="plus" size={16} />
</button> {t('card.newOrder')}
</button>
)}
</div> </div>
{d.orders.length === 0 ? ( {d.orders.length === 0 ? (
<p className="bo-hint">{t('card.noOrders')}</p> <p className="bo-hint">{t('card.noOrders')}</p>
@@ -200,9 +204,9 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
<ul className="bo-list"> <ul className="bo-list">
{d.orders.map((o) => ( {d.orders.map((o) => (
<li key={o.id} style={{ flexWrap: 'wrap' }}> <li key={o.id} style={{ flexWrap: 'wrap' }}>
<span className="bo-num bo-faint" style={{ width: 52 }}> <a href={`#/orders/${o.id}`} className="bo-num bo-faint" style={{ width: 52 }}>
№{o.number} №{o.number}
</span> </a>
<span className="bo-grow" style={{ minWidth: 140 }}> <span className="bo-grow" style={{ minWidth: 140 }}>
<b style={{ fontWeight: 600 }}>{shared(`services.${o.service}.name`)}</b> <b style={{ fontWeight: 600 }}>{shared(`services.${o.service}.name`)}</b>
<span className="bo-muted bo-small" style={{ display: 'block' }}> <span className="bo-muted bo-small" style={{ display: 'block' }}>
@@ -324,6 +328,7 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
</div> </div>
{dlg && <SubDialog clientId={d.id} mode={dlg.mode} sub={dlg.sub} current={current} onClose={() => setDlg(null)} />} {dlg && <SubDialog clientId={d.id} mode={dlg.mode} sub={dlg.sub} current={current} onClose={() => setDlg(null)} />}
{newOrder && <NewOrderDialog clientId={d.id} onClose={() => setNewOrder(false)} />}
</> </>
); );
} }
+2 -2
View File
@@ -55,12 +55,12 @@ export function Dashboard({ me }: { me: Me }) {
type Att = { unassigned: number; unreadChats: number; webRequests: number; leads: number; appRequests: number; lowRatings: number; expiring: number }; type Att = { unassigned: number; unreadChats: number; webRequests: number; leads: number; appRequests: number; lowRatings: number; expiring: number };
const ATT: { key: keyof Att; href: string; urgent?: boolean; fill: string; ink: string }[] = [ const ATT: { key: keyof Att; href: string; urgent?: boolean; fill: string; ink: string }[] = [
{ key: 'unassigned', href: '#/orders', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' }, { key: 'unassigned', href: '#/orders?group=new', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' },
{ key: 'unreadChats', href: '#/chats', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' }, { key: 'unreadChats', href: '#/chats', urgent: true, fill: 'var(--bc-accent)', ink: '#fff' },
{ key: 'webRequests', href: `${OLD_ADMIN_URL}#/requests`, fill: 'var(--bc-blue)', ink: 'var(--bc-blue-ink)' }, { key: 'webRequests', href: `${OLD_ADMIN_URL}#/requests`, fill: 'var(--bc-blue)', ink: 'var(--bc-blue-ink)' },
{ key: 'leads', href: `${OLD_ADMIN_URL}#/leads`, fill: 'var(--bc-docs)', ink: 'var(--bc-docs-ink)' }, { key: 'leads', href: `${OLD_ADMIN_URL}#/leads`, fill: 'var(--bc-docs)', ink: 'var(--bc-docs-ink)' },
{ key: 'appRequests', href: `${OLD_ADMIN_URL}#/app-requests`, fill: 'var(--bc-learn)', ink: 'var(--bc-learn-ink)' }, { key: 'appRequests', href: `${OLD_ADMIN_URL}#/app-requests`, fill: 'var(--bc-learn)', ink: 'var(--bc-learn-ink)' },
{ key: 'lowRatings', href: '#/orders', fill: 'var(--bc-pink)', ink: 'var(--bc-plum)' }, { key: 'lowRatings', href: '#/providers', fill: 'var(--bc-pink)', ink: 'var(--bc-plum)' },
{ key: 'expiring', href: '#/clients?expiring=7', fill: 'var(--bc-perks)', ink: 'var(--bc-perks-ink)' }, { key: 'expiring', href: '#/clients?expiring=7', fill: 'var(--bc-perks)', ink: 'var(--bc-perks-ink)' },
]; ];
+391
View File
@@ -0,0 +1,391 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { formatDateTime, type Locale } from '@barev/i18n';
import { navigate, useToast, useTRPC } from '@barev/web';
import { useEffect, useMemo, useState } from 'react';
import { errText, useT } from '../i18n.tsx';
import { Avatar, Dialog, Field, I, PlanBadge } from '../components/kit.tsx';
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 }
| { key: string; type: 'bool'; default?: boolean }
| { key: string; type: 'extras'; items: { code: string; max: number }[] };
type Params = Record<string, unknown>;
/** Дата по Еревану в виде YYYY-MM-DD */
const dayKey = (d: Date) => new Date(d.getTime() + 4 * 3600_000).toISOString().slice(0, 10);
/** Время по Еревану из даты и «чч:мм» */
const atYerevan = (key: string, hhmm: string) => new Date(`${key}T${hhmm}:00+04:00`);
function defaults(fields: ParamField[]): Params {
const out: Params = {};
for (const f of fields) {
if (f.type === 'select') out[f.key] = f.default ?? f.options[0];
else if (f.type === 'int') out[f.key] = f.default ?? f.min;
else if (f.type === 'bool') out[f.key] = f.default ?? false;
else if (f.type === 'extras') out[f.key] = {};
}
return out;
}
/**
* Время заказа: свободные слоты на выбранный день (с учётом графиков и уже назначенных заказов)
* или «другое время» — оператор договорился сам.
*/
export function SlotPicker({ service, params, excludeOrderId, initial, onChange }: { service: string; params: Params; excludeOrderId?: string; initial?: Date; onChange: (v: { when: Date; busy: boolean } | null) => void }) {
const { t, time, locale } = useT();
const trpc = useTRPC();
const [day, setDay] = useState(dayKey(initial && initial > new Date() ? initial : new Date()));
const [picked, setPicked] = useState<Date | null>(null);
const [custom, setCustom] = useState(false);
const [hhmm, setHhmm] = useState('10:00');
const slots = useQuery({ ...trpc.bo.orders.slots.queryOptions({ service, params, dateKey: day, excludeOrderId }), placeholderData: keepPreviousData });
useEffect(() => {
if (custom) onChange(hhmm ? { when: atYerevan(day, hhmm), busy: true } : null);
else onChange(picked ? { when: picked, busy: false } : null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [custom, hhmm, day, picked]);
const days = useMemo(() => {
const out: string[] = [];
for (let i = 0; i < 14; i++) out.push(dayKey(new Date(Date.now() + i * 86_400_000)));
return out;
}, []);
const free = (slots.data ?? []).filter((s) => s.capacity > 0);
return (
<div className="bo-col" style={{ gap: 12 }}>
<div className="bo-days" role="group" aria-label={t('no.day')}>
{days.map((k) => {
const d = atYerevan(k, '12:00');
return (
<button
key={k}
type="button"
className="bo-day"
aria-pressed={k === day}
onClick={() => {
setDay(k);
setPicked(null);
}}
>
<span>{formatDateTime(locale as Locale, d, { weekday: 'short' })}</span>
<b>{formatDateTime(locale as Locale, d, { day: 'numeric' })}</b>
</button>
);
})}
</div>
{!custom ? (
free.length === 0 ? (
<p className="bo-hint">{slots.isFetching ? t('ui.loading') : t('no.noSlots')}</p>
) : (
<div className="bo-slots">
{free.map((s) => (
<button key={s.start.toISOString()} type="button" className="bo-slot" aria-pressed={picked?.getTime() === s.start.getTime()} onClick={() => setPicked(s.start)}>
{time(s.start)}
<span>{s.capacity}</span>
</button>
))}
</div>
)
) : (
<Field label={t('no.customTime')} hint={t('no.customHint')}>
<input className="bo-input" type="time" value={hhmm} onChange={(e) => setHhmm(e.target.value)} step={900} />
</Field>
)}
<label className="bo-check">
<input type="checkbox" checked={custom} onChange={(e) => setCustom(e.target.checked)} />
{t('no.otherTime')}
</label>
</div>
);
}
/** Новый заказ за клиента: услуга → параметры → адрес → время. Цена — с его скидкой по подписке */
export function NewOrderDialog({ clientId: fixedClient, onClose }: { clientId?: string; onClose: () => void }) {
const { t, shared, amd } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const [clientId, setClientId] = useState(fixedClient ?? '');
const services = useQuery(trpc.bo.orders.services.queryOptions());
const dict = useQuery(trpc.bo.orders.dictionaries.queryOptions());
const [svc, setSvc] = useState('');
const service = services.data?.find((s) => s.code === svc) ?? services.data?.[0];
const fields = (service?.paramsSchema.fields ?? []) as ParamField[];
const [params, setParams] = useState<Params>({});
useEffect(() => {
if (service) setParams(defaults(fields));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [service?.code]);
const [address, setAddress] = useState('');
const [districtId, setDistrictId] = useState('');
const [note, setNote] = useState('');
const [payment, setPayment] = useState('');
const [at, setAt] = useState<{ when: Date; busy: boolean } | null>(null);
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 create = useMutation(
trpc.bo.orders.create.mutationOptions({
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: trpc.bo.orders.list.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.clients.get.queryKey() });
toast(t('no.created', { n: r.number }));
onClose();
navigate(`/orders/${r.id}`);
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
const needsAddress = service && !service.isOnline;
const canSubmit = !!clientId && ready && !!at && (!needsAddress || (address.trim() && districtId)) && !create.isPending;
return (
<Dialog
open
wide
onClose={onClose}
title={t('no.title')}
footer={
<>
{quote.data && (
<span className="bo-grow bo-quote">
<b className="bo-num">{amd(quote.data.total)}</b>
<span className="bo-small bo-muted">
{' '}
· {t('ord.minutes', { n: quote.data.durationMinutes })}
{quote.data.discountPct > 0 && ` · −${quote.data.discountPct}%`}
</span>
</span>
)}
<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={!canSubmit}
onClick={() =>
at &&
service &&
create.mutate({
clientId,
service: service.code,
params,
scheduledAt: at.when,
allowBusySlot: at.busy,
addressText: needsAddress ? address : undefined,
districtId: needsAddress ? districtId : undefined,
note: note || undefined,
paymentMethodId: payment || undefined,
})
}
>
{t('no.create')}
</button>
</>
}
>
{!fixedClient && <ClientPicker value={clientId} onChange={setClientId} />}
{(services.data?.length ?? 0) > 1 && (
<Field label={t('no.service')}>
<select className="bo-select" value={service?.code ?? ''} onChange={(e) => setSvc(e.target.value)}>
{services.data!.map((s) => (
<option key={s.code} value={s.code}>
{shared(`services.${s.code}.name`)}
</option>
))}
</select>
</Field>
)}
{service && <ParamsForm service={service.code} fields={fields} value={params} onChange={setParams} />}
{needsAddress && (
<div className="bo-row2">
<Field label={t('no.address')}>
<input className="bo-input" value={address} maxLength={300} onChange={(e) => setAddress(e.target.value)} placeholder={t('no.addressPh')} />
</Field>
<Field label={t('no.district')}>
<select className="bo-select" value={districtId} onChange={(e) => setDistrictId(e.target.value)}>
<option value="">—</option>
{dict.data?.districts.map((d) => (
<option key={d.id} value={d.id}>
{shared(`districts.${d.code}`)}
</option>
))}
</select>
</Field>
</div>
)}
{ready && (
<div>
<span className="bo-label">{t('no.when')}</span>
<div style={{ marginTop: 6 }}>
<SlotPicker service={service!.code} params={params} onChange={setAt} />
</div>
</div>
)}
<div className="bo-row2">
<Field label={t('no.note')}>
<input className="bo-input" value={note} maxLength={1000} onChange={(e) => setNote(e.target.value)} placeholder={t('no.notePh')} />
</Field>
<Field label={t('no.payment')}>
<select className="bo-select" value={payment} onChange={(e) => setPayment(e.target.value)}>
<option value="">—</option>
{dict.data?.paymentMethods.map((m) => (
<option key={m.id} value={m.id}>
{m.name.ru ?? m.code}
</option>
))}
</select>
</Field>
</div>
<p className="bo-hint">{t('no.hint')}</p>
</Dialog>
);
}
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`;
const v = shared(key);
return v === key ? k : v;
};
const opt = (k: string, o: string) => {
const key = `params.${service}.${k}.${o}`;
const v = shared(key);
return v === key ? o : v;
};
const set = (k: string, v: unknown) => onChange({ ...value, [k]: v });
const [showExtras, setShowExtras] = useState(false);
return (
<div className="bo-col" style={{ gap: 12 }}>
<div className="bo-row2">
{fields
.filter((f) => f.type === 'select' || f.type === 'int')
.map((f) =>
f.type === 'select' ? (
<Field key={f.key} label={label(f.key)}>
<select className="bo-select" value={String(value[f.key] ?? '')} onChange={(e) => set(f.key, e.target.value)}>
{f.options.map((o) => (
<option key={o} value={o}>
{opt(f.key, o)}
</option>
))}
</select>
</Field>
) : f.type === 'int' ? (
<Field key={f.key} label={label(f.key)}>
<Stepper value={Number(value[f.key] ?? f.min)} min={f.min} max={f.max} onChange={(n) => set(f.key, n)} />
</Field>
) : null,
)}
</div>
{fields.map((f) =>
f.type === 'text' ? (
<Field key={f.key} label={label(f.key)}>
<textarea className="bo-textarea" style={{ minHeight: 70 }} maxLength={f.maxLength} value={String(value[f.key] ?? '')} onChange={(e) => set(f.key, e.target.value)} />
</Field>
) : f.type === 'bool' ? (
<label key={f.key} className="bo-check">
<input type="checkbox" checked={!!value[f.key]} onChange={(e) => set(f.key, e.target.checked)} />
{label(f.key)}
</label>
) : f.type === 'extras' ? (
<div key={f.key}>
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-accent)', padding: 0 }} onClick={() => setShowExtras((s) => !s)} aria-expanded={showExtras}>
{t('no.extras')} {Object.values((value[f.key] as Record<string, number>) ?? {}).filter((n) => n > 0).length || ''} {showExtras ? '▴' : '▾'}
</button>
{showExtras && (
<div className="bo-extras">
{f.items.map((it) => {
const ex = (value[f.key] as Record<string, number>) ?? {};
const n = ex[it.code] ?? 0;
return (
<div key={it.code} className="bo-extra" data-on={n > 0 || undefined}>
<span className="bo-grow">{shared(`extras.${it.code}.name`)}</span>
{it.max === 1 ? (
<input type="checkbox" checked={n > 0} onChange={(e) => set(f.key, { ...ex, [it.code]: e.target.checked ? 1 : 0 })} aria-label={shared(`extras.${it.code}.name`)} />
) : (
<Stepper value={n} min={0} max={it.max} onChange={(v) => set(f.key, { ...ex, [it.code]: v })} small />
)}
</div>
);
})}
</div>
)}
</div>
) : null,
)}
</div>
);
}
function Stepper({ value, min, max, onChange, small }: { value: number; min: number; max: number; onChange: (n: number) => void; small?: boolean }) {
return (
<span className={`bo-stepper${small ? ' bo-stepper--sm' : ''}`}>
<button type="button" onClick={() => onChange(Math.max(min, value - 1))} disabled={value <= min} aria-label="−">
−
</button>
<b className="bo-num">{value}</b>
<button type="button" onClick={() => onChange(Math.min(max, value + 1))} disabled={value >= max} aria-label="+">
+
</button>
</span>
);
}
function ClientPicker({ value, onChange }: { value: string; onChange: (id: string) => void }) {
const { t } = useT();
const trpc = useTRPC();
const [q, setQ] = useState('');
const [debounced, setDebounced] = useState('');
useEffect(() => {
const id = setTimeout(() => setDebounced(q.trim()), 250);
return () => clearTimeout(id);
}, [q]);
const list = useQuery({ ...trpc.bo.clients.list.queryOptions({ search: debounced || undefined, sort: 'new', page: 0 }), enabled: debounced.length >= 2 && !value, placeholderData: keepPreviousData });
const picked = useQuery({ ...trpc.bo.clients.get.queryOptions({ id: value }), enabled: !!value });
if (value && picked.data)
return (
<div className="bo-person">
<Avatar name={picked.data.name} id={picked.data.id} size={36} />
<span className="bo-grow">
<b>{picked.data.name}</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{[picked.data.username && `@${picked.data.username}`, picked.data.phone].filter(Boolean).join(' · ')}
</span>
</span>
<button type="button" className="bo-iconbtn" onClick={() => onChange('')} aria-label={t('no.changeClient')}>
<I name="close" size={18} />
</button>
</div>
);
return (
<Field label={t('no.client')}>
<input className="bo-input" autoFocus value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('no.clientPh')} />
{debounced.length >= 2 && (
<ul className="bo-picklist">
{(list.data?.items ?? []).slice(0, 8).map((c) => (
<li key={c.id}>
<button type="button" onClick={() => onChange(c.id)}>
<Avatar name={c.name} id={c.id} size={28} />
<span className="bo-grow">
{c.name}
<span className="bo-faint bo-small"> {[c.username && `@${c.username}`, c.phone].filter(Boolean).join(' · ')}</span>
</span>
<PlanBadge plan={c.sub?.plan ?? null} t={t} />
</button>
</li>
))}
{list.data && list.data.items.length === 0 && <li className="bo-hint" style={{ padding: 8 }}>{t('no.noClients')}</li>}
</ul>
)}
</Field>
);
}
+585
View File
@@ -0,0 +1,585 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { navigate, paramsExtras, paramsSummary, useToast, useTRPC } from '@barev/web';
import { useEffect, useState } from 'react';
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';
const MIN = 60_000;
export function OrderCard({ id, me }: { id: string; me: Me }) {
const { t, shared, sharedT, date, dateTime, time, amd } = useT();
const trpc = useTRPC();
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 refresh = () => {
qc.invalidateQueries({ queryKey: trpc.bo.orders.get.queryKey({ id }) });
qc.invalidateQueries({ queryKey: trpc.bo.orders.candidates.queryKey({ id }) });
qc.invalidateQueries({ queryKey: trpc.bo.orders.list.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.orders.counts.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.dashboard.attention.queryKey() });
};
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 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');
const back = (
<a className="bo-back" href="#/orders">
<I name="back" size={16} />
{t('ord.back')}
</a>
);
if (o.isError)
return (
<>
{back}
<div className="bo-panel bo-empty">{errText(t, o.error)}</div>
</>
);
if (!o.data)
return (
<div className="bo-col">
<Skeleton h={90} />
<Skeleton h={260} />
</div>
);
const d = o.data;
const end = new Date(d.scheduledAt.getTime() + d.durationMinutes * MIN);
const extras = paramsExtras(sharedT, d.params);
const stepIdx = d.steps.indexOf(d.status as never);
return (
<>
{back}
<header className="bo-ohead">
<div style={{ minWidth: 0 }}>
<div className="bo-ohead__kicker">
<span className="bo-num">№{d.number}</span>
<span className="bo-status" style={{ ['--dot' as string]: GROUP_DOT[d.status === 'CANCELLED' ? 'cancelled' : d.group === 'new' && d.pending ? 'pending' : d.group] }}>
{statusLabel(shared, d.flow, d.status)}
</span>
</div>
<h1 className="bo-title" style={{ fontSize: 'clamp(26px, 3.4vw, 38px)' }}>
{shared(`services.${d.service}.name`)}
</h1>
<div className="bo-muted" style={{ marginTop: 6 }}>
{dateTime(d.scheduledAt)} – {time(end)}
{d.service === 'cleaning' && <> · {paramsSummary(sharedT, d.service, d.params)}</>}
</div>
</div>
</header>
{d.status !== 'CANCELLED' && (
<ol className="bo-steps" aria-label={t('ord.progress')}>
{d.steps.map((s, i) => (
<li key={s} data-state={i < stepIdx ? 'done' : i === stepIdx ? 'now' : 'next'}>
{statusLabel(shared, d.flow, s)}
</li>
))}
</ol>
)}
<div className="bo-client-grid">
<div className="bo-col">
<ProviderPanel order={d} onChanged={refresh} canChat={canChat} />
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.details')}</h2>
</div>
<dl className="bo-kv">
<dt>{t('ord.when')}</dt>
<dd>
{date(d.scheduledAt)}, {time(d.scheduledAt)} – {time(end)}
<span className="bo-faint"> · {t('ord.minutes', { n: d.durationMinutes })}</span>
</dd>
<dt>{t('ord.where')}</dt>
<dd>{d.isOnline ? shared('common.online') : [d.address, d.district ? shared(`districts.${d.district}`) : null].filter(Boolean).join(', ') || '—'}</dd>
{d.service === 'cleaning' && (
<>
<dt>{t('ord.params')}</dt>
<dd>{paramsSummary(sharedT, d.service, d.params)}</dd>
</>
)}
{d.service !== 'cleaning' &&
Object.entries(d.params)
.filter(([, v]) => typeof v === 'string' || typeof v === 'number')
.map(([k, v]) => (
<FragmentKV key={k} k={paramLabel(shared, d.service, k)} v={paramValue(shared, d.service, k, v)} />
))}
{extras.length > 0 && (
<>
<dt>{t('ord.extras')}</dt>
<dd>{extras.map((x) => x.label).join(', ')}</dd>
</>
)}
{d.note && (
<>
<dt>{t('ord.note')}</dt>
<dd style={{ whiteSpace: 'pre-line' }}>{d.note}</dd>
</>
)}
<dt>{t('ord.lang')}</dt>
<dd>{d.locale.toUpperCase()}</dd>
<dt>{t('ord.created')}</dt>
<dd>{dateTime(d.createdAt)}</dd>
</dl>
</section>
{d.checklist.length > 0 && (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.checklist')}</h2>
<span className="bo-faint">
{d.checklist.filter((c) => c.done).length}/{d.checklist.length}
</span>
</div>
<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>}
</li>
))}
</ul>
{d.photos.length > 0 && (
<div className="bo-photos">
{d.photos.map((f) => (
<a key={f} href={`/files/bo/order-file/${f}`} target="_blank" rel="noreferrer">
<img src={`/files/bo/order-file/${f}`} alt={t('ord.photo')} loading="lazy" />
</a>
))}
</div>
)}
</section>
)}
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.history')}</h2>
</div>
<ol className="bo-timeline">
{d.history.map((h) => (
<li key={h.id}>
<span className="bo-feed__time">{dateTime(h.at)}</span>
<span>
<b>{historyTitle(t, shared, d.flow, h)}</b>
<span className="bo-muted">
{' '}
· {h.actorName ?? shared(`admin.actors.${h.actor}`)}
{h.comment && ` · ${h.comment}`}
</span>
</span>
</li>
))}
</ol>
</section>
</div>
<div className="bo-col">
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.client')}</h2>
</div>
<a href={`#/clients/${d.client.id}`} className="bo-person">
<Avatar name={d.client.name} id={d.client.id} size={40} />
<span style={{ minWidth: 0 }}>
<b>{d.client.name}</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{[d.client.username && `@${d.client.username}`, d.client.phone].filter(Boolean).join(' · ') || '—'}
</span>
</span>
<PlanBadge plan={d.client.plan} t={t} />
</a>
{canChat && (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" style={{ marginTop: 12 }} onClick={() => chat.mutate({ clientId: d.client.id, channel: 'SUPPORT' })}>
<I name="chats" size={16} />
{t('ord.writeClient')}
</button>
)}
</section>
{d.money && (
<section className="bo-panel bo-money-card">
<span className="bo-label" style={{ color: 'inherit', opacity: 0.75 }}>
{t('ord.money')}
</span>
<div className="bo-bignum" style={{ margin: '6px 0 10px' }}>
{amd(d.money.total)}
</div>
<dl className="bo-kv bo-kv--plain">
<dt>{t('ord.base')}</dt>
<dd className="bo-num">{amd(d.money.base)}</dd>
{d.money.discount > 0 && (
<>
<dt>{t('ord.discount', { pct: d.money.discountPct })}</dt>
<dd className="bo-num">−{amd(d.money.discount)}</dd>
</>
)}
{d.money.payout != null && (
<>
<dt>{t('ord.payout')}</dt>
<dd className="bo-num">{amd(d.money.payout)}</dd>
</>
)}
{d.money.cancelFee > 0 && (
<>
<dt>{t('ord.cancelFee')}</dt>
<dd className="bo-num">{amd(d.money.cancelFee)}</dd>
</>
)}
</dl>
</section>
)}
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.payment')}</h2>
<span className={`bo-chip${d.paymentStatus === 'PAID' ? ' bo-chip--ok' : ''}`}>{shared(`payment.${d.paymentStatus}`)}</span>
</div>
{d.paidAt && (
<p className="bo-hint">
{dateTime(d.paidAt)}
{d.paymentMethod && ` · ${d.paymentMethod.ru}`}
</p>
)}
{d.paymentStatus === 'UNPAID' && d.status !== 'CANCELLED' && (
<button type="button" className="bo-btn bo-btn--sm" onClick={() => confirm(t('ord.confirmPaid')) && markPaid.mutate({ id })} disabled={markPaid.isPending}>
<I name="check" size={16} />
{t('ord.markPaid')}
</button>
)}
</section>
{(d.adminTransitions.length > 0 || d.canReschedule || d.canCancel) && (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.actions')}</h2>
</div>
<div className="bo-col" style={{ gap: 8 }}>
{d.adminTransitions.map((to) => (
<button
key={to}
type="button"
className="bo-btn bo-btn--ghost bo-btn--sm"
disabled={transition.isPending}
onClick={() => confirm(t('ord.confirmStatus', { status: statusLabel(shared, d.flow, to) })) && transition.mutate({ id, to })}
>
{t('ord.setStatus', { status: statusLabel(shared, d.flow, to) })}
</button>
))}
{d.canReschedule && (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setDlg('reschedule')}>
{t('ord.reschedule')}
</button>
)}
{d.canCancel && (
<button type="button" className="bo-btn bo-btn--danger bo-btn--sm" onClick={() => setDlg('cancel')}>
{t('ord.cancel')}
</button>
)}
</div>
</section>
)}
{d.rating && (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.rating')}</h2>
<span className="bo-stars">{'★'.repeat(d.rating.score)}{'☆'.repeat(5 - d.rating.score)}</span>
</div>
{d.rating.comment ? <p style={{ margin: 0 }}>{d.rating.comment}</p> : <p className="bo-hint">{t('ord.noComment')}</p>}
</section>
)}
{d.offers.length > 0 && (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.offers')}</h2>
</div>
<ul className="bo-list">
{d.offers.map((x) => (
<li key={x.id}>
<span className="bo-grow">
<b>{x.name}</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{dateTime(x.createdAt)} · {x.by ?? t('ord.bySystem')}
</span>
</span>
<span className="bo-chip">{shared(`admin.offerStatus.${x.status}`)}</span>
</li>
))}
</ul>
</section>
)}
{d.cancelReason && (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.cancelReason')}</h2>
</div>
<p style={{ margin: 0 }}>{d.cancelReason}</p>
</section>
)}
</div>
</div>
{dlg === 'reschedule' && <RescheduleDialog order={d} onClose={() => setDlg(null)} onDone={refresh} />}
{dlg === 'cancel' && <CancelDialog id={id} showFee={!!d.money} onClose={() => setDlg(null)} onDone={refresh} />}
</>
);
}
function FragmentKV({ k, v }: { k: string; v: string }) {
return (
<>
<dt>{k}</dt>
<dd>{v}</dd>
</>
);
}
const paramLabel = (shared: (k: string) => string, svc: string, key: string) => {
const k = `params.${svc}.${key}.label`;
const v = shared(k);
return v === k ? key : v;
};
const paramValue = (shared: (k: string) => string, svc: string, key: string, value: unknown) => {
const k = `params.${svc}.${key}.${String(value)}`;
const v = shared(k);
return v === k ? String(value) : v;
};
type HistoryRow = { event: string | null; from: string | null; to: string; comment: string | null };
function historyTitle(t: ReturnType<typeof useT>['t'], shared: (k: string) => string, flow: string, h: HistoryRow) {
if (h.event) {
const own = `ord.ev.${h.event}`;
const ownTr = t(own);
if (ownTr !== own) return ownTr;
const sh = `admin.events.${h.event}`;
const shTr = shared(sh);
if (shTr !== sh) return shTr;
}
if (!h.from) return t('ord.ev.created');
return statusLabel(shared, flow, h.to);
}
// ───── исполнитель и подбор ─────
type OrderData = NonNullable<ReturnType<typeof useOrderData>>;
function useOrderData(id: string) {
const trpc = useTRPC();
return useQuery(trpc.bo.orders.get.queryOptions({ id })).data;
}
function ProviderPanel({ order: d, onChanged, canChat }: { order: OrderData; onChanged: () => void; canChat: boolean }) {
const { t, shared } = useT();
const trpc = useTRPC();
const toast = useToast();
const [showBusy, setShowBusy] = useState(false);
const onError = (e: unknown) => toast(errText(t, e), 'error');
const cands = useQuery({ ...trpc.bo.orders.candidates.queryOptions({ id: d.id }), enabled: d.canAssign && !d.pending });
const assign = useMutation(trpc.bo.orders.assign.mutationOptions({ onSuccess: () => (onChanged(), toast(t('ord.offered'))), onError }));
const auto = useMutation(
trpc.bo.orders.auto.mutationOptions({
onSuccess: (r) => {
onChanged();
toast(r.result === 'offered' ? t('ord.autoOffered', { name: r.target }) : t('ord.autoNone'), r.result === 'offered' ? undefined : 'error');
},
onError,
}),
);
const unassign = useMutation(trpc.bo.orders.unassign.mutationOptions({ onSuccess: () => (onChanged(), toast(t('ord.unassigned'))), onError }));
const chat = useMutation(trpc.bo.chats.openFor.mutationOptions({ onSuccess: (r) => navigate(`/chats/${r.id}`), onError }));
const [left, setLeft] = useState('');
useEffect(() => {
const exp = d.pending?.expiresAt;
if (!exp) return setLeft('');
const tick = () => setLeft(t('ord.minLeft', { n: Math.max(0, Math.ceil((exp.getTime() - Date.now()) / MIN)) }));
tick();
const iv = setInterval(tick, 15_000);
return () => clearInterval(iv);
}, [d.pending?.expiresAt, t]);
return (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('ord.provider')}</h2>
{d.canUnassign && (
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" disabled={unassign.isPending} onClick={() => confirm(d.provider ? t('ord.confirmUnassign') : t('ord.confirmRevoke')) && unassign.mutate({ id: d.id })}>
{d.provider ? t('ord.unassign') : t('ord.revoke')}
</button>
)}
</div>
{d.provider ? (
<div className="bo-person">
<Avatar name={d.provider.name} id={d.provider.id} size={40} />
<a href={`#/providers/${d.provider.id}`} style={{ minWidth: 0, color: 'inherit', textDecoration: 'none' }}>
<b>{d.provider.name}</b>
<span className="bo-small bo-muted" style={{ display: 'block' }}>
{[d.company?.name, d.provider.phone, d.provider.username && `@${d.provider.username}`].filter(Boolean).join(' · ') || '—'}
</span>
</a>
{canChat && (
<button type="button" className="bo-iconbtn" title={t('ord.writeProvider')} aria-label={t('ord.writeProvider')} onClick={() => chat.mutate({ providerId: d.provider!.id })}>
<I name="chats" size={20} />
</button>
)}
</div>
) : d.pending ? (
<div className="bo-pendingbox">
{d.pending.auto ? <I name="bot" size={20} /> : <I name="send" size={20} />}
<span className="bo-grow">
<b>{d.pending.company ? t('ord.pendingCompany', { name: d.pending.name }) : t('ord.pendingProvider', { name: d.pending.name })}</b>
<span className="bo-small" style={{ display: 'block', opacity: 0.8 }}>
{[d.pending.auto ? t('ord.byAuto') : null, left].filter(Boolean).join(' · ')}
</span>
</span>
</div>
) : d.canAssign ? (
<>
<div className="bo-assignbar">
<p className="bo-hint bo-grow" style={{ margin: 0 }}>
{t('ord.pickHint')}
</p>
<button type="button" className="bo-btn bo-btn--sm" onClick={() => auto.mutate({ id: d.id })} disabled={auto.isPending}>
<I name="bot" size={16} />
{t('ord.autoPick')}
</button>
</div>
{!cands.data ? (
<Skeleton h={120} />
) : cands.data.free.length === 0 ? (
<p className="bo-empty" style={{ padding: '16px 0' }}>
{t('ord.noFree')}
</p>
) : (
<ul className="bo-cands">
{cands.data.free.map((c, i) => (
<li key={`${c.kind}:${c.id}`}>
<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.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(' · ')}
</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')}
</button>
</li>
))}
</ul>
)}
{!!cands.data?.busy.length && (
<>
<button type="button" className="bo-side__link" style={{ color: 'var(--bc-ink-2)', marginTop: 10 }} onClick={() => setShowBusy((s) => !s)}>
{t('ord.busy', { n: cands.data.busy.length })} {showBusy ? '▴' : '▾'}
</button>
{showBusy && <p className="bo-hint" style={{ marginTop: 6 }}>{cands.data.busy.map((c) => c.name).join(', ')}</p>}
</>
)}
</>
) : (
<p className="bo-hint">{t('ord.noProvider')}</p>
)}
</section>
);
}
// ───── диалоги ─────
function RescheduleDialog({ order: d, onClose, onDone }: { order: OrderData; onClose: () => void; onDone: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const toast = useToast();
const [at, setAt] = useState<{ when: Date; busy: boolean } | null>(null);
const [comment, setComment] = useState('');
const save = useMutation(
trpc.bo.orders.reschedule.mutationOptions({
onSuccess: (r) => {
onDone();
toast(r.keptProvider ? t('ord.rescheduled') : t('ord.rescheduledFree'));
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={t('ord.rescheduleTitle', { n: d.number })}
footer={
<>
<button type="button" className="bo-btn bo-btn--ghost" onClick={onClose}>
{t('ui.close')}
</button>
<button type="button" className="bo-btn" disabled={!at || save.isPending} onClick={() => at && save.mutate({ id: d.id, scheduledAt: at.when, allowBusySlot: at.busy, comment: comment || undefined })}>
{t('ord.reschedule')}
</button>
</>
}
>
<SlotPicker service={d.service} params={d.params} excludeOrderId={d.id} initial={d.scheduledAt} onChange={setAt} />
<Field label={t('ord.comment')}>
<input className="bo-input" value={comment} maxLength={500} onChange={(e) => setComment(e.target.value)} placeholder={t('ord.commentPh')} />
</Field>
{d.provider && <p className="bo-hint">{t('ord.rescheduleHint')}</p>}
</Dialog>
);
}
function CancelDialog({ id, showFee, onClose, onDone }: { id: string; showFee: boolean; onClose: () => void; onDone: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const toast = useToast();
const [reason, setReason] = useState('');
const [fee, setFee] = useState('');
const cancel = useMutation(
trpc.bo.orders.cancel.mutationOptions({
onSuccess: () => {
onDone();
toast(t('ord.cancelled'));
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={t('ord.cancelTitle')}
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={!reason.trim() || cancel.isPending} onClick={() => cancel.mutate({ id, reason, fee: Number(fee) || 0 })}>
{t('ord.cancel')}
</button>
</>
}
>
<Field label={t('ord.cancelReason')}>
<textarea className="bo-textarea" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} placeholder={t('ord.cancelReasonPh')} />
</Field>
{showFee && (
<Field label={t('ord.cancelFee')} hint={t('ord.cancelFeeHint')}>
<input className="bo-input" inputMode="numeric" value={fee} onChange={(e) => setFee(e.target.value.replace(/\D/g, ''))} placeholder="0" />
</Field>
)}
<p className="bo-hint">{t('ord.cancelHint')}</p>
</Dialog>
);
}
+231
View File
@@ -0,0 +1,231 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { formatDateTime, type Locale } from '@barev/i18n';
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 { I, Skeleton } from '../components/kit.tsx';
import { NewOrderDialog } from './NewOrder.tsx';
type Group = 'active' | 'new' | 'pending' | 'assigned' | 'in_progress' | 'done' | 'cancelled' | 'all';
type Period = 'all' | 'today' | 'tomorrow' | 'week' | 'past';
const GROUPS: Group[] = ['active', 'new', 'pending', 'assigned', 'in_progress', 'done', 'cancelled'];
const PERIODS: Period[] = ['all', 'today', 'tomorrow', 'week', 'past'];
const MIN = 60_000;
/** Цвет группы — как точка статуса */
export const GROUP_DOT: Record<string, string> = {
new: 'var(--bc-accent)',
pending: 'var(--bc-perks-ink)',
assigned: 'var(--bc-blue-ink)',
in_progress: 'var(--bc-ok)',
done: 'var(--bc-ink-3)',
cancelled: 'var(--bc-danger)',
};
export function Orders({ me }: { me: Me }) {
const { t, locale, shared, amd, time } = useT();
const trpc = useTRPC();
const { query } = useLocation();
const group = (query.get('group') as Group) || 'active';
const period = (query.get('period') as Period) || 'all';
const [search, setSearch] = useState(query.get('q') ?? '');
const [q, setQ] = useState(search);
const [creating, setCreating] = useState(false);
const isSuper = me.roles.includes('SUPERADMIN');
useEffect(() => {
const id = setTimeout(() => setQ(search.trim()), 300);
return () => clearTimeout(id);
}, [search]);
const set = (patch: { group?: Group; period?: Period }) => {
const p = new URLSearchParams();
const g = patch.group ?? group;
const per = patch.period ?? period;
if (g !== 'active') p.set('group', g);
if (per !== 'all') p.set('period', per);
const s = p.toString();
navigate(`/orders${s ? `?${s}` : ''}`, { replace: true });
};
const list = useQuery({ ...trpc.bo.orders.list.queryOptions({ group, period, search: q || undefined }), refetchInterval: 20_000, placeholderData: keepPreviousData });
const counts = useQuery({ ...trpc.bo.orders.counts.queryOptions({ period, search: q || undefined }), refetchInterval: 20_000 });
const c = counts.data;
const countOf = (g: Group) => (!c ? null : g === 'active' ? c.new + c.pending + c.assigned + c.in_progress : g === 'all' ? null : c[g]);
// по дням: «Сегодня, 21 окт.»
const days = useMemo(() => {
const out: { key: string; label: string; rows: NonNullable<typeof list.data> }[] = [];
const dayKey = (d: Date) => formatDateTime(locale as Locale, d, { year: 'numeric', month: '2-digit', day: '2-digit' });
const today = dayKey(new Date());
const tomorrow = dayKey(new Date(Date.now() + 86_400_000));
for (const r of list.data ?? []) {
const k = dayKey(r.scheduledAt);
let d = out.at(-1);
if (!d || d.key !== k) {
const nice = formatDateTime(locale as Locale, r.scheduledAt, { weekday: 'short', day: 'numeric', month: 'long' });
const label = k === today ? `${t('ord.today')}, ${nice}` : k === tomorrow ? `${t('ord.tomorrow')}, ${nice}` : nice;
d = { key: k, label: label.charAt(0).toUpperCase() + label.slice(1), rows: [] };
out.push(d);
}
d.rows.push(r);
}
return out;
}, [list.data, locale, t]);
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('ord.title')}</h1>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<AutoSwitch canEdit={isSuper} />
<button type="button" className="bo-btn bo-btn--accent" onClick={() => setCreating(true)}>
<I name="plus" size={16} />
{t('ord.new')}
</button>
</div>
</div>
{!!c?.needsDispatcher && group !== 'new' && (
<button type="button" className="bo-alert" onClick={() => set({ group: 'new' })}>
<I name="warn" size={18} />
<span className="bo-grow">{t('ord.needsDispatcher', { n: c.needsDispatcher })}</span>
<I name="arrowDown" size={16} />
</button>
)}
<div className="bo-toolbar">
<label className="bo-search">
<I name="search" size={18} />
<input className="bo-input" type="search" value={search} placeholder={t('ord.search')} aria-label={t('ord.search')} onChange={(e) => setSearch(e.target.value)} />
</label>
<div className="bo-pills" role="group" aria-label={t('ord.period')}>
{PERIODS.map((p) => (
<button key={p} type="button" className="bo-pill" aria-pressed={period === p} onClick={() => set({ period: p })}>
{t(`ord.period.${p}`)}
</button>
))}
</div>
</div>
<div className="bo-ogroups" role="tablist">
{GROUPS.map((g) => {
const n = countOf(g);
return (
<button key={g} type="button" role="tab" aria-selected={group === g} className="bo-ogroup" onClick={() => set({ group: g })}>
{g !== 'active' && <span className="bo-ogroup__dot" style={{ background: GROUP_DOT[g] }} />}
{t(`ord.group.${g}`)}
{n != null && <span className="bo-ogroup__n">{n}</span>}
</button>
);
})}
</div>
{list.isPending ? (
<div className="bo-col">
<Skeleton h={64} />
<Skeleton h={64} />
<Skeleton h={64} />
</div>
) : days.length === 0 ? (
<div className="bo-panel bo-empty">{t('ord.empty')}</div>
) : (
<div className="bo-col" style={{ gap: 18 }}>
{days.map((d) => (
<section key={d.key}>
<h2 className="bo-oday">
{d.label}
<span className="bo-faint">{d.rows.length}</span>
</h2>
<ul className="bo-orows">
{d.rows.map((o) => {
const end = new Date(o.scheduledAt.getTime() + o.durationMinutes * MIN);
return (
<li key={o.id}>
<a href={`#/orders/${o.id}`} className="bo-orow" data-group={o.group}>
<span className="bo-orow__time">
<b>{time(o.scheduledAt)}</b>
<span>{time(end)}</span>
</span>
<span className="bo-orow__main">
<span className="bo-orow__title">
<span className="bo-num bo-faint">№{o.number}</span> {shared(`services.${o.service}.name`)}
</span>
<span className="bo-orow__sub">
{[o.isOnline ? shared('common.online') : o.district ? shared(`districts.${o.district}`) : null, o.client.name].filter(Boolean).join(' · ')}
</span>
</span>
<span className="bo-orow__who">
{o.provider ? (
<span>{o.provider.name}</span>
) : o.pending ? (
<span className="bo-orow__pending">
{o.pending.auto ? <I name="bot" size={14} /> : null}
{t('ord.waitsAnswer', { name: o.pending.name })}
</span>
) : o.needsDispatcher ? (
<span className="bo-wait-chip bo-wait-chip--hot">{t('ord.needsHuman')}</span>
) : o.group === 'new' && o.scheduledAt.getTime() < Date.now() ? (
<span className="bo-wait-chip bo-wait-chip--hot">{t('ord.overdue')}</span>
) : o.group === 'new' ? (
<span className="bo-faint">{t('ord.searching')}</span>
) : (
<span className="bo-faint">—</span>
)}
</span>
<span className="bo-orow__status">
<span className="bo-status" style={{ ['--dot' as string]: GROUP_DOT[o.group] }}>
{statusLabel(shared, o.flow, o.status)}
</span>
{isSuper && o.total != null && <b className="bo-num">{amd(o.total)}</b>}
{o.group === 'done' && o.paymentStatus === 'UNPAID' && <span className="bo-chip bo-chip--warn">{shared('payment.UNPAID')}</span>}
</span>
</a>
</li>
);
})}
</ul>
</section>
))}
</div>
)}
{creating && <NewOrderDialog onClose={() => setCreating(false)} />}
</>
);
}
export function statusLabel(shared: (k: string) => string, flow: string, status: string) {
const k = `status.${flow}.${status}`;
const v = shared(k);
return v === k ? status : v;
}
/** Автораспределение: видно всем, переключает суперадмин */
function AutoSwitch({ canEdit }: { canEdit: boolean }) {
const { t } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const s = useQuery(trpc.bo.orders.autoSetting.queryOptions());
const save = useMutation(
trpc.bo.orders.setAutoSetting.mutationOptions({
onSuccess: () => qc.invalidateQueries({ queryKey: trpc.bo.orders.autoSetting.queryKey() }),
onError: (e) => toast(errText(t, e), 'error'),
}),
);
if (!s.data) return null;
const on = s.data.auto;
return (
<button
type="button"
className={`bo-autosw${on ? ' is-on' : ''}`}
role="switch"
aria-checked={on}
disabled={!canEdit || save.isPending}
title={t('ord.autoHint', { tries: s.data.maxTries, minutes: s.data.timeoutMinutes })}
onClick={() => save.mutate({ auto: !on })}
>
<span className="bo-autosw__knob" />
{t(on ? 'ord.autoOn' : 'ord.autoOff')}
</button>
);
}
+659
View File
@@ -0,0 +1,659 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { navigate, useToast, useTRPC } from '@barev/web';
import { useRef, useState } from 'react';
import type { Me } from '../App.tsx';
import { errText, useT } from '../i18n.tsx';
import { Avatar, Dialog, Field, I, Skeleton } from '../components/kit.tsx';
import { statusLabel } from './Orders.tsx';
const ROLES = ['CLEANER', 'HANDYMAN', 'DRIVER', 'TEACHER', 'CAREER_CONSULTANT', 'DOCS_AGENT'] as const;
const LANGS = ['ru', 'en', 'hy', 'zh', 'es'] as const;
type Role = (typeof ROLES)[number];
type Lang = (typeof LANGS)[number];
type Slot = { weekday: number; startMinute: number; endMinute: number };
const hhmm = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
const minutes = (s: string) => {
const [h, m] = s.split(':').map(Number);
return (h ?? 0) * 60 + (m ?? 0);
};
export function ProviderCard({ id, me }: { id: string; me: Me }) {
const { t, shared, date, dateTime, amd } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const p = useQuery(trpc.bo.providers.get.queryOptions({ id }));
const dict = useQuery(trpc.bo.providers.dictionaries.queryOptions());
const [dlg, setDlg] = useState<'edit' | 'block' | 'payout' | null>(null);
const refresh = () => {
qc.invalidateQueries({ queryKey: trpc.bo.providers.get.queryKey({ id }) });
qc.invalidateQueries({ queryKey: trpc.bo.providers.list.queryKey() });
};
const onError = (e: unknown) => toast(errText(t, e), 'error');
const setActive = useMutation(trpc.bo.providers.setActive.mutationOptions({ onSuccess: () => (refresh(), toast(t('prv.saved'))), onError }));
const reset = useMutation(trpc.bo.providers.resetInvite.mutationOptions({ onSuccess: refresh, onError }));
const removeDoc = useMutation(trpc.bo.providers.removeDoc.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');
const back = (
<a className="bo-back" href="#/providers">
<I name="back" size={16} />
{t('prv.back')}
</a>
);
if (p.isError)
return (
<>
{back}
<div className="bo-panel bo-empty">{errText(t, p.error)}</div>
</>
);
if (!p.data)
return (
<div className="bo-col">
<Skeleton h={90} />
<Skeleton h={300} />
</div>
);
const d = p.data;
const districtName = new Map((dict.data?.districts ?? []).map((x) => [x.id, shared(`districts.${x.code}`)]));
const q = d.quality;
const offers30 = q.accepted30 + q.declined30 + q.expired30;
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', alignItems: 'center' }}>
<span>{shared(`roles.${d.role}`)}</span>
{d.company && <span>{d.company.name}</span>}
{d.phone && <a href={`tel:${d.phone.replace(/\s/g, '')}`}>{d.phone}</a>}
{d.username && <span>@{d.username}</span>}
{d.active ? (
d.onShift ? <span className="bo-chip bo-chip--ok">{t('prv.onShift')}</span> : <span className="bo-chip">{t('prv.offShift')}</span>
) : (
<span className="bo-chip bo-chip--danger">{t('prv.blocked')}</span>
)}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{canChat && d.linked && (
<button type="button" className="bo-btn bo-btn--ghost" onClick={() => chat.mutate({ providerId: d.id })}>
<I name="chats" size={16} />
{t('prv.write')}
</button>
)}
<button type="button" className="bo-btn" onClick={() => setDlg('edit')}>
{t('prv.edit')}
</button>
</div>
</header>
{!d.active && (
<div className="bo-banner" style={{ background: 'var(--bc-pink)', color: 'var(--bc-plum)' }}>
<span>
<b>{t('prv.blockedSince', { date: d.blockedAt ? date(d.blockedAt) : '—' })}</b>
{d.blockedReason && ` ${d.blockedReason}`}
</span>
<button type="button" className="bo-btn bo-btn--sm" onClick={() => setActive.mutate({ id: d.id, active: true })} disabled={setActive.isPending}>
{t('prv.unblock')}
</button>
</div>
)}
{!d.linked && d.inviteUrl && (
<div className="bo-banner">
<span>{t('prv.inviteHint')}</span>
<span style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="bo-btn bo-btn--sm"
onClick={() => {
void navigator.clipboard?.writeText(d.inviteUrl!);
toast(t('ui.copied'));
}}
>
{t('prv.copyInvite')}
</button>
</span>
</div>
)}
<div className="bo-client-grid">
<div className="bo-col">
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('prv.quality')}</h2>
<span className="bo-faint bo-small">{t('prv.days30')}</span>
</div>
<div className="bo-quality">
<div>
<b className="bo-bignum">{q.rating != null ? `★ ${q.rating}` : '—'}</b>
<span className="bo-small bo-muted">{t('prv.ratings', { n: q.ratingCount })}</span>
</div>
<div>
<b className="bo-bignum">{offers30 ? `${Math.round((q.accepted30 / offers30) * 100)}%` : '—'}</b>
<span className="bo-small bo-muted">{t('prv.acceptRate', { a: q.accepted30, n: offers30 })}</span>
</div>
<div>
<b className="bo-bignum" style={{ color: q.declined30 + q.expired30 >= 3 ? 'var(--bc-danger)' : undefined }}>
{q.declined30 + q.expired30}
</b>
<span className="bo-small bo-muted">{t('prv.refusals', { d: q.declined30, e: q.expired30 })}</span>
</div>
<div>
<b className="bo-bignum" style={{ color: q.low30 ? 'var(--bc-danger)' : undefined }}>
{q.low30}
</b>
<span className="bo-small bo-muted">{t('prv.lowRatings')}</span>
</div>
</div>
{d.ratings.length > 0 && (
<ul className="bo-list" style={{ marginTop: 14 }}>
{d.ratings.slice(0, 8).map((r) => (
<li key={r.id} style={{ alignItems: 'flex-start' }}>
<span className="bo-stars" style={{ minWidth: 76 }} data-low={r.score <= 3 || undefined}>
{'★'.repeat(r.score)}
{'☆'.repeat(5 - r.score)}
</span>
<span className="bo-grow" style={{ whiteSpace: 'normal' }}>
{r.comment ?? <span className="bo-faint">{t('ord.noComment')}</span>}
<span className="bo-small bo-faint" style={{ display: 'block' }}>
<a href={`#/orders/${r.orderId}`}>№{r.orderNumber}</a> · {r.client} · {date(r.at)}
</span>
</span>
</li>
))}
</ul>
)}
</section>
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('prv.upcoming')}</h2>
</div>
{d.upcoming.length === 0 ? (
<p className="bo-hint">{t('prv.noUpcoming')}</p>
) : (
<ul className="bo-list">
{d.upcoming.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-muted" style={{ display: 'block' }}>
{dateTime(o.scheduledAt)}
{o.district && ` · ${shared(`districts.${o.district}`)}`}
</span>
</a>
<span className="bo-chip">{statusLabel(shared, o.flow, o.status)}</span>
</li>
))}
</ul>
)}
{d.recent.length > 0 && (
<p className="bo-hint" style={{ marginTop: 12 }}>
{t('prv.recent')}:{' '}
{d.recent.slice(0, 8).map((o, i) => (
<span key={o.id}>
{i > 0 && ', '}
<a href={`#/orders/${o.id}`}>№{o.number}</a>
{o.rating && ` ★${o.rating}`}
</span>
))}
</p>
)}
</section>
</div>
<div className="bo-col">
{d.money && (
<section className="bo-panel bo-money-card">
<div className="bo-panel__head" style={{ marginBottom: 6 }}>
<span className="bo-label" style={{ color: 'inherit', opacity: 0.75 }}>
{t('prv.balance')}
</span>
<span className="bo-small">{rateText(t, d.money.rate)}</span>
</div>
<div className="bo-bignum">{amd(d.money.balance)}</div>
<div className="bo-small" style={{ display: 'flex', gap: 16, flexWrap: 'wrap', margin: '8px 0 12px' }}>
<span>
{t('prv.thisMonth')} <b className="bo-num">{amd(d.money.thisMonth)}</b>
</span>
<span>
{t('prv.prevMonth')} <b className="bo-num">{amd(d.money.prevMonth)}</b>
</span>
<span>
{t('prv.paidTotal')} <b className="bo-num">{amd(d.money.paidTotal)}</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.payouts.length > 0 && (
<ul className="bo-list" style={{ marginTop: 12 }}>
{d.money.payouts.slice(0, 6).map((x) => (
<li key={x.id}>
<span className="bo-grow">
<b className="bo-num">{amd(x.amount)}</b>
<span className="bo-small" style={{ display: 'block', opacity: 0.75 }}>
{[x.paidAt && date(x.paidAt), x.by, x.note].filter(Boolean).join(' · ')}
</span>
</span>
</li>
))}
</ul>
)}
</section>
)}
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('prv.work')}</h2>
</div>
<dl className="bo-kv">
<dt>{t('prv.languages')}</dt>
<dd>{d.languages.join(', ').toUpperCase()}</dd>
<dt>{t('prv.districts')}</dt>
<dd>{d.districtIds.length ? d.districtIds.map((x) => districtName.get(x) ?? '').join(', ') : t('prv.anyDistrict')}</dd>
</dl>
<WeekView schedule={d.schedule} />
{d.adminNote && <p className="bo-side-panel__note" style={{ marginTop: 12 }}>{d.adminNote}</p>}
</section>
<DocsPanel providerId={d.id} docs={d.docs} onChanged={refresh} onRemove={(docId) => confirm(t('prv.removeDocConfirm')) && removeDoc.mutate({ id: docId })} />
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('prv.access')}</h2>
</div>
<div className="bo-col" style={{ gap: 8 }}>
{d.active ? (
<button type="button" className="bo-btn bo-btn--danger bo-btn--sm" onClick={() => setDlg('block')}>
{t('prv.block')}
</button>
) : (
<button type="button" className="bo-btn bo-btn--sm" onClick={() => setActive.mutate({ id: d.id, active: true })}>
{t('prv.unblock')}
</button>
)}
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => confirm(t('prv.resetConfirm')) && reset.mutate({ id: d.id })} disabled={reset.isPending}>
{t('prv.resetInvite')}
</button>
<p className="bo-hint">{t('prv.since', { date: date(d.createdAt) })}</p>
</div>
</section>
</div>
</div>
{dlg === 'edit' && <ProviderForm me={me} provider={d} onClose={() => setDlg(null)} />}
{dlg === 'block' && <BlockDialog id={d.id} onClose={() => setDlg(null)} onDone={refresh} />}
{dlg === 'payout' && d.money && <PayoutDialog id={d.id} balance={d.money.balance} onClose={() => setDlg(null)} onDone={refresh} />}
</>
);
}
function rateText(t: ReturnType<typeof useT>['t'], r: { kind: 'company' | 'preset' | 'own'; type?: string; value?: number; name?: string }) {
if (r.kind === 'preset') return t('prv.ratePreset', { name: r.name ?? '' });
const v = r.type === 'PERCENT' ? `${r.value}%` : `${r.value} ֏`;
return r.kind === 'company' ? t('prv.rateCompany', { v }) : t('prv.rateOwn', { v });
}
function WeekView({ schedule }: { schedule: Slot[] }) {
const { shared } = useT();
return (
<div className="bo-week">
{[0, 1, 2, 3, 4, 5, 6].map((wd) => {
const s = schedule.filter((x) => x.weekday === wd);
return (
<div key={wd} className="bo-week__day" data-off={!s.length || undefined}>
<b>{shared(`admin.weekdays.${wd}`)}</b>
<span>{s.length ? s.map((x) => `${hhmm(x.startMinute)}–${hhmm(x.endMinute)}`).join(', ') : '—'}</span>
</div>
);
})}
</div>
);
}
function DocsPanel({ providerId, docs, onChanged, onRemove }: { providerId: string; docs: { id: string; title: string; fileName: string; mime: string; size: number; createdAt: Date }[]; onChanged: () => void; onRemove: (id: string) => void }) {
const { t, date } = useT();
const toast = useToast();
const file = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState('');
const [busy, setBusy] = useState(false);
const upload = async (f: File) => {
if (f.size > 10 * 1024 * 1024) return toast(t('chat.fileTooBig'), 'error');
setBusy(true);
try {
const form = new FormData();
form.set('title', title || f.name);
form.set('file', f);
const res = await fetch(`/files/bo/provider-doc/${providerId}`, { method: 'POST', body: form, credentials: 'include' });
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as { error?: string }).error ?? String(res.status));
setTitle('');
onChanged();
toast(t('prv.docAdded'));
} catch (e) {
toast(errText(t, e), 'error');
} finally {
setBusy(false);
}
};
return (
<section className="bo-panel">
<div className="bo-panel__head">
<h2 className="bo-h2">{t('prv.docs')}</h2>
<span className="bo-faint bo-small">{docs.length}</span>
</div>
{docs.length === 0 ? (
<p className="bo-hint">{t('prv.noDocsHint')}</p>
) : (
<ul className="bo-list">
{docs.map((d) => (
<li key={d.id}>
<I name="file" size={20} />
<a className="bo-grow" href={`/files/bo/provider-doc-file/${d.id}`} target="_blank" rel="noreferrer" style={{ color: 'inherit', minWidth: 0 }}>
<b>{d.title}</b>
<span className="bo-small bo-faint" style={{ display: 'block' }}>
{d.fileName} · {date(d.createdAt)}
</span>
</a>
<button type="button" className="bo-iconbtn" onClick={() => onRemove(d.id)} aria-label={t('chat.tplDelete')}>
<I name="close" size={16} />
</button>
</li>
))}
</ul>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<input className="bo-input" value={title} onChange={(e) => setTitle(e.target.value)} placeholder={t('prv.docTitlePh')} maxLength={120} style={{ minHeight: 36, padding: '6px 12px' }} />
<button type="button" className="bo-btn bo-btn--sm" onClick={() => file.current?.click()} disabled={busy}>
<I name="clip" size={16} />
{t('prv.upload')}
</button>
<input
ref={file}
type="file"
hidden
accept="image/*,application/pdf"
onChange={(e) => {
const f = e.target.files?.[0];
e.target.value = '';
if (f) void upload(f);
}}
/>
</div>
</section>
);
}
function BlockDialog({ id, onClose, onDone }: { id: string; onClose: () => void; onDone: () => void }) {
const { t } = useT();
const trpc = useTRPC();
const toast = useToast();
const [reason, setReason] = useState('');
const save = useMutation(
trpc.bo.providers.setActive.mutationOptions({
onSuccess: () => {
onDone();
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={t('prv.blockTitle')}
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={!reason.trim() || save.isPending} onClick={() => save.mutate({ id, active: false, reason })}>
{t('prv.block')}
</button>
</>
}
>
<Field label={t('prv.blockReason')}>
<textarea className="bo-textarea" value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} placeholder={t('prv.blockReasonPh')} />
</Field>
<p className="bo-hint">{t('prv.blockHint')}</p>
</Dialog>
);
}
function PayoutDialog({ 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.providers.payout.mutationOptions({
onSuccess: () => {
onDone();
toast(t('prv.payoutDone'));
onClose();
},
onError: (e) => toast(errText(t, e), 'error'),
}),
);
return (
<Dialog
open
onClose={onClose}
title={t('prv.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('prv.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>
);
}
// ───── форма исполнителя ─────
type ProviderData = {
id: string;
name: string;
phone: string | null;
role: string;
company: { id: string } | null;
languages: string[];
districtIds: string[];
schedule: Slot[];
adminNote: string | null;
money: { rateType: string; rateValue: number; payoutPresetId: string | null; presets: { id: string; name: string }[] } | null;
};
export function ProviderForm({ me, provider, onClose }: { me: Me; provider?: ProviderData; onClose: () => void }) {
const { t, shared } = useT();
const trpc = useTRPC();
const qc = useQueryClient();
const toast = useToast();
const dict = useQuery(trpc.bo.providers.dictionaries.queryOptions());
const isSuper = me.roles.includes('SUPERADMIN');
const [f, setF] = useState({
name: provider?.name ?? '',
phone: provider?.phone ?? '',
role: (provider?.role ?? 'CLEANER') as Role,
companyId: provider?.company?.id ?? '',
languages: (provider?.languages ?? ['ru']) as Lang[],
districtIds: provider?.districtIds ?? [],
adminNote: provider?.adminNote ?? '',
rateType: provider?.money?.rateType ?? 'PERCENT',
rateValue: String(provider?.money?.rateValue ?? 70),
payoutPresetId: provider?.money?.payoutPresetId ?? '',
});
// график: одно окно на день (так заведено в рабочем боте); дни без окна — выходные
const [week, setWeek] = useState<(null | { from: string; to: string })[]>(() =>
[0, 1, 2, 3, 4, 5, 6].map((wd) => {
const s = provider ? provider.schedule.find((x) => x.weekday === wd) : wd < 6 ? { startMinute: 540, endMinute: 1200 } : null;
return s ? { from: hhmm(s.startMinute), to: hhmm(s.endMinute) } : null;
}),
);
const done = (r?: { id: string; inviteUrl: string } | void) => {
qc.invalidateQueries({ queryKey: trpc.bo.providers.list.queryKey() });
qc.invalidateQueries({ queryKey: trpc.bo.providers.get.queryKey() });
toast(t('prv.saved'));
onClose();
if (r && 'id' in r) navigate(`/providers/${r.id}`);
};
const onError = (e: unknown) => toast(errText(t, e), 'error');
const create = useMutation(trpc.bo.providers.create.mutationOptions({ onSuccess: done, onError }));
const update = useMutation(trpc.bo.providers.update.mutationOptions({ onSuccess: () => done(), onError }));
const companies = (dict.data?.companies ?? []).filter((c) => c.roles.includes(f.role));
const payload = {
name: f.name,
phone: f.phone || null,
role: f.role,
companyId: f.companyId || null,
languages: f.languages,
districtIds: f.districtIds,
adminNote: f.adminNote || null,
schedule: week.flatMap((w, wd) => (w ? [{ weekday: wd, startMinute: minutes(w.from), endMinute: minutes(w.to) }] : [])),
rate: isSuper ? { rateType: f.rateType as 'FIXED' | 'PERCENT', rateValue: Number(f.rateValue) || 0, payoutPresetId: f.payoutPresetId || null } : undefined,
};
const toggle = <T,>(list: T[], v: T) => (list.includes(v) ? list.filter((x) => x !== v) : [...list, v]);
return (
<Dialog
open
wide
onClose={onClose}
title={provider ? t('prv.editTitle') : t('prv.addTitle')}
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() || !f.languages.length || create.isPending || update.isPending}
onClick={() => (provider ? update.mutate({ id: provider.id, ...payload }) : create.mutate(payload))}
>
{t('ui.save')}
</button>
</>
}
>
<div className="bo-row2">
<Field label={t('prv.name')}>
<input className="bo-input" value={f.name} maxLength={100} onChange={(e) => setF({ ...f, name: e.target.value })} />
</Field>
<Field label={t('chat.phone')}>
<input className="bo-input" value={f.phone} maxLength={30} onChange={(e) => setF({ ...f, phone: e.target.value })} placeholder="+374 …" />
</Field>
</div>
<div className="bo-row2">
<Field label={t('prv.role')}>
<select className="bo-select" value={f.role} onChange={(e) => setF({ ...f, role: e.target.value as Role, companyId: '' })}>
{ROLES.map((r) => (
<option key={r} value={r}>
{shared(`roles.${r}`)}
</option>
))}
</select>
</Field>
<Field label={t('prv.company')}>
<select className="bo-select" value={f.companyId} onChange={(e) => setF({ ...f, companyId: e.target.value })}>
<option value="">{t('prv.freelance')}</option>
{companies.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</Field>
</div>
<div>
<span className="bo-label">{t('prv.languages')}</span>
<div className="bo-pills" style={{ marginTop: 6 }}>
{LANGS.map((l) => (
<button key={l} type="button" className="bo-pill" aria-pressed={f.languages.includes(l)} onClick={() => setF({ ...f, languages: toggle(f.languages, l) })}>
{l.toUpperCase()}
</button>
))}
</div>
</div>
<div>
<span className="bo-label">{t('prv.districts')}</span>
<div className="bo-pills" style={{ marginTop: 6 }}>
{(dict.data?.districts ?? []).map((d) => (
<button key={d.id} type="button" className="bo-pill" aria-pressed={f.districtIds.includes(d.id)} onClick={() => setF({ ...f, districtIds: toggle(f.districtIds, d.id) })}>
{shared(`districts.${d.code}`)}
</button>
))}
</div>
<span className="bo-hint">{t('prv.districtsHint')}</span>
</div>
<div>
<span className="bo-label">{t('prv.schedule')}</span>
<div className="bo-sched">
{week.map((w, wd) => (
<div key={wd} className="bo-sched__row" data-off={!w || undefined}>
<label className="bo-check" style={{ minWidth: 64 }}>
<input type="checkbox" checked={!!w} onChange={(e) => setWeek(week.map((x, i) => (i === wd ? (e.target.checked ? { from: '09:00', to: '20:00' } : null) : x)))} />
{shared(`admin.weekdays.${wd}`)}
</label>
{w && (
<>
<input className="bo-input" type="time" value={w.from} step={900} onChange={(e) => setWeek(week.map((x, i) => (i === wd ? { ...w, from: e.target.value } : x)))} />
<span>–</span>
<input className="bo-input" type="time" value={w.to} step={900} onChange={(e) => setWeek(week.map((x, i) => (i === wd ? { ...w, to: e.target.value } : x)))} />
</>
)}
</div>
))}
</div>
</div>
{isSuper && !f.companyId && (
<div className="bo-row2">
<Field label={t('prv.rate')}>
<select className="bo-select" value={f.payoutPresetId || f.rateType} onChange={(e) => (e.target.value === 'PERCENT' || e.target.value === 'FIXED' ? setF({ ...f, payoutPresetId: '', rateType: e.target.value }) : setF({ ...f, payoutPresetId: e.target.value }))}>
<option value="PERCENT">{shared('admin.rate.PERCENT')}</option>
<option value="FIXED">{shared('admin.rate.FIXED')}</option>
{(provider?.money?.presets ?? []).map((p) => (
<option key={p.id} value={p.id}>
{t('prv.ratePreset', { name: p.name })}
</option>
))}
</select>
</Field>
{!f.payoutPresetId && (
<Field label={f.rateType === 'PERCENT' ? '%' : '֏'}>
<input className="bo-input" inputMode="numeric" value={f.rateValue} onChange={(e) => setF({ ...f, rateValue: e.target.value.replace(/\D/g, '') })} />
</Field>
)}
</div>
)}
<Field label={t('prv.note')}>
<textarea className="bo-textarea" style={{ minHeight: 70 }} value={f.adminNote} maxLength={4000} onChange={(e) => setF({ ...f, adminNote: e.target.value })} placeholder={t('card.notePh')} />
</Field>
</Dialog>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useTRPC } from '@barev/web';
import { useEffect, useState } from 'react';
import type { Me } from '../App.tsx';
import { useT } from '../i18n.tsx';
import { Avatar, I, Skeleton } from '../components/kit.tsx';
import { ProviderForm } from './ProviderCard.tsx';
type Status = 'active' | 'blocked' | 'all';
const ROLES = ['CLEANER', 'HANDYMAN', 'DRIVER', 'TEACHER', 'CAREER_CONSULTANT', 'DOCS_AGENT'] as const;
export function Providers({ me }: { me: Me }) {
const { t, shared, dateTime } = useT();
const trpc = useTRPC();
const [status, setStatus] = useState<Status>('active');
const [role, setRole] = useState<(typeof ROLES)[number] | undefined>();
const [search, setSearch] = useState('');
const [q, setQ] = useState('');
const [creating, setCreating] = useState(false);
useEffect(() => {
const id = setTimeout(() => setQ(search.trim()), 300);
return () => clearTimeout(id);
}, [search]);
const list = useQuery({ ...trpc.bo.providers.list.queryOptions({ status, role, search: q || undefined }), placeholderData: keepPreviousData });
const roles = ROLES.filter((r) => role === r || (list.data ?? []).some((p) => p.role === r));
return (
<>
<div className="bo-head">
<h1 className="bo-title">{t('prv.title')}</h1>
<button type="button" className="bo-btn bo-btn--accent" onClick={() => setCreating(true)}>
<I name="plus" size={16} />
{t('prv.add')}
</button>
</div>
<div className="bo-toolbar">
<label className="bo-search">
<I name="search" size={18} />
<input className="bo-input" type="search" value={search} placeholder={t('prv.search')} aria-label={t('prv.search')} onChange={(e) => setSearch(e.target.value)} />
</label>
<div className="bo-pills" role="group">
{(['active', 'blocked', 'all'] as const).map((s) => (
<button key={s} type="button" className="bo-pill" aria-pressed={status === s} onClick={() => setStatus(s)}>
{t(`prv.status.${s}`)}
</button>
))}
</div>
{roles.length > 1 && (
<div className="bo-pills" role="group">
{roles.map((r) => (
<button key={r} type="button" className="bo-pill" aria-pressed={role === r} onClick={() => setRole(role === r ? undefined : r)}>
{shared(`roles.${r}`)}
</button>
))}
</div>
)}
</div>
{!list.data ? (
<div className="bo-col">
<Skeleton h={72} />
<Skeleton h={72} />
</div>
) : list.data.length === 0 ? (
<div className="bo-panel bo-empty">{t('prv.empty')}</div>
) : (
<div className="bo-panel" style={{ padding: 8 }}>
<table className="bo-table bo-ptable">
<thead>
<tr>
<th>{t('prv.col.name')}</th>
<th className="bo-r">{t('prv.col.rating')}</th>
<th className="bo-r">{t('prv.col.done')}</th>
<th className="bo-r">{t('prv.col.refused')}</th>
<th>{t('prv.col.next')}</th>
</tr>
</thead>
<tbody>
{list.data.map((p) => (
<tr key={p.id} onClick={() => (location.hash = `#/providers/${p.id}`)} data-off={!p.active || undefined}>
<td>
<a href={`#/providers/${p.id}`} className="bo-who" onClick={(e) => e.stopPropagation()}>
<span className="bo-pavatar" data-shift={p.onShift || undefined}>
<Avatar name={p.name} id={p.id} size={38} />
</span>
<span style={{ minWidth: 0 }}>
<span className="bo-who__name">{p.name}</span>
<span className="bo-who__meta">
{[shared(`roles.${p.role}`), p.company?.name, p.languages.join(' ').toUpperCase()].filter(Boolean).join(' · ')}
</span>
<span className="bo-tags" style={{ marginTop: 4 }}>
{!p.active && <span className="bo-chip bo-chip--danger">{t('prv.blocked')}</span>}
{p.active && p.onShift && <span className="bo-chip bo-chip--ok">{t('prv.onShift')}</span>}
{!p.linked && <span className="bo-chip bo-chip--warn">{t('prv.notLinked')}</span>}
{p.docs === 0 && p.active && <span className="bo-chip">{t('prv.noDocs')}</span>}
</span>
</span>
</a>
</td>
<td className="bo-r">
{p.rating != null ? (
<>
<b className="bo-num">★ {p.rating}</b>
<span className="bo-faint bo-small" style={{ display: 'block' }}>
{t('prv.ratings', { n: p.ratingCount })}
{p.low30 > 0 && <span style={{ color: 'var(--bc-danger)' }}> · {t('prv.low', { n: p.low30 })}</span>}
</span>
</>
) : (
<span className="bo-faint">—</span>
)}
</td>
<td className="bo-r bo-num">{p.done30}</td>
<td className="bo-r bo-num" style={{ color: p.refused30 >= 3 ? 'var(--bc-danger)' : undefined }}>
{p.refused30}
</td>
<td>
{p.next ? (
<a href={`#/orders/${p.next.id}`} onClick={(e) => e.stopPropagation()} className="bo-small">
№{p.next.number} · {dateTime(p.next.scheduledAt)}
</a>
) : (
<span className="bo-faint">—</span>
)}
{p.today > 0 && (
<span className="bo-faint bo-small" style={{ display: 'block' }}>
{t('prv.today', { n: p.today })}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
<p className="bo-hint" style={{ padding: '10px 12px 4px' }}>
{t('prv.period30')}
</p>
</div>
)}
{creating && <ProviderForm me={me} onClose={() => setCreating(false)} />}
</>
);
}
+27 -3
View File
@@ -4,7 +4,7 @@
*/ */
import { getDb } from '@barev/db'; import { getDb } from '@barev/db';
import { createT, formatAmd, formatDateTime, type Locale } from '@barev/i18n'; import { createT, formatAmd, formatDateTime, type Locale } from '@barev/i18n';
import { chatRecipients, dueReminders, EVENTS_QUEUE, expireOffers, type Ctx, type DomainEvent } from '@barev/services'; import { autoAssign, chatRecipients, dueReminders, EVENTS_QUEUE, expireOffers, retryAutoAssign, type AutoAssignResult, type Ctx, type DomainEvent } from '@barev/services';
import { getFile } from '@barev/services/storage'; import { getFile } from '@barev/services/storage';
import { loadRootEnv } from '@barev/services/env'; import { loadRootEnv } from '@barev/services/env';
import { createQueueNotifier, redisConnection } from '@barev/services/queue'; import { createQueueNotifier, redisConnection } from '@barev/services/queue';
@@ -31,6 +31,21 @@ async function toAdmins(text: string, buttons: InlineButton[][] = []) {
const adminT = createT('ru'); const adminT = createT('ru');
/** Ссылка на раздел админки — Telegram принимает url-кнопки только с https */ /** Ссылка на раздел админки — Telegram принимает url-кнопки только с https */
const adminButton = (path: string): InlineButton | null => (ADMIN_URL.startsWith('https://') ? { text: adminT('bot.admin.open'), url: `${ADMIN_URL}/#${path}` } : null); const adminButton = (path: string): InlineButton | null => (ADMIN_URL.startsWith('https://') ? { text: adminT('bot.admin.open'), url: `${ADMIN_URL}/#${path}` } : null);
/** Карточка заказа: в новой админке, если она уже на сервере, иначе — канбан старой */
const orderButton = (orderId: string): InlineButton | null =>
BACKOFFICE_URL.startsWith('https://') ? { text: adminT('bot.admin.open'), url: `${BACKOFFICE_URL}/#/orders/${orderId}` } : adminButton('/');
/** Автораспределение не нашло исполнителя (впервые для заказа) — команде в рабочий бот */
async function reportAuto(orderId: string, res: AutoAssignResult) {
if ((res.result !== 'none' && res.result !== 'gaveUp') || !res.first) return;
const o = await loadOrder(orderId);
if (!o) return;
await toAdmins(
`⚠️ ${escapeHtml(adminT('bot.admin.autoFailed', { number: o.number }))}\n${escapeHtml(adminT(`services.${o.service.code}.name`))} · ${when(o, 'ru')}`,
compactRows([[orderButton(o.id)]]),
);
}
/** Новая админка, если уже на сервере; иначе — раздел переписки в старой */ /** Новая админка, если уже на сервере; иначе — раздел переписки в старой */
const BACKOFFICE_URL = process.env.BACKOFFICE_URL ?? ''; const BACKOFFICE_URL = process.env.BACKOFFICE_URL ?? '';
const chatButton = (threadId: string | null, peer: string): InlineButton | null => const chatButton = (threadId: string | null, peer: string): InlineButton | null =>
@@ -104,8 +119,10 @@ async function handle(event: DomainEvent) {
await toClient(o, 'notify.created', { total: formatAmd(locale, o.totalPrice) }); await toClient(o, 'notify.created', { total: formatAmd(locale, o.totalPrice) });
await toAdmins( await toAdmins(
`📦 <b>${adminT('bot.admin.newOrder', { number: o.number })}</b>\n${escapeHtml(adminT(`services.${o.service.code}.name`))} · ${when(o, 'ru')}\n${who(o.client.user)} · ${formatAmd('ru', o.totalPrice)}`, `📦 <b>${adminT('bot.admin.newOrder', { number: o.number })}</b>\n${escapeHtml(adminT(`services.${o.service.code}.name`))} · ${when(o, 'ru')}\n${who(o.client.user)} · ${formatAmd('ru', o.totalPrice)}`,
compactRows([[adminButton('/')]]), compactRows([[orderButton(o.id)]]),
); );
// автораспределение: сразу предлагаем лучшему свободному исполнителю
await reportAuto(o.id, await autoAssign(ctx, o.id));
return; return;
} }
case 'order.status': { case 'order.status': {
@@ -173,7 +190,12 @@ async function handle(event: DomainEvent) {
const offer = await db.orderOffer.findUnique({ where: { id: event.offerId }, include: { provider: true } }); const offer = await db.orderOffer.findUnique({ where: { id: event.offerId }, include: { provider: true } });
const companyId = offer?.provider?.companyId; const companyId = offer?.provider?.companyId;
const o = offer && (await loadOrder(offer.orderId)); const o = offer && (await loadOrder(offer.orderId));
if (!o || !companyId) return; if (!o) return;
// частный исполнитель отказался или промолчал — система предлагает следующему
if (!companyId) {
await reportAuto(o.id, await autoAssign(ctx, o.id));
return;
}
for (const d of await dispatchers(companyId)) await toProvider(o, d.user, 'notify.dispatcher.returned'); for (const d of await dispatchers(companyId)) await toProvider(o, d.user, 'notify.dispatcher.returned');
return; return;
} }
@@ -343,6 +365,8 @@ async function tick() {
try { try {
const [reminders, expired] = await Promise.all([dueReminders(ctx), expireOffers(ctx)]); const [reminders, expired] = await Promise.all([dueReminders(ctx), expireOffers(ctx)]);
if (reminders || expired) console.log(`[worker] reminders=${reminders} expiredOffers=${expired}`); if (reminders || expired) console.log(`[worker] reminders=${reminders} expiredOffers=${expired}`);
// заказы, которые ещё ждут исполнителя, — пробуем распределить снова
for (const { orderId, res } of await retryAutoAssign(ctx)) await reportAuto(orderId, res);
} catch (e) { } catch (e) {
console.error('[worker] tick failed', e); console.error('[worker] tick failed', e);
} finally { } finally {
+4
View File
@@ -41,6 +41,10 @@ export const settingsSchema = z.object({
'transfer.premiumPrice': z.number().int().min(0).default(20000), 'transfer.premiumPrice': z.number().int().min(0).default(20000),
/** PRO-5: сколько минут исполнитель может думать над предложением */ /** PRO-5: сколько минут исполнитель может думать над предложением */
'offer.acceptTimeoutMinutes': z.number().int().min(1).max(1440).default(15), 'offer.acceptTimeoutMinutes': z.number().int().min(1).max(1440).default(15),
/** автораспределение: новый заказ сам предлагается лучшему свободному исполнителю; отказ — следующему */
'assign.auto': z.boolean().default(true),
/** сколько исполнителей подряд система пробует сама, дальше заказ ждёт диспетчера */
'assign.maxAutoOffers': z.number().int().min(1).max(20).default(5),
/** MSG-1: чат закрывается через N часов после завершения (Этап 2) */ /** MSG-1: чат закрывается через N часов после завершения (Этап 2) */
'chat.closeAfterHours': z.number().int().min(0).default(24), 'chat.closeAfterHours': z.number().int().min(0).default(24),
/** раздел 6: автоудаление сканов через N дней после завершения */ /** раздел 6: автоудаление сканов через N дней после завершения */
@@ -0,0 +1,26 @@
-- AlterTable
ALTER TABLE "Provider" ADD COLUMN "adminNote" TEXT,
ADD COLUMN "blockedAt" TIMESTAMP(3),
ADD COLUMN "blockedReason" TEXT;
-- CreateTable
CREATE TABLE "ProviderDoc" (
"id" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"fileKey" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"mime" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"uploadedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProviderDoc_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ProviderDoc_providerId_idx" ON "ProviderDoc"("providerId");
-- AddForeignKey
ALTER TABLE "ProviderDoc" ADD CONSTRAINT "ProviderDoc_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "Provider"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "Payout" ADD COLUMN "createdById" TEXT,
ADD COLUMN "note" TEXT,
ADD COLUMN "paidAt" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX "Payout_providerId_createdAt_idx" ON "Payout"("providerId", "createdAt");
+28
View File
@@ -290,10 +290,16 @@ model Provider {
/// пресет ставок (джун / мидл / прайват); если задан — заменяет rateType/rateValue /// пресет ставок (джун / мидл / прайват); если задан — заменяет rateType/rateValue
payoutPresetId String? payoutPresetId String?
payoutPreset PayoutPreset? @relation(fields: [payoutPresetId], references: [id]) payoutPreset PayoutPreset? @relation(fields: [payoutPresetId], references: [id])
/// допуск: почему заблокирован и когда (active = false)
blockedReason String?
blockedAt DateTime?
/// заметка команды об исполнителе (он не видит)
adminNote String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
districts ProviderDistrict[] districts ProviderDistrict[]
docs ProviderDoc[]
schedule ProviderSchedule[] schedule ProviderSchedule[]
orders Order[] orders Order[]
offers OrderOffer[] offers OrderOffer[]
@@ -305,6 +311,22 @@ model Provider {
chatThreads ChatThread[] chatThreads ChatThread[]
} }
/// Документы исполнителя для допуска: паспорт, договор, справка… Файл — в приватном хранилище
model ProviderDoc {
id String @id @default(cuid())
providerId String
provider Provider @relation(fields: [providerId], references: [id], onDelete: Cascade)
title String
fileKey String
fileName String
mime String
size Int
uploadedById String?
createdAt DateTime @default(now())
@@index([providerId])
}
/// Пресет ставок исполнителя: для каждой строки цены — фикс или % (см. packages/core/src/payout.ts) /// Пресет ставок исполнителя: для каждой строки цены — фикс или % (см. packages/core/src/payout.ts)
model PayoutPreset { model PayoutPreset {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -832,7 +854,13 @@ model Payout {
periodEnd DateTime periodEnd DateTime
amount Int amount Int
status PayoutStatus @default(DRAFT) status PayoutStatus @default(DRAFT)
/// когда выплачено и как (наличные, перевод…), кто отметил
paidAt DateTime?
note String?
createdById String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([providerId, createdAt])
} }
// ───────────── Админка ───────────── // ───────────── Админка ─────────────
+5 -2
View File
@@ -871,7 +871,8 @@
"file": "file", "file": "file",
"chatAssigned": "{{by}} passed you a conversation: {{name}}", "chatAssigned": "{{by}} passed you a conversation: {{name}}",
"order": "Order #{{number}}", "order": "Order #{{number}}",
"noOrder": "No current order" "noOrder": "No current order",
"autoFailed": "No free provider found for order #{{number}} — please assign manually."
}, },
"partner": { "partner": {
"valid": "{{name}} — {{level}}, card is valid", "valid": "{{name}} — {{level}}, card is valid",
@@ -1269,7 +1270,9 @@
"residence_escortFrom": "Residence permit: support from, AMD", "residence_escortFrom": "Residence permit: support from, AMD",
"residence_stateFee": "Residence permit: state fee (1 year), AMD", "residence_stateFee": "Residence permit: state fee (1 year), AMD",
"transfer_standardPrice": "Transfer “Standard”, AMD", "transfer_standardPrice": "Transfer “Standard”, AMD",
"transfer_premiumPrice": "Transfer “Premium”, AMD" "transfer_premiumPrice": "Transfer “Premium”, AMD",
"assign_auto": "Assign orders automatically",
"assign_maxAutoOffers": "How many providers to try automatically"
}, },
"presets": { "presets": {
"add": "Add preset", "add": "Add preset",
+5 -2
View File
@@ -871,7 +871,8 @@
"file": "archivo", "file": "archivo",
"chatAssigned": "{{by}} te ha pasado una conversación: {{name}}", "chatAssigned": "{{by}} te ha pasado una conversación: {{name}}",
"order": "Pedido n.º {{number}}", "order": "Pedido n.º {{number}}",
"noOrder": "Sin pedido en curso" "noOrder": "Sin pedido en curso",
"autoFailed": "No hay profesional libre para el pedido n.º {{number}}: asígnalo manualmente."
}, },
"partner": { "partner": {
"valid": "{{name}} — {{level}}, tarjeta válida", "valid": "{{name}} — {{level}}, tarjeta válida",
@@ -1269,7 +1270,9 @@
"residence_escortFrom": "Residencia: acompañamiento desde, AMD", "residence_escortFrom": "Residencia: acompañamiento desde, AMD",
"residence_stateFee": "Residencia: tasa estatal (1 año), AMD", "residence_stateFee": "Residencia: tasa estatal (1 año), AMD",
"transfer_standardPrice": "Traslado «Estándar», AMD", "transfer_standardPrice": "Traslado «Estándar», AMD",
"transfer_premiumPrice": "Traslado «Premium», AMD" "transfer_premiumPrice": "Traslado «Premium», AMD",
"assign_auto": "Asignar pedidos automáticamente",
"assign_maxAutoOffers": "Cuántos profesionales probar automáticamente"
}, },
"presets": { "presets": {
"add": "Añadir plantilla", "add": "Añadir plantilla",
+5 -2
View File
@@ -871,7 +871,8 @@
"file": "ֆայլ", "file": "ֆայլ",
"chatAssigned": "{{by}}-ը ձեզ փոխանցեց զրույցը՝ {{name}}", "chatAssigned": "{{by}}-ը ձեզ փոխանցեց զրույցը՝ {{name}}",
"order": "Պատվեր №{{number}}", "order": "Պատվեր №{{number}}",
"noOrder": "Այժմ պատվեր չկա" "noOrder": "Այժմ պատվեր չկա",
"autoFailed": "№{{number}} պատվերի համար ազատ կատարող չգտնվեց․ նշանակեք ձեռքով։"
}, },
"partner": { "partner": {
"valid": "{{name}} — {{level}}, քարտը գործում է", "valid": "{{name}} — {{level}}, քարտը գործում է",
@@ -1269,7 +1270,9 @@
"residence_escortFrom": "Կացության քարտ՝ ուղեկցում «սկսած», ֏", "residence_escortFrom": "Կացության քարտ՝ ուղեկցում «սկսած», ֏",
"residence_stateFee": "Կացության քարտ՝ պետտուրք 1 տարի, ֏", "residence_stateFee": "Կացության քարտ՝ պետտուրք 1 տարի, ֏",
"transfer_standardPrice": "Տրանսֆեր «Ստանդարտ», ֏", "transfer_standardPrice": "Տրանսֆեր «Ստանդարտ», ֏",
"transfer_premiumPrice": "Տրանսֆեր «Պրեմիում», ֏" "transfer_premiumPrice": "Տրանսֆեր «Պրեմիում», ֏",
"assign_auto": "Պատվերների ավտոմատ բաշխում",
"assign_maxAutoOffers": "Քանի կատարող փորձել ավտոմատ"
}, },
"presets": { "presets": {
"add": "Ավելացնել նախադրվածք", "add": "Ավելացնել նախադրվածք",
+5 -2
View File
@@ -871,7 +871,8 @@
"file": "файл", "file": "файл",
"chatAssigned": "{{by}} передал(а) вам диалог: {{name}}", "chatAssigned": "{{by}} передал(а) вам диалог: {{name}}",
"order": "Заказ №{{number}}", "order": "Заказ №{{number}}",
"noOrder": "Сейчас без заказа" "noOrder": "Сейчас без заказа",
"autoFailed": "Не нашли свободного исполнителя для заказа №{{number}} — назначьте вручную."
}, },
"partner": { "partner": {
"valid": "{{name}} — {{level}}, карта действует", "valid": "{{name}} — {{level}}, карта действует",
@@ -1269,7 +1270,9 @@
"residence_escortFrom": "ВНЖ: сопровождение «от», ֏", "residence_escortFrom": "ВНЖ: сопровождение «от», ֏",
"residence_stateFee": "ВНЖ: госпошлина на 1 год, ֏", "residence_stateFee": "ВНЖ: госпошлина на 1 год, ֏",
"transfer_standardPrice": "Трансфер «Стандарт», ֏", "transfer_standardPrice": "Трансфер «Стандарт», ֏",
"transfer_premiumPrice": "Трансфер «Премиум», ֏" "transfer_premiumPrice": "Трансфер «Премиум», ֏",
"assign_auto": "Автораспределение заказов",
"assign_maxAutoOffers": "Сколько исполнителей пробовать автоматически"
}, },
"presets": { "presets": {
"add": "Добавить пресет", "add": "Добавить пресет",
+5 -2
View File
@@ -871,7 +871,8 @@
"file": "文件", "file": "文件",
"chatAssigned": "{{by}} 将对话转交给你:{{name}}", "chatAssigned": "{{by}} 将对话转交给你:{{name}}",
"order": "订单 #{{number}}", "order": "订单 #{{number}}",
"noOrder": "当前没有订单" "noOrder": "当前没有订单",
"autoFailed": "订单 #{{number}} 没有找到空闲的服务人员,请手动分配。"
}, },
"partner": { "partner": {
"valid": "{{name}} — {{level}},会员卡有效", "valid": "{{name}} — {{level}},会员卡有效",
@@ -1269,7 +1270,9 @@
"residence_escortFrom": "居留许可:陪同办理起价,֏", "residence_escortFrom": "居留许可:陪同办理起价,֏",
"residence_stateFee": "居留许可:1年国家规费,֏", "residence_stateFee": "居留许可:1年国家规费,֏",
"transfer_standardPrice": "接机「标准」,֏", "transfer_standardPrice": "接机「标准」,֏",
"transfer_premiumPrice": "接机「高级」,֏" "transfer_premiumPrice": "接机「高级」,֏",
"assign_auto": "自动分配订单",
"assign_maxAutoOffers": "自动尝试的服务人员数量"
}, },
"presets": { "presets": {
"add": "添加方案", "add": "添加方案",
+96 -4
View File
@@ -1,4 +1,7 @@
import { averageRating, getFlow, isProviderFree, rankCandidates, startOfYerevanDay, type Candidate, type Locale } from '@barev/core'; import { averageRating, FLOWS, getFlow, isProviderFree, rankCandidates, startOfYerevanDay, type Candidate, type Locale } from '@barev/core';
/** статусы «только создан, ждёт исполнителя» во всех сценариях */
const INITIAL_STATUSES = new Set(Object.values(FLOWS).map((f) => f.initial));
import type { Tx } from '@barev/db'; import type { Tx } from '@barev/db';
import { loadAvailability } from './availability.ts'; import { loadAvailability } from './availability.ts';
import { DomainError, type Ctx, type DomainEvent } from './context.ts'; import { DomainError, type Ctx, type DomainEvent } from './context.ts';
@@ -84,10 +87,13 @@ async function revokeActiveOffers(tx: Tx, orderId: string) {
await tx.orderOffer.updateMany({ where: { orderId, status: 'OFFERED' }, data: { status: 'REVOKED', respondedAt: new Date() } }); await tx.orderOffer.updateMany({ where: { orderId, status: 'OFFERED' }, data: { status: 'REVOKED', respondedAt: new Date() } });
} }
/** Назначение из админки: частному исполнителю (с подтверждением) или компании (PRO-5, PRO-7) */ /**
* Назначение: частному исполнителю (с подтверждением) или компании (PRO-5, PRO-7).
* adminId = null — предложение сделала система (автораспределение).
*/
export async function offerOrder( export async function offerOrder(
ctx: Ctx, ctx: Ctx,
input: { orderId: string; adminId: string; target: { kind: 'provider' | 'company'; id: string } }, input: { orderId: string; adminId: string | null; target: { kind: 'provider' | 'company'; id: string } },
) { ) {
const settings = await getSettings(ctx.db); const settings = await getSettings(ctx.db);
const timeout = settings['offer.acceptTimeoutMinutes']; const timeout = settings['offer.acceptTimeoutMinutes'];
@@ -98,6 +104,8 @@ export async function offerOrder(
const order = await tx.order.findUniqueOrThrow({ where: { id: input.orderId }, include: { service: true } }); const order = await tx.order.findUniqueOrThrow({ where: { id: input.orderId }, include: { service: true } });
const flow = getFlow(order.service.flow); const flow = getFlow(order.service.flow);
if (order.status !== flow.initial || order.providerId) throw new DomainError('ALREADY_ASSIGNED'); if (order.status !== flow.initial || order.providerId) throw new DomainError('ALREADY_ASSIGNED');
// система не перебивает действующее предложение (его может отозвать только человек)
if (!input.adminId && (await tx.orderOffer.count({ where: { orderId: order.id, status: 'OFFERED' } }))) throw new DomainError('ALREADY_ASSIGNED');
if (input.target.kind === 'provider') { if (input.target.kind === 'provider') {
const provider = await tx.provider.findUnique({ where: { id: input.target.id } }); const provider = await tx.provider.findUnique({ where: { id: input.target.id } });
@@ -117,6 +125,9 @@ export async function offerOrder(
createdById: input.adminId, createdById: input.adminId,
}, },
}); });
await tx.orderStatusHistory.create({
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: input.adminId ? 'offered' : 'autoOffered', actorType: input.adminId ? 'ADMIN' : 'SYSTEM', actorId: input.adminId, comment: provider.name },
});
events.push({ type: 'offer.created', offerId: offer.id }); events.push({ type: 'offer.created', offerId: offer.id });
return offer; return offer;
} }
@@ -130,7 +141,7 @@ export async function offerOrder(
data: { orderId: order.id, companyId: company.id, status: 'OFFERED', createdById: input.adminId }, data: { orderId: order.id, companyId: company.id, status: 'OFFERED', createdById: input.adminId },
}); });
await tx.orderStatusHistory.create({ await tx.orderStatusHistory.create({
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: 'assignedToCompany', actorType: 'ADMIN', actorId: input.adminId, comment: company.name }, data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: 'assignedToCompany', actorType: input.adminId ? 'ADMIN' : 'SYSTEM', actorId: input.adminId, comment: company.name },
}); });
events.push({ type: 'company.order', orderId: order.id, companyId: company.id }); events.push({ type: 'company.order', orderId: order.id, companyId: company.id });
return offer; return offer;
@@ -277,3 +288,84 @@ export async function unassignOrder(ctx: Ctx, input: { orderId: string; adminId:
}); });
for (const e of events) await ctx.notify.emit(e); for (const e of events) await ctx.notify.emit(e);
} }
// ───── автораспределение ─────
export type AutoAssignResult =
/** предложили исполнителю или компании */
| { result: 'offered'; offerId: string; target: string }
/** выключено в настройках */
| { result: 'off' }
/** уже назначен, есть активное предложение, заказ в прошлом или начат */
| { result: 'skipped' }
/** свободных подходящих нет; first — впервые для этого заказа (сообщить команде) */
| { result: 'none'; first: boolean }
/** исчерпан лимит попыток — дальше вручную; first — впервые */
| { result: 'gaveUp'; first: boolean };
/**
* Автораспределение: новый заказ сам предлагается лучшему свободному исполнителю (те же правила, что у подсказки:
* язык клиента, район, смена, загрузка, рейтинг). Отказ или молчание — следующему.
* Кому заказ уже предлагали (в т.ч. снятым с заказа), второй раз не предлагаем.
* После N попыток подряд система останавливается — заказ ждёт диспетчера.
*/
export async function autoAssign(ctx: Ctx, orderId: string, opts: { force?: boolean } = {}): Promise<AutoAssignResult> {
const settings = await getSettings(ctx.db);
// force — кнопка «Подобрать автоматически» у диспетчера: работает и при выключенном автораспределении, без лимита попыток
if (!settings['assign.auto'] && !opts.force) return { result: 'off' };
const order = await ctx.db.order.findUnique({
where: { id: orderId },
include: { service: true, offers: true, history: { where: { event: { in: ['autoNoCandidate', 'autoGaveUp', 'autoOffered'] } }, orderBy: { createdAt: 'desc' }, take: 1 } },
});
if (!order) return { result: 'skipped' };
const flow = getFlow(order.service.flow);
if (order.status !== flow.initial || order.providerId || order.offers.some((o) => o.status === 'OFFERED') || order.scheduledAt <= ctx.now()) {
return { result: 'skipped' };
}
const lastAuto = order.history[0]?.event ?? null;
const mark = (event: 'autoNoCandidate' | 'autoGaveUp') =>
ctx.db.orderStatusHistory.create({ data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event, actorType: 'SYSTEM' } });
const autoTries = order.offers.filter((o) => !o.createdById).length;
if (!opts.force && autoTries >= settings['assign.maxAutoOffers']) {
if (lastAuto === 'autoGaveUp') return { result: 'gaveUp', first: false };
await mark('autoGaveUp');
return { result: 'gaveUp', first: true };
}
const triedProviders = new Set(order.offers.map((o) => o.providerId).filter((x): x is string => !!x));
const triedCompanies = new Set(order.offers.filter((o) => !o.providerId).map((o) => o.companyId).filter((x): x is string => !!x));
const { ranked } = await assignmentCandidates(ctx, order.id);
const pick = ranked.find((c) => (c.kind === 'company' ? !triedCompanies.has(c.id) : !triedProviders.has(c.id)));
if (!pick) {
if (lastAuto === 'autoNoCandidate') return { result: 'none', first: false };
await mark('autoNoCandidate');
return { result: 'none', first: true };
}
try {
const offer = await offerOrder(ctx, { orderId: order.id, adminId: null, target: { kind: pick.kind, id: pick.id } });
return { result: 'offered', offerId: offer.id, target: pick.name };
} catch (e) {
// кто-то успел назначить вручную или исполнитель только что занялся — попробуем позже
if (e instanceof DomainError && (e.code === 'ALREADY_ASSIGNED' || e.code === 'PROVIDER_BUSY')) return { result: 'skipped' };
throw e;
}
}
/**
* Воркер раз в минуту: заказы, которые всё ещё ждут исполнителя (никому не предложены),
* пробуем распределить снова — вдруг кто-то вышел на смену или освободился.
*/
export async function retryAutoAssign(ctx: Ctx) {
const settings = await getSettings(ctx.db);
if (!settings['assign.auto']) return [];
const waiting = await ctx.db.order.findMany({
where: { providerId: null, companyId: null, scheduledAt: { gt: ctx.now() }, status: { in: [...INITIAL_STATUSES] }, offers: { none: { status: 'OFFERED' } } },
select: { id: true },
orderBy: { scheduledAt: 'asc' },
take: 50,
});
const out: { orderId: string; res: AutoAssignResult }[] = [];
for (const o of waiting) out.push({ orderId: o.id, res: await autoAssign(ctx, o.id) });
return out;
}
+43 -2
View File
@@ -74,6 +74,10 @@ export interface CreateOrderInput {
note?: string; note?: string;
paymentMethodId?: string; paymentMethodId?: string;
repeatOfId?: string; repeatOfId?: string;
/** заказ оформила команда за клиента (новая админка) */
createdBy?: { type: 'ADMIN'; id: string };
/** оператор ставит время, даже если по графику все заняты (договорился с исполнителем сам) */
allowBusySlot?: boolean;
} }
/** ORD-5: подтверждение заказа → «Новый», чек-лист из шаблона, запись в историю */ /** ORD-5: подтверждение заказа → «Новый», чек-лист из шаблона, запись в историю */
@@ -88,7 +92,8 @@ export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
async (tx) => { async (tx) => {
// сериализуем создание заказов одной роли, чтобы не продать слот дважды // сериализуем создание заказов одной роли, чтобы не продать слот дважды
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'slots:' + service.providerRole}))`; await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'slots:' + service.providerRole}))`;
await assertSlotFree(tx, service, input.scheduledAt, q.durationMinutes, ctx.now()); if (!input.allowBusySlot) await assertSlotFree(tx, service, input.scheduledAt, q.durationMinutes, ctx.now());
else if (input.scheduledAt <= ctx.now()) throw new DomainError('SLOT_UNAVAILABLE');
const checklist = await tx.checklistItem.findMany({ where: { serviceId: service.id }, orderBy: { sortOrder: 'asc' } }); const checklist = await tx.checklistItem.findMany({ where: { serviceId: service.id }, orderBy: { sortOrder: 'asc' } });
return tx.order.create({ return tx.order.create({
data: { data: {
@@ -115,7 +120,7 @@ export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
checklist: { checklist: {
create: checklist.map((c) => ({ code: c.code, requiresPhoto: c.requiresPhoto, sortOrder: c.sortOrder })), create: checklist.map((c) => ({ code: c.code, requiresPhoto: c.requiresPhoto, sortOrder: c.sortOrder })),
}, },
history: { create: { toStatus: flow.initial, actorType: 'CLIENT', actorId: input.clientId } }, history: { create: input.createdBy ? { toStatus: flow.initial, actorType: 'ADMIN', actorId: input.createdBy.id, event: 'createdByStaff' } : { toStatus: flow.initial, actorType: 'CLIENT', actorId: input.clientId } },
}, },
}); });
}, },
@@ -355,6 +360,42 @@ export async function rescheduleByClient(ctx: Ctx, input: { orderId: string; cli
for (const e of events) await ctx.notify.emit(e); for (const e of events) await ctx.notify.emit(e);
} }
/**
* Перенос командой: в любой момент до начала работы. Если назначенный исполнитель занят в новое время —
* заказ возвращается в «Новые» (и уйдёт в автораспределение). allowBusySlot — время вне свободных слотов.
*/
export async function rescheduleByAdmin(ctx: Ctx, input: { orderId: string; adminId: string; scheduledAt: Date; allowBusySlot?: boolean; comment?: string }) {
const order = await ctx.db.order.findUnique({ where: { id: input.orderId }, include: { service: true } });
if (!order) throw new DomainError('NOT_FOUND');
const flow = getFlow(order.service.flow);
if (order.status !== flow.initial && order.status !== flow.assigned) throw new DomainError('TRANSITION_NOT_ALLOWED');
if (input.scheduledAt <= ctx.now()) throw new DomainError('SLOT_UNAVAILABLE');
const service = await getServiceByCode(ctx.db, order.service.code);
const events: DomainEvent[] = [{ type: 'order.rescheduled', orderId: order.id }];
await ctx.db.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'slots:' + service.providerRole}))`;
if (!input.allowBusySlot) await assertSlotFree(tx, service, input.scheduledAt, order.durationMinutes, ctx.now(), order.id);
let keepProvider = false;
if (order.providerId) {
const end = new Date(input.scheduledAt.getTime() + order.durationMinutes * 60_000);
const [p] = (await loadAvailability(tx, service.providerRole, input.scheduledAt, end, { excludeOrderId: order.id })).filter((x) => x.id === order.providerId);
keepProvider = !!p && isProviderFree(p, { start: input.scheduledAt, end });
}
// действующее предложение было на старое время — отзываем
await tx.orderOffer.updateMany({ where: { orderId: order.id, status: 'OFFERED', providerId: { not: null } }, data: { status: 'REVOKED', respondedAt: ctx.now() } });
await tx.order.update({ where: { id: order.id }, data: { scheduledAt: input.scheduledAt, reminded24hAt: null, reminded1hAt: null } });
await tx.orderStatusHistory.create({
data: { orderId: order.id, fromStatus: order.status, toStatus: order.status, event: 'rescheduled', actorType: 'ADMIN', actorId: input.adminId, comment: input.comment },
});
if (order.providerId && !keepProvider) {
const r = await transitionOrder(ctx, { orderId: order.id, to: flow.initial, actor: 'ADMIN', actorId: input.adminId, event: 'rescheduled', data: { providerId: null, companyId: null } }, tx);
events.push({ type: 'order.unassigned', orderId: order.id, providerId: order.providerId }, r.event);
}
});
for (const e of events) await ctx.notify.emit(e);
return { keptProvider: events.length === 1 };
}
/** ORD-7: черновик повтора — те же параметры и адрес, новое время выбирает клиент */ /** ORD-7: черновик повтора — те же параметры и адрес, новое время выбирает клиент */
export async function repeatDraft(db: Db, orderId: string, clientId: string) { export async function repeatDraft(db: Db, orderId: string, clientId: string) {
const order = await db.order.findUnique({ where: { id: orderId }, include: { service: true } }); const order = await db.order.findUnique({ where: { id: orderId }, include: { service: true } });
+96
View File
@@ -0,0 +1,96 @@
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { autoAssign, createOrder, giveConsent, respondToOffer, retryAutoAssign, startClient, unassignOrder } from '../src/index.ts';
import { db, fixtures, makeCtx, NOW, slotAt, tgUser } from './helpers.ts';
let fx: Awaited<ReturnType<typeof fixtures>>;
beforeEach(async () => {
fx = await fixtures();
// при равном счёте компания идёт раньше по алфавиту — в большинстве тестов проверяем частных исполнителей
await db.providerCompany.update({ where: { id: fx.company.id }, data: { active: false } });
});
afterAll(() => db.$disconnect());
async function order(ctx: ReturnType<typeof makeCtx>['ctx'], tg: number, lang = 'ru') {
const s = await startClient(ctx, tgUser(tg, lang));
await giveConsent(ctx, s.clientId);
return createOrder(ctx, { clientId: s.clientId, serviceCode: 'cleaning', params: { cleaningType: 'regular', rooms: 2 }, scheduledAt: slotAt(2, 11), addressText: 'Pushkin 10', districtId: fx.kentron.id });
}
const setting = (key: string, value: unknown) => db.setting.upsert({ where: { key }, create: { key, value: value as never }, update: { value: value as never } });
const history = async (orderId: string) => (await db.orderStatusHistory.findMany({ where: { orderId }, orderBy: { createdAt: 'asc' } })).map((h) => h.event).filter(Boolean);
describe('автораспределение заказов', () => {
it('новый заказ сразу предлагается лучшему: язык клиента и район; система — автор предложения', async () => {
const { ctx, events } = makeCtx(NOW);
const o = await order(ctx, 801);
const res = await autoAssign(ctx, o.id);
expect(res).toMatchObject({ result: 'offered', target: 'Maria' });
const offer = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id } });
expect(offer).toMatchObject({ providerId: fx.maria.id, status: 'OFFERED', createdById: null });
expect(events).toContainEqual({ type: 'offer.created', offerId: offer.id });
expect(await history(o.id)).toContain('autoOffered');
// пока предложение действует — не трогаем
expect(await autoAssign(ctx, o.id)).toEqual({ result: 'skipped' });
});
it('отказ — следующему; кому уже предлагали, повторно не предлагаем', async () => {
const { ctx } = makeCtx(NOW);
const o = await order(ctx, 802);
await autoAssign(ctx, o.id);
const first = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id } });
await respondToOffer(ctx, { offerId: first.id, providerId: fx.maria.id, accept: false });
expect(await autoAssign(ctx, o.id)).toMatchObject({ result: 'offered', target: 'Ani' });
const second = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id, status: 'OFFERED' } });
await respondToOffer(ctx, { offerId: second.id, providerId: fx.ani.id, accept: false });
// больше свободных частных нет — компания включается и получает заказ
await db.providerCompany.update({ where: { id: fx.company.id }, data: { active: true } });
expect(await autoAssign(ctx, o.id)).toMatchObject({ result: 'offered', target: 'Chisto' });
const offers = await db.orderOffer.findMany({ where: { orderId: o.id }, orderBy: { createdAt: 'asc' } });
expect(offers.map((x) => x.providerId ?? x.companyId)).toEqual([fx.maria.id, fx.ani.id, fx.company.id]);
});
it('снятый с заказа исполнитель не получает его снова автоматически', async () => {
const { ctx } = makeCtx(NOW);
const o = await order(ctx, 803);
await autoAssign(ctx, o.id);
const offer = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id } });
await respondToOffer(ctx, { offerId: offer.id, providerId: fx.maria.id, accept: true });
const admin = await db.adminUser.create({ data: { name: 'Op', roles: ['SUPPORT'] } });
await unassignOrder(ctx, { orderId: o.id, adminId: admin.id });
const again = await autoAssign(ctx, o.id);
expect(again.result).toBe('offered');
expect(again).not.toMatchObject({ target: 'Maria' });
});
it('лимит попыток: дальше вручную, команде сообщаем один раз', async () => {
const { ctx } = makeCtx(NOW);
await setting('assign.maxAutoOffers', 1);
const o = await order(ctx, 804);
await autoAssign(ctx, o.id);
const offer = await db.orderOffer.findFirstOrThrow({ where: { orderId: o.id } });
await respondToOffer(ctx, { offerId: offer.id, providerId: fx.maria.id, accept: false });
expect(await autoAssign(ctx, o.id)).toEqual({ result: 'gaveUp', first: true });
expect(await autoAssign(ctx, o.id)).toEqual({ result: 'gaveUp', first: false });
expect((await history(o.id)).filter((e) => e === 'autoGaveUp')).toHaveLength(1);
});
it('свободных нет — пометка один раз; повторная попытка воркера находит освободившегося', async () => {
const { ctx } = makeCtx(NOW);
const o = await order(ctx, 805);
// все недоступны: исполнители отключены
await db.provider.updateMany({ data: { active: false } });
expect(await autoAssign(ctx, o.id)).toEqual({ result: 'none', first: true });
expect(await autoAssign(ctx, o.id)).toEqual({ result: 'none', first: false });
await db.provider.update({ where: { id: fx.ani.id }, data: { active: true } });
const retried = await retryAutoAssign(ctx);
expect(retried).toEqual([{ orderId: o.id, res: expect.objectContaining({ result: 'offered', target: 'Ani' }) }]);
});
it('выключено в настройках — заказы ждут диспетчера', async () => {
const { ctx } = makeCtx(NOW);
await setting('assign.auto', false);
const o = await order(ctx, 806);
expect(await autoAssign(ctx, o.id)).toEqual({ result: 'off' });
expect(await retryAutoAssign(ctx)).toEqual([]);
expect(await db.orderOffer.count({ where: { orderId: o.id } })).toBe(0);
});
});
+1 -1
View File
@@ -302,7 +302,7 @@ describe('исполнитель ведёт заказ (PRO-3, PRO-4)', () => {
expect(done.status).toBe('DONE'); expect(done.status).toBe('DONE');
expect(done.completedAt).not.toBeNull(); expect(done.completedAt).not.toBeNull();
const hist = await db.orderStatusHistory.findMany({ where: { orderId: o.id }, orderBy: { createdAt: 'asc' } }); const hist = await db.orderStatusHistory.findMany({ where: { orderId: o.id }, orderBy: { createdAt: 'asc' } });
expect(hist.map((h) => h.event ?? h.toStatus)).toEqual(['NEW', 'accepted', 'depart', 'arrived', 'startCleaning', 'finishCleaning']); expect(hist.map((h) => h.event ?? h.toStatus)).toEqual(['NEW', 'offered', 'accepted', 'depart', 'arrived', 'startCleaning', 'finishCleaning']);
}); });
it('чужой исполнитель не может двигать заказ', async () => { it('чужой исполнитель не может двигать заказ', async () => {