feat(supply): «Проведено» внутри формы + обязательная дата и ≥1 позиция
Some checks failed
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
CI / Backend (.NET 8) (push) Successful in 50s
CI / Web (React + Vite) (push) Has been cancelled
Docker API / Build + push API (push) Successful in 45s
Docker API / Deploy API on stage (push) Successful in 17s
Some checks failed
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
CI / Backend (.NET 8) (push) Successful in 50s
CI / Web (React + Vite) (push) Has been cancelled
Docker API / Build + push API (push) Successful in 45s
Docker API / Deploy API on stage (push) Successful in 17s
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:
parent
458797f417
commit
cd191bd872
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue