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

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:
nns 2026-10-12 21:46:15 +05:00
parent 005e5457af
commit 0ce0e0df77
22 changed files with 690 additions and 127 deletions

View file

@ -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);

View file

@ -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);
}

View file

@ -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)

View file

@ -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);
}

View file

@ -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; }
}

View file

@ -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"";");
}
}

View file

@ -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) { }
}
}

View file

@ -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);

View file

@ -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 (

View file

@ -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>}

View file

@ -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».

View file

@ -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 ловит любые попытки

View file

@ -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">
Посмотреть

View 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 недоступен — предпочтение просто не сохранится */
}
}

View file

@ -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),

View file

@ -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() {

View file

@ -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>

View file

@ -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

View file

@ -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="Себестоимость">

View file

@ -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') : '—' },

View 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»");
}
}

View file

@ -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("единственный штрихкод должен быть «Основным»");
}
}