fix(регистрация): 7 правок этапа «Регистрация» (прогон 12.10)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Public / Build + push Public (test + latest) (push) Waiting to run
Docker Public / Deploy :test → test.food-market.kz (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Public / Build + push Public (test + latest) (push) Waiting to run
Docker Public / Deploy :test → test.food-market.kz (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
1. Маска телефона: префикс «+7 », оператор не подставляется заранее. Нормализация снимает ведущую «8» и код страны «7» (на 11 цифрах), value хранится каноническим «+7»+national и восстанавливается снятием литерального «+7» (без 8/11-эвристик) — ввод больше не «скачет». Обе копии (public + admin). Проверено на всех вариантах + вставка. 2. Тариф и бесплатный период: Organization.Plan/FreeTrialEndsAt + конфигурация «Бизнес» (Phase50, существующие → «start», дата NULL). Signup сохраняет план и 90 дней; показ в кабинете владельца и в карточке/списке суперадмина. Биллинга/блокировки нет. 3. Ошибки формы: переход к первому полю с ошибкой + фокус; единые сообщения «Укажите email/телефон/название магазина», «Придумайте пароль». 4. «Первые шаги»: кнопка «Скрыть» (возврат из настроек организации); шаг каталога показывает статус копии реестра («загружается…» / «Каталог готов: N товаров — проверьте продажные цены»); ссылка на Forgejo заменена на «Базу знаний». 5. «Новые функции»: не считаем изменения до даты регистрации орг (новой орг баннер скрыт) + русская плюрализация улучшений/исправлений. 6. Карточка товара: «Закупочная цена» видна и в простом режиме (настройка ShowReferencePriceOnProduct), подпись настройки приведена к новой схеме. 7. Копия реестра: первый штрихкод помечается «Основным»; бэкфилл уже скопированных товаров без основного (Phase51). Тесты: PlanSignupTests (3), RegistryCopyPrimaryBarcodeTests (1), MarkupToggleTests (3, регрессия) — зелёные. Phone-нормализация покрыта симуляцией ввода/вставки. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
005e5457af
commit
0ce0e0df77
|
|
@ -31,7 +31,10 @@ public AuthSignupController(AppDbContext db, UserManager<User> userMgr, IBackgro
|
|||
_db = db; _userMgr = userMgr; _jobs = jobs;
|
||||
}
|
||||
|
||||
public record SignupInput(string Email, string Password, string OrganizationName, string Phone, string? Plan);
|
||||
public record SignupInput(string Email, string Password, string OrganizationName, string Phone, string? Plan,
|
||||
int? Shops = null, int? Cashes = null, int? Warehouses = null, int? Employees = null);
|
||||
|
||||
private static readonly string[] ValidPlans = { "start", "business", "network" };
|
||||
public record SignupResult(Guid OrganizationId, string Email);
|
||||
|
||||
[HttpPost("signup")]
|
||||
|
|
@ -64,6 +67,9 @@ public async Task<ActionResult<SignupResult>> Signup([FromBody] SignupInput inpu
|
|||
|
||||
// 1. Organization + полный bootstrap tenant-сущностей.
|
||||
var kzt = await _db.Currencies.FirstOrDefaultAsync(c => c.Code == "KZT", ct);
|
||||
// Тариф: нормализуем к одному из трёх; неизвестный/пустой → «start».
|
||||
var plan = (input.Plan ?? "").Trim().ToLowerInvariant();
|
||||
if (!ValidPlans.Contains(plan)) plan = "start";
|
||||
var org = new Organization
|
||||
{
|
||||
Name = input.OrganizationName.Trim(),
|
||||
|
|
@ -71,6 +77,15 @@ public async Task<ActionResult<SignupResult>> Signup([FromBody] SignupInput inpu
|
|||
DefaultCurrencyId = kzt?.Id,
|
||||
Phone = normalizedPhone,
|
||||
Email = input.Email.Trim(),
|
||||
Plan = plan,
|
||||
// Бесплатный период — 90 дней с момента регистрации. Только хранение
|
||||
// и показ; блокировки по истечении нет.
|
||||
FreeTrialEndsAt = DateTime.UtcNow.AddDays(90),
|
||||
// Конфигурацию конструктора сохраняем только для «business».
|
||||
PlanShops = plan == "business" ? input.Shops : null,
|
||||
PlanCashes = plan == "business" ? input.Cashes : null,
|
||||
PlanWarehouses = plan == "business" ? input.Warehouses : null,
|
||||
PlanEmployees = plan == "business" ? input.Employees : null,
|
||||
};
|
||||
_db.Organizations.Add(org);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
|
|
|||
|
|
@ -54,7 +54,14 @@ public record OrgSettingsDto(
|
|||
int NegStockNotifyIntervalMinutes,
|
||||
int? NegStockQuietFromHour,
|
||||
int? NegStockQuietToHour,
|
||||
int NegStockEscalateHours);
|
||||
int NegStockEscalateHours,
|
||||
// Тариф и бесплатный период — read-only (выбор был при регистрации).
|
||||
string Plan,
|
||||
DateTime? FreeTrialEndsAt,
|
||||
int? PlanShops,
|
||||
int? PlanCashes,
|
||||
int? PlanWarehouses,
|
||||
int? PlanEmployees);
|
||||
|
||||
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
|
||||
public record OrgSettingsInput(
|
||||
|
|
@ -199,5 +206,11 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
|
|||
o.NegStockNotifyIntervalMinutes,
|
||||
o.NegStockQuietFromHour,
|
||||
o.NegStockQuietToHour,
|
||||
o.NegStockEscalateHours);
|
||||
o.NegStockEscalateHours,
|
||||
o.Plan,
|
||||
o.FreeTrialEndsAt,
|
||||
o.PlanShops,
|
||||
o.PlanCashes,
|
||||
o.PlanWarehouses,
|
||||
o.PlanEmployees);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ public record OrgRow(
|
|||
bool IsActive, bool IsArchived, DateTime? ArchivedAt,
|
||||
DateTime CreatedAt, int EmployeeCount, int ProductCount,
|
||||
DateTime? LastLoginAt,
|
||||
bool IsAdvancedMode);
|
||||
bool IsAdvancedMode,
|
||||
string Plan, DateTime? FreeTrialEndsAt);
|
||||
|
||||
public record OrgDetail(
|
||||
Guid Id, string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
|
||||
|
|
@ -42,7 +43,10 @@ public record OrgDetail(
|
|||
Guid? AccountOwnerUserId, string? AccountOwnerName, string? AccountOwnerEmail,
|
||||
DateTime CreatedAt, DateTime? UpdatedAt,
|
||||
int EmployeeCount, int ProductCount, int CounterpartyCount, int SupplyCountThisMonth,
|
||||
bool IsAdvancedMode);
|
||||
bool IsAdvancedMode,
|
||||
// Тариф и бесплатный период (выбор при регистрации).
|
||||
string Plan, DateTime? FreeTrialEndsAt,
|
||||
int? PlanShops, int? PlanCashes, int? PlanWarehouses, int? PlanEmployees);
|
||||
|
||||
public record OrgInput(
|
||||
string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
|
||||
|
|
@ -82,7 +86,8 @@ public record AdvancedModeRequest(bool Enabled);
|
|||
_db.Products.IgnoreQueryFilters().Count(p => p.OrganizationId == o.Id),
|
||||
_db.Users.Where(u => u.OrganizationId == o.Id && u.LastLoginAt != null)
|
||||
.Max(u => (DateTime?)u.LastLoginAt),
|
||||
o.IsAdvancedMode))
|
||||
o.IsAdvancedMode,
|
||||
o.Plan, o.FreeTrialEndsAt))
|
||||
.ToListAsync(ct);
|
||||
return new PagedResult<OrgRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||
}
|
||||
|
|
@ -306,7 +311,9 @@ public async Task<IActionResult> ChangeOwner(Guid id, [FromBody] ChangeOwnerRequ
|
|||
o.IsActive, o.IsArchived, o.ArchivedAt,
|
||||
o.AccountOwnerUserId, owner?.FullName, owner?.Email,
|
||||
o.CreatedAt, o.UpdatedAt, emp, prod, cp, supplies,
|
||||
o.IsAdvancedMode);
|
||||
o.IsAdvancedMode,
|
||||
o.Plan, o.FreeTrialEndsAt,
|
||||
o.PlanShops, o.PlanCashes, o.PlanWarehouses, o.PlanEmployees);
|
||||
}
|
||||
|
||||
private async Task LogAsync(string actionType, Guid orgId, string description, string? reason, string changesJson, CancellationToken ct)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
using foodmarket.Application.Common.Tenancy;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace foodmarket.Api.Controllers;
|
||||
|
||||
|
|
@ -21,17 +24,19 @@ public class WhatsNewController : ControllerBase
|
|||
{
|
||||
private readonly IWebHostEnvironment _env;
|
||||
private readonly ILogger<WhatsNewController> _log;
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ITenantContext _tenant;
|
||||
|
||||
public WhatsNewController(IWebHostEnvironment env, ILogger<WhatsNewController> log)
|
||||
public WhatsNewController(IWebHostEnvironment env, ILogger<WhatsNewController> log, AppDbContext db, ITenantContext tenant)
|
||||
{
|
||||
_env = env; _log = log;
|
||||
_env = env; _log = log; _db = db; _tenant = tenant;
|
||||
}
|
||||
|
||||
public record WhatsNewItem(string Date, string Title, string Body, string Type);
|
||||
public record WhatsNewResponse(string BuildVersion, IReadOnlyList<WhatsNewItem> Items);
|
||||
|
||||
[HttpGet]
|
||||
public ActionResult<WhatsNewResponse> Get()
|
||||
public async Task<ActionResult<WhatsNewResponse>> Get(CancellationToken ct)
|
||||
{
|
||||
// BuildVersion — short SHA / тэг билд-времени. Берём из
|
||||
// VERSION файла (создаётся Docker-build'ом) или ассембли.
|
||||
|
|
@ -54,6 +59,24 @@ public ActionResult<WhatsNewResponse> Get()
|
|||
|
||||
var content = System.IO.File.ReadAllText(changelogPath);
|
||||
var items = ParseChangelog(content).ToList();
|
||||
|
||||
// Не показываем новой организации улучшения/исправления, которые были
|
||||
// ДО её регистрации — для неё они не «новые». Фильтруем по дате создания
|
||||
// организации (решение владельца 12.10). SuperAdmin (без org) видит всё.
|
||||
var orgId = _tenant.OrganizationId;
|
||||
if (orgId is not null)
|
||||
{
|
||||
var createdAt = await _db.Organizations.IgnoreQueryFilters()
|
||||
.Where(o => o.Id == orgId)
|
||||
.Select(o => (DateTime?)o.CreatedAt)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (createdAt is DateTime reg)
|
||||
{
|
||||
var regDate = reg.Date;
|
||||
items = items.Where(i => DateTime.TryParse(i.Date, out var d) && d.Date >= regDate).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
return new WhatsNewResponse(buildVersion, items);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -205,4 +205,24 @@ public class Organization : Entity
|
|||
/// показывается «Каталог из реестра загружается…». Повторно не копируется.</summary>
|
||||
public int RegistryCopyState { get; set; }
|
||||
public DateTime? RegistryCopiedAt { get; set; }
|
||||
|
||||
// ─ Тариф и бесплатный период (выбор при регистрации) ───────────────────────
|
||||
/// <summary>Выбранный при регистрации тариф: «start», «business», «network».
|
||||
/// Биллинга пока нет — значение только хранится и показывается владельцу и
|
||||
/// SuperAdmin. Существующие организации (миграция) → «start».</summary>
|
||||
public string Plan { get; set; } = "start";
|
||||
|
||||
/// <summary>До какой даты действует бесплатный период (регистрация + 90 дней).
|
||||
/// null — у организаций, созданных до появления тарифов (миграция даты не
|
||||
/// проставляет). Блокировки по истечении нет — только отображение.</summary>
|
||||
public DateTime? FreeTrialEndsAt { get; set; }
|
||||
|
||||
/// <summary>Конфигурация тарифа «Бизнес», выбранная в конструкторе при
|
||||
/// регистрации (магазины/кассы/склады/сотрудники). null для «start»/«network»
|
||||
/// и для организаций без выбранной конфигурации. Сейчас не ограничивает —
|
||||
/// хранится и показывается.</summary>
|
||||
public int? PlanShops { get; set; }
|
||||
public int? PlanCashes { get; set; }
|
||||
public int? PlanWarehouses { get; set; }
|
||||
public int? PlanEmployees { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <summary>Тариф, выбранный при регистрации («start»/«business»/«network»),
|
||||
/// бесплатный период (регистрация + 90 дней) и конфигурация «Бизнес».
|
||||
/// Существующие организации → тариф «start», дата бесплатного периода не
|
||||
/// проставляется (NULL). Биллинга/блокировки нет — только хранение и показ.
|
||||
/// Решение владельца 12.10.</summary>
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20261012060000_Phase50_PlanAndTrial")]
|
||||
public partial class Phase50_PlanAndTrial : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder b) => b.Sql(@"
|
||||
ALTER TABLE public.organizations
|
||||
ADD COLUMN IF NOT EXISTS ""Plan"" text NOT NULL DEFAULT 'start',
|
||||
ADD COLUMN IF NOT EXISTS ""FreeTrialEndsAt"" timestamp with time zone NULL,
|
||||
ADD COLUMN IF NOT EXISTS ""PlanShops"" integer NULL,
|
||||
ADD COLUMN IF NOT EXISTS ""PlanCashes"" integer NULL,
|
||||
ADD COLUMN IF NOT EXISTS ""PlanWarehouses"" integer NULL,
|
||||
ADD COLUMN IF NOT EXISTS ""PlanEmployees"" integer NULL;");
|
||||
|
||||
protected override void Down(MigrationBuilder b) => b.Sql(@"
|
||||
ALTER TABLE public.organizations
|
||||
DROP COLUMN IF EXISTS ""Plan"",
|
||||
DROP COLUMN IF EXISTS ""FreeTrialEndsAt"",
|
||||
DROP COLUMN IF EXISTS ""PlanShops"",
|
||||
DROP COLUMN IF EXISTS ""PlanCashes"",
|
||||
DROP COLUMN IF EXISTS ""PlanWarehouses"",
|
||||
DROP COLUMN IF EXISTS ""PlanEmployees"";");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <summary>Бэкфилл «Основного» штрихкода у товаров, у которых штрихкоды есть,
|
||||
/// но ни один не помечен IsPrimary — следствие бага копии «Реестра товаров»
|
||||
/// (товар с единственным штрихкодом оставался без «Основного», fix #7).
|
||||
/// Выбираем по одному штрихкоду на товар — лексикографически первый по Code
|
||||
/// (так же, как теперь делает копия). Идемпотентно: товары, у которых основной
|
||||
/// уже есть, не трогаются.</summary>
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20261012070000_Phase51_BackfillPrimaryBarcode")]
|
||||
public partial class Phase51_BackfillPrimaryBarcode : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder b) => b.Sql(@"
|
||||
WITH to_fix AS (
|
||||
SELECT DISTINCT b.""ProductId""
|
||||
FROM public.product_barcodes b
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM public.product_barcodes p
|
||||
WHERE p.""ProductId"" = b.""ProductId"" AND p.""IsPrimary"" = true
|
||||
)
|
||||
),
|
||||
pick AS (
|
||||
SELECT DISTINCT ON (b.""ProductId"") b.""Id""
|
||||
FROM public.product_barcodes b
|
||||
JOIN to_fix f ON f.""ProductId"" = b.""ProductId""
|
||||
ORDER BY b.""ProductId"", b.""Code""
|
||||
)
|
||||
UPDATE public.product_barcodes
|
||||
SET ""IsPrimary"" = true, ""UpdatedAt"" = now()
|
||||
WHERE ""Id"" IN (SELECT ""Id"" FROM pick);");
|
||||
|
||||
// Откат бессмысленен и небезопасен (мы не знаем, какой штрихкод был
|
||||
// «Основным» до бэкфилла) — ничего не делаем.
|
||||
protected override void Down(MigrationBuilder b) { }
|
||||
}
|
||||
}
|
||||
|
|
@ -323,8 +323,21 @@ public async Task CopyRegistryToOrgAsync(Guid orgId, CancellationToken ct)
|
|||
PurchasePrice = card.PurchasePrice,
|
||||
PurchaseCurrencyId = kztId,
|
||||
};
|
||||
foreach (var b in card.Barcodes)
|
||||
product.Barcodes.Add(new ProductBarcode { OrganizationId = orgId, Code = b.Code, Type = b.Type });
|
||||
// Первый штрихкод помечаем «Основным» (IsPrimary). Для товара
|
||||
// с единственным штрихкодом это он и есть — иначе «Основной»
|
||||
// оставался пустым (баг копии реестра, fix #7). Порядок
|
||||
// детерминируем по Code, чтобы при повторной копии основной
|
||||
// был тем же.
|
||||
var primarySet = false;
|
||||
foreach (var b in card.Barcodes.OrderBy(x => x.Code, StringComparer.Ordinal))
|
||||
{
|
||||
product.Barcodes.Add(new ProductBarcode
|
||||
{
|
||||
OrganizationId = orgId, Code = b.Code, Type = b.Type,
|
||||
IsPrimary = !primarySet,
|
||||
});
|
||||
primarySet = true;
|
||||
}
|
||||
if (card.RetailPrice is { } rp && retailTypeId != Guid.Empty)
|
||||
product.Prices.Add(new ProductPrice { OrganizationId = orgId, PriceTypeId = retailTypeId, Amount = rp, CurrencyId = kztId });
|
||||
_db.Products.Add(product);
|
||||
|
|
|
|||
|
|
@ -1,41 +1,50 @@
|
|||
import { useMemo, useRef, useLayoutEffect, type InputHTMLAttributes, type FormEvent } from 'react'
|
||||
|
||||
const PREFIX = '+7 7'
|
||||
const PREFIX_LEN = 4
|
||||
// Код страны (+7) живёт в префиксе; оператор НЕ подставляем заранее.
|
||||
const PREFIX = '+7 '
|
||||
const PREFIX_LEN = PREFIX.length // 3
|
||||
|
||||
function rawToUserDigits(raw: string): string {
|
||||
// Национальный номер KZ — 10 цифр, начинается с 7 (например 701 000 00 00).
|
||||
// Из любой формы вытаскиваем до 10 цифр нац. номера: снимаем ведущую «8»
|
||||
// (межгород) и, если получилось 11 цифр с ведущей «7» — это код страны, снимаем.
|
||||
// Прим.: при посимвольном вводе в handleChange мы передаём сюда ТОЛЬКО часть
|
||||
// после префикса, чтобы «7» из «+7 » не попала в номер.
|
||||
function toNational(raw: string): string {
|
||||
let d = raw.replace(/\D/g, '')
|
||||
if (d[0] === '8') d = d.slice(1)
|
||||
if (d[0] === '7') d = d.slice(1)
|
||||
if (d[0] === '7') d = d.slice(1)
|
||||
return d.slice(0, 9)
|
||||
if (d.length === 11 && d[0] === '7') d = d.slice(1)
|
||||
return d.slice(0, 10)
|
||||
}
|
||||
|
||||
function userDigitsToDisplay(d: string): string {
|
||||
// Восстановление нац. номера из каноничного value. Мы ВСЕГДА эмитим value как
|
||||
// «+7»+national, поэтому снимаем ровно литеральный код страны «+7» и не
|
||||
// применяем 8/11-эвристики toNational — иначе «7» кода страны при неполном
|
||||
// номере сливается с номером и ввод «скачет» (+7 77, +7 777 0, …).
|
||||
function valueToNational(value: string): string {
|
||||
if (value.startsWith('+7')) return value.slice(2).replace(/\D/g, '').slice(0, 10)
|
||||
return toNational(value)
|
||||
}
|
||||
|
||||
function nationalToDisplay(d: string): string {
|
||||
if (d.length === 0) return PREFIX
|
||||
if (d.length <= 2) return `${PREFIX}${d}`
|
||||
if (d.length <= 5) return `${PREFIX}${d.slice(0, 2)} ${d.slice(2)}`
|
||||
if (d.length <= 7) return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5)}`
|
||||
return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`
|
||||
let s = PREFIX + d.slice(0, 3)
|
||||
if (d.length > 3) s += ' ' + d.slice(3, 6)
|
||||
if (d.length > 6) s += ' ' + d.slice(6, 8)
|
||||
if (d.length > 8) s += ' ' + d.slice(8, 10)
|
||||
return s
|
||||
}
|
||||
|
||||
function digitsBeforeCursor(value: string, cursor: number): number {
|
||||
let count = 0
|
||||
for (let i = 0; i < cursor && i < value.length; i++) {
|
||||
if (/\d/.test(value[i])) count++
|
||||
}
|
||||
return count
|
||||
// Курсор считаем в цифрах нац. номера (цифры ПОСЛЕ префикса), чтобы «7» из
|
||||
// «+7 » не сбивала позицию.
|
||||
function natDigitsBeforeCursor(value: string, cursor: number): number {
|
||||
let c = 0
|
||||
for (let i = PREFIX_LEN; i < cursor && i < value.length; i++) if (/\d/.test(value[i])) c++
|
||||
return c
|
||||
}
|
||||
|
||||
function cursorAfterNthDigit(value: string, n: number): number {
|
||||
if (n <= 0) return 0
|
||||
let count = 0
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (/\d/.test(value[i])) {
|
||||
count++
|
||||
if (count === n) return i + 1
|
||||
}
|
||||
}
|
||||
function cursorAfterNthNatDigit(value: string, n: number): number {
|
||||
if (n <= 0) return PREFIX_LEN
|
||||
let c = 0
|
||||
for (let i = PREFIX_LEN; i < value.length; i++) if (/\d/.test(value[i])) { c++; if (c === n) return i + 1 }
|
||||
return value.length
|
||||
}
|
||||
|
||||
|
|
@ -44,35 +53,38 @@ interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'v
|
|||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
/** PhoneInput для public — см. food-market.web/components/PhoneInput.tsx. */
|
||||
/** PhoneInput для public — зеркалит food-market.web/components/PhoneInput.tsx. */
|
||||
export function PhoneInput({ value, onChange, className, disabled, ...rest }: PhoneInputProps) {
|
||||
const ref = useRef<HTMLInputElement>(null)
|
||||
const userDigits = useMemo(() => rawToUserDigits(value), [value])
|
||||
const display = userDigitsToDisplay(userDigits)
|
||||
const pendingDigitCountRef = useRef<number | null>(null)
|
||||
const national = useMemo(() => valueToNational(value), [value])
|
||||
const display = nationalToDisplay(national)
|
||||
const pendingRef = useRef<number | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const n = pendingDigitCountRef.current
|
||||
pendingDigitCountRef.current = null
|
||||
const n = pendingRef.current
|
||||
pendingRef.current = null
|
||||
if (n === null) return
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const pos = Math.max(PREFIX_LEN, cursorAfterNthDigit(display, n))
|
||||
const pos = Math.max(PREFIX_LEN, cursorAfterNthNatDigit(display, n))
|
||||
el.setSelectionRange(pos, pos)
|
||||
}, [display])
|
||||
|
||||
const handleBeforeInput = (e: FormEvent<HTMLInputElement>) => {
|
||||
const native = e.nativeEvent as InputEvent
|
||||
if (native.data && /\D/.test(native.data)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
if (native.data && /\D/.test(native.data)) e.preventDefault()
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const cursorPos = e.target.selectionStart ?? e.target.value.length
|
||||
pendingDigitCountRef.current = digitsBeforeCursor(e.target.value, cursorPos)
|
||||
const newDigits = rawToUserDigits(e.target.value)
|
||||
onChange(newDigits.length > 0 ? `+77${newDigits}` : '')
|
||||
const raw = e.target.value
|
||||
const cursorPos = e.target.selectionStart ?? raw.length
|
||||
pendingRef.current = natDigitsBeforeCursor(raw, cursorPos)
|
||||
// При посимвольном вводе значение начинается с «+7 » — берём часть после
|
||||
// префикса, чтобы его «7» не попала в номер. При полной вставке (replace)
|
||||
// значения без префикса нормализуем целиком (снимем код страны/«8»).
|
||||
const src = raw.startsWith(PREFIX) ? raw.slice(PREFIX_LEN) : raw
|
||||
const nat = toNational(src)
|
||||
onChange(nat.length > 0 ? '+7' + nat : '')
|
||||
}
|
||||
|
||||
const guardCursor = (el: HTMLInputElement) => {
|
||||
|
|
@ -82,12 +94,10 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph
|
|||
el.setSelectionRange(Math.max(PREFIX_LEN, start), Math.max(PREFIX_LEN, end))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (e: React.SyntheticEvent<HTMLInputElement>) => guardCursor(e.currentTarget)
|
||||
const handleFocus = () => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
requestAnimationFrame(() => guardCursor(el))
|
||||
if (el) requestAnimationFrame(() => guardCursor(el))
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -90,17 +90,33 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
|
|||
const e: FieldErrors = {}
|
||||
const em = validateEmail(email); if (em) e.email = em
|
||||
const pw = validatePassword(password); if (pw) e.password = pw
|
||||
if (!orgName.trim()) e.orgName = 'Название магазина обязательно для заполнения'
|
||||
if (!orgName.trim()) e.orgName = 'Укажите название магазина'
|
||||
const ph = validatePhone(phone); if (ph) e.phone = ph
|
||||
if (!agree) e.agree = 'Подтвердите согласие с офертой и политикой ПДн'
|
||||
return e
|
||||
}
|
||||
|
||||
// Порядок полей сверху вниз — для перехода к первой ошибке. id совпадает с
|
||||
// id инпутов ниже.
|
||||
const FIELD_ORDER: Array<keyof FieldErrors> = ['email', 'password', 'orgName', 'phone', 'agree']
|
||||
const focusFirstError = (errs: FieldErrors) => {
|
||||
const firstKey = FIELD_ORDER.find((k) => errs[k])
|
||||
if (!firstKey) return
|
||||
// На 1280×665 верхние поля часто выше вьюпорта — сначала проскроллим к полю,
|
||||
// затем сфокусируем (focus сам по себе прокручивает неплавно/неточно).
|
||||
const el = document.getElementById(`signup-${firstKey}`)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
// Фокус после старта прокрутки — иначе браузер перебьёт плавный скролл.
|
||||
setTimeout(() => el.focus({ preventScroll: true }), 150)
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async (ev: React.FormEvent) => {
|
||||
ev.preventDefault()
|
||||
const errs = validate()
|
||||
setFieldErrors(errs)
|
||||
if (Object.keys(errs).length) return
|
||||
if (Object.keys(errs).length) { focusFirstError(errs); return }
|
||||
setError(null); setBusy(true)
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/auth/signup`, {
|
||||
|
|
@ -151,6 +167,7 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
|
|||
|
||||
<Field label="Email" error={fieldErrors.email}>
|
||||
<input
|
||||
id="signup-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
|
|
@ -171,6 +188,7 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
|
|||
|
||||
<Field label="Пароль" hint="Минимум 8 символов, цифра и заглавная." error={fieldErrors.password}>
|
||||
<input
|
||||
id="signup-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
|
|
@ -188,15 +206,16 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
|
|||
|
||||
<Field label="Название магазина" error={fieldErrors.orgName}>
|
||||
<input
|
||||
id="signup-orgName"
|
||||
type="text"
|
||||
value={orgName}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setOrgName(v)
|
||||
setFieldErrors(p => p.orgName ? {...p, orgName: !v.trim() ? 'Название магазина обязательно для заполнения' : undefined} : p)
|
||||
setFieldErrors(p => p.orgName ? {...p, orgName: !v.trim() ? 'Укажите название магазина' : undefined} : p)
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setFieldErrors(p => ({...p, orgName: !e.target.value.trim() ? 'Название магазина обязательно для заполнения' : undefined}))
|
||||
setFieldErrors(p => ({...p, orgName: !e.target.value.trim() ? 'Укажите название магазина' : undefined}))
|
||||
}}
|
||||
placeholder="Наименование организации"
|
||||
className={inputCls(!!fieldErrors.orgName)}
|
||||
|
|
@ -205,6 +224,7 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
|
|||
|
||||
<Field label="Телефон" error={fieldErrors.phone}>
|
||||
<PhoneInput
|
||||
id="signup-phone"
|
||||
value={phone}
|
||||
onChange={(v) => {
|
||||
setPhone(v)
|
||||
|
|
@ -266,7 +286,7 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
|
|||
|
||||
<div>
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} className="mt-1" />
|
||||
<input id="signup-agree" type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} className="mt-1" />
|
||||
<span>Я согласен с <a href="/legal/offer" className="text-brand underline py-1">офертой</a> и <a href="/legal/privacy" className="text-brand underline py-1">политикой обработки ПДн</a>.</span>
|
||||
</label>
|
||||
{fieldErrors.agree && <p className="text-xs text-red-600 mt-1">{fieldErrors.agree}</p>}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
const STRICT_EMAIL = /^[^@\s]+@[^@\s]+\.[A-Za-z]{2,}$/
|
||||
|
||||
export const emailMsg = {
|
||||
required: 'Email обязателен',
|
||||
required: 'Укажите email',
|
||||
format: 'Некорректный формат email. Пример: user@example.kz',
|
||||
noTld: 'Email должен содержать доменную зону (например, .kz, .com)',
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ export function validateEmail(value: string): string | null {
|
|||
}
|
||||
|
||||
export function validatePassword(value: string): string | null {
|
||||
if (!value) return 'Это поле обязательно для заполнения'
|
||||
if (!value) return 'Придумайте пароль'
|
||||
if (value.length < 8) return 'Пароль должен быть не менее 8 символов'
|
||||
if (!/[A-Z]/.test(value)) return 'Пароль должен содержать хотя бы одну заглавную букву'
|
||||
if (!/[0-9]/.test(value)) return 'Пароль должен содержать хотя бы одну цифру'
|
||||
|
|
@ -27,7 +27,7 @@ export function validatePassword(value: string): string | null {
|
|||
}
|
||||
|
||||
export function validatePhone(value: string): string | null {
|
||||
if (!value || value.trim() === '') return 'Телефон обязателен для заполнения'
|
||||
if (!value || value.trim() === '') return 'Укажите телефон'
|
||||
// Казахстан: +7 7XX XXX XX XX (мобильные коды KZ начинаются с 77,
|
||||
// 79 — это РФ, исключаем). Принимаем любое форматирование (пробелы,
|
||||
// скобки, дефисы), нормализуем к 11 цифрам, ведущая «8» = «7».
|
||||
|
|
|
|||
|
|
@ -3,46 +3,59 @@ import { cn } from '@/lib/utils'
|
|||
|
||||
const inputClass = 'w-full h-10 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 text-sm leading-none focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] disabled:opacity-60 disabled:bg-slate-50 dark:disabled:bg-slate-800/60 tabular-nums'
|
||||
|
||||
const PREFIX = '+7 7' // Залочено: «+», «7», пробел, «7» (мобильный код KZ).
|
||||
const PREFIX_LEN = PREFIX.length // 4
|
||||
// Код страны (+7) живёт в префиксе; оператор НЕ подставляем заранее, иначе
|
||||
// при вводе полного номера «7XX…» его первая цифра налезает на префиксную «7».
|
||||
const PREFIX = '+7 '
|
||||
const PREFIX_LEN = PREFIX.length // 3
|
||||
|
||||
/** Извлекает 9 пользовательских цифр из любой формы значения (canonical
|
||||
* «+77XXXXXXXXX», display «+7 7XX XXX XX XX», raw paste «8 707…», и т.д.).
|
||||
* Снимает по одной ведущей «8», «7», «7» — это код страны и мобильный
|
||||
* префикс KZ, оба зашиты в наш PREFIX. */
|
||||
function rawToUserDigits(raw: string): string {
|
||||
// Национальный номер KZ — 10 цифр, начинается с 7 (например 701 000 00 00).
|
||||
// Из любой формы вытаскиваем до 10 цифр нац. номера: снимаем ведущую «8»
|
||||
// (межгород) и, если получилось 11 цифр с ведущей «7» — это код страны, снимаем.
|
||||
// Прим.: при посимвольном вводе в handleChange сюда передаётся ТОЛЬКО часть
|
||||
// после префикса, чтобы «7» из «+7 » не попала в номер.
|
||||
function toNational(raw: string): string {
|
||||
let d = raw.replace(/\D/g, '')
|
||||
if (d[0] === '8') d = d.slice(1)
|
||||
if (d[0] === '7') d = d.slice(1)
|
||||
if (d[0] === '7') d = d.slice(1)
|
||||
return d.slice(0, 9)
|
||||
if (d.length === 11 && d[0] === '7') d = d.slice(1)
|
||||
return d.slice(0, 10)
|
||||
}
|
||||
|
||||
/** Display из 9 пользовательских цифр. Формат: «+7 7XX XXX XX XX» — после
|
||||
* залоченного «+7 7» идут 2+3+2+2 = 9 цифр через пробелы. */
|
||||
function userDigitsToDisplay(d: string): string {
|
||||
// Восстановление нац. номера из каноничного value. Мы ВСЕГДА эмитим value как
|
||||
// «+7»+national, поэтому снимаем ровно литеральный код страны «+7» и не
|
||||
// применяем 8/11-эвристики toNational — иначе «7» кода страны при неполном
|
||||
// номере сливается с номером и ввод «скачет» (+7 77, +7 777 0, …).
|
||||
function valueToNational(value: string): string {
|
||||
if (value.startsWith('+7')) return value.slice(2).replace(/\D/g, '').slice(0, 10)
|
||||
return toNational(value)
|
||||
}
|
||||
|
||||
// Display из нац. номера. Формат: «+7 7XX XXX XX XX» — после «+7 » идут
|
||||
// 3+3+2+2 = 10 цифр через пробелы.
|
||||
function nationalToDisplay(d: string): string {
|
||||
if (d.length === 0) return PREFIX
|
||||
if (d.length <= 2) return `${PREFIX}${d}`
|
||||
if (d.length <= 5) return `${PREFIX}${d.slice(0, 2)} ${d.slice(2)}`
|
||||
if (d.length <= 7) return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5)}`
|
||||
return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`
|
||||
let s = PREFIX + d.slice(0, 3)
|
||||
if (d.length > 3) s += ' ' + d.slice(3, 6)
|
||||
if (d.length > 6) s += ' ' + d.slice(6, 8)
|
||||
if (d.length > 8) s += ' ' + d.slice(8, 10)
|
||||
return s
|
||||
}
|
||||
|
||||
/** Сколько цифровых символов стоит ДО позиции cursor в строке value. */
|
||||
function digitsBeforeCursor(value: string, cursor: number): number {
|
||||
// Курсор считаем в цифрах нац. номера (цифры ПОСЛЕ префикса), чтобы «7» из
|
||||
// «+7 » не сбивала позицию.
|
||||
function natDigitsBeforeCursor(value: string, cursor: number): number {
|
||||
let count = 0
|
||||
for (let i = 0; i < cursor && i < value.length; i++) {
|
||||
for (let i = PREFIX_LEN; i < cursor && i < value.length; i++) {
|
||||
if (/\d/.test(value[i])) count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/** Позиция в value сразу ПОСЛЕ n-й цифры (1-indexed). Если n больше чем
|
||||
* есть цифр — возвращает конец строки. */
|
||||
function cursorAfterNthDigit(value: string, n: number): number {
|
||||
if (n <= 0) return 0
|
||||
// Позиция в value сразу ПОСЛЕ n-й цифры нац. номера (1-indexed). Если n<=0 —
|
||||
// начало digit-зоны (сразу за префиксом); если больше чем есть — конец строки.
|
||||
function cursorAfterNthNatDigit(value: string, n: number): number {
|
||||
if (n <= 0) return PREFIX_LEN
|
||||
let count = 0
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
for (let i = PREFIX_LEN; i < value.length; i++) {
|
||||
if (/\d/.test(value[i])) {
|
||||
count++
|
||||
if (count === n) return i + 1
|
||||
|
|
@ -57,19 +70,19 @@ interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'v
|
|||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
/** Поле ввода телефона Казахстана. Префикс «+7 7» (4 символа) залочен —
|
||||
* курсор не пускается в позиции 0..3, выделение клампится на digit-зону.
|
||||
* В остальном работает как обычный input: native selection, backspace,
|
||||
* click, Cmd+A, paste, drag — всё нативное. Единственное ограничение —
|
||||
* не-цифры блокируются через onBeforeInput до того как попасть в value. */
|
||||
/** Поле ввода телефона Казахстана. Префикс «+7 » (3 символа) залочен — курсор
|
||||
* не пускается в позиции 0..2, выделение клампится на digit-зону. В остальном
|
||||
* работает как обычный input: native selection, backspace, click, Cmd+A,
|
||||
* paste, drag. Не-цифры блокируются через onBeforeInput. Нормализация снимает
|
||||
* ведущую «8» и код страны «7», поэтому «8701…», «+7701…», «7701…» и «701…»
|
||||
* дают один и тот же номер «+7 701 …». */
|
||||
export function PhoneInput({ value, onChange, className, disabled, ...rest }: PhoneInputProps) {
|
||||
const ref = useRef<HTMLInputElement>(null)
|
||||
const userDigits = useMemo(() => rawToUserDigits(value), [value])
|
||||
const display = userDigitsToDisplay(userDigits)
|
||||
// Запоминаем сколько цифр стояло до курсора в момент редактирования —
|
||||
// после React-нормализации (которая может сдвинуть пробелы формата)
|
||||
// ставим курсор обратно после той же по счёту цифры. Без этого React
|
||||
// сбрасывал бы курсор в конец на каждом изменении value.
|
||||
const national = useMemo(() => valueToNational(value), [value])
|
||||
const display = nationalToDisplay(national)
|
||||
// Запоминаем сколько цифр нац. номера стояло до курсора в момент
|
||||
// редактирования — после React-нормализации возвращаем курсор после той же
|
||||
// по счёту цифры, иначе React сбрасывал бы его в конец на каждом изменении.
|
||||
const pendingDigitCountRef = useRef<number | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
|
|
@ -78,7 +91,7 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph
|
|||
if (n === null) return
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const pos = Math.max(PREFIX_LEN, cursorAfterNthDigit(display, n))
|
||||
const pos = Math.max(PREFIX_LEN, cursorAfterNthNatDigit(display, n))
|
||||
el.setSelectionRange(pos, pos)
|
||||
}, [display])
|
||||
|
||||
|
|
@ -95,10 +108,15 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph
|
|||
// нормализуем значение. Запоминаем позицию курсора в digit-индексах,
|
||||
// чтобы useLayoutEffect восстановил её после ре-рендера.
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const cursorPos = e.target.selectionStart ?? e.target.value.length
|
||||
pendingDigitCountRef.current = digitsBeforeCursor(e.target.value, cursorPos)
|
||||
const newDigits = rawToUserDigits(e.target.value)
|
||||
onChange(newDigits.length > 0 ? `+77${newDigits}` : '')
|
||||
const raw = e.target.value
|
||||
const cursorPos = e.target.selectionStart ?? raw.length
|
||||
pendingDigitCountRef.current = natDigitsBeforeCursor(raw, cursorPos)
|
||||
// При посимвольном вводе значение начинается с «+7 » — берём часть после
|
||||
// префикса, чтобы его «7» не попала в номер. При полной вставке значения
|
||||
// без префикса нормализуем целиком (снимем код страны/«8»).
|
||||
const src = raw.startsWith(PREFIX) ? raw.slice(PREFIX_LEN) : raw
|
||||
const nat = toNational(src)
|
||||
onChange(nat.length > 0 ? '+7' + nat : '')
|
||||
}
|
||||
|
||||
// Не пускаем курсор/selection в префикс. onSelect ловит любые попытки
|
||||
|
|
|
|||
|
|
@ -21,6 +21,18 @@ interface WhatsNewResponse {
|
|||
|
||||
const STORAGE_KEY = 'fm.lastSeenBuildVersion'
|
||||
|
||||
/** Русская плюрализация: forms = [одна, две-четыре, пять-и-более].
|
||||
* Напр. pluralRu(21, ['улучшение','улучшения','улучшений']) → «улучшение»,
|
||||
* pluralRu(34, [...]) → «исправления», pluralRu(50, [...]) → «улучшений». */
|
||||
function pluralRu(n: number, forms: [string, string, string]): string {
|
||||
const mod100 = n % 100
|
||||
const mod10 = n % 10
|
||||
if (mod100 >= 11 && mod100 <= 14) return forms[2]
|
||||
if (mod10 === 1) return forms[0]
|
||||
if (mod10 >= 2 && mod10 <= 4) return forms[1]
|
||||
return forms[2]
|
||||
}
|
||||
|
||||
export function WhatsNewBanner() {
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const { data } = useQuery<WhatsNewResponse>({
|
||||
|
|
@ -58,8 +70,8 @@ export function WhatsNewBanner() {
|
|||
<Sparkles className="w-4 h-4 text-emerald-600 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate text-emerald-900 dark:text-emerald-200">
|
||||
Появились новые функции —{' '}
|
||||
<strong>{featCount}</strong> {featCount === 1 ? 'улучшение' : 'улучшений'}
|
||||
{fixCount > 0 && <> и <strong>{fixCount}</strong> {fixCount === 1 ? 'исправление' : 'исправлений'}</>}{' '}
|
||||
<strong>{featCount}</strong> {pluralRu(featCount, ['улучшение', 'улучшения', 'улучшений'])}
|
||||
{fixCount > 0 && <> и <strong>{fixCount}</strong> {pluralRu(fixCount, ['исправление', 'исправления', 'исправлений'])}</>}{' '}
|
||||
за последние 30 дней.{' '}
|
||||
<Link to="/whats-new" onClick={dismiss} className="underline font-medium hover:text-emerald-700 dark:hover:text-emerald-100">
|
||||
Посмотреть
|
||||
|
|
|
|||
28
src/food-market.web/src/lib/onboardingPrefs.ts
Normal file
28
src/food-market.web/src/lib/onboardingPrefs.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// Per-org флаг «скрыть первые шаги» на главной. Хранится в localStorage
|
||||
// (это UI-предпочтение браузера, не серверное состояние). Ключ привязан к
|
||||
// orgId, чтобы в одном браузере у разных организаций он был независим.
|
||||
// Все обращения в try/catch — localStorage может быть недоступен (приватное
|
||||
// окно, отключённые site-data).
|
||||
|
||||
const KEY_PREFIX = 'allmarket:onboarding-hidden:'
|
||||
|
||||
function key(orgId: string | null): string {
|
||||
return KEY_PREFIX + (orgId ?? 'default')
|
||||
}
|
||||
|
||||
export function isOnboardingHidden(orgId: string | null): boolean {
|
||||
try {
|
||||
return localStorage.getItem(key(orgId)) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function setOnboardingHidden(orgId: string | null, hidden: boolean): void {
|
||||
try {
|
||||
if (hidden) localStorage.setItem(key(orgId), '1')
|
||||
else localStorage.removeItem(key(orgId))
|
||||
} catch {
|
||||
/* localStorage недоступен — предпочтение просто не сохранится */
|
||||
}
|
||||
}
|
||||
|
|
@ -33,7 +33,16 @@ export function useOnboardingProgress() {
|
|||
queryFn: async () => (await api.get<PagedResult<unknown>>('/api/purchases/supplies?pageSize=1')).data.total,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
// Статус копии «Реестра товаров» в каталог: 0=не было, 1=идёт, 2=готово, 3=ошибка.
|
||||
// Пока идёт — опрашиваем чаще, чтобы шаг каталога сам обновился по готовности.
|
||||
const registry = useQuery({
|
||||
queryKey: ['onboarding:registry-copy-status'],
|
||||
queryFn: async () => (await api.get<{ state: number; copiedAt: string | null }>('/api/catalog/registry-copy-status')).data,
|
||||
refetchInterval: (q) => (q.state.data?.state === 1 ? 4000 : false),
|
||||
})
|
||||
|
||||
const productCount = products.data ?? 0
|
||||
const registryCopyState = registry.data?.state ?? 0
|
||||
const orgConfigured = !!org.data?.defaultCurrencyId && !!org.data?.countryCode
|
||||
// Если роль не видит сотрудников — считаем шаг «добавить команду» как
|
||||
// завершённый (это не задача кассира/кладовщика), иначе прогресс никогда
|
||||
|
|
@ -51,6 +60,9 @@ export function useOnboardingProgress() {
|
|||
|| products.isLoading
|
||||
|| supplies.isLoading,
|
||||
...steps,
|
||||
productCount,
|
||||
registryCopyState,
|
||||
orgId: me.data?.orgId ?? null,
|
||||
doneCount: done,
|
||||
totalCount: total,
|
||||
overall: total === 0 ? 0 : Math.round((done / total) * 100),
|
||||
|
|
|
|||
|
|
@ -25,6 +25,13 @@ export interface OrgSettings {
|
|||
negStockQuietFromHour: number | null
|
||||
negStockQuietToHour: number | null
|
||||
negStockEscalateHours: number
|
||||
// Тариф и бесплатный период — read-only (выбор был при регистрации).
|
||||
plan: string
|
||||
freeTrialEndsAt: string | null
|
||||
planShops: number | null
|
||||
planCashes: number | null
|
||||
planWarehouses: number | null
|
||||
planEmployees: number | null
|
||||
}
|
||||
|
||||
export function useOrgSettings() {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { Settings, UserCog, Package, ShoppingCart, ArrowRight, CheckCircle2, BarChart3 } from 'lucide-react'
|
||||
import { Link, useNavigate, Navigate } from 'react-router-dom'
|
||||
import { Settings, UserCog, Package, ShoppingCart, ArrowRight, CheckCircle2, BarChart3, EyeOff } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { useOnboardingProgress } from '@/lib/useOnboardingProgress'
|
||||
import { isOnboardingHidden, setOnboardingHidden } from '@/lib/onboardingPrefs'
|
||||
|
||||
interface StepCard {
|
||||
done: boolean
|
||||
|
|
@ -16,6 +17,53 @@ interface StepCard {
|
|||
|
||||
export function OnboardingPage() {
|
||||
const p = useOnboardingProgress()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// «Скрыть первые шаги»: если владелец спрятал виджет, главная ведёт сразу на
|
||||
// аналитику-дашборд. Вернуть можно из настроек организации («Показать первые
|
||||
// шаги на главной»). Ждём orgId, чтобы не мигать до загрузки /api/me.
|
||||
if (p.orgId && isOnboardingHidden(p.orgId)) {
|
||||
return <Navigate to="/dashboard" replace />
|
||||
}
|
||||
|
||||
const copying = p.registryCopyState === 1
|
||||
const copied = p.registryCopyState === 2
|
||||
|
||||
const catalogCard: StepCard = copying
|
||||
? {
|
||||
done: false,
|
||||
badge: 'Каталог',
|
||||
title: 'Каталог загружается из реестра ALL MARKET',
|
||||
description: 'Каталог загружается… Товары появятся через минуту-другую — можно продолжать настройку, обновлять страницу не нужно.',
|
||||
ctaLabel: 'Каталог загружается…',
|
||||
ctaTo: '/catalog/products',
|
||||
ctaDisabled: true,
|
||||
icon: Package,
|
||||
}
|
||||
: copied && p.productCount > 0
|
||||
? {
|
||||
done: true,
|
||||
badge: 'Каталог',
|
||||
title: `Каталог готов: ${p.productCount} товаров из реестра ALL MARKET`,
|
||||
description: 'Товары скопированы из реестра ALL MARKET. Проверьте продажные цены — закупочные и себестоимость появятся после первой приёмки.',
|
||||
ctaLabel: 'Проверить продажные цены',
|
||||
ctaTo: '/catalog/products',
|
||||
icon: Package,
|
||||
}
|
||||
: {
|
||||
done: p.hasProducts,
|
||||
badge: 'Каталог',
|
||||
title: 'Заведите товары вручную или импортируйте из МойСклад',
|
||||
description: 'Товары — основа всего. Импорт подтянет наименования, штрихкоды, цены и остатки одним нажатием.',
|
||||
ctaLabel: p.hasProducts ? 'Перейти в каталог' : 'Добавить товары',
|
||||
ctaTo: p.hasProducts ? '/catalog/products' : '/admin/import/moysklad',
|
||||
icon: Package,
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
setOnboardingHidden(p.orgId, true)
|
||||
navigate('/dashboard')
|
||||
}
|
||||
|
||||
const cards: StepCard[] = [
|
||||
{
|
||||
|
|
@ -36,15 +84,7 @@ export function OnboardingPage() {
|
|||
ctaTo: '/settings/employees',
|
||||
icon: UserCog,
|
||||
},
|
||||
{
|
||||
done: p.hasProducts,
|
||||
badge: 'Каталог',
|
||||
title: 'Заведите товары вручную или импортируйте из МойСклад',
|
||||
description: 'Товары — основа всего. Импорт подтянет наименования, штрихкоды, цены и остатки одним нажатием.',
|
||||
ctaLabel: p.hasProducts ? 'Перейти в каталог' : 'Добавить товары',
|
||||
ctaTo: p.hasProducts ? '/catalog/products' : '/admin/import/moysklad',
|
||||
icon: Package,
|
||||
},
|
||||
catalogCard,
|
||||
{
|
||||
done: p.hasSupplies,
|
||||
badge: 'Закупки',
|
||||
|
|
@ -59,10 +99,19 @@ export function OnboardingPage() {
|
|||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="max-w-5xl mx-auto p-4 sm:p-6 space-y-5 sm:space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<PageHeader
|
||||
title="Первые шаги в ALL MARKET"
|
||||
description="Складской учёт, закупки и продажи в одной системе. Пройдите 4 шага — и можно работать."
|
||||
/>
|
||||
<button
|
||||
onClick={hide}
|
||||
title="Скрыть первые шаги (вернуть можно в настройках организации)"
|
||||
className="flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-slate-200 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800"
|
||||
>
|
||||
<EyeOff className="w-4 h-4" /> Скрыть первые шаги
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Прогресс-плашка */}
|
||||
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5">
|
||||
|
|
@ -134,7 +183,7 @@ export function OnboardingPage() {
|
|||
<h3 className="text-sm font-semibold mb-1">Помощь и поддержка</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Возникли вопросы по настройке или работе системы? Напишите администратору
|
||||
или загляните в документацию проекта на Forgejo.
|
||||
или загляните в <Link to="/help" className="text-[var(--color-brand)] underline">Базу знаний</Link>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { FormSkeleton } from '@/components/Skeleton'
|
|||
import { useCountries } from '@/lib/useLookups'
|
||||
import { useOrgSettings, type OrgSettings } from '@/lib/useOrgSettings'
|
||||
import { useMe } from '@/lib/useMe'
|
||||
import { isOnboardingHidden, setOnboardingHidden } from '@/lib/onboardingPrefs'
|
||||
import { ListChecks } from 'lucide-react'
|
||||
|
||||
interface DemoSeedSummary {
|
||||
alreadySeeded: boolean
|
||||
|
|
@ -82,6 +84,7 @@ export function OrganizationSettingsPage() {
|
|||
<PageHeader title="Настройки организации" description="Страна, валюта, ставка НДС по умолчанию." />
|
||||
|
||||
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5 space-y-4">
|
||||
<PlanBanner settings={form} />
|
||||
<Field label="Название организации" error={nameErr ?? undefined}>
|
||||
<TextInput value={form.name}
|
||||
onChange={(e) => { setForm({ ...form, name: e.target.value }); if (nameErr) setNameErr(null) }}
|
||||
|
|
@ -189,8 +192,9 @@ export function OrganizationSettingsPage() {
|
|||
onChange={(v) => setForm({ ...form, showReferencePriceOnProduct: v })}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 -mt-2">
|
||||
Справочная цена закупа — необязательное поле. Авто-заполняется первой
|
||||
проведённой приёмкой и через 30 дней без приёмок переписывается на текущую себестоимость.
|
||||
Если включено — в карточке товара есть поле «Закупочная цена» (цена последней
|
||||
закупки). Её обновляет каждая проведённая приёмка; отмена проведения возвращает
|
||||
предыдущее значение. Себестоимость (скользящее среднее) считается отдельно.
|
||||
</p>
|
||||
|
||||
<Checkbox
|
||||
|
|
@ -259,6 +263,7 @@ export function OrganizationSettingsPage() {
|
|||
{save.error && <span className="text-sm text-red-600">{(save.error as Error).message}</span>}
|
||||
</div>
|
||||
|
||||
<OnboardingVisibilitySection orgId={form.id} />
|
||||
<FiscalSection />
|
||||
<TelegramSection />
|
||||
<DemoSeedSection />
|
||||
|
|
@ -267,6 +272,68 @@ export function OrganizationSettingsPage() {
|
|||
)
|
||||
}
|
||||
|
||||
/** Восстановление виджета «Первые шаги» на главной (скрывается кнопкой на самой
|
||||
* странице первых шагов). Предпочтение хранится в localStorage per-org. */
|
||||
function OnboardingVisibilitySection({ orgId }: { orgId: string }) {
|
||||
const [hidden, setHidden] = useState(() => isOnboardingHidden(orgId))
|
||||
return (
|
||||
<section className="border-t border-slate-200 dark:border-slate-800 pt-6 mt-6">
|
||||
<h2 className="text-base font-semibold flex items-center gap-2">
|
||||
<ListChecks className="w-4 h-4 text-emerald-600" /> Первые шаги
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||
{hidden
|
||||
? 'Сейчас виджет «Первые шаги» скрыт — на главной открывается дашборд.'
|
||||
: 'Виджет «Первые шаги» показывается на главной странице. Его можно скрыть кнопкой «Скрыть первые шаги» на самой странице.'}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => { setOnboardingHidden(orgId, !hidden); setHidden(!hidden) }}
|
||||
>
|
||||
{hidden ? 'Показать первые шаги на главной' : 'Скрыть первые шаги'}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const PLAN_LABELS: Record<string, string> = { start: 'Старт', business: 'Бизнес', network: 'Сеть' }
|
||||
|
||||
/** Дата ISO → ДД.ММ.ГГГГ (Asia/Almaty не важна — дата без времени). */
|
||||
function fmtDate(iso: string | null): string | null {
|
||||
if (!iso) return null
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return null
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
return `${dd}.${mm}.${d.getFullYear()}`
|
||||
}
|
||||
|
||||
/** Read-only плашка тарифа и бесплатного периода. Выбор был при регистрации;
|
||||
* биллинга/смены тарифа в кабинете пока нет — только показ. */
|
||||
function PlanBanner({ settings }: { settings: OrgSettings }) {
|
||||
const label = PLAN_LABELS[settings.plan] ?? 'Старт'
|
||||
const until = fmtDate(settings.freeTrialEndsAt)
|
||||
const isBusiness = settings.plan === 'business'
|
||||
return (
|
||||
<div className="rounded-lg border border-emerald-200 dark:border-emerald-900/50 bg-emerald-50 dark:bg-emerald-900/20 px-4 py-3">
|
||||
<div className="text-sm text-slate-800 dark:text-slate-100">
|
||||
<span className="font-semibold">Тариф {label}</span>
|
||||
{until && <span className="text-slate-600 dark:text-slate-300"> · бесплатно до {until}</span>}
|
||||
</div>
|
||||
{isBusiness && (settings.planShops || settings.planCashes || settings.planWarehouses || settings.planEmployees) && (
|
||||
<div className="mt-1.5 text-xs text-slate-600 dark:text-slate-300 flex flex-wrap gap-x-4 gap-y-0.5">
|
||||
{settings.planShops != null && <span>Магазинов: <b>{settings.planShops}</b></span>}
|
||||
{settings.planCashes != null && <span>Касс: <b>{settings.planCashes}</b></span>}
|
||||
{settings.planWarehouses != null && <span>Складов: <b>{settings.planWarehouses}</b></span>}
|
||||
{settings.planEmployees != null && <span>Сотрудников: <b>{settings.planEmployees}</b></span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TelegramStatus {
|
||||
botEnabled: boolean
|
||||
botUsername: string | null
|
||||
|
|
|
|||
|
|
@ -424,7 +424,10 @@ export function ProductEditPage() {
|
|||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-3">Закупка</h3>
|
||||
<div className="space-y-3">
|
||||
{!simpleMode && org.data?.showReferencePriceOnProduct && (
|
||||
{/* Закупочная цена — базовое поле схемы цен (каждая приёмка её
|
||||
пишет), показываем и в простом режиме. Скрыть можно только
|
||||
настройкой организации ShowReferencePriceOnProduct. */}
|
||||
{org.data?.showReferencePriceOnProduct && (
|
||||
<Field label="Закупочная цена">
|
||||
<MoneyInput
|
||||
value={form.referencePrice === '' ? null : Number(form.referencePrice)}
|
||||
|
|
@ -432,7 +435,7 @@ export function ProductEditPage() {
|
|||
currencyCode={currencies.data?.find((c) => c.id === form.purchaseCurrencyId)?.code ?? org.data?.defaultCurrencyCode ?? undefined}
|
||||
currencySymbol={currencies.data?.find((c) => c.id === form.purchaseCurrencyId)?.symbol ?? org.data?.defaultCurrencySymbol ?? undefined}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">не обязательное поле</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">цена последней закупки; необязательное поле</p>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Себестоимость">
|
||||
|
|
|
|||
|
|
@ -19,8 +19,11 @@ interface OrgRow {
|
|||
isActive: boolean; isArchived: boolean; archivedAt: string | null
|
||||
createdAt: string; employeeCount: number; productCount: number; lastLoginAt: string | null
|
||||
isAdvancedMode: boolean
|
||||
plan: string; freeTrialEndsAt: string | null
|
||||
}
|
||||
|
||||
const PLAN_LABELS: Record<string, string> = { start: 'Старт', business: 'Бизнес', network: 'Сеть' }
|
||||
|
||||
export function SuperAdminOrganizationsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [archived, setArchived] = useState<boolean | null>(false)
|
||||
|
|
@ -118,6 +121,16 @@ export function SuperAdminOrganizationsPage() {
|
|||
</div>
|
||||
)},
|
||||
{ header: 'Создана', width: '140px', cell: (r) => new Date(r.createdAt).toLocaleDateString('ru') },
|
||||
{ header: 'Тариф', width: '150px', cell: (r) => (
|
||||
<div>
|
||||
<div className="text-xs font-medium">{PLAN_LABELS[r.plan] ?? 'Старт'}</div>
|
||||
{r.freeTrialEndsAt && (
|
||||
<div className="text-[11px] text-slate-500 dark:text-slate-400">
|
||||
беспл. до {new Date(r.freeTrialEndsAt).toLocaleDateString('ru')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)},
|
||||
{ header: 'Сотрудники', width: '110px', className: 'text-right', cell: (r) => r.employeeCount },
|
||||
{ header: 'Товары', width: '110px', className: 'text-right', cell: (r) => r.productCount },
|
||||
{ header: 'Last login', width: '140px', cell: (r) => r.lastLoginAt ? new Date(r.lastLoginAt).toLocaleDateString('ru') : '—' },
|
||||
|
|
|
|||
81
tests/food-market.IntegrationTests/PlanSignupTests.cs
Normal file
81
tests/food-market.IntegrationTests/PlanSignupTests.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using foodmarket.IntegrationTests.Support;
|
||||
using Xunit;
|
||||
|
||||
namespace foodmarket.IntegrationTests;
|
||||
|
||||
/// <summary>Регистрация сохраняет выбранный тариф и бесплатный период (90 дней),
|
||||
/// а для «Бизнес» — конфигурацию конструктора. Settings-эндпоинт отдаёт их
|
||||
/// read-only для показа в кабинете владельца. Биллинга/блокировки нет.
|
||||
/// Решение владельца 12.10.</summary>
|
||||
[Collection(ApiCollection.Name)]
|
||||
public class PlanSignupTests
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public PlanSignupTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
private async Task<JsonElement> SignupWithPlanAndGetSettingsAsync(ApiActor api, string slug, object body)
|
||||
{
|
||||
var resp = await api.Http.PostAsJsonAsync("/api/auth/signup", body);
|
||||
resp.IsSuccessStatusCode.Should().BeTrue(await resp.Content.ReadAsStringAsync());
|
||||
api.UseToken(await api.TokenAsync($"{slug}@example.kz", "Passw0rd!"));
|
||||
return await api.GetJsonAsync("/api/organization/settings");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Business_signup_saves_plan_trial_and_config()
|
||||
{
|
||||
var api = new ApiActor(_factory.CreateClient());
|
||||
var slug = $"plan-biz-{Guid.NewGuid():N}";
|
||||
var s = await SignupWithPlanAndGetSettingsAsync(api, slug, new
|
||||
{
|
||||
email = $"{slug}@example.kz", password = "Passw0rd!", organizationName = $"Org {slug}",
|
||||
phone = "+77010000000", plan = "business",
|
||||
shops = 3, cashes = 4, warehouses = 2, employees = 25,
|
||||
});
|
||||
|
||||
s.GetProperty("plan").GetString().Should().Be("business");
|
||||
s.GetProperty("planShops").GetInt32().Should().Be(3);
|
||||
s.GetProperty("planCashes").GetInt32().Should().Be(4);
|
||||
s.GetProperty("planWarehouses").GetInt32().Should().Be(2);
|
||||
s.GetProperty("planEmployees").GetInt32().Should().Be(25);
|
||||
|
||||
var trial = s.GetProperty("freeTrialEndsAt").GetDateTime();
|
||||
var expected = DateTime.UtcNow.AddDays(90);
|
||||
trial.Should().BeCloseTo(expected, TimeSpan.FromHours(1), "бесплатный период = регистрация + 90 дней");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_signup_saves_plan_without_config()
|
||||
{
|
||||
var api = new ApiActor(_factory.CreateClient());
|
||||
var slug = $"plan-start-{Guid.NewGuid():N}";
|
||||
var s = await SignupWithPlanAndGetSettingsAsync(api, slug, new
|
||||
{
|
||||
email = $"{slug}@example.kz", password = "Passw0rd!", organizationName = $"Org {slug}",
|
||||
phone = "+77010000000", plan = "start",
|
||||
});
|
||||
|
||||
s.GetProperty("plan").GetString().Should().Be("start");
|
||||
s.GetProperty("freeTrialEndsAt").ValueKind.Should().Be(JsonValueKind.String, "дата бесплатного периода проставлена");
|
||||
// Конфигурация «Бизнес» не относится к «Старт» — поля null.
|
||||
s.GetProperty("planShops").ValueKind.Should().Be(JsonValueKind.Null);
|
||||
s.GetProperty("planEmployees").ValueKind.Should().Be(JsonValueKind.Null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_plan_falls_back_to_start()
|
||||
{
|
||||
var api = new ApiActor(_factory.CreateClient());
|
||||
var slug = $"plan-unk-{Guid.NewGuid():N}";
|
||||
var s = await SignupWithPlanAndGetSettingsAsync(api, slug, new
|
||||
{
|
||||
email = $"{slug}@example.kz", password = "Passw0rd!", organizationName = $"Org {slug}",
|
||||
phone = "+77010000000", plan = "enterprise-xxl",
|
||||
});
|
||||
|
||||
s.GetProperty("plan").GetString().Should().Be("start", "неизвестный тариф нормализуется к «start»");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using foodmarket.Domain.Registry;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using foodmarket.Infrastructure.Registry;
|
||||
using foodmarket.IntegrationTests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace foodmarket.IntegrationTests;
|
||||
|
||||
/// <summary>Копия «Реестра товаров» в каталог магазина должна помечать первый
|
||||
/// штрихкод «Основным» (IsPrimary). Товар с единственным штрихкодом раньше
|
||||
/// оставался без «Основного» (баг, fix #7).</summary>
|
||||
[Collection(ApiCollection.Name)]
|
||||
public class RegistryCopyPrimaryBarcodeTests
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public RegistryCopyPrimaryBarcodeTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
private static string RandomBarcode()
|
||||
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
|
||||
|
||||
[Fact]
|
||||
public async Task Copy_sets_single_barcode_as_primary()
|
||||
{
|
||||
var api = new ApiActor(_factory.CreateClient());
|
||||
var slug = $"regcopy-{Guid.NewGuid():N}";
|
||||
await api.SignupAndLoginAsync(slug);
|
||||
var settings = await api.GetJsonAsync("/api/organization/settings");
|
||||
var orgId = Guid.Parse(settings.GetProperty("id").GetString()!);
|
||||
|
||||
var barcode = RandomBarcode();
|
||||
var cardName = $"REG-{slug}";
|
||||
|
||||
// Готовим карточку реестра с ОДНИМ штрихкодом и запускаем копию напрямую
|
||||
// (Hangfire в тестах выключен, поэтому зовём сервис сами).
|
||||
using (var scope = _factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var unitId = await db.UnitsOfMeasure.AsNoTracking().Select(u => u.Id).FirstAsync();
|
||||
db.ProductRegistryCards.Add(new ProductRegistryCard
|
||||
{
|
||||
Name = cardName,
|
||||
UnitOfMeasureId = unitId,
|
||||
Cost = 100m,
|
||||
PurchasePrice = 100m,
|
||||
RetailPrice = 150m,
|
||||
Source = RegistryCardSource.Shop,
|
||||
Barcodes = new List<ProductRegistryBarcode> { new() { Code = barcode } },
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Регистрация выставила RegistryCopyState=1; копия отработает.
|
||||
var svc = scope.ServiceProvider.GetRequiredService<ProductRegistryService>();
|
||||
await svc.CopyRegistryToOrgAsync(orgId, default);
|
||||
}
|
||||
|
||||
// Находим скопированный товар по штрихкоду и проверяем «Основной».
|
||||
var found = await api.GetJsonAsync($"/api/catalog/products?pageSize=50&search={cardName}");
|
||||
var items = found.TryGetProperty("items", out var it) ? it : found;
|
||||
var product = items.EnumerateArray()
|
||||
.FirstOrDefault(p => p.GetProperty("name").GetString() == cardName);
|
||||
product.ValueKind.Should().Be(JsonValueKind.Object, "товар из реестра скопирован в каталог");
|
||||
|
||||
var detail = await api.GetJsonAsync($"/api/catalog/products/{product.GetProperty("id").GetString()}");
|
||||
var barcodes = detail.GetProperty("barcodes").EnumerateArray().ToList();
|
||||
barcodes.Should().ContainSingle(b => b.GetProperty("code").GetString() == barcode);
|
||||
barcodes.Count(b => b.GetProperty("isPrimary").GetBoolean()).Should().Be(1, "ровно один штрихкод — «Основной»");
|
||||
barcodes.Single(b => b.GetProperty("code").GetString() == barcode)
|
||||
.GetProperty("isPrimary").GetBoolean().Should().BeTrue("единственный штрихкод должен быть «Основным»");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue