feat(product+filters): срок годности (shelfLifeDays) + фильтр от/до
Some checks are pending
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 41s
CI / Web (React + Vite) (push) Successful in 33s
Docker API / Build + push API (push) Successful in 42s
Docker API / Deploy API on stage (push) Successful in 16s
Some checks are pending
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 41s
CI / Web (React + Vite) (push) Successful in 33s
Docker API / Build + push API (push) Successful in 42s
Docker API / Deploy API on stage (push) Successful in 16s
ProductEditPage: - В секции «Основное» добавлено поле «Срок годности (дней)» рядом с Артикулом — NumberInput, целое ≥ 0, hint «не обязательное поле». - form.shelfLifeDays хранится строкой и сериализуется в payload как number | null. ProductsPage filters: - Добавлен диапазон «Срок годности (дней) — от / до». - Удалён остаток фильтра isActive (поле выпилено в Phase3b foundation). ProductsController.List: - Принимает shelfLifeDaysFrom / shelfLifeDaysTo (int?) и применяет ≥ / ≤ к p.ShelfLifeDays. - Также теперь принимает referencePriceFrom / referencePriceTo (новые имена); старые purchasePriceFrom/To работают как алиасы для обратной совместимости с уже отрендеренным UI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5a020cfafa
commit
2976070a2a
|
|
@ -94,6 +94,10 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
|||
[FromQuery] bool? isMarked,
|
||||
[FromQuery] decimal? purchasePriceFrom,
|
||||
[FromQuery] decimal? purchasePriceTo,
|
||||
[FromQuery] decimal? referencePriceFrom,
|
||||
[FromQuery] decimal? referencePriceTo,
|
||||
[FromQuery] int? shelfLifeDaysFrom,
|
||||
[FromQuery] int? shelfLifeDaysTo,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var q = QueryIncludes().AsNoTracking();
|
||||
|
|
@ -115,8 +119,14 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
|||
if (isService is not null) q = q.Where(p => p.IsService == isService);
|
||||
if (packaging is not null) q = q.Where(p => p.Packaging == packaging);
|
||||
if (isMarked is not null) q = q.Where(p => p.IsMarked == isMarked);
|
||||
if (purchasePriceFrom is not null) q = q.Where(p => p.ReferencePrice >= purchasePriceFrom);
|
||||
if (purchasePriceTo is not null) q = q.Where(p => p.ReferencePrice <= purchasePriceTo);
|
||||
// referencePriceFrom/To — новый, актуальный параметр; purchasePriceFrom/To
|
||||
// — обратная совместимость c прежним UI (тоже по ReferencePrice).
|
||||
var refFrom = referencePriceFrom ?? purchasePriceFrom;
|
||||
var refTo = referencePriceTo ?? purchasePriceTo;
|
||||
if (refFrom is not null) q = q.Where(p => p.ReferencePrice >= refFrom);
|
||||
if (refTo is not null) q = q.Where(p => p.ReferencePrice <= refTo);
|
||||
if (shelfLifeDaysFrom is not null) q = q.Where(p => p.ShelfLifeDays >= shelfLifeDaysFrom);
|
||||
if (shelfLifeDaysTo is not null) q = q.Where(p => p.ShelfLifeDays <= shelfLifeDaysTo);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ interface Form {
|
|||
referencePrice: string
|
||||
purchaseCurrencyId: string
|
||||
imageUrl: string
|
||||
shelfLifeDays: string
|
||||
prices: PriceRow[]
|
||||
barcodes: BarcodeRow[]
|
||||
}
|
||||
|
|
@ -47,6 +48,7 @@ const emptyForm: Form = {
|
|||
minStock: '', maxStock: '',
|
||||
referencePrice: '', purchaseCurrencyId: '',
|
||||
imageUrl: '',
|
||||
shelfLifeDays: '',
|
||||
prices: [],
|
||||
barcodes: [],
|
||||
}
|
||||
|
|
@ -89,6 +91,7 @@ export function ProductEditPage() {
|
|||
referencePrice: p.referencePrice?.toString() ?? '',
|
||||
purchaseCurrencyId: p.purchaseCurrencyId ?? '',
|
||||
imageUrl: p.imageUrl ?? '',
|
||||
shelfLifeDays: p.shelfLifeDays?.toString() ?? '',
|
||||
prices: p.prices.map((x) => ({ id: x.id, priceTypeId: x.priceTypeId, amount: x.amount, currencyId: x.currencyId })),
|
||||
barcodes: p.barcodes.map((x) => ({ id: x.id, code: x.code, type: x.type, isPrimary: x.isPrimary })),
|
||||
})
|
||||
|
|
@ -149,6 +152,7 @@ export function ProductEditPage() {
|
|||
referencePrice: form.referencePrice === '' ? null : Number(form.referencePrice),
|
||||
purchaseCurrencyId: form.purchaseCurrencyId || null,
|
||||
imageUrl: form.imageUrl || null,
|
||||
shelfLifeDays: form.shelfLifeDays === '' ? null : Number(form.shelfLifeDays),
|
||||
prices: form.prices.map((p) => ({ priceTypeId: p.priceTypeId, amount: Number(p.amount), currencyId: p.currencyId })),
|
||||
barcodes: form.barcodes.map((b) => ({ code: b.code, type: b.type, isPrimary: b.isPrimary })),
|
||||
}
|
||||
|
|
@ -250,6 +254,14 @@ export function ProductEditPage() {
|
|||
<Field label="Артикул">
|
||||
<TextInput value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Срок годности (дней)">
|
||||
<NumberInput
|
||||
value={form.shelfLifeDays === '' ? null : Number(form.shelfLifeDays)}
|
||||
onChange={(n) => setForm({ ...form, shelfLifeDays: n == null ? '' : String(Math.max(0, Math.round(n))) })}
|
||||
placeholder="—"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">не обязательное поле</p>
|
||||
</Field>
|
||||
<Field label="Описание" className="col-span-3">
|
||||
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
</Field>
|
||||
|
|
|
|||
|
|
@ -18,45 +18,49 @@ type TriFilter = 'all' | 'yes' | 'no'
|
|||
|
||||
interface Filters {
|
||||
groupId: string | null
|
||||
isActive: TriFilter
|
||||
isService: TriFilter
|
||||
packaging: number | null // null = все, 1/2/3 = Piece/Weight/Liquid
|
||||
isMarked: TriFilter
|
||||
referencePriceFrom: number | null
|
||||
referencePriceTo: number | null
|
||||
shelfLifeDaysFrom: number | null
|
||||
shelfLifeDaysTo: number | null
|
||||
}
|
||||
|
||||
const defaultFilters: Filters = {
|
||||
groupId: null,
|
||||
isActive: 'yes',
|
||||
isService: 'all',
|
||||
packaging: null,
|
||||
isMarked: 'all',
|
||||
referencePriceFrom: null,
|
||||
referencePriceTo: null,
|
||||
shelfLifeDaysFrom: null,
|
||||
shelfLifeDaysTo: null,
|
||||
}
|
||||
|
||||
const toExtra = (f: Filters): Record<string, string | number | boolean | undefined> => {
|
||||
const e: Record<string, string | number | boolean | undefined> = {}
|
||||
if (f.groupId) e.groupId = f.groupId
|
||||
if (f.isActive !== 'all') e.isActive = f.isActive === 'yes'
|
||||
if (f.isService !== 'all') e.isService = f.isService === 'yes'
|
||||
if (f.packaging) e.packaging = f.packaging
|
||||
if (f.isMarked !== 'all') e.isMarked = f.isMarked === 'yes'
|
||||
if (f.referencePriceFrom != null) e.referencePriceFrom = f.referencePriceFrom
|
||||
if (f.referencePriceTo != null) e.referencePriceTo = f.referencePriceTo
|
||||
if (f.shelfLifeDaysFrom != null) e.shelfLifeDaysFrom = f.shelfLifeDaysFrom
|
||||
if (f.shelfLifeDaysTo != null) e.shelfLifeDaysTo = f.shelfLifeDaysTo
|
||||
return e
|
||||
}
|
||||
|
||||
const activeFilterCount = (f: Filters) => {
|
||||
let n = 0
|
||||
if (f.groupId) n++
|
||||
if (f.isActive !== 'yes') n++ // 'yes' is default, count non-default
|
||||
if (f.isService !== 'all') n++
|
||||
if (f.packaging) n++
|
||||
if (f.isMarked !== 'all') n++
|
||||
if (f.referencePriceFrom != null) n++
|
||||
if (f.referencePriceTo != null) n++
|
||||
if (f.shelfLifeDaysFrom != null) n++
|
||||
if (f.shelfLifeDaysTo != null) n++
|
||||
return n
|
||||
}
|
||||
|
||||
|
|
@ -213,7 +217,6 @@ export function ProductsPage() {
|
|||
{/* Filter panel */}
|
||||
{filtersOpen && (
|
||||
<div className="px-4 sm:px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-900/60 flex flex-wrap gap-3 sm:gap-4 items-center">
|
||||
<Tri label="Активные" value={filters.isActive} onChange={(v) => { setFilters({ ...filters, isActive: v }); setPage(1) }} />
|
||||
{showService && (
|
||||
<Tri label="Услуга" value={filters.isService} onChange={(v) => { setFilters({ ...filters, isService: v }); setPage(1) }} />
|
||||
)}
|
||||
|
|
@ -256,6 +259,23 @@ export function ProductsPage() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-slate-500">Срок годности (дней)</span>
|
||||
<input
|
||||
type="number" min="0"
|
||||
value={filters.shelfLifeDaysFrom ?? ''}
|
||||
placeholder="от"
|
||||
onChange={(e) => { const v = e.target.value === '' ? null : Number(e.target.value); setFilters({ ...filters, shelfLifeDaysFrom: v }); setPage(1) }}
|
||||
className="w-20 px-2 py-0.5 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900"
|
||||
/>
|
||||
<input
|
||||
type="number" min="0"
|
||||
value={filters.shelfLifeDaysTo ?? ''}
|
||||
placeholder="до"
|
||||
onChange={(e) => { const v = e.target.value === '' ? null : Number(e.target.value); setFilters({ ...filters, shelfLifeDaysTo: v }); setPage(1) }}
|
||||
className="w-20 px-2 py-0.5 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900"
|
||||
/>
|
||||
</div>
|
||||
{activeCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
Loading…
Reference in a new issue