feat(phone): единый PhoneInput с зашитым «+7» и ФЛК Казахстана
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m1s
CI / Web (React + Vite) (push) Failing after 37s
Docker Public / Build + push Public (push) Successful in 27s
Docker Web / Build + push Web (push) Failing after 25s
Docker Web / Deploy Web on stage (push) Has been skipped
Docker Public / Deploy Public on stage (push) Successful in 10s

Поле телефона во всех формах (web + public) теперь использует общий компонент:
- Префикс «+7 » всегда виден и не удаляется (Backspace/Delete на позиции ≤3 блокируется).
- Принимаются только цифры (буквы и спецсимволы автоматически отфильтровываются).
- Авто-форматирование при вводе: «+7 7XX XXX XX XX».
- Paste произвольного формата нормализуется (поддерживается ведущая «8», «+7…», скобки, дефисы).
- Наружу через onChange отдаётся каноничное «+7XXXXXXXXXX».

Подключено в:
- food-market.public/SignupForm
- food-market.web/CounterpartiesPage (контрагенты)
- food-market.web/EmployeesPage (сотрудники)
- food-market.web/SuperAdminOrgEmployeesPage (управление сотрудниками SuperAdmin)
- food-market.web/SuperAdminOrgCreatePage (создание организации SuperAdmin)
This commit is contained in:
nurdotnet 2026-05-03 11:01:55 +05:00
parent fd7df631e1
commit 2301446b06
7 changed files with 174 additions and 7 deletions

View file

@ -0,0 +1,72 @@
import { useMemo, useRef, type InputHTMLAttributes } from 'react'
function extractDigits(value: string): string {
if (!value) return ''
let d = value.replace(/\D/g, '')
if (d.length === 11 && (d[0] === '7' || d[0] === '8')) d = d.slice(1)
return d.slice(0, 10)
}
function formatLocal(d: string): string {
if (d.length === 0) return ''
if (d.length <= 3) return d
if (d.length <= 6) return `${d.slice(0, 3)} ${d.slice(3)}`
if (d.length <= 8) return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6)}`
return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6, 8)} ${d.slice(8, 10)}`
}
interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> {
value: string
onChange: (value: string) => void
}
/** Телефон Казахстана с зашитым префиксом "+7 ". См. food-market.web/components/PhoneInput.tsx. */
export function PhoneInput({ value, onChange, className, disabled, ...rest }: PhoneInputProps) {
const ref = useRef<HTMLInputElement>(null)
const digits = useMemo(() => extractDigits(value), [value])
const display = `+7 ${formatLocal(digits)}`.trimEnd()
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const d = extractDigits(e.target.value)
onChange(d.length > 0 ? `+7${d}` : '')
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
const el = e.currentTarget
const start = el.selectionStart ?? 0
const end = el.selectionEnd ?? 0
if (e.key === 'Backspace' && start <= 3 && end === start) e.preventDefault()
if (e.key === 'Delete' && start < 3) e.preventDefault()
}
const handleFocus = () => {
if (digits.length === 0) {
requestAnimationFrame(() => {
ref.current?.setSelectionRange(3, 3)
})
}
}
const handleClick = (e: React.MouseEvent<HTMLInputElement>) => {
const el = e.currentTarget
if ((el.selectionStart ?? 0) < 3) el.setSelectionRange(3, 3)
}
return (
<input
ref={ref}
type="tel"
inputMode="tel"
autoComplete="tel"
value={display}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onClick={handleClick}
disabled={disabled}
placeholder="+7 700 123 45 67"
className={className}
{...rest}
/>
)
}

View file

