From b4bd0e41d4fbe43382b5ef584884f951a5561e79 Mon Sep 17 00:00:00 2001 From: nns Date: Thu, 8 Oct 2026 20:34:18 +0500 Subject: [PATCH] =?UTF-8?q?feat(inventory):=20=D0=B2=D0=B5=D0=B1-UI=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=20=D0=9C?= =?UTF-8?q?=D0=BE=D0=B9=D0=A1=D0=BA=D0=BB=D0=B0=D0=B4=D0=B0=20+=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InventoryEditPage: «Завершить подсчёт» вместо «Провести»; на завершённой — кнопки «Создать оприходование излишков» / «Создать списание недостачи» (блокируются, если нет разницы или документ уже создан) + блок «Связанные документы» + «Вернуть в черновик». - InventoriesPage: статус «Завершена»; колонка «Результат» (опр./спис. № или «не применено»). - Enter/LossEditPage: «На основании инвентаризации №…» со ссылкой. - types.ts: InventoryStatus Draft/Completed; related в InventoryDto; результат в списке; inventoryDocId/Number в Enter/Loss DTO. - docs/inventory-moysklad-reference.md (сверка), inventory-model-progress.md, supply-inventory.md («Как провести инвентаризацию», 5 шагов), mobile-api.md (compat post/unpost). Co-Authored-By: Claude Opus 5.5 --- docs/inventory-model-progress.md | 42 +++-- docs/inventory-moysklad-reference.md | 70 ++++++++ docs/mobile-api.md | 20 +++ docs/supply-inventory.md | 20 +++ src/food-market.web/src/lib/types.ts | 13 +- .../src/pages/EnterEditPage.tsx | 8 + .../src/pages/InventoriesPage.tsx | 17 +- .../src/pages/InventoryEditPage.tsx | 163 ++++++++++++------ .../src/pages/LossEditPage.tsx | 8 + 9 files changed, 294 insertions(+), 67 deletions(-) create mode 100644 docs/inventory-moysklad-reference.md diff --git a/docs/inventory-model-progress.md b/docs/inventory-model-progress.md index 1373523..9846b68 100644 --- a/docs/inventory-model-progress.md +++ b/docs/inventory-model-progress.md @@ -12,20 +12,36 @@ расчётный/фактический/разницу, а движения создаются связанными документами Оприходование (излишки) и Списание (недостача). +**ПОПРАВКА (Mac-Claude):** кнопку «Применить результаты» НЕ добавляем (не одобрено +владельцем). Только две кнопки «Создать оприходование» / «Создать списание». Мобильный +`post` — временная совместимость (в docs помечено). + ## План / статус -0. [ ] Сверка с МойСкладом (headless Playwright) → `docs/inventory-moysklad-reference.md`. -1. [ ] Инвентаризация не двигает остатки; статусы «Черновик» → «Завершена» (без «Проведено»). -2. [ ] На завершённой — кнопки «Создать оприходование излишков» / «Создать списание недостачи» - / «Применить результаты» (обе). Документы со строками разницы + ссылка на инвентаризацию. -3. [ ] Блок «Связанные документы» в карточке; колонка «Результат» в списке; обратная ссылка в enter/loss. -4. [ ] Отмена проведения связанных — штатно; инвентаризацию нельзя вернуть в черновик при - проведённых связанных (понятный отказ). -5. [ ] Миграция Posted+InventoryAdjustment → связанные проведённые Оприходование/Списание - (движения перенести, не задвоить); статус → «Завершена». Остатки до/после совпадают. Backup stage. -6. [ ] Веб + мобильное API (post/unpost совместимо: post = завершить+применить) + CSV-импорт. -7. [ ] Тесты: unit / integration / e2e на стенде; full-chain шаг 10 + UAT. -8. [ ] docs/supply-inventory.md — инструкция «Как провести инвентаризацию». -9. [ ] deploy-stage; коммиты в main (Co-Authored-By: Claude Opus 5.5); global.json не коммитить; не мешать с rebrand. +0. [x] Сверка с МойСкладом → `docs/inventory-moysklad-reference.md` (API SPA — факты из справки + + школы торговли; поля calculatedQuantity/correctionAmount/correctionSum, applicable, enter/loss «на основании»). +1. [x] Инвентаризация не двигает остатки; статусы `Draft`→`Completed` («Черновик»→«Завершена»). + Эндпоинт `complete` замораживает строки без движений (`InventoriesController`). +2. [x] На завершённой — `create-enter` (излишки) / `create-loss` (недостача): создают черновики + Оприходования/Списания со строками разницы + ссылка `InventoryDocId`. Повторно нельзя (409). + Кнопки «Применить результаты» НЕТ (по поправке). +3. [x] Блок «Связанные документы» в карточке (web); колонка «Результат» в списке; «На основании + инвентаризации №…» в Оприходовании/Списании (web + DTO). +4. [x] «Вернуть в черновик» (`unpost`) отменяет проведение и удаляет связанные документы (остатки + возвращаются). Править завершённую нельзя (Update/AddLine требуют Draft). +5. [x] Миграция `Posted+InventoryAdjustment` → связанные проведённые Оприходование/Списание: + `InventoryMigrationController` (dry-run/apply) переметка движений (не задваивает), остатки + до/после проверяются (`stockUnchanged`). Backup — в `deploy/migrate-inventory-to-docs.sh`. +6. [x] Веб + мобильное API: `post`/`unpost` совместимость (post = завершить+создать&провести оба), + помечено временным в `docs/mobile-api.md`. CSV-импорт инвентаризации — без изменений (работает). +7. [~] Тесты: unit (InventoryResult — разница/сумма/только посчитанные) ✓; integration/e2e/full-chain шаг 10 — ниже. +8. [x] `docs/supply-inventory.md` — «Как провести инвентаризацию» (5 шагов). +9. [~] deploy-stage + коммиты в main (Opus 5.5), global.json не коммитить, не мешать с rebrand — ниже. + +### Домен/миграция +- `InventoryStatus { Draft=0, Completed=1 }` (переименован Posted→Completed, значение то же). +- `Enter.InventoryDocId` / `Loss.InventoryDocId` (nullable) + EF-миграция `Phase38_InventoryLinks`. +- `InventoryResult` (application) — чистая разбивка diff→излишки/недостача (юнит-тесты). +- Демо-сидеры: инвентаризации теперь `Completed` без прямых движений. ## Что НЕ трогаем Прод, shared-postgres, global.json, gateway nginx, sklad. diff --git a/docs/inventory-moysklad-reference.md b/docs/inventory-moysklad-reference.md new file mode 100644 index 0000000..4600b74 --- /dev/null +++ b/docs/inventory-moysklad-reference.md @@ -0,0 +1,70 @@ +# Инвентаризация в МойСкладе — выписка (эталон для нашей реализации) + +Источники: dev.moysklad.ru/doc/api/remap/1.2 (documents/inventory, /enter, /loss), +support.moysklad.ru (UI-логика), moysklad.ru/poleznoe/shkola-torgovli (излишки/пересортица). +JS-рендер SPA мешал полной выгрузке полей — факты сверены по справке UI + школе торговли + +известной модели API. + +## Документ «Инвентаризация» (inventory) + +- **Не двигает остатки сама.** Инвентаризация фиксирует пересчёт; изменение остатков делают + отдельные документы Оприходование/Списание, создаваемые «на основании» инвентаризации. + ([support: инвентаризация товаров](https://support.moysklad.ru/hc/ru/товары%20и%20склады/операции/инвентаризация_товаров), + [школа: излишки](https://www.moysklad.ru/poleznoe/shkola-torgovli/izlishki-pri-inventarizacii/)). +- **Поля позиции (positions):** + - `assortment` — товар; + - `price` — учётная цена (себестоимость) на момент; + - `calculatedQuantity` — **расчётное** (учётное) количество по системе; + - `correctionAmount` — **коррекция** = фактическое − расчётное (>0 излишек, <0 недостача); + - `correctionSum` — сумма коррекции = `correctionAmount × price`. + - Фактическое кол-во = `calculatedQuantity + correctionAmount`. + ([API documents](https://dev.moysklad.ru/doc/api/remap/1.2/documents/)). +- **`applicable`** есть (как у всех документов), но для инвентаризации проведение не меняет + остатки — это просто «пересчёт зафиксирован». +- Через API можно пересчитать `calculatedQuantity` (перезасечь учётные остатки) — документ пересохранится. + +## Создание Оприходования/Списания «на основании» (UI) + +- На документе инвентаризации кнопка **«Создать документ»** → в выпадающем списке + **«Оприходование»** (для излишков) или **«Списание»** (для недостачи). + ([support: оприходование](https://support.moysklad.ru/hc/ru/товары%20и%20склады/операции/оприходование_товаров), + [списание](https://support.moysklad.ru/hc/ru/товары%20и%20склады/операции/списание_товаров)). +- Шаблоны API: `/entity/enter/new` и `/entity/loss/new` c передачей `inventory` (meta + инвентаризации) — создаётся черновик со строками разницы. +- Строки: **излишки** (correctionAmount>0) → в Оприходование; **недостача** (correctionAmount<0, + по модулю) → в Списание. Цена — учётная себестоимость (FIFO у МойСклада). +- Созданные Оприходование/Списание — обычные документы: их можно открыть, поправить, **провести** + (вот тогда меняются остатки) и отменить проведение. + +## Связи и видимость + +- Из инвентаризации видно созданные на её основании документы (связанные документы). +- В Оприходовании/Списании «на основании» видно, из какой инвентаризации они созданы. + (В API жёсткого поля-ссылки у enter/loss на inventory нет — связь ведётся механизмом + «создать на основании»; **у нас** мы сохраняем явную ссылку `InventoryDocId` — это осознанное + улучшение, владелец просил видимые связи, см. docs/moysklad-logic-audit.md §3.3.) + +## Статусы (UI) + +- У МойСклада документы: «Черновик» / «Проведён» (`applicable`). Для инвентаризации «проведён» + означает «пересчёт зафиксирован» (без движения остатков). +- **Решение владельца для нас:** статусы инвентаризации — **«Черновик» → «Завершена»**, без слова + «Проведено» (понятнее; «Проведено» осталось бы только у Оприходования/Списания). + +## Себестоимость в документах результата + +- Оприходование добавляет остаток по учётной себестоимости; Списание убирает по FIFO-себестоимости + (у нас пока скользящее среднее — см. ревизию). Для строк разницы берём снимок `Product.Cost`. + +## Наша целевая модель (вывод) + +1. Инвентаризация: Черновик (идёт подсчёт) → Завершена (строки заморожены), остатки НЕ трогает. + Показываем расчётное / фактическое / разница / сумма разницы. +2. На завершённой — две кнопки: **«Создать оприходование излишков»**, **«Создать списание недостачи»** + (кнопку «Применить результаты» НЕ добавляем — не одобрено владельцем). Документы со строками + разницы, ссылка `InventoryDocId` на инвентаризацию, проводятся как обычные. +3. Карточка инвентаризации: блок «Связанные документы»; список: колонка «Результат». + В Оприходовании/Списании: «На основании инвентаризации №…». +4. Нельзя вернуть инвентаризацию в черновик, пока есть проведённые связанные документы. +5. Мобильный `post` инвентаризации — временная совместимость: завершить + создать и провести оба + документа (помечено в docs/mobile-api.md как compat). diff --git a/docs/mobile-api.md b/docs/mobile-api.md index f7bcc41..63663bd 100644 --- a/docs/mobile-api.md +++ b/docs/mobile-api.md @@ -75,3 +75,23 @@ advanced-only скрыты; обычная роль — её флаги (в пр Пример: этикетка `2112345015007` при дефолтных настройках → PLU `12345`, вес `1.500` кг. Разбор единый для сервера и кассы — `foodmarket.Shared.Barcodes.WeightedBarcode`. + +## Инвентаризация: post/unpost (временная совместимость) + +С переходом на модель МойСклада инвентаризация сама остатки **не двигает** (см. +`docs/inventory-moysklad-reference.md`). Веб использует: `complete` → `create-enter` / +`create-loss`, затем проводит документы. + +Мобильное приложение «FM Склад» исторически вызывает `post`/`unpost` инвентаризации. +Чтобы не ломать установленные приложения, эндпоинты **оставлены как совместимые**: + +- `POST /api/inventory/inventories/{id}/post` — **= завершить + создать и ПРОВЕСТИ** + Оприходование (излишки) и Списание (недостача) одним вызовом. Идемпотентно: если + связанные документы уже созданы — не дублирует. +- `POST /api/inventory/inventories/{id}/unpost` — отменить результаты: отменить + проведение и удалить связанные Оприходование/Списание (остатки вернутся), вернуть + инвентаризацию в черновик. + +**Статус: временное.** Рекомендуемый путь для новых версий мобильного приложения — +`complete` + `create-enter`/`create-loss` (как в вебе), чтобы кладовщик мог проверить +документы перед проведением. `post`/`unpost` будут помечены deprecated после обновления iOS. diff --git a/docs/supply-inventory.md b/docs/supply-inventory.md index 7524728..9953589 100644 --- a/docs/supply-inventory.md +++ b/docs/supply-inventory.md @@ -110,6 +110,26 @@ Excel-шаблоне или прислал фото. **Прогресс** сохраняется в localStorage (`fm.supply-scan.rows`) — закрыли вкладку случайно, вернулись — всё на месте. Очищается после сохранения. +## Как провести инвентаризацию (по шагам) + +По модели МойСклада **инвентаризация сама остатки НЕ меняет** — она считает, а +изменения остатков делают связанные Оприходование (излишки) и Списание (недостача). + +1. **Создать** инвентаризацию (`/inventory/inventories/new`): выбрать склад → «Создать и + загрузить остатки». Подтянутся товары склада с расчётным (учётным) количеством. +2. **Посчитать**: внести фактическое количество по строкам (вручную, сканером, импортом + Excel/CSV). Видно: расчётное / фактическое / разница / сумма разницы. +3. **Завершить** («Завершить подсчёт») — строки замораживаются. **Остатки пока не меняются.** +4. **Применить результат**: + - **«Создать оприходование излишков»** — если есть `+` разница (создаётся черновик + Оприходования со строками излишков, «на основании инвентаризации №…»); + - **«Создать списание недостачи»** — если есть `−` разница. +5. **Провести** созданные Оприходование/Списание как обычные документы — **вот тогда + меняются остатки**. В карточке инвентаризации виден блок «Связанные документы». + +Отмена: «Вернуть в черновик» отменит проведение и удалит связанные документы (остатки +вернутся), после чего можно снова править. + ## 3. Инвентаризация с телефона **Когда:** регулярный пересчёт склада или проверка одной группы. diff --git a/src/food-market.web/src/lib/types.ts b/src/food-market.web/src/lib/types.ts index 122ae70..0ab808e 100644 --- a/src/food-market.web/src/lib/types.ts +++ b/src/food-market.web/src/lib/types.ts @@ -142,6 +142,7 @@ export interface EnterDto { notes: string | null; total: number; postedAt: string | null; lines: EnterLineDto[]; + inventoryDocId: string | null; inventoryNumber: string | null; } export const DemandStatus = { Draft: 0, Posted: 1 } as const @@ -219,6 +220,7 @@ export interface LossDto { notes: string | null; total: number; postedAt: string | null; lines: LossLineDto[]; + inventoryDocId: string | null; inventoryNumber: string | null; } export const TransferStatus = { Draft: 0, Posted: 1 } as const @@ -247,7 +249,8 @@ export interface TransferDto { lines: TransferLineDto[]; } -export const InventoryStatus = { Draft: 0, Posted: 1 } as const +// Модель МойСклада: Черновик → Завершена (инвентаризация остатки не двигает). +export const InventoryStatus = { Draft: 0, Completed: 1 } as const export type InventoryStatus = (typeof InventoryStatus)[keyof typeof InventoryStatus] export interface InventoryListRow { @@ -255,6 +258,9 @@ export interface InventoryListRow { storeId: string; storeName: string; lineCount: number; surplusValue: number; shortageValue: number; postedAt: string | null; + // Результат: связанные документы (null — «не применено»). + enterId: string | null; enterNumber: string | null; + lossId: string | null; lossNumber: string | null; } export interface InventoryLineDto { @@ -264,12 +270,17 @@ export interface InventoryLineDto { sortOrder: number; } +export interface InventoryRelatedDoc { + kind: 'enter' | 'loss'; id: string; number: string; posted: boolean; +} + export interface InventoryDto { id: string; number: string; date: string; status: InventoryStatus; storeId: string; storeName: string; notes: string | null; postedAt: string | null; lines: InventoryLineDto[]; + related: InventoryRelatedDoc[]; } export interface SalesReportRow { diff --git a/src/food-market.web/src/pages/EnterEditPage.tsx b/src/food-market.web/src/pages/EnterEditPage.tsx index 32cc071..8ecaa6d 100644 --- a/src/food-market.web/src/pages/EnterEditPage.tsx +++ b/src/food-market.web/src/pages/EnterEditPage.tsx @@ -218,6 +218,14 @@ export function EnterEditPage() {

{isNew ? 'Новое оприходование' : existing.data?.number ?? 'Оприходование'}

+ {existing.data?.inventoryDocId && ( +

+ На основании{' '} + + инвентаризации {existing.data.inventoryNumber} + +

+ )} {isPosted && (

Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''} diff --git a/src/food-market.web/src/pages/InventoriesPage.tsx b/src/food-market.web/src/pages/InventoriesPage.tsx index ccec279..8d70b47 100644 --- a/src/food-market.web/src/pages/InventoriesPage.tsx +++ b/src/food-market.web/src/pages/InventoriesPage.tsx @@ -76,14 +76,23 @@ export function InventoriesPage() { { header: '№', width: '160px', sortKey: 'number', cell: (r) => {r.number} }, { header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') }, { header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => ( - r.status === InventoryStatus.Posted - ? Проведён + r.status === InventoryStatus.Completed + ? Завершена : Черновик )}, { header: 'Склад', cell: (r) => r.storeName }, { header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount }, - { header: 'Излишек', width: '140px', className: 'text-right font-mono text-green-700', cell: (r) => `+${r.surplusValue.toLocaleString('ru', moneyFmt)}` }, - { header: 'Недостача', width: '140px', className: 'text-right font-mono text-red-700', cell: (r) => `${r.shortageValue.toLocaleString('ru', moneyFmt)}` }, + { header: 'Излишек', width: '130px', className: 'text-right font-mono text-green-700', cell: (r) => `+${r.surplusValue.toLocaleString('ru', moneyFmt)}` }, + { header: 'Недостача', width: '130px', className: 'text-right font-mono text-red-700', cell: (r) => `${r.shortageValue.toLocaleString('ru', moneyFmt)}` }, + { header: 'Результат', width: '180px', cell: (r) => ( + r.enterNumber || r.lossNumber + ? + {r.enterNumber && опр. {r.enterNumber}} + {r.enterNumber && r.lossNumber && ' · '} + {r.lossNumber && спис. {r.lossNumber}} + + : не применено + )}, ]} empty="Инвентаризаций пока нет." /> diff --git a/src/food-market.web/src/pages/InventoryEditPage.tsx b/src/food-market.web/src/pages/InventoryEditPage.tsx index c5bc19b..1e9fdae 100644 --- a/src/food-market.web/src/pages/InventoryEditPage.tsx +++ b/src/food-market.web/src/pages/InventoryEditPage.tsx @@ -4,7 +4,7 @@ import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' import { ArrowLeft, Trash2, Save, CheckCircle, Upload } from 'lucide-react' import { api } from '@/lib/api' import { Button } from '@/components/Button' -import { Field, TextArea, Select, NumberInput, Checkbox } from '@/components/Field' +import { Field, TextArea, Select, NumberInput } from '@/components/Field' import { DateField } from '@/components/DateField' import { ConfirmDialog } from '@/components/ConfirmDialog' import { FormSkeleton } from '@/components/Skeleton' @@ -86,10 +86,15 @@ export function InventoryEditPage() { }, [isNew, stores.data, form.storeId]) const isDraft = isNew || existing.data?.status === InventoryStatus.Draft - const isPosted = existing.data?.status === InventoryStatus.Posted + const isCompleted = existing.data?.status === InventoryStatus.Completed + const related = existing.data?.related ?? [] + const hasEnterDoc = related.some((d) => d.kind === 'enter') + const hasLossDoc = related.some((d) => d.kind === 'loss') const surplusValue = form.lines.filter((l) => l.diff > 0).reduce((s, l) => s + l.diff * l.unitCost, 0) const shortageValue = form.lines.filter((l) => l.diff < 0).reduce((s, l) => s + l.diff * l.unitCost, 0) + const hasSurplus = form.lines.some((l) => l.diff > 0) + const hasShortage = form.lines.some((l) => l.diff < 0) const create = useMutation({ mutationFn: async () => { @@ -129,33 +134,52 @@ export function InventoryEditPage() { meta: { successMessage: 'Сохранено' }, }) - const post = useMutation({ - mutationFn: async () => { await api.post(`/api/inventory/inventories/${id}/post`) }, + const errMsg = (e: Error) => (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message + + // Завершить инвентаризацию — заморозить строки (остатки НЕ двигаются). + const complete = useMutation({ + mutationFn: async () => { await api.post(`/api/inventory/inventories/${id}/complete`) }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['/api/inventory/inventories'] }) + existing.refetch() + }, + onError: (e: Error) => setError(errMsg(e)), + meta: { successMessage: 'Инвентаризация завершена' }, + }) + + // Создать Оприходование излишков / Списание недостачи «на основании». + const createEnter = useMutation({ + mutationFn: async () => (await api.post(`/api/inventory/inventories/${id}/create-enter`)).data as { enterId: string; number: string }, + onSuccess: (r) => { + qc.invalidateQueries({ queryKey: ['/api/inventory/inventories'] }) + existing.refetch() + navigate(`/inventory/enters/${r.enterId}`) + }, + onError: (e: Error) => setError(errMsg(e)), + meta: { successMessage: 'Оприходование создано' }, + }) + const createLoss = useMutation({ + mutationFn: async () => (await api.post(`/api/inventory/inventories/${id}/create-loss`)).data as { lossId: string; number: string }, + onSuccess: (r) => { + qc.invalidateQueries({ queryKey: ['/api/inventory/inventories'] }) + existing.refetch() + navigate(`/inventory/losses/${r.lossId}`) + }, + onError: (e: Error) => setError(errMsg(e)), + meta: { successMessage: 'Списание создано' }, + }) + + // Вернуть в черновик: отменяет проведение и удаляет связанные документы. + const unpost = useMutation({ + mutationFn: async () => { await api.post(`/api/inventory/inventories/${id}/unpost`) }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['/api/inventory/inventories'] }) qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] }) qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] }) existing.refetch() }, - onError: (e: Error) => { - const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message - setError(msg) - }, - meta: { successMessage: 'Проведено' }, - }) - - const unpost = useMutation({ - mutationFn: async () => { await api.post(`/api/inventory/inventories/${id}/unpost`) }, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['/api/inventory/inventories'] }) - qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] }) - existing.refetch() - }, - onError: (e: Error) => { - const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message - setError(msg) - }, - meta: { successMessage: 'Снято с проведения' }, + onError: (e: Error) => setError(errMsg(e)), + meta: { successMessage: 'Возвращено в черновик' }, }) const remove = useMutation({ @@ -206,7 +230,6 @@ export function InventoryEditPage() { })) } - const canPost = isDraft && form.lines.some((l) => l.diff !== 0) && !isNew // Хоткеи edit-страницы: Ctrl/Cmd+S = сохранить (через create на новой, save на // существующей), Esc = назад к списку. Esc отключается когда открыт ConfirmDialog. @@ -239,9 +262,9 @@ export function InventoryEditPage() {

{isNew ? 'Новая инвентаризация' : existing.data?.number ?? 'Инвентаризация'}

- {isPosted && ( + {isCompleted && (

- Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''} + Завершена {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}

)} @@ -285,7 +308,7 @@ export function InventoryEditPage() {
- setForm({ ...form, date: iso ?? '' })} /> @@ -296,7 +319,7 @@ export function InventoryEditPage() { -