ui: barcode регенерация / h-10 поля / прыжок на страницу / min-max скрытие
- lib/barcode.ts: новая утилита generateBarcode(type) — валидные коды под все форматы (EAN-13 с префиксом 2, EAN-8 с checksum, UPC-A с checksum, UPC-E 8 цифр, Code128/Code39 12 буквенно-цифровых). - ProductEditPage: при смене типа штрихкода в dropdown поле кода регенерируется под новый формат. - Field.tsx: единая высота h-10 и leading-none для TextInput/Select чтобы Страна/Валюта/НДС в настройках были одного размера. TextArea оставлен с h-auto для multiline. - Pagination.tsx: рядом с ← → добавлен input «Страница [N] из M» для прыжка на произвольную страницу (Enter / blur применяют). - ProductEditPage: блок мин/макс остатков теперь показывается только при org.showMinMaxStock (сама настройка добавится следующим коммитом). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
efeeb61e42
commit
195ca2e2bb
|
|
@ -18,14 +18,15 @@ export function Field({ label, error, children, className }: FieldProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputClass = 'w-full rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] disabled:opacity-60'
|
const inputClass = 'w-full h-10 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 text-sm leading-none focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] disabled:opacity-60 disabled:bg-slate-50 dark:disabled:bg-slate-800/60'
|
||||||
|
|
||||||
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
export function TextInput(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||||
return <input {...props} className={cn(inputClass, props.className)} />
|
return <input {...props} className={cn(inputClass, props.className)} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TextArea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
export function TextArea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||||
return <textarea {...props} className={cn(inputClass, 'font-[inherit]', props.className)} />
|
// TextArea — multi-line, высоту не фиксируем.
|
||||||
|
return <textarea {...props} className={cn(inputClass, 'h-auto py-2 font-[inherit] leading-normal', props.className)} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {
|
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
interface PaginationProps {
|
interface PaginationProps {
|
||||||
page: number
|
page: number
|
||||||
pageSize: number
|
pageSize: number
|
||||||
|
|
@ -9,8 +11,20 @@ export function Pagination({ page, pageSize, total, onPageChange }: PaginationPr
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||||
const from = (page - 1) * pageSize + 1
|
const from = (page - 1) * pageSize + 1
|
||||||
const to = Math.min(page * pageSize, total)
|
const to = Math.min(page * pageSize, total)
|
||||||
|
const [jumpValue, setJumpValue] = useState<string>(String(page))
|
||||||
|
|
||||||
|
useEffect(() => { setJumpValue(String(page)) }, [page])
|
||||||
|
|
||||||
if (total === 0) return null
|
if (total === 0) return null
|
||||||
|
|
||||||
|
const commitJump = () => {
|
||||||
|
const n = parseInt(jumpValue, 10)
|
||||||
|
if (!Number.isFinite(n)) { setJumpValue(String(page)); return }
|
||||||
|
const clamped = Math.min(Math.max(1, n), totalPages)
|
||||||
|
if (clamped !== page) onPageChange(clamped)
|
||||||
|
setJumpValue(String(clamped))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between mt-3 text-sm text-slate-500">
|
<div className="flex items-center justify-between mt-3 text-sm text-slate-500">
|
||||||
<span>{from}–{to} из {total}</span>
|
<span>{from}–{to} из {total}</span>
|
||||||
|
|
@ -22,7 +36,20 @@ export function Pagination({ page, pageSize, total, onPageChange }: PaginationPr
|
||||||
>
|
>
|
||||||
←
|
←
|
||||||
</button>
|
</button>
|
||||||
<span>{page} / {totalPages}</span>
|
<span className="flex items-center gap-1">
|
||||||
|
<span>Страница</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={totalPages}
|
||||||
|
value={jumpValue}
|
||||||
|
onChange={(e) => setJumpValue(e.target.value)}
|
||||||
|
onBlur={commitJump}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitJump() } }}
|
||||||
|
className="w-14 px-1.5 py-0.5 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-center tabular-nums"
|
||||||
|
/>
|
||||||
|
<span>из {totalPages}</span>
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
disabled={page >= totalPages}
|
disabled={page >= totalPages}
|
||||||
onClick={() => onPageChange(page + 1)}
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,73 @@
|
||||||
// EAN-13 утилиты.
|
// Штрихкод-утилиты: генерация валидных кодов под разные форматы.
|
||||||
//
|
//
|
||||||
// Внутренние штрихкоды магазина начинаются с "2" — это зарезервированный
|
// Внутренние EAN-13 магазина начинаются с "2" — зарезервированный префикс
|
||||||
// префикс для in-store use, он не пересекается с GTIN реальных товаров
|
// для in-store use, не пересекается с GTIN реальных товаров.
|
||||||
// от производителей.
|
|
||||||
|
|
||||||
function ean13Checksum(first12: string): number {
|
import { BarcodeType } from '@/lib/types'
|
||||||
|
|
||||||
|
function digitsChecksum(first: string, weightAtOdd: number): number {
|
||||||
|
// Общая EAN/UPC-подобная формула: сумма с чередующимися весами, остаток от 10.
|
||||||
|
// Нечётные позиции (с индекса 0) — weightAtOdd, чётные — 1.
|
||||||
let sum = 0
|
let sum = 0
|
||||||
for (let i = 0; i < 12; i++) {
|
for (let i = 0; i < first.length; i++) {
|
||||||
const d = first12.charCodeAt(i) - 48
|
const d = first.charCodeAt(i) - 48
|
||||||
sum += i % 2 === 0 ? d : d * 3
|
sum += i % 2 === 0 ? d * weightAtOdd : d
|
||||||
}
|
}
|
||||||
return (10 - (sum % 10)) % 10
|
return (10 - (sum % 10)) % 10
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function randomDigits(n: number): string {
|
||||||
|
let s = ''
|
||||||
|
for (let i = 0; i < n; i++) s += Math.floor(Math.random() * 10).toString()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomAlnum(n: number): string {
|
||||||
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||||
|
let s = ''
|
||||||
|
for (let i = 0; i < n; i++) s += alphabet[Math.floor(Math.random() * alphabet.length)]
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
function ean13(): string {
|
||||||
|
const body = '2' + randomDigits(11)
|
||||||
|
return body + digitsChecksum(body, 3).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function ean8(): string {
|
||||||
|
// EAN-8: 7 цифр + checksum. Веса: нечётные×3, чётные×1 (с индекса 0).
|
||||||
|
const body = randomDigits(7)
|
||||||
|
return body + digitsChecksum(body, 3).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function upca(): string {
|
||||||
|
// UPC-A: 11 цифр + checksum. Та же формула что у EAN-13.
|
||||||
|
const body = randomDigits(11)
|
||||||
|
return body + digitsChecksum(body, 3).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function upce(): string {
|
||||||
|
// Упрощённая генерация: 8 случайных цифр (реальный UPC-E строится через
|
||||||
|
// сжатие UPC-A по спецправилам; для внутренних нужд достаточно числовой
|
||||||
|
// последовательности нужной длины).
|
||||||
|
return randomDigits(8)
|
||||||
|
}
|
||||||
|
|
||||||
/** Сгенерировать внутренний EAN-13 с префиксом "2" и случайной серединой. */
|
/** Сгенерировать внутренний EAN-13 с префиксом "2" и случайной серединой. */
|
||||||
export function generateEan13InternalPrefix2(): string {
|
export function generateEan13InternalPrefix2(): string {
|
||||||
let body = '2'
|
return ean13()
|
||||||
for (let i = 0; i < 11; i++) body += Math.floor(Math.random() * 10).toString()
|
}
|
||||||
return body + ean13Checksum(body).toString()
|
|
||||||
|
/** Сгенерировать штрихкод под указанный формат. */
|
||||||
|
export function generateBarcode(type: BarcodeType): string {
|
||||||
|
switch (type) {
|
||||||
|
case BarcodeType.Ean13: return ean13()
|
||||||
|
case BarcodeType.Ean8: return ean8()
|
||||||
|
case BarcodeType.Upca: return upca()
|
||||||
|
case BarcodeType.Upce: return upce()
|
||||||
|
case BarcodeType.Code128:
|
||||||
|
case BarcodeType.Code39: return randomAlnum(12)
|
||||||
|
case BarcodeType.Other:
|
||||||
|
default: return ean13()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { BarcodeType, Packaging, type Product } from '@/lib/types'
|
import { BarcodeType, Packaging, type Product } from '@/lib/types'
|
||||||
import { ProductImageGallery } from '@/components/ProductImageGallery'
|
import { ProductImageGallery } from '@/components/ProductImageGallery'
|
||||||
import { generateEan13InternalPrefix2 } from '@/lib/barcode'
|
import { generateEan13InternalPrefix2, generateBarcode } from '@/lib/barcode'
|
||||||
|
|
||||||
interface PriceRow { id?: string; priceTypeId: string; amount: number; currencyId: string }
|
interface PriceRow { id?: string; priceTypeId: string; amount: number; currencyId: string }
|
||||||
interface BarcodeRow { id?: string; code: string; type: BarcodeType; isPrimary: boolean }
|
interface BarcodeRow { id?: string; code: string; type: BarcodeType; isPrimary: boolean }
|
||||||
|
|
@ -316,6 +316,7 @@ export function ProductEditPage() {
|
||||||
</Grid>
|
</Grid>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{org.data?.showMinMaxStock && (
|
||||||
<AdvancedSection>
|
<AdvancedSection>
|
||||||
<Grid cols={4}>
|
<Grid cols={4}>
|
||||||
<Field label="Минимальный остаток (для уведомления)">
|
<Field label="Минимальный остаток (для уведомления)">
|
||||||
|
|
@ -326,6 +327,7 @@ export function ProductEditPage() {
|
||||||
</Field>
|
</Field>
|
||||||
</Grid>
|
</Grid>
|
||||||
</AdvancedSection>
|
</AdvancedSection>
|
||||||
|
)}
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
title="Цены продажи"
|
title="Цены продажи"
|
||||||
|
|
@ -389,7 +391,10 @@ export function ProductEditPage() {
|
||||||
<TextInput placeholder="Код" value={b.code} onChange={(e) => updateBarcode(i, { code: e.target.value })} />
|
<TextInput placeholder="Код" value={b.code} onChange={(e) => updateBarcode(i, { code: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-3">
|
<div className="col-span-3">
|
||||||
<Select value={b.type} onChange={(e) => updateBarcode(i, { type: Number(e.target.value) as BarcodeType })}>
|
<Select value={b.type} onChange={(e) => {
|
||||||
|
const newType = Number(e.target.value) as BarcodeType
|
||||||
|
updateBarcode(i, { type: newType, code: generateBarcode(newType) })
|
||||||
|
}}>
|
||||||
<option value={BarcodeType.Ean13}>EAN-13</option>
|
<option value={BarcodeType.Ean13}>EAN-13</option>
|
||||||
<option value={BarcodeType.Ean8}>EAN-8</option>
|
<option value={BarcodeType.Ean8}>EAN-8</option>
|
||||||
<option value={BarcodeType.Code128}>CODE 128</option>
|
<option value={BarcodeType.Code128}>CODE 128</option>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue