feat(s29): простой режим — упрощённые формы товара/контрагента/приёмки (шаг 5/8)

Везде формула: simpleMode = me && !me.advancedMode && !SuperAdmin.
Значения скрытых полей в state сохраняются и при save на сервер
отправляются как есть; данные НЕ стираются.

- ProductEditPage: скрыты Артикул (auto-gen), Описание, Страна,
  Эталонная цена, Ставка НДС, «В т.ч. НДС», Валюта закупки,
  «Услуга», «Маркируемый», Максимальный остаток, не-retail типы
  цен в блоке «Цены продажи».
- CounterpartiesPage: скрыты Юр.название, Страна, Email, Банк,
  Р.счёт, БИК, Контактное лицо. Остаются: Название, Тип лица,
  БИН/ИИН, Адрес, Телефон, Заметки.
- SupplyEditPage: колонка «Валюта» всегда скрыта в простом режиме
  (одна валюта по умолчанию).

«Поставщик/Покупатель»-галочки отложены — требуют новых колонок
IsSupplier/IsBuyer на Counterparty (новая функциональность).

pnpm run build — ✓ 2.36s, 0 TS errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-04 11:48:23 +05:00
parent 67c1b590ad
commit 792ea0fd41
4 changed files with 118 additions and 42 deletions

View file

@ -13,7 +13,7 @@ SuperAdmin и только для конкретной организации.
- [x] 2. API `PUT /api/super-admin/organizations/{id}/advanced-mode` + аудит + `/api/me.advancedMode`; org-admin → 403
- [x] 3. Серверная защита: `[RequiresAdvancedMode]` → 403 ProblemDetails на скрытых модулях
- [x] 4. Web: меню, маршруты, дашборд фильтруются; прямой переход → страница «Недоступно»
- [ ] 5. Упрощённые формы (товар / контрагент / документы) — скрыть «не нужные» поля, значения сохраняются
- [x] 5. Упрощённые формы (товар / контрагент / документы) — скрыть «не нужные» поля, значения сохраняются
- [ ] 6. SuperAdmin UI — бейдж «Простой/Расширенный» + переключатель с подтверждением
- [ ] 7. Тесты (unit + integration + e2e); патч quality-watchdog при необходимости
- [ ] 8. Деплой стейджа + `docs/simple-mode.md`
@ -108,3 +108,26 @@ SuperAdmin и только для конкретной организации.
- `DashboardPage` — в простом режиме скрыты виджеты «Топ товаров» и «Маржа». Остаются KPI (выручка today/week/month, средний чек), график продаж, «Последние продажи», «Низкий остаток» — ровно то, что в чек-листе.
- Данные НЕ удаляются при выключении режима: это только UI-гейтинг + серверный 403; при повторном включении всё видно.
- `pnpm run build` — ✓ (2.60s, 0 TS errors).
### 2026-10-04 — Шаг 5 ✓
- Везде формула простого режима: `simpleMode = me && !me.advancedMode && !roles.includes('SuperAdmin')`. SuperAdmin не ощущает — ему доступно всё.
- **Карточка товара** (`ProductEditPage.tsx`) в простом режиме прячет (значения в state сохраняются, при save не стираются):
- «Артикул» (генерируется автоматически, Название на 3 колонки)
- «Описание», «Страна происхождения», «Эталонная цена»
- «Ставка НДС», чекбокс «В том числе НДС»
- «Валюта закупки» (мульти-валюта)
- Чекбоксы «Услуга», «Маркируемый»
- «Максимальный остаток» — остаётся только «Минимальный»
- Все не-retail типы цен в блоке «Цены продажи» — рендерится только retail/system price type.
- **Контрагенты** (`CounterpartiesPage.tsx`) — в простом режиме скрыты Юр. название, Страна, Email, Банк/Р.счёт/БИК, Контактное лицо. Остаются: Название, Тип лица, БИН/ИИН, Адрес, Телефон, Заметки.
- **Приёмка** (`SupplyEditPage.tsx`) — колонка «Валюта» всегда скрыта в простом режиме (одна валюта по умолчанию).
- Другие документы (Enter, Loss, Inventory, SupplierReturn) используют те же `org.ShowXxx` / `org.MultiCurrencyEnabled` флаги — уже скрывают advanced-поля для магазинов с «простым» org-настройками.
Доп. список спрятанного за `simpleMode` (из шагов 4/5, не вошёл напрямую в чек-лист):
- `AppLayout` → `LanguageSwitcher`
- `DashboardPage` → виджеты «Топ товаров» и «Маржа»
Что отложено (новая функциональность, в этой задаче не трогаем):
- `Counterparty.IsSupplier` / `IsBuyer` — задача просит галочки «Поставщик/Покупатель», но на `Counterparty` таких bool-колонок нет. Это новая миграция и расширение API — отдельная задача.
- `pnpm run build` — ✓ (2.36s, 0 TS errors).

View file

@ -17,6 +17,7 @@ import { PhoneInput } from '@/components/PhoneInput'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { useMe } from '@/lib/useMe'
import { type Counterparty, type Country, type PagedResult, CounterpartyType } from '@/lib/types'
const URL = '/api/catalog/counterparties'
@ -56,6 +57,11 @@ const typeLabel: Record<CounterpartyType, string> = {
export function CounterpartiesPage() {
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<Counterparty>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL)
const me = useMe()
// Sprint 29 — простой режим скрывает редко нужные поля (юр.название,
// банк, страна, контактное лицо, email). Значения существующих
// контрагентов сохраняются и при переключении в расширенный снова видны.
const simpleMode = !!me.data && !me.data.advancedMode && !me.data.roles?.includes('SuperAdmin')
const [form, setForm] = useState<Form | null>(null)
const [fieldErrors, setFieldErrors] = useState<Partial<Record<'name' | 'email' | 'phone', string>>>({})
const { confirm, dialogProps } = useConfirm()
@ -165,9 +171,11 @@ export function CounterpartiesPage() {
onChange={(e) => { setForm({ ...form, name: e.target.value }); if (fieldErrors.name) setFieldErrors(p => ({...p, name: undefined})) }}
onBlur={() => { if (!form.name.trim()) setFieldErrors(p => ({...p, name: 'Название обязательно'})) }} />
</Field>
{!simpleMode && (
<Field label="Юридическое название" className="col-span-2">
<TextInput value={form.legalName} onChange={(e) => setForm({ ...form, legalName: e.target.value })} />
</Field>
)}
<Field label="Тип лица">
<Select value={form.type} onChange={(e) => setForm({ ...form, type: Number(e.target.value) as CounterpartyType })}>
<option value={CounterpartyType.LegalEntity}>Юрлицо</option>
@ -180,12 +188,14 @@ export function CounterpartiesPage() {
<Field label="ИИН (физлицо РК)">
<TextInput value={form.iin} onChange={(e) => setForm({ ...form, iin: e.target.value })} placeholder="12 цифр" maxLength={12} inputMode="numeric" />
</Field>
{!simpleMode && (
<Field label="Страна">
<Select value={form.countryId} onChange={(e) => setForm({ ...form, countryId: e.target.value })}>
<option value="">—</option>
{countries.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</Field>
)}
<Field label="Адрес" className="col-span-2">
<TextInput value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
</Field>
@ -194,11 +204,15 @@ export function CounterpartiesPage() {
onChange={(v) => { setForm({ ...form, phone: v }); if (fieldErrors.phone) setFieldErrors(p => ({...p, phone: undefined})) }}
onBlur={() => { if (form.phone) { const e = validatePhone(form.phone); if (e) setFieldErrors(p => ({...p, phone: e})) } }} />
</Field>
{!simpleMode && (
<Field label="Email" error={fieldErrors.email}>
<TextInput type="email" value={form.email}
onChange={(e) => { setForm({ ...form, email: e.target.value }); if (fieldErrors.email) setFieldErrors(p => ({...p, email: undefined})) }}
onBlur={() => { if (form.email) { const e = validateEmail(form.email); if (e) setFieldErrors(p => ({...p, email: e})) } }} />
</Field>
)}
{!simpleMode && (
<>
<Field label="Банк" className="col-span-2">
<TextInput value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} />
</Field>
@ -211,6 +225,8 @@ export function CounterpartiesPage() {
<Field label="Контактное лицо" className="col-span-2">
<TextInput value={form.contactPerson} onChange={(e) => setForm({ ...form, contactPerson: e.target.value })} />
</Field>
</>
)}
<Field label="Заметки" className="col-span-2">
<TextArea rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</Field>

View file

@ -9,6 +9,7 @@ import {
useUnits, useCountries, useCurrencies, usePriceTypes, useProductGroups,
} from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { useMe } from '@/lib/useMe'
import { BarcodeType, Packaging, type Product } from '@/lib/types'
import { ProductImageGallery } from '@/components/ProductImageGallery'
import { ConfirmDialog } from '@/components/ConfirmDialog'
@ -68,6 +69,11 @@ export function ProductEditPage() {
const { confirm, dialogProps } = useConfirm()
const priceTypes = usePriceTypes()
const org = useOrgSettings()
const me = useMe()
// Sprint 29 — простой режим: все advanced-only поля скрываются (значения
// сохраняются, при переключении в расширенный снова видно). SuperAdmin
// режим не ощущает — ему доступно всё.
const simpleMode = !!me.data && !me.data.advancedMode && !me.data.roles?.includes('SuperAdmin')
const existing = useQuery({
queryKey: ['/api/catalog/products', id],
@ -298,12 +304,16 @@ export function ProductEditPage() {
<Section title="Основное">
<Grid cols={3}>
<Field label="Название *" className="col-span-2">
<Field label="Название *" className={simpleMode ? 'col-span-3' : 'col-span-2'}>
<TextInput required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
{/* Артикул — только расширенный режим; в простом генерируется
автоматически (см. useEffect с generateArticle). */}
{!simpleMode && (
<Field label="Артикул *">
<TextInput required value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} />
</Field>
)}
</Grid>
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
@ -361,7 +371,7 @@ export function ProductEditPage() {
)}
</div>
{org.data?.showDescriptionOnProduct && (
{!simpleMode && org.data?.showDescriptionOnProduct && (
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
<Field label="Описание">
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
@ -406,7 +416,7 @@ 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">
{org.data?.showReferencePriceOnProduct && (
{!simpleMode && org.data?.showReferencePriceOnProduct && (
<Field label="Эталонная цена">
<MoneyInput
value={form.referencePrice === '' ? null : Number(form.referencePrice)}
@ -427,7 +437,7 @@ export function ProductEditPage() {
/>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">расчётная (скользящее среднее)</p>
</Field>
{org.data?.multiCurrencyEnabled && (
{!simpleMode && org.data?.multiCurrencyEnabled && (
<Field label="Валюта закупки">
<Select value={form.purchaseCurrencyId} onChange={(e) => setForm({ ...form, purchaseCurrencyId: e.target.value })}>
<option value="">—</option>
@ -446,7 +456,12 @@ export function ProductEditPage() {
* новая, при стирании — null Amount (UI пустое). Обязательные (IsRequired)
* помечаются звёздочкой. */}
<div className="space-y-3">
{priceTypes.data?.slice().sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)).map((pt) => {
{priceTypes.data?.slice()
// В простом режиме у магазина одна цена продажи — показываем
// только retail/system price type, остальные (оптовая, VIP и т.п.)
// скрыты, но их значения сохраняются и не стираются при save.
.filter((pt) => !simpleMode || pt.isRetail || pt.isSystem)
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)).map((pt) => {
const idx = form.prices.findIndex(p => p.priceTypeId === pt.id)
const row = idx >= 0 ? form.prices[idx] : null
const required = pt.isRequired
@ -515,7 +530,7 @@ export function ProductEditPage() {
</Select>
</Field>
</Grid>
{org.data?.showCountryOfOriginOnProduct && (
{!simpleMode && org.data?.showCountryOfOriginOnProduct && (
<Grid cols={2}>
<Field label="Страна происхождения">
<Select value={form.countryOfOriginId} onChange={(e) => setForm({ ...form, countryOfOriginId: e.target.value })}>
@ -525,7 +540,7 @@ export function ProductEditPage() {
</Field>
</Grid>
)}
{org.data?.showVatEnabledOnProduct && form.vatEnabled && (
{!simpleMode && org.data?.showVatEnabledOnProduct && form.vatEnabled && (
<Grid cols={3}>
<Field label="Ставка НДС, %">
<NumberInput
@ -536,19 +551,35 @@ export function ProductEditPage() {
</Grid>
)}
<div className="pt-3 mt-3 border-t border-slate-100 dark:border-slate-800 flex flex-wrap gap-x-6 gap-y-2">
{org.data?.showVatEnabledOnProduct && (
{!simpleMode && org.data?.showVatEnabledOnProduct && (
<Checkbox label="В том числе НДС" checked={form.vatEnabled} onChange={(v) => setForm({ ...form, vatEnabled: v })} />
)}
{org.data?.showServiceOnProduct && (
{!simpleMode && org.data?.showServiceOnProduct && (
<Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} />
)}
{org.data?.showMarkedOnProduct && (
{!simpleMode && org.data?.showMarkedOnProduct && (
<Checkbox label="Маркируемый (Честный знак / Datamatrix)" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
)}
</div>
</Section>
{org.data?.showMinMaxStock && (
{/* В простом режиме «Мин. остаток» входит в базовый набор полей
карточки (см. docs/simple-mode.md), поэтому показываем его
всегда — но без «Максимального» (это для автозаказа, продвинутая
фича). В расширенном — обе колонки, при условии org.ShowMinMaxStock. */}
{simpleMode ? (
<AdvancedSection>
<Grid cols={4}>
<Field label="Минимальный остаток (для уведомления)">
<NumberInput
value={form.minStock === '' ? null : Number(form.minStock)}
onChange={(n) => setForm({ ...form, minStock: n == null ? '' : String(n) })}
placeholder="—"
/>
</Field>
</Grid>
</AdvancedSection>
) : org.data?.showMinMaxStock && (
<AdvancedSection>
<Grid cols={4}>
<Field label="Минимальный остаток (для уведомления)">

View file

@ -15,6 +15,7 @@ import { useConfirm } from '@/lib/useConfirm'
import { useShortcuts } from '@/lib/useShortcuts'
import { useStores, useCurrencies, usePriceTypes } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { useMe } from '@/lib/useMe'
import { SupplyStatus, type SupplyDto, type Product } from '@/lib/types'
interface LineRow {
@ -68,6 +69,11 @@ export function SupplyEditPage() {
const stores = useStores()
const currencies = useCurrencies()
const org = useOrgSettings()
const me = useMe()
// Sprint 29 — простой режим: мульти-валюта всегда выключена, независимо
// от org-настроек. В UI колонка валюты скрыта, send-side уже отправляет
// DefaultCurrencyId.
const simpleMode = !!me.data && !me.data.advancedMode && !me.data.roles?.includes('SuperAdmin')
const priceTypes = usePriceTypes()
const { confirm, dialogProps } = useConfirm()
// Системный (главный) тип цен — на нём по умолчанию ведётся розница на кассе.
@ -368,7 +374,7 @@ export function SupplyEditPage() {
</Select>
</Field>
)}
{org.data?.multiCurrencyEnabled && (
{!simpleMode && org.data?.multiCurrencyEnabled && (
<Field label="Валюта *">
<Select value={form.currencyId} disabled={isPosted}
onChange={(e) => setForm({ ...form, currencyId: e.target.value })}>