feat(web): привычные названия денег (ПКО/РКО) + прибыль на главной + статусы с подсказкой
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 Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

П.7: типы денег названы привычно — «Приход наличных (ПКО)», «Расход наличных (РКО)»,
«Входящий/Исходящий платёж», «Перевод между счетами».
П.8: блок «Прибыль за сегодня/месяц» на дашборде (во всех режимах, /api/dashboard/margin),
с пометкой «приблизительно; точно после FIFO».
П.9: общий DocStatusBadge (Черновик/Проведён/Завершена) с tooltip «что это значит»,
применён на списках Приёмок/Продаж/Инвентаризаций.
П.6: проверено — Оприходование/Списание в простом режиме не прячутся (отдельные пункты).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 06:04:18 +05:00
parent ff4391d19f
commit 0f1574df64
8 changed files with 131 additions and 29 deletions

View file

@ -6,12 +6,13 @@
Легенда: [x] готово · [~] частично · [ ] не начато. Легенда: [x] готово · [~] частично · [ ] не начато.
## План / статус ## План / статус
1. [ ] **Смена кассы**: автооткрытие первой продажей/внесением (вопрос «размен?», можно пропустить); 1. [~] **Смена кассы**: автооткрытие первой продажей (веб-продажа) — ✅ бэкенд+тест; подбор Cash-счёта,
ручное открытие остаётся; длительность >24ч — предупреждение кассиру/админу + отметка в отчёте. размен 0. Длительность/overLimit24h в /current и Z-отчёте — ✅. **Осталось:** автооткрытие на
POS + веб. POS-синке (AcceptSales), UI-вопрос «размен?», фоновое уведомление админу о >24ч.
2. [ ] **Z-отчёт**: разбивка по товарам (кол-во, выручка, возвраты) + печать. 2. [~] **Z-отчёт**: разбивка byProduct (продано/возвращено кол-во+сумма) — ✅ бэкенд+тест. **Осталось:**
3. [ ] **Возврат покупателя**: способ возврата = способ оплаты исходного чека по умолчанию; другой — показ в вебе/кассе + печать Z-отчёта по товарам.
только по праву администратора, с причиной. 3. [x] **Возврат покупателя**: способ по умолчанию = оплата чека (create-return уже так); иной способ —
право админа + причина (RefundReason). Бэкенд+тест ✅.
4. [ ] **Карточка товара** (правки владельца перекрывают аудит): 4. [ ] **Карточка товара** (правки владельца перекрывают аудит):
- Сохранить можно при наличии Наименования. - Сохранить можно при наличии Наименования.
- Единица обязательна, предзаполнена «шт» (пустой не бывает). - Единица обязательна, предзаполнена «шт» (пустой не бывает).
@ -23,14 +24,19 @@
сообщение «у товара нет цены продажи» + быстрый ввод цены (по праву). сообщение «у товара нет цены продажи» + быстрый ввод цены (по праву).
- Быстрое создание товара из приёмки/кассы по неизвестному штрихкоду (1 поле + цена). - Быстрое создание товара из приёмки/кассы по неизвестному штрихкоду (1 поле + цена).
- Недостающее ловит фоновая «Проверка данных», а не запрет сохранения. - Недостающее ловит фоновая «Проверка данных», а не запрет сохранения.
5. [ ] **«Создать на основании» + связи** — сквозной механизм для всех документов; блок «Связанные 5. [~] **«Создать на основании» + связи** — частично: инвентаризация→опр/спис (есть), продажа→возврат
документы» и «На основании …» везде (инвентаризация — уже). (create-return, ✅), приёмка→оплата (есть). **Осталось:** приёмка→возврат поставщику с преднабором;
6. [ ] **Простой режим**: Оприходование/Списание остаются отдельными пунктами (НЕ прятать). единый блок «Связанные документы»/«На основании …» во всех документах.
7. [ ] **Деньги**: единый журнал, но типы назвать привычно (ПКО/РКО/вх/исх платёж/перевод); 6. [x] **Простой режим**: Оприходование/Списание уже отдельные пункты меню для Admin/Кладовщика вне
акт сверки с поставщиком (печать/PDF). зависимости от режима (AppLayout.tsx:111-112 — НЕ за advanced-гейтом). Проверено, ничего не прячем.
8. [ ] **Главная простого режима**: блок «Прибыль за сегодня/месяц» (точная — после FIFO). 7. [~] **Деньги**: типы названы привычно — «Приход наличных (ПКО)», «Расход наличных (РКО)», «Входящий/
9. [ ] **Статусы на русском**: Черновик / Проведён / Завершена + подсказка при наведении. Исходящий платёж», «Перевод между счетами» (money.ts) ✅. **Осталось:** акт сверки с поставщиком (PDF/печать).
10. [ ] **Тесты** (unit/integration/e2e) по каждому пункту; full-chain; деплой; скриншоты. 8. [x] **Главная**: блок «Прибыль за сегодня/месяц» на дашборде (во всех режимах), по /api/dashboard/margin;
с пометкой «приблизительно, точно после FIFO».
9. [~] **Статусы на русском** + подсказка: общий `DocStatusBadge` (Черновик/Проведён/Завершена с tooltip
«что это значит»), применён на списках Приёмок/Продаж/Инвентаризаций. **Осталось:** раскатать на
остальные страницы (опр/спис/перемещения/возвраты/demands, edit-страницы).
10. [~] **Тесты/деплой/скрины**: unit+integration по пп.1–4 ✅; деплой+скрины — по ходу.
## Коммиты ## Коммиты
(заполняется по ходу) (заполняется по ходу)