@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { validateEmail, validatePassword, validatePhone } from '@/lib/validation' import { validateEmail, validatePassword, validatePhone } from '@/lib/validation'
import { PhoneInput } from '@/components/PhoneInput'
// Админский / API endpoint — переключается через PUBLIC_APP_URL на этапе // Админский / API endpoint — переключается через PUBLIC_APP_URL на этапе
// билда. Дефолт — admin.food-market.kz (там и API и админ-SPA). // билда. Дефолт — admin.food-market.kz (там и API и админ-SPA).
@ -93,9 +94,7 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
placeholder="Наименование организации" className={inputCls(!!fieldErrors.orgName)} /> placeholder="Наименование организации" className={inputCls(!!fieldErrors.orgName)} />
</Field> </Field>
<Field label="Телефон" error={fieldErrors.phone}> <Field label="Телефон" error={fieldErrors.phone}>
<input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} <PhoneInput value={phone} onChange={setPhone} required className={inputCls(!!fieldErrors.phone)} />
required autoComplete="tel" inputMode="tel"
placeholder="+7 700 123 45 67" className={inputCls(!!fieldErrors.phone)} />
</Field> </Field>
<div> <div>
<div className="text-sm font-medium mb-2">Тариф</div> <div className="text-sm font-medium mb-2">Тариф</div>

View file

@ -0,0 +1,92 @@
import { useMemo, useRef, type InputHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
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 tabular-nums'
/** Извлекает 10 цифр KZ-номера (после +7) из любого формата.
* "+7 700 123 45 67" "7001234567", "8(707)1234567" "0712345670" */
function extractDigits(value: string): string {
if (!value) return ''
let d = value.replace(/\D/g, '')
// 11 цифр и ведущая 7 или 8 — это код страны, отрезаем.
if (d.length === 11 && (d[0] === '7' || d[0] === '8')) d = d.slice(1)
return d.slice(0, 10)
}
/** Формат "XXX XXX XX XX" по 10 цифрам субскрайбера. */
function formatLocal(d: string): string {
if (d.length === 0) return ''
if (d.length <= 3) return d
if (d.length <= 6) return `${d.slice(0, 3)} ${d.slice(3)}`
if (d.length <= 8) return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6)}`
return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6, 8)} ${d.slice(8, 10)}`
}
interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> {
/** Каноничное значение в формате "+7XXXXXXXXXX" либо пустая строка. */
value: string
/** Возвращает каноничное "+7XXXXXXXXXX" если введено 10 цифр, иначе пустую строку. */
onChange: (value: string) => void
}
/** Поле ввода телефона Казахстана. Префикс "+7 " зашит и не удаляется,
* принимаются только цифры (буквы и спецсимволы блокируются), при вводе
* автоматически форматируется как "+7 7XX XXX XX XX". На onChange наружу
* отдаётся каноничное "+7XXXXXXXXXX" (если все 10 цифр введены) или "".
* Поддерживает paste произвольного формата (включая "8 …" и "+7 …"). */
export function PhoneInput({ value, onChange, className, disabled, ...rest }: PhoneInputProps) {
const ref = useRef<HTMLInputElement>(null)
const digits = useMemo(() => extractDigits(value), [value])
const display = `+7 ${formatLocal(digits)}`.trimEnd()
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const d = extractDigits(e.target.value)
onChange(d.length === 10 ? `+7${d}` : d.length > 0 ? `+7${d}` : '')
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
const el = e.currentTarget
const start = el.selectionStart ?? 0
const end = el.selectionEnd ?? 0
// Не позволяем удалить префикс "+7 " (3 символа). Backspace на позиции
// ≤3 без выделения, либо Delete на позиции <3.
if (e.key === 'Backspace' && start <= 3 && end === start) e.preventDefault()
if (e.key === 'Delete' && start < 3) e.preventDefault()
}
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
// При первом фокусе на пустом поле — курсор после "+7 ".
if (digits.length === 0) {
requestAnimationFrame(() => {
const el = ref.current
if (!el) return
el.setSelectionRange(3, 3)
})
}
}
const handleClick = (e: React.MouseEvent<HTMLInputElement>) => {
const el = e.currentTarget
// Если кликнули внутрь "+7 " префикса — переставляем курсор после него.
if ((el.selectionStart ?? 0) < 3) el.setSelectionRange(3, 3)
}
return (
<input
ref={ref}
type="tel"
inputMode="tel"
autoComplete="tel"
value={display}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onClick={handleClick}
disabled={disabled}
placeholder="+7 700 123 45 67"
className={cn(inputClass, className)}
{...rest}
/>
)
}

View file

@ -9,6 +9,7 @@ import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal' import { Modal } from '@/components/Modal'
import { Field, TextInput, TextArea, Select } from '@/components/Field' import { Field, TextInput, TextArea, Select } from '@/components/Field'
import { PhoneInput } from '@/components/PhoneInput'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog' import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { type Counterparty, type Country, type PagedResult, CounterpartyType } from '@/lib/types' import { type Counterparty, type Country, type PagedResult, CounterpartyType } from '@/lib/types'
@ -160,7 +161,7 @@ export function CounterpartiesPage() {
<TextInput value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} /> <TextInput value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
</Field> </Field>
<Field label="Телефон"> <Field label="Телефон">
<TextInput value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /> <PhoneInput value={form.phone} onChange={(v) => setForm({ ...form, phone: v })} />
</Field> </Field>
<Field label="Email"> <Field label="Email">
<TextInput type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /> <TextInput type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />

View file

@ -9,6 +9,7 @@ import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal' import { Modal } from '@/components/Modal'
import { Field, TextInput, TextArea, Checkbox } from '@/components/Field' import { Field, TextInput, TextArea, Checkbox } from '@/components/Field'
import { PhoneInput } from '@/components/PhoneInput'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog' import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import type { PagedResult, RetailPoint } from '@/lib/types' import type { PagedResult, RetailPoint } from '@/lib/types'
import type { EmployeeRoleDto } from '@/pages/EmployeeRolesPage' import type { EmployeeRoleDto } from '@/pages/EmployeeRolesPage'
@ -290,7 +291,7 @@ export function EmployeesPage() {
<TextInput type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /> <TextInput type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
</Field> </Field>
<Field label="Телефон"> <Field label="Телефон">
<TextInput value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /> <PhoneInput value={form.phone} onChange={(v) => setForm({ ...form, phone: v })} />
</Field> </Field>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">

View file

@ -5,6 +5,7 @@ import { api } from '@/lib/api'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal' import { Modal } from '@/components/Modal'
import { Field, TextInput, Select } from '@/components/Field' import { Field, TextInput, Select } from '@/components/Field'
import { PhoneInput } from '@/components/PhoneInput'
import { useCountries, useCurrencies } from '@/lib/useLookups' import { useCountries, useCurrencies } from '@/lib/useLookups'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
@ -79,7 +80,7 @@ export function SuperAdminOrgCreatePage() {
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Field label="БИН/ИНН"><TextInput value={bin} onChange={(e) => setBin(e.target.value)} /></Field> <Field label="БИН/ИНН"><TextInput value={bin} onChange={(e) => setBin(e.target.value)} /></Field>
<Field label="Телефон"><TextInput value={phone} onChange={(e) => setPhone(e.target.value)} /></Field> <Field label="Телефон"><PhoneInput value={phone} onChange={setPhone} /></Field>
</div> </div>
<Field label="Адрес"><TextInput value={address} onChange={(e) => setAddress(e.target.value)} /></Field> <Field label="Адрес"><TextInput value={address} onChange={(e) => setAddress(e.target.value)} /></Field>
<Field label="Email организации"><TextInput type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></Field> <Field label="Email организации"><TextInput type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></Field>

View file

@ -10,6 +10,7 @@ import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal' import { Modal } from '@/components/Modal'
import { Field, TextInput, TextArea, Checkbox } from '@/components/Field' import { Field, TextInput, TextArea, Checkbox } from '@/components/Field'
import { PhoneInput } from '@/components/PhoneInput'
import { useCatalogList } from '@/lib/useCatalog' import { useCatalogList } from '@/lib/useCatalog'
import type { PagedResult } from '@/lib/types' import type { PagedResult } from '@/lib/types'
import type { EmployeeRoleDto } from '@/pages/EmployeeRolesPage' import type { EmployeeRoleDto } from '@/pages/EmployeeRolesPage'
@ -283,7 +284,7 @@ export function SuperAdminOrgEmployeesPage() {
<TextInput type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /> <TextInput type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
</Field> </Field>
<Field label="Телефон"> <Field label="Телефон">
<TextInput value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} /> <PhoneInput value={form.phone} onChange={(v) => setForm({ ...form, phone: v })} />
</Field> </Field>
</div> </div>
<Field label="Роль *"> <Field label="Роль *">