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:
1 parent
2772837e29
commit
e7de4f5893
33 files changed
+4521
-45
No files matched your search
@@ -34,12 +34,16 @@ export function Settings() {
|
||||
<div className="form-grid">
|
||||
{SETTING_KEYS.map((k) => (
|
||||
<Field key={k} label={t(`admin.settings.${k.replace('.', '_')}`)}>
|
||||
<input
|
||||
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 })}
|
||||
/>
|
||||
{typeof draft[k] === 'boolean' ? (
|
||||
<input type="checkbox" checked={draft[k] as boolean} onChange={(e) => setDraft({ ...draft, [k]: e.target.checked })} style={{ width: 22, height: 22 }} />
|
||||
) : (
|
||||
<input
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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 }) => {
|
||||
const channel = input.channel ?? (ctx.channels.length === 1 ? ctx.channels[0] : undefined);
|
||||
const t = await openThreadFor(ctx.svc, { clientId: input.clientId }, channel);
|
||||
openFor: chatProcedure.input(z.object({ clientId: z.string().optional(), providerId: z.string().optional(), channel: z.enum(CHANNELS).optional() })).mutation(async ({ ctx, input }) => {
|
||||
if (!input.clientId && !input.providerId) throw new TRPCError({ code: 'BAD_REQUEST' });
|
||||
// исполнитель пишет только в поддержку
|
||||
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' });
|
||||
return { id: t.id };
|
||||
}),
|
||||
|
||||
@@ -3,6 +3,8 @@ import { boAuthRouter, boTeamRouter } from './auth.ts';
|
||||
import { boClientsRouter, boSegmentsRouter } from './clients.ts';
|
||||
import { boDashboardRouter } from './dashboard.ts';
|
||||
import { boChatsRouter } from './chats.ts';
|
||||
import { boOrdersRouter } from './orders.ts';
|
||||
import { boProvidersRouter } from './providers.ts';
|
||||
|
||||
/** API новой админки (apps/backoffice) */
|
||||
export const backofficeRouter = router({
|
||||
@@ -12,4 +14,6 @@ export const backofficeRouter = router({
|
||||
clients: boClientsRouter,
|
||||
segments: boSegmentsRouter,
|
||||
chats: boChatsRouter,
|
||||
orders: boOrdersRouter,
|
||||
providers: boProvidersRouter,
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -163,6 +163,48 @@ app.get<{ Params: { messageId: string } }>('/files/bo/chat-file/:messageId', asy
|
||||
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) {
|
||||
const auth = req.headers.authorization?.startsWith('tma ') ? req.headers.authorization.slice(4) : undefined;
|
||||
|
||||
@@ -11,6 +11,10 @@ import { ClientCard } from './pages/ClientCard.tsx';
|
||||
import { Team } from './pages/Team.tsx';
|
||||
import { Soon } from './pages/Soon.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 interface Me {
|
||||
@@ -44,8 +48,8 @@ const NAV: NavItem[] = [
|
||||
{ path: '/', key: 'nav.dashboard', icon: 'dashboard', roles: 'all' },
|
||||
{ path: '/clients', key: 'nav.clients', icon: 'clients', roles: ['SUPPORT', 'CONCIERGE'] },
|
||||
{ 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: '/providers', key: 'nav.providers', icon: 'providers', stage: 3, roles: ['SUPPORT'], group: 'work' },
|
||||
{ path: '/orders', key: 'nav.orders', icon: 'orders', roles: ['SUPPORT'], count: 'orders' },
|
||||
{ path: '/providers', key: 'nav.providers', icon: 'providers', roles: ['SUPPORT'], group: 'work' },
|
||||
{ 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: '/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 threadId = match('/chats/:id', path)?.id;
|
||||
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} />;
|
||||
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 (clientId && items.some((n) => n.path === '/clients')) page = <ClientCard id={clientId} me={me} />;
|
||||
else if (path === '/team' && isSuper) page = <Team me={me} />;
|
||||
|
||||
@@ -95,7 +95,7 @@ export function sourceKeyLabel(t: T, key: string) {
|
||||
}
|
||||
|
||||
/** Модальное окно на <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);
|
||||
useEffect(() => {
|
||||
const d = ref.current;
|
||||
@@ -106,7 +106,7 @@ export function Dialog({ open, onClose, title, children, footer }: { open: boole
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
className="bo-dialog"
|
||||
className={`bo-dialog${wide ? ' bo-dialog--wide' : ''}`}
|
||||
onClose={onClose}
|
||||
onClick={(e) => {
|
||||
if (e.target === ref.current) onClose();
|
||||
|
||||
@@ -386,6 +386,242 @@ const ru = {
|
||||
'chat.notLinked': 'Рабочий бот не открыт',
|
||||
'chat.details': 'Подробнее',
|
||||
'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.noOrder': 'Без заказа',
|
||||
'chat.noOrderNow': 'Сейчас без заказа',
|
||||
@@ -792,6 +1028,242 @@ const en: Dict = {
|
||||
'chat.notLinked': 'Work bot not opened',
|
||||
'chat.details': 'Details',
|
||||
'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.noOrder': 'No order',
|
||||
'chat.noOrderNow': 'No current order',
|
||||
@@ -1195,6 +1667,242 @@ const hy: Dict = {
|
||||
'chat.notLinked': 'Աշխատանքային բոտը բացված չէ',
|
||||
'chat.details': 'Մանրամասներ',
|
||||
'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.noOrder': 'Առանց պատվերի',
|
||||
'chat.noOrderNow': 'Այժմ պատվեր չկա',
|
||||
@@ -1235,8 +1943,12 @@ interface Ctx {
|
||||
locale: BoLocale;
|
||||
setLocale: (l: BoLocale) => void;
|
||||
t: T;
|
||||
/** общие переводы проекта: services.<code>.name и т.п. */
|
||||
shared: (key: string) => string;
|
||||
/** общие переводы проекта: services.<code>.name, roles.<ROLE>, params.* и т.п. */
|
||||
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;
|
||||
date: (d: Date) => string;
|
||||
dateTime: (d: Date) => string;
|
||||
@@ -1279,10 +1991,12 @@ export function BoI18nProvider({ children }: { children: ReactNode }) {
|
||||
},
|
||||
t: makeT(locale),
|
||||
shared: sharedT,
|
||||
sharedT,
|
||||
amd: (n) => formatAmd(locale as SharedLocale, n),
|
||||
amdShort: (n) => `${compact.format(n)} ֏`,
|
||||
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' }),
|
||||
time: (d) => formatDateTime(locale as SharedLocale, d, { hour: '2-digit', minute: '2-digit' }),
|
||||
};
|
||||
}, [locale]);
|
||||
return <I18nCtx.Provider value={value}>{children}</I18nCtx.Provider>;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import '@barev/ui/styles.css';
|
||||
import './styles.css';
|
||||
import './chats.css';
|
||||
import './orders.css';
|
||||
import { ApiProvider, ToastProvider } from '@barev/web';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -528,7 +528,7 @@ function OrderTag({ order }: { order: OrderBrief | null }) {
|
||||
</div>
|
||||
);
|
||||
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} />
|
||||
{text(order)}
|
||||
</a>
|
||||
@@ -545,7 +545,7 @@ function ProviderOrders({ current, next }: { current: OrderBrief | null; next: O
|
||||
<span className="bo-grow" style={{ minWidth: 0 }}>
|
||||
{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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { navigate, useToast, useTRPC } from '@barev/web';
|
||||
import { myChannels } from './Chats.tsx';
|
||||
import { NewOrderDialog } from './NewOrder.tsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Me } from '../App.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 c = useQuery(trpc.bo.clients.get.queryOptions({ id }));
|
||||
const [dlg, setDlg] = useState<{ mode: SubMode; sub?: SubTarget } | null>(null);
|
||||
const [newOrder, setNewOrder] = useState(false);
|
||||
const isSuper = me.roles.includes('SUPERADMIN');
|
||||
const canManage = isSuper || me.roles.includes('SUPPORT');
|
||||
const canChat = myChannels(me).length > 0;
|
||||
@@ -189,10 +191,12 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
|
||||
<section className="bo-panel">
|
||||
<div className="bo-panel__head">
|
||||
<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')}>
|
||||
<I name="plus" size={16} />
|
||||
{t('card.newOrder')}
|
||||
</button>
|
||||
{canManage && (
|
||||
<button type="button" className="bo-btn bo-btn--ghost bo-btn--sm" onClick={() => setNewOrder(true)}>
|
||||
<I name="plus" size={16} />
|
||||
{t('card.newOrder')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{d.orders.length === 0 ? (
|
||||
<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">
|
||||
{d.orders.map((o) => (
|
||||
<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}
|
||||
</span>
|
||||
</a>
|
||||
<span className="bo-grow" style={{ minWidth: 140 }}>
|
||||
<b style={{ fontWeight: 600 }}>{shared(`services.${o.service}.name`)}</b>
|
||||
<span className="bo-muted bo-small" style={{ display: 'block' }}>
|
||||
@@ -324,6 +328,7 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
|
||||
</div>
|
||||
|
||||
{dlg && <SubDialog clientId={d.id} mode={dlg.mode} sub={dlg.sub} current={current} onClose={() => setDlg(null)} />}
|
||||
{newOrder && <NewOrderDialog clientId={d.id} onClose={() => setNewOrder(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
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: '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: '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)' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { getDb } from '@barev/db';
|
||||
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 { loadRootEnv } from '@barev/services/env';
|
||||
import { createQueueNotifier, redisConnection } from '@barev/services/queue';
|
||||
@@ -31,6 +31,21 @@ async function toAdmins(text: string, buttons: InlineButton[][] = []) {
|
||||
const adminT = createT('ru');
|
||||
/** Ссылка на раздел админки — Telegram принимает url-кнопки только с https */
|
||||
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 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 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)}`,
|
||||
compactRows([[adminButton('/')]]),
|
||||
compactRows([[orderButton(o.id)]]),
|
||||
);
|
||||
// автораспределение: сразу предлагаем лучшему свободному исполнителю
|
||||
await reportAuto(o.id, await autoAssign(ctx, o.id));
|
||||
return;
|
||||
}
|
||||
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 companyId = offer?.provider?.companyId;
|
||||
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');
|
||||
return;
|
||||
}
|
||||
@@ -343,6 +365,8 @@ async function tick() {
|
||||
try {
|
||||
const [reminders, expired] = await Promise.all([dueReminders(ctx), expireOffers(ctx)]);
|
||||
if (reminders || expired) console.log(`[worker] reminders=${reminders} expiredOffers=${expired}`);
|
||||
// заказы, которые ещё ждут исполнителя, — пробуем распределить снова
|
||||
for (const { orderId, res } of await retryAutoAssign(ctx)) await reportAuto(orderId, res);
|
||||
} catch (e) {
|
||||
console.error('[worker] tick failed', e);
|
||||
} finally {
|
||||
|
||||
Reference in new issue
Block a user