Users typing on a Persian keyboard produced two distinct failures. Fields with type="number" silently returned an empty string — the browser rejects Persian digits, so the value was lost and saved as empty or zero. Text fields passed the Persian characters straight through to the database, where a mobile stored as ۰۹۱۲… never matches 09… again. The secretary form hit the second case with no validation at all. Frontend: - Adds digitsOnly() and the national-code schemas to lib/utils, plus lib/forms with numericField()/latinDigitsField() wrappers for React Hook Form fields. - Converts every type="number" input to type="text" inputMode="numeric" with digit normalization; none remain. Fields that legitimately carry non-digits (sheba, landline) only get the digits translated, keeping IR and separators. - Points the patient national-code and mobile schemas at the shared normalizing schemas, which accept Persian input instead of rejecting it. - Drops two duplicate local digit converters in favour of the shared helper. Backend: - Adds NumericFieldNormalizerSubscriber, translating digits in whitelisted numeric keys of JSON request bodies under /api/v1/ before controllers run, so nobat724_front and clinic-pro-tauri are covered too. Translation only — no characters are stripped, non-string values and other keys are untouched. Three component tests asserted on role="spinbutton" and numeric input values; both are properties of type="number", so they were updated to match the new text inputs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
141 lines
5.4 KiB
TypeScript
141 lines
5.4 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import Modal from '../ui/Modal';
|
|
import SearchableSelect from '../ui/SearchableSelect';
|
|
import { rialToToman, tomanToRial, digitsOnly } from '../../lib/utils';
|
|
import type { InventoryItem, InventoryMeta, ItemPayload } from '../../hooks/useInventory';
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
editing: InventoryItem | null;
|
|
meta: InventoryMeta;
|
|
saving: boolean;
|
|
onClose: () => void;
|
|
onSave: (payload: ItemPayload, uuid?: string) => void;
|
|
}
|
|
|
|
interface FormState {
|
|
name: string;
|
|
consumable: string;
|
|
category: string;
|
|
unit: string;
|
|
price: string; // Toman, as typed
|
|
stock: string;
|
|
alertThreshold: string;
|
|
}
|
|
|
|
const DEFAULT_UNIT = 'عدد';
|
|
const BLANK: FormState = { name: '', consumable: '', category: '', unit: DEFAULT_UNIT, price: '', stock: '', alertThreshold: '' };
|
|
|
|
// group thousands: "1200000" → "1,200,000"
|
|
const group = (v: string) => v.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
|
|
/** «افزودن/ویرایش کالای جدید» — واحد و دستهبندی از لیست استاندارد Backend. */
|
|
export default function AddItemModal({ open, editing, meta, saving, onClose, onSave }: Props) {
|
|
const [form, setForm] = useState<FormState>(BLANK);
|
|
const [error, setError] = useState('');
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setError('');
|
|
setForm(editing
|
|
? {
|
|
name: editing.name,
|
|
consumable: editing.consumable ?? '',
|
|
category: editing.category ?? '',
|
|
unit: editing.unit || DEFAULT_UNIT,
|
|
price: editing.price ? String(rialToToman(editing.price)) : '',
|
|
stock: String(editing.stock),
|
|
alertThreshold: String(editing.alertThreshold),
|
|
}
|
|
: BLANK);
|
|
}, [open, editing]);
|
|
|
|
const set = (k: keyof FormState) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
|
setForm((f) => ({ ...f, [k]: e.target.value }));
|
|
const setNum = (k: keyof FormState) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
|
setForm((f) => ({ ...f, [k]: digitsOnly(e.target.value) }));
|
|
const setSelect = (k: keyof FormState) => (v: string | number | null) =>
|
|
setForm((f) => ({ ...f, [k]: v == null ? '' : String(v) }));
|
|
|
|
const submit = () => {
|
|
if (form.name.trim() === '') { setError('نام کالا الزامی است'); return; }
|
|
if (form.category === '') { setError('دستهبندی کالا الزامی است'); return; }
|
|
const payload: ItemPayload = {
|
|
name: form.name.trim(),
|
|
consumable: form.consumable.trim() || null,
|
|
category: form.category,
|
|
unit: form.unit || DEFAULT_UNIT,
|
|
price: form.price ? tomanToRial(Number(form.price)) : 0,
|
|
stock: form.stock ? Number(form.stock) : 0,
|
|
alertThreshold: form.alertThreshold ? Number(form.alertThreshold) : 0,
|
|
};
|
|
onSave(payload, editing?.uuid);
|
|
};
|
|
|
|
const inputs: { key: keyof FormState; label: string; placeholder: string; numeric?: boolean }[] = [
|
|
{ key: 'name', label: 'نام کالا', placeholder: 'نام کالا' },
|
|
{ key: 'consumable', label: 'مصرفی', placeholder: 'مصرفی' },
|
|
{ key: 'price', label: 'قیمت (تومان)', placeholder: 'قیمت', numeric: true },
|
|
{ key: 'stock', label: 'موجودی', placeholder: 'موجودی', numeric: true },
|
|
{ key: 'alertThreshold', label: 'هشدار اتمام', placeholder: 'هشدار اتمام', numeric: true },
|
|
];
|
|
|
|
const toOptions = (list: string[]) => list.map((v) => ({ value: v, label: v }));
|
|
|
|
return (
|
|
<Modal open={open} onClose={onClose} title={editing ? 'ویرایش کالا' : 'افزودن کالای جدید'} size="lg">
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
|
<div>
|
|
<label className="field-label">نام کالا</label>
|
|
<div className="field">
|
|
<input value={form.name} onChange={set('name')} placeholder="نام کالا" autoFocus />
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="field-label">دستهبندی</label>
|
|
<SearchableSelect
|
|
options={toOptions(meta.categories)}
|
|
value={form.category || null}
|
|
onChange={setSelect('category')}
|
|
placeholder="انتخاب دستهبندی"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="field-label">واحد</label>
|
|
<SearchableSelect
|
|
options={toOptions(meta.units)}
|
|
value={form.unit || DEFAULT_UNIT}
|
|
onChange={setSelect('unit')}
|
|
placeholder="انتخاب واحد"
|
|
/>
|
|
</div>
|
|
|
|
{inputs.filter((f) => f.key !== 'name').map((f) => (
|
|
<div key={f.key}>
|
|
<label className="field-label">{f.label}</label>
|
|
<div className="field">
|
|
<input
|
|
value={f.key === 'price' ? group(form.price) : form[f.key]}
|
|
onChange={f.numeric ? setNum(f.key) : set(f.key)}
|
|
placeholder={f.placeholder}
|
|
inputMode={f.numeric ? 'numeric' : undefined}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{error && <span className="field-error">{error}</span>}
|
|
<button
|
|
className="btn primary"
|
|
style={{ width: '100%', justifyContent: 'center', height: 46 }}
|
|
onClick={submit}
|
|
disabled={saving}
|
|
>
|
|
{editing ? 'ذخیره تغییرات' : 'اضافه کردن کالا'}
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|