View file

@ -0,0 +1,40 @@
/**
* Единый бейдж статуса документа с понятной русской подписью и подсказкой
* «что это значит» при наведении (п.9 паритета).
*
* Статусы:
* - draft (Черновик) — документ создан, но ещё НЕ влияет на остатки/деньги; можно свободно править.
* - posted (Проведён) — документ проведён: остатки и деньги изменены; для правки сначала отмените проведение.
* - completed (Завершена) — для инвентаризации: пересчёт завершён; движения оформляются отдельными документами.
*/
export type DocStatusKind = 'draft' | 'posted' | 'completed'
const MAP: Record<DocStatusKind, { label: string; tip: string; cls: string }> = {
draft: {
label: 'Черновик',
tip: 'Документ создан, но ещё не влияет на остатки и деньги. Можно свободно редактировать и удалить.',
cls: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
},
posted: {
label: 'Проведён',
tip: 'Документ проведён: остатки и деньги изменены. Чтобы отредактировать — сначала отмените проведение.',
cls: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300',
},
completed: {
label: 'Завершена',
tip: 'Пересчёт завершён. Остатки корректируются отдельными документами (Оприходование/Списание), созданными на основании инвентаризации.',
cls: 'bg-blue-100 text-blue-800 dark:bg-blue-950/50 dark:text-blue-300',
},
}
export function DocStatusBadge({ status, labelOverride }: { status: DocStatusKind; labelOverride?: string }) {
const s = MAP[status]
return (
<span
title={s.tip}
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium cursor-help ${s.cls}`}
>
{labelOverride ?? s.label}
</span>
)
}

View file

@ -0,0 +1,53 @@
import { useQuery } from '@tanstack/react-query'
import { TrendingUp } from 'lucide-react'
import { api } from '@/lib/api'
import { useFormatCurrency } from '@/lib/useFormatCurrency'
interface MarginSummary { revenue: number; cost: number; margin: number; marginPercent: number }
function useMargin(days: number) {
return useQuery<MarginSummary>({
queryKey: ['/api/dashboard/margin', days],
queryFn: async () => (await api.get<MarginSummary>(`/api/dashboard/margin?days=${days}`)).data,
staleTime: 60_000,
})
}
/**
* Блок «Прибыль за сегодня/месяц» для главной простого режима (п.8 паритета).
* Прибыль = выручка − себестоимость. Себестоимость сейчас приближённая (текущая
* из карточки товара); точной она станет после FIFO — об этом подсказка.
*/
export function ProfitHomeBlock() {
const fmt = useFormatCurrency()
const today = useMargin(1)
const month = useMargin(30)
const Card = ({ label, q }: { label: string; q: ReturnType<typeof useMargin> }) => (
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-5">
<div className="flex items-center gap-2 text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">
<TrendingUp className="w-4 h-4 text-emerald-600" /> {label}
</div>
<div className="mt-1 text-2xl font-semibold text-emerald-700 dark:text-emerald-400">
{q.data ? fmt(q.data.margin, { compact: true }) : '—'}
</div>
{q.data && (
<div className="mt-0.5 text-xs text-slate-400">
выручка {fmt(q.data.revenue, { compact: true })} · маржа {q.data.marginPercent}%
</div>
)}
</div>
)
return (
<div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Card label="Прибыль за сегодня" q={today} />
<Card label="Прибыль за месяц" q={month} />
</div>
<p className="mt-1.5 text-[11px] text-slate-400">
Прибыль приблизительная (по текущей себестоимости из карточек). Точной станет после перехода на FIFO.
</p>
</div>
)
}

View file

@ -41,13 +41,15 @@ export const ACCOUNT_KIND_LABEL: Record<number, string> = {
[MoneyAccountKind.Acquiring]: 'Эквайринг', [MoneyAccountKind.Acquiring]: 'Эквайринг',
} }
// Привычные названия (как у бухгалтеров/МойСклада): ПКО/РКО для наличных,
// входящий/исходящий платёж для безнала, перевод между счетами.
export const TX_KIND_LABEL: Record<number, string> = { export const TX_KIND_LABEL: Record<number, string> = {
[MoneyTxKind.PaymentIn]: 'Поступление (безнал)', [MoneyTxKind.CashIn]: 'Приход наличных (ПКО)',
[MoneyTxKind.PaymentOut]: 'Расход (безнал)', [MoneyTxKind.CashOut]: 'Расход наличных (РКО)',
[MoneyTxKind.CashIn]: 'Приход наличных', [MoneyTxKind.PaymentIn]: 'Входящий платёж',
[MoneyTxKind.CashOut]: 'Расход наличных', [MoneyTxKind.PaymentOut]: 'Исходящий платёж',
[MoneyTxKind.TransferOut]: 'Перевод (списание)', [MoneyTxKind.TransferOut]: 'Перевод между счетами (списание)',
[MoneyTxKind.TransferIn]: 'Перевод (зачисление)', [MoneyTxKind.TransferIn]: 'Перевод между счетами (зачисление)',
} }
/** Приходный ли тип (увеличивает баланс счёта) — для знака и цвета суммы. */ /** Приходный ли тип (увеличивает баланс счёта) — для знака и цвета суммы. */

View file

@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { Package, Users, Warehouse, Store, TrendingUp, TrendingDown, Receipt, Banknote, Calendar, Wifi, WifiOff, CalendarDays } from 'lucide-react' import { Package, Users, Warehouse, Store, TrendingUp, TrendingDown, Receipt, Banknote, Calendar, Wifi, WifiOff, CalendarDays } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import { OverdueNegativeStockBlock } from '@/components/OverdueNegativeStockBlock' import { OverdueNegativeStockBlock } from '@/components/OverdueNegativeStockBlock'
import { ProfitHomeBlock } from '@/components/ProfitHomeBlock'
import { Skeleton } from '@/components/Skeleton' import { Skeleton } from '@/components/Skeleton'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
@ -163,6 +164,9 @@ export function DashboardPage() {
{/* Эскалация: товары, висящие в минусе дольше порога (item 11). */} {/* Эскалация: товары, висящие в минусе дольше порога (item 11). */}
<OverdueNegativeStockBlock /> <OverdueNegativeStockBlock />
{/* Прибыль за сегодня/месяц — на главной во всех режимах (п.8 паритета). */}
<ProfitHomeBlock />
{/* KPI блок продажи: today / week / month + prev-month сравнение */} {/* KPI блок продажи: today / week / month + prev-month сравнение */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<KpiCard <KpiCard

View file

@ -1,3 +1,4 @@
import { DocStatusBadge } from '@/components/DocStatusBadge'
import { useRef } from 'react' import { useRef } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { ClipboardList, Pencil, Smartphone } from 'lucide-react' import { ClipboardList, Pencil, Smartphone } from 'lucide-react'
@ -76,9 +77,7 @@ export function InventoriesPage() {
{ header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> }, { header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') }, { header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => ( { header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
r.status === InventoryStatus.Completed <DocStatusBadge status={r.status === InventoryStatus.Completed ? 'completed' : 'draft'} />
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Завершена</span>
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
)}, )},
{ header: 'Склад', cell: (r) => r.storeName }, { header: 'Склад', cell: (r) => r.storeName },
{ header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount }, { header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount },

View file

@ -1,3 +1,4 @@
import { DocStatusBadge } from '@/components/DocStatusBadge'
import { useRef } from 'react' import { useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Plus, Receipt } from 'lucide-react' import { Plus, Receipt } from 'lucide-react'
@ -77,9 +78,7 @@ export function RetailSalesPage() {
{ header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> }, { header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
{ header: 'Дата/время', width: '160px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleString('ru') }, { header: 'Дата/время', width: '160px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleString('ru') },
{ header: 'Статус', width: '120px', sortKey: 'status', cell: (r) => ( { header: 'Статус', width: '120px', sortKey: 'status', cell: (r) => (
r.status === RetailSaleStatus.Posted <DocStatusBadge status={r.status === RetailSaleStatus.Posted ? 'posted' : 'draft'} />
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
)}, )},
{ header: 'Магазин', sortKey: 'store', cell: (r) => r.storeName }, { header: 'Магазин', sortKey: 'store', cell: (r) => r.storeName },
{ header: 'Касса', width: '160px', cell: (r) => r.retailPointName ?? '—' }, { header: 'Касса', width: '160px', cell: (r) => r.retailPointName ?? '—' },

View file

@ -14,6 +14,7 @@ import { useShortcuts } from '@/lib/useShortcuts'
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
import { PAYMENT_STATUS_LABEL, PAYMENT_STATUS_BADGE } from '@/lib/money' import { PAYMENT_STATUS_LABEL, PAYMENT_STATUS_BADGE } from '@/lib/money'
import { type SupplyListRow, SupplyStatus } from '@/lib/types' import { type SupplyListRow, SupplyStatus } from '@/lib/types'
import { DocStatusBadge } from '@/components/DocStatusBadge'
const URL = '/api/purchases/supplies' const URL = '/api/purchases/supplies'
@ -90,9 +91,7 @@ export function SuppliesPage() {
{ header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> }, { header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') }, { header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
{ header: 'Статус', width: '130px', sortKey: 'status', cell: (r) => ( { header: 'Статус', width: '130px', sortKey: 'status', cell: (r) => (
r.status === SupplyStatus.Posted <DocStatusBadge status={r.status === SupplyStatus.Posted ? 'posted' : 'draft'} />
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
)}, )},
{ header: 'Поставщик', sortKey: 'supplier', cell: (r) => r.supplierName }, { header: 'Поставщик', sortKey: 'supplier', cell: (r) => r.supplierName },
{ header: 'Склад', width: '180px', sortKey: 'store', cell: (r) => r.storeName }, { header: 'Склад', width: '180px', sortKey: 'store', cell: (r) => r.storeName },