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
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:
parent
fd7df631e1
commit
2301446b06
72
src/food-market.public/src/components/PhoneInput.tsx
Normal file
72
src/food-market.public/src/components/PhoneInput.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
92
src/food-market.web/src/components/PhoneInput.tsx
Normal file
92
src/food-market.web/src/components/PhoneInput.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 })} />
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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="Роль *">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue