feat(supply): «Проведено» внутри формы + обязательная дата и ≥1 позиция

UI:
- Чекбокс «Проведено» переехал из шапки в секцию «Реквизиты документа»,
  чтобы было визуально как в сторонняя система. С хинтом «только проведённый
  документ влияет на остатки и себестоимость».
- Поле «Дата» помечено как обязательное (звёздочка + required).
- canSave требует form.lines.length > 0; пустое состояние секции
  «Позиции» теперь красное «должна быть хотя бы одна позиция».
- onError приёмки достаёт сообщение из response.data.error.

API:
- Create/Update приёмки 400-ят без позиций
  («Приёмка должна содержать хотя бы одну позицию.»).
- Post (проведение) уже валидирует это; теперь и на этапе сохранения.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 01:28:29 +05:00
parent 72e602f4ca
commit b7288bac1b
2 changed files with 40 additions and 25 deletions

View file

@ -123,6 +123,8 @@ public async Task<ActionResult<SupplyDto>> Get(Guid id, CancellationToken ct)
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")] [HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct) public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input, CancellationToken ct)
{ {
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Приёмка должна содержать хотя бы одну позицию." });
var number = await GenerateNumberAsync(input.Date, ct); var number = await GenerateNumberAsync(input.Date, ct);
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct); var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
var supply = new Supply var supply = new Supply
@ -164,6 +166,8 @@ public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input,
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct) public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, CancellationToken ct)
{ {
if (input.Lines is null || input.Lines.Count == 0)
return BadRequest(new { error = "Приёмка должна содержать хотя бы одну позицию." });
var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); var supply = await _db.Supplies.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
if (supply is null) return NotFound(); if (supply is null) return NotFound();
if (supply.Status != SupplyStatus.Draft) if (supply.Status != SupplyStatus.Draft)

View file

@ -4,7 +4,7 @@ import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react' import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { Field, TextInput, TextArea, Select, MoneyInput, NumberInput } from '@/components/Field' import { Field, TextInput, TextArea, Select, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
import { ProductPicker } from '@/components/ProductPicker' import { ProductPicker } from '@/components/ProductPicker'
import { useStores, useCurrencies, useSuppliers } from '@/lib/useLookups' import { useStores, useCurrencies, useSuppliers } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
@ -162,7 +162,10 @@ export function SupplyEditPage() {
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] }) qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
existing.refetch() existing.refetch()
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message
setError(msg)
},
}) })
const remove = useMutation({ const remove = useMutation({
@ -195,7 +198,9 @@ export function SupplyEditPage() {
const removeLine = (i: number) => const removeLine = (i: number) =>
setForm({ ...form, lines: form.lines.filter((_, ix) => ix !== i) }) setForm({ ...form, lines: form.lines.filter((_, ix) => ix !== i) })
const canSave = !!form.supplierId && !!form.storeId && !!form.currencyId && isDraft const canSave = !!form.date && !!form.supplierId && !!form.storeId && !!form.currencyId
&& form.lines.length > 0
&& isDraft
return ( return (
<form onSubmit={onSubmit} className="flex flex-col h-full"> <form onSubmit={onSubmit} className="flex flex-col h-full">
@ -217,25 +222,6 @@ export function SupplyEditPage() {
</div> </div>
</div> </div>
<div className="flex gap-3 flex-shrink-0 items-center"> <div className="flex gap-3 flex-shrink-0 items-center">
{!isNew && (
<label className={`flex items-center gap-2 text-sm ${form.lines.length === 0 ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}>
<input
type="checkbox"
checked={isPosted}
disabled={post.isPending || unpost.isPending || form.lines.length === 0
|| form.lines.some(l => l.quantity <= 0 || l.unitPrice <= 0)}
onChange={(e) => {
if (e.target.checked) {
if (confirm('После проведения товары будут оприходованы на склад. Продолжить?')) post.mutate()
} else {
if (confirm('Снять проведение? Остатки откатятся, себестоимость останется (пересчитать вручную при необходимости).')) unpost.mutate()
}
}}
className="w-4 h-4 rounded border-slate-300 text-[var(--color-brand)] focus:ring-[var(--color-brand)]"
/>
<span className="font-medium">Проведено</span>
</label>
)}
{isDraft && !isNew && ( {isDraft && !isNew && (
<Button type="button" variant="danger" size="sm" onClick={() => { if (confirm('Удалить черновик приёмки?')) remove.mutate() }}> <Button type="button" variant="danger" size="sm" onClick={() => { if (confirm('Удалить черновик приёмки?')) remove.mutate() }}>
<Trash2 className="w-4 h-4" /> Удалить <Trash2 className="w-4 h-4" /> Удалить
@ -258,8 +244,8 @@ export function SupplyEditPage() {
<Section title="Реквизиты документа"> <Section title="Реквизиты документа">
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3"> <div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
<Field label="Дата"> <Field label="Дата *">
<TextInput type="date" value={form.date} disabled={isPosted} <TextInput type="date" required value={form.date} disabled={isPosted}
onChange={(e) => setForm({ ...form, date: e.target.value })} /> onChange={(e) => setForm({ ...form, date: e.target.value })} />
</Field> </Field>
<Field label="Поставщик *"> <Field label="Поставщик *">
@ -308,6 +294,31 @@ export function SupplyEditPage() {
onChange={(e) => setForm({ ...form, notes: e.target.value })} /> onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</Field> </Field>
</div> </div>
{!isNew && (
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
<Checkbox
label="Проведено"
checked={isPosted}
disabled={post.isPending || unpost.isPending || form.lines.length === 0
|| form.lines.some(l => l.quantity <= 0 || l.unitPrice <= 0)}
onChange={(v) => {
if (v) {
if (confirm('После проведения товары будут оприходованы на склад и обновят себестоимость (скользящее среднее). Продолжить?')) post.mutate()
} else {
if (confirm('Снять проведение? Остатки откатятся, себестоимость останется (пересчитать вручную при необходимости).')) unpost.mutate()
}
}}
/>
<p className="text-xs text-slate-500 mt-1">
Только проведённый документ влияет на остатки склада и себестоимость.
Черновик можно править, проведённый только распровести и редактировать заново.
{isPosted && existing.data?.postedAt && (
<> Проведён {new Date(existing.data.postedAt).toLocaleString('ru')}.</>
)}
</p>
</div>
)}
</Section> </Section>
<Section <Section
@ -319,7 +330,7 @@ export function SupplyEditPage() {
)} )}
> >
{form.lines.length === 0 ? ( {form.lines.length === 0 ? (
<div className="text-sm text-slate-400 py-4 text-center">Позиций нет. Нажми «Добавить товар».</div> <div className="text-sm text-red-600 py-4 text-center">В приёмке должна быть хотя бы одна позиция. Нажми «Добавить товар».</div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">