Desktop POS — Phase 3

Что сделано

Domain layer

  • packages/domain/src/types/customer.ts (new)
    • Customer { id, phone, email, first_name, last_name, ... }
    • QuickCreateCustomerRequest { phone, first_name?, last_name?, email? }
    • customerDisplayName(c) — fallback на phone если имени нет
  • packages/domain/src/types/discount.ts (new)
    • Discount { kind: "percent"|"fixed", value: number, approval_token? }
    • calcDiscountAmount(d, baseTotal) — копейки, ≤ baseTotal
    • applyDiscount(d, baseTotal) — итог после скидки
  • packages/domain/src/types/order.tsOrder.customer_id, CheckoutRequest.customer_id

API client

  • packages/api-client/src/endpoints/customers.ts (new) — searchByPhone (404→null), quickCreate, attach/detachToOrder, getById
  • packages/api-client/src/endpoints/managerAuth.ts (new) — verifyPin(pin, scope) → ApprovalToken
  • packages/api-client/src/endpoints/orders.ts:
    • submit(req, extras?: SubmitExtras) — extras { discount_pct?, approval_token? }; bff body содержит customer_id+discount_pct, approval_token идёт через X-Approval-Token
    • open(req) — customer_id прокидывается
  • packages/api-client/src/endpoints/refunds.tscreate({ ..., approval_token? }); body перекладывается в плоский RefundBody под BFF (original_order_id, client_refund_id, amount, created_at)
  • packages/api-client/src/bff-client.tsRequestOptions.headers для произвольных headers (X-Approval-Token); все методы (get/post/patch/del) принимают opts последним аргументом

Stores

  • stores/cartStore.ts:
    • customer: Customer | null, discount: Discount | null
    • setCustomer, setDiscount
    • subtotal() — без скидки, discountAmount() — копейки скидки, total() — после скидки
    • clear() сбрасывает customer + discount
  • stores/customerStore.ts (new) — кэш byId: Record<string, Customer | null>, load(id) ленивая загрузка через getCustomersEndpoints().getById

Components (new)

  • CustomerAttachModal — поиск по телефону (debounce 400ms, минимум 10 цифр), показ найденного с кнопкой «Прикрепить» либо плейсхолдером «Не найден» + «Создать нового». Mode searchcreate форма (имя/фамилия/email).
  • ManagerPinModal — переиспользуемый. scope ({type, max_amount/pct}), reason (текст в красном bar), onApproved(token). NumericKeypad + 6 PIN dots. verifyPin через api-client.
  • DiscountModal — toggle Процент/Сумма, инпут, live-preview «Сумма заказа / Скидка / Итог». Если discount > maxFreePct (20) — встроенный ManagerPinModal перед onApply (token уходит в Discount.approval_token).

Screens

  • MainScreen:
    • Кнопки + Клиент / + Скидка (рядом, после OrderTypeSwitcher); индикаторы заполнения; кнопка × для отвязки клиента
    • Bar Без скидки vs −X ₽ если скидка применена
    • buildCheckoutItems / buildFiscalCart применяют discountFactor() (= total/subtotal) пропорционально к unit_price
    • handlePay: submit(req, { discount_pct, approval_token }) + requestPayment с customer_name/phone/email из cartStore.customer
    • handleSendToKitchen: customer_id передаётся в open
    • Скрыто в append-mode (нельзя менять клиента/скидку при дозаказе)
  • RefundScreen:
    • Перед submit: если amt/100 > 1000 → ManagerPinModal с scope={refund_amount, max_amount}
    • При onApproved → submitRefund(approval_token)
    • Hint в форме: «Сумма превышает лимит — потребуется PIN менеджера»
  • OrderDetailScreen:
    • Если order.customer_id — Meta «Клиент» через CustomerMeta (фетчит из customerStore.load)
    • Показывает customerDisplayName + phone

Wiring

  • api/client.tsgetCustomersEndpoints(), getManagerAuthEndpoints() factories (mock/real)
  • api/mockEndpoints.ts:
    • 3 mockCustomers с разными форматами phone
    • createMockCustomersEndpoints — поиск через нормализацию \D → digits
    • createMockManagerAuthEndpoints — PIN 9999 → token
    • mock attach/detachToOrder обновляет orders.get(orderId).customer_id

Файлы

Created (8):

  • packages/domain/src/types/customer.ts
  • packages/domain/src/types/discount.ts
  • packages/api-client/src/endpoints/customers.ts
  • packages/api-client/src/endpoints/managerAuth.ts
  • apps/desktop/src/stores/customerStore.ts
  • apps/desktop/src/components/CustomerAttachModal.tsx
  • apps/desktop/src/components/ManagerPinModal.tsx
  • apps/desktop/src/components/DiscountModal.tsx

Modified (10):

  • packages/domain/src/types/index.ts
  • packages/domain/src/types/order.ts
  • packages/api-client/src/index.ts
  • packages/api-client/src/bff-client.ts
  • packages/api-client/src/endpoints/orders.ts
  • packages/api-client/src/endpoints/refunds.ts
  • apps/desktop/src/api/client.ts
  • apps/desktop/src/api/mockEndpoints.ts
  • apps/desktop/src/stores/cartStore.ts
  • apps/desktop/src/screens/MainScreen.tsx
  • apps/desktop/src/screens/RefundScreen.tsx
  • apps/desktop/src/screens/OrderDetailScreen.tsx

Тесты

  • pnpm typecheck — zero errors
  • pnpm exec vite build — passing (196 modules, +8 от Phase 2)
  • Mock-mode E2E (browser):
    • Прикрепить клиента +79161234567 → найден «Иван Петров» → Attached
    • Применить скидку 15% → нет PIN → итог пересчитан
    • Применить скидку 30% → ManagerPinModal → PIN 9999 → applied
    • Refund 1500 ₽ → ManagerPinModal → PIN 9999 → submitted

Ссылки