From e7de4f58935091a596f27c9b77887d110221d5be Mon Sep 17 00:00:00 2001 From: zabudko-ru Date: Wed, 21 Oct 2026 02:25:41 +0400 Subject: [PATCH] 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) --- apps/admin/src/pages/Settings.tsx | 16 +- apps/api/src/routers/backoffice/chats.ts | 8 +- apps/api/src/routers/backoffice/index.ts | 4 + apps/api/src/routers/backoffice/orders.ts | 397 ++++++++++ apps/api/src/routers/backoffice/providers.ts | 258 +++++++ apps/api/src/server.ts | 42 + apps/backoffice/src/App.tsx | 15 +- apps/backoffice/src/components/kit.tsx | 4 +- apps/backoffice/src/i18n.tsx | 718 +++++++++++++++++- apps/backoffice/src/main.tsx | 1 + apps/backoffice/src/orders.css | 694 +++++++++++++++++ apps/backoffice/src/pages/Chats.tsx | 4 +- apps/backoffice/src/pages/ClientCard.tsx | 17 +- apps/backoffice/src/pages/Dashboard.tsx | 4 +- apps/backoffice/src/pages/NewOrder.tsx | 391 ++++++++++ apps/backoffice/src/pages/OrderCard.tsx | 585 ++++++++++++++ apps/backoffice/src/pages/Orders.tsx | 231 ++++++ apps/backoffice/src/pages/ProviderCard.tsx | 659 ++++++++++++++++ apps/backoffice/src/pages/Providers.tsx | 144 ++++ apps/worker/src/index.ts | 30 +- packages/core/src/settings.ts | 4 + .../migration.sql | 26 + .../20261021170000_payout_paid/migration.sql | 8 + packages/db/prisma/schema.prisma | 28 + packages/i18n/locales/en.json | 7 +- packages/i18n/locales/es.json | 7 +- packages/i18n/locales/hy.json | 7 +- packages/i18n/locales/ru.json | 7 +- packages/i18n/locales/zh.json | 7 +- packages/services/src/assignment.ts | 100 ++- packages/services/src/orders.ts | 45 +- packages/services/test/autoassign.test.ts | 96 +++ packages/services/test/flows.test.ts | 2 +- 33 files changed, 4521 insertions(+), 45 deletions(-) create mode 100644 apps/api/src/routers/backoffice/orders.ts create mode 100644 apps/api/src/routers/backoffice/providers.ts create mode 100644 apps/backoffice/src/orders.css create mode 100644 apps/backoffice/src/pages/NewOrder.tsx create mode 100644 apps/backoffice/src/pages/OrderCard.tsx create mode 100644 apps/backoffice/src/pages/Orders.tsx create mode 100644 apps/backoffice/src/pages/ProviderCard.tsx create mode 100644 apps/backoffice/src/pages/Providers.tsx create mode 100644 packages/db/prisma/migrations/20261021160000_providers_admittance/migration.sql create mode 100644 packages/db/prisma/migrations/20261021170000_payout_paid/migration.sql create mode 100644 packages/services/test/autoassign.test.ts diff --git a/apps/admin/src/pages/Settings.tsx b/apps/admin/src/pages/Settings.tsx index 19435ba..8d47eb8 100644 --- a/apps/admin/src/pages/Settings.tsx +++ b/apps/admin/src/pages/Settings.tsx @@ -34,12 +34,16 @@ export function Settings() {
{SETTING_KEYS.map((k) => ( - setDraft({ ...draft, [k]: typeof draft[k] === 'number' ? Number(e.target.value) : e.target.value })} - /> + {typeof draft[k] === 'boolean' ? ( + setDraft({ ...draft, [k]: e.target.checked })} style={{ width: 22, height: 22 }} /> + ) : ( + setDraft({ ...draft, [k]: typeof draft[k] === 'number' ? Number(e.target.value) : e.target.value })} + /> + )} ))}
diff --git a/apps/api/src/routers/backoffice/chats.ts b/apps/api/src/routers/backoffice/chats.ts index ebcb3a4..cc07a3a 100644 --- a/apps/api/src/routers/backoffice/chats.ts +++ b/apps/api/src/routers/backoffice/chats.ts @@ -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 }; }), diff --git a/apps/api/src/routers/backoffice/index.ts b/apps/api/src/routers/backoffice/index.ts index 86dc37f..1e29779 100644 --- a/apps/api/src/routers/backoffice/index.ts +++ b/apps/api/src/routers/backoffice/index.ts @@ -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, }); diff --git a/apps/api/src/routers/backoffice/orders.ts b/apps/api/src/routers/backoffice/orders.ts new file mode 100644 index 0000000..9881bb1 --- /dev/null +++ b/apps/api/src/routers/backoffice/orders.ts @@ -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, 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([...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; + 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, + 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) : 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 })), + })), + + /** Цена и длительность для клиента (с его скидкой по подписке) */ + 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 } }); + } + }), +}); diff --git a/apps/api/src/routers/backoffice/providers.ts b/apps/api/src/routers/backoffice/providers.ts new file mode 100644 index 0000000..bca09ae --- /dev/null +++ b/apps/api/src/routers/backoffice/providers.ts @@ -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 = (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 }, + }); + }), +}); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index d9f8805..c1ca651 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -163,6 +163,48 @@ app.get<{ Params: { messageId: string } }>('/files/bo/chat-file/:messageId', asy return sendChatFile(reply, m); }); +// ───── исполнители и заказы (этап 3) ───── + +/** Заказы и исполнителей ведёт поддержка; суперадмину — всё */ +const canDispatch = (staff: NonNullable>>) => 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; diff --git a/apps/backoffice/src/App.tsx b/apps/backoffice/src/App.tsx index 14e1eda..b52fa66 100644 --- a/apps/backoffice/src/App.tsx +++ b/apps/backoffice/src/App.tsx @@ -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 = ; else if ((path === '/chats' || threadId) && canChat) page = ; + else if (path === '/orders' && canOrders) page = ; + else if (orderId && canOrders) page = ; + else if (path === '/providers' && canOrders) page = ; + else if (providerId && canOrders) page = ; else if (path === '/clients' && current) page = ; else if (clientId && items.some((n) => n.path === '/clients')) page = ; else if (path === '/team' && isSuper) page = ; diff --git a/apps/backoffice/src/components/kit.tsx b/apps/backoffice/src/components/kit.tsx index 6548e31..7171190 100644 --- a/apps/backoffice/src/components/kit.tsx +++ b/apps/backoffice/src/components/kit.tsx @@ -95,7 +95,7 @@ export function sourceKeyLabel(t: T, key: string) { } /** Модальное окно на : фокус, 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(null); useEffect(() => { const d = ref.current; @@ -106,7 +106,7 @@ export function Dialog({ open, onClose, title, children, footer }: { open: boole return ( { if (e.target === ref.current) onClose(); diff --git a/apps/backoffice/src/i18n.tsx b/apps/backoffice/src/i18n.tsx index 4fc2c6d..8902a25 100644 --- a/apps/backoffice/src/i18n.tsx +++ b/apps/backoffice/src/i18n.tsx @@ -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..name и т.п. */ - shared: (key: string) => string; + /** общие переводы проекта: services..name, roles., params.* и т.п. */ + shared: (key: string, params?: Record) => string; + /** время по Еревану: 14:00 */ + time: (d: Date) => string; + /** общий t проекта целиком — для хелперов @barev/web (paramsSummary и т.п.) */ + sharedT: ReturnType; 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 {children}; diff --git a/apps/backoffice/src/main.tsx b/apps/backoffice/src/main.tsx index 5d917a1..c892561 100644 --- a/apps/backoffice/src/main.tsx +++ b/apps/backoffice/src/main.tsx @@ -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'; diff --git a/apps/backoffice/src/orders.css b/apps/backoffice/src/orders.css new file mode 100644 index 0000000..b482c31 --- /dev/null +++ b/apps/backoffice/src/orders.css @@ -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; + } +} diff --git a/apps/backoffice/src/pages/Chats.tsx b/apps/backoffice/src/pages/Chats.tsx index 9035006..10dc04d 100644 --- a/apps/backoffice/src/pages/Chats.tsx +++ b/apps/backoffice/src/pages/Chats.tsx @@ -528,7 +528,7 @@ function OrderTag({ order }: { order: OrderBrief | null }) { ); return ( - + {text(order)} @@ -545,7 +545,7 @@ function ProviderOrders({ current, next }: { current: OrderBrief | null; next: O {current ? ( <> - {t('chat.orderNow')}: {text(current, true)} + {t('chat.orderNow')}: {text(current, true)} ) : ( {t('chat.noOrderNow')} diff --git a/apps/backoffice/src/pages/ClientCard.tsx b/apps/backoffice/src/pages/ClientCard.tsx index 3415ec7..afebc2e 100644 --- a/apps/backoffice/src/pages/ClientCard.tsx +++ b/apps/backoffice/src/pages/ClientCard.tsx @@ -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 }) {

{t('card.orders')}

- + {canManage && ( + + )}
{d.orders.length === 0 ? (

{t('card.noOrders')}

@@ -200,9 +204,9 @@ export function ClientCard({ id, me }: { id: string; me: Me }) {
    {d.orders.map((o) => (
  • - + №{o.number} - + {shared(`services.${o.service}.name`)} @@ -324,6 +328,7 @@ export function ClientCard({ id, me }: { id: string; me: Me }) { {dlg && setDlg(null)} />} + {newOrder && setNewOrder(false)} />} ); } diff --git a/apps/backoffice/src/pages/Dashboard.tsx b/apps/backoffice/src/pages/Dashboard.tsx index 45407e7..0b78c66 100644 --- a/apps/backoffice/src/pages/Dashboard.tsx +++ b/apps/backoffice/src/pages/Dashboard.tsx @@ -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)' }, ]; diff --git a/apps/backoffice/src/pages/NewOrder.tsx b/apps/backoffice/src/pages/NewOrder.tsx new file mode 100644 index 0000000..0904f7b --- /dev/null +++ b/apps/backoffice/src/pages/NewOrder.tsx @@ -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; + +/** Дата по Еревану в виде 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(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 ( +
    +
    + {days.map((k) => { + const d = atYerevan(k, '12:00'); + return ( + + ); + })} +
    + {!custom ? ( + free.length === 0 ? ( +

    {slots.isFetching ? t('ui.loading') : t('no.noSlots')}

    + ) : ( +
    + {free.map((s) => ( + + ))} +
    + ) + ) : ( + + setHhmm(e.target.value)} step={900} /> + + )} + +
    + ); +} + +/** Новый заказ за клиента: услуга → параметры → адрес → время. Цена — с его скидкой по подписке */ +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({}); + 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 ( + + {quote.data && ( + + {amd(quote.data.total)} + + {' '} + · {t('ord.minutes', { n: quote.data.durationMinutes })} + {quote.data.discountPct > 0 && ` · −${quote.data.discountPct}%`} + + + )} + + + + } + > + {!fixedClient && } + + {(services.data?.length ?? 0) > 1 && ( + + + + )} + + {service && } + + {needsAddress && ( +
    + + setAddress(e.target.value)} placeholder={t('no.addressPh')} /> + + + + +
    + )} + + {ready && ( +
    + {t('no.when')} +
    + +
    +
    + )} + +
    + + setNote(e.target.value)} placeholder={t('no.notePh')} /> + + + + +
    +

    {t('no.hint')}

    +
    + ); +} + +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 ( +
    +
    + {fields + .filter((f) => f.type === 'select' || f.type === 'int') + .map((f) => + f.type === 'select' ? ( + + + + ) : f.type === 'int' ? ( + + set(f.key, n)} /> + + ) : null, + )} +
    + {fields.map((f) => + f.type === 'text' ? ( + +