Add JSON files for security audit and test data
- Created a JSON file for the security audit report dated 2026-07-19, detailing various security findings and their relationships. - Added a JSON file for seed test data, including user creation logic and dependencies in the `seed_testdata.php` file. - Introduced a JSON file for the AdminCspSubscriberTest, outlining test cases and their structure in the `AdminCspSubscriberTest.php`.
This commit is contained in:
@@ -95,6 +95,50 @@ describe('NewAppointmentModal — جستجوی موبایلمحور', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('NewAppointmentModal — جستجو با کد ملی', () => {
|
||||
it('searches by national_code and books the found patient with its mobile', async () => {
|
||||
get.mockResolvedValue({ success: true, data: { found: true, name: 'رضا کریمی', mobile: '09121112233', national_code: '0012345678' } });
|
||||
renderWithProviders(<NewAppointmentModal slot={slot} onClose={() => {}} onSuccess={() => {}} />);
|
||||
|
||||
// تغییر معیار جستجو به کد ملی
|
||||
fireEvent.click(screen.getByRole('button', { name: 'کد ملی' }));
|
||||
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
|
||||
|
||||
await waitFor(() => expect(get).toHaveBeenCalledWith('/api/v1/my/appointment/patient-lookup?national_code=0012345678'));
|
||||
await screen.findByText('بیمار یافت شد');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
|
||||
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/my/appointment', expect.objectContaining({
|
||||
patient_mobile: '09121112233',
|
||||
patient_name: 'رضا کریمی',
|
||||
patient_national_code: '0012345678',
|
||||
})));
|
||||
});
|
||||
|
||||
it('unknown national code reveals mobile + name fields and books the new patient', async () => {
|
||||
get.mockResolvedValue({ success: true, data: { found: false } });
|
||||
renderWithProviders(<NewAppointmentModal slot={slot} onClose={() => {}} onSuccess={() => {}} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'کد ملی' }));
|
||||
fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '1234567891' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'جستجو' }));
|
||||
|
||||
// موبایل خواسته میشود؛ کد ملی فقط همان ورودیِ جستجو است (بدون فیلد تکراری)
|
||||
const mob = await screen.findByPlaceholderText('مثال: 09123456789');
|
||||
expect(screen.getAllByPlaceholderText('کد ملی ۱۰ رقمی')).toHaveLength(1);
|
||||
fireEvent.change(screen.getByPlaceholderText('مثال: علی محمدی'), { target: { value: 'مریم خلیلی' } });
|
||||
fireEvent.change(mob, { target: { value: '09990001122' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' }));
|
||||
await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/my/appointment', expect.objectContaining({
|
||||
patient_mobile: '09990001122',
|
||||
patient_name: 'مریم خلیلی',
|
||||
patient_national_code: '1234567891',
|
||||
})));
|
||||
});
|
||||
});
|
||||
|
||||
describe('NewAppointmentModal — هزینه ویزیت', () => {
|
||||
/** تعرفه را برای همان پزشکِ اسلات برمیگرداند؛ بقیهٔ GETها بیمارِ ناشناس. */
|
||||
function mockPricing(rials: number, requireVisit = false) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
PlusIcon, ChevronRightIcon, ChevronLeftIcon, CalendarDaysIcon,
|
||||
PlusIcon, ChevronRightIcon, ChevronLeftIcon, ChevronDownIcon, CalendarDaysIcon,
|
||||
AdjustmentsHorizontalIcon, ClockIcon, MagnifyingGlassIcon, CheckCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
@@ -118,6 +118,8 @@ export function NewAppointmentModal({
|
||||
const [lookup, setLookup] = useState<PatientLookup | null>(null);
|
||||
const [patientName, setPatientName] = useState('');
|
||||
const [nationalCode, setNationalCode] = useState('');
|
||||
// معیار جستجوی بیمار: موبایل یا کد ملی.
|
||||
const [searchBy, setSearchBy] = useState<'mobile' | 'national'>('mobile');
|
||||
const [pick, setPick] = useState<{ serviceUuids: string[]; slot: { start: number; end: number } | null }>({ serviceUuids: [], slot: null });
|
||||
|
||||
const role = useAuthStore(s => s.primaryRole);
|
||||
@@ -149,8 +151,14 @@ export function NewAppointmentModal({
|
||||
useEffect(() => {
|
||||
if (!visitPriceTouched && freeVisit > 0) setVisitPriceToman(rialToToman(freeVisit));
|
||||
}, [freeVisit, visitPriceTouched]);
|
||||
// هزینه ویزیت اختیاری داخل کلپسِ بسته مینشیند؛ وقتی الزامی است کلپس همیشه باز است.
|
||||
const [visitPriceOpen, setVisitPriceOpen] = useState(false);
|
||||
const visitPriceExpanded = requireVisit || visitPriceOpen;
|
||||
|
||||
const mobileValid = /^09\d{9}$/.test(mobile);
|
||||
const nationalCodeValid = /^\d{10}$/.test(nationalCode);
|
||||
// اعتبار کلید جستجو بسته به معیار انتخابشده.
|
||||
const searchValid = searchBy === 'mobile' ? mobileValid : nationalCodeValid;
|
||||
const serviceTimingValid = !serviceMode || (pick.serviceUuids.length > 0 && !!pick.slot);
|
||||
// یک بیمارِ یافتشده که کد ملی دارد، بدون فرم اضافی قابل استفاده است.
|
||||
const foundWithNationalCode = !!lookup?.found && !!lookup.national_code;
|
||||
@@ -163,12 +171,23 @@ export function NewAppointmentModal({
|
||||
const isValid = mobileValid && (foundWithNationalCode || (needsDetails && detailsValid)) && serviceTimingValid && visitPriceValid;
|
||||
|
||||
const search = useMutation({
|
||||
mutationFn: () => api.get(`/api/v1/my/appointment/patient-lookup?mobile=${encodeURIComponent(mobile)}`),
|
||||
mutationFn: () => {
|
||||
const q = searchBy === 'mobile'
|
||||
? `mobile=${encodeURIComponent(mobile)}`
|
||||
: `national_code=${encodeURIComponent(nationalCode)}`;
|
||||
return api.get(`/api/v1/my/appointment/patient-lookup?${q}`);
|
||||
},
|
||||
onSuccess: (res: any) => {
|
||||
const data: PatientLookup = res?.data ?? { found: false };
|
||||
setLookup(data);
|
||||
setPatientName(data.found ? (data.name ?? '') : '');
|
||||
setNationalCode(data.found ? (data.national_code ?? '') : '');
|
||||
// موبایل و کد ملیِ بیمارِ یافتشده را پر میکنیم تا ثبت مستقل از معیار جستجو کار کند.
|
||||
if (data.found) {
|
||||
if (data.mobile) setMobile(data.mobile);
|
||||
setNationalCode(data.national_code ?? '');
|
||||
} else if (searchBy === 'mobile') {
|
||||
setNationalCode('');
|
||||
}
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در جستجو'),
|
||||
});
|
||||
@@ -196,11 +215,24 @@ export function NewAppointmentModal({
|
||||
},
|
||||
});
|
||||
|
||||
// تغییر موبایل نتیجهی جستجوی قبلی را باطل میکند تا کاربر دوباره جستجو کند.
|
||||
// تغییر کلید جستجو نتیجهی جستجوی قبلی را باطل میکند تا کاربر دوباره جستجو کند.
|
||||
function invalidateLookup() {
|
||||
if (lookup !== null) { setLookup(null); setPatientName(''); }
|
||||
}
|
||||
function onMobileChange(v: string) {
|
||||
// ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته میشود).
|
||||
setMobile(sanitizeMobileInput(v));
|
||||
if (lookup !== null) { setLookup(null); setPatientName(''); setNationalCode(''); }
|
||||
if (lookup !== null) { setLookup(null); setPatientName(''); if (searchBy === 'mobile') setNationalCode(''); }
|
||||
}
|
||||
function onNationalSearchChange(v: string) {
|
||||
setNationalCode(digitsOnly(v, 10));
|
||||
invalidateLookup();
|
||||
}
|
||||
// جابهجایی معیار جستجو همهچیز را از نو شروع میکند.
|
||||
function onSwitchSearchBy(mode: 'mobile' | 'national') {
|
||||
setSearchBy(mode);
|
||||
setLookup(null); setPatientName('');
|
||||
setMobile(''); setNationalCode('');
|
||||
}
|
||||
|
||||
const priceHint = pricingLoading
|
||||
@@ -257,25 +289,54 @@ export function NewAppointmentModal({
|
||||
)}
|
||||
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label>شماره موبایل بیمار <span className="req">*</span></label>
|
||||
<label>جستجوی بیمار <span className="req">*</span></label>
|
||||
{/* انتخاب معیار جستجو: موبایل یا کد ملی */}
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
||||
{(['mobile', 'national'] as const).map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={`btn sm ${searchBy === mode ? 'primary' : 'ghost'}`}
|
||||
onClick={() => onSwitchSearchBy(mode)}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{mode === 'mobile' ? 'شماره موبایل' : 'کد ملی'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div className="field" style={{ flex: 1 }}>
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
maxLength={11}
|
||||
value={mobile}
|
||||
onChange={e => onMobileChange(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && mobileValid && !search.isPending) search.mutate(); }}
|
||||
placeholder="مثال: 09123456789"
|
||||
style={{ direction: 'ltr' }}
|
||||
autoFocus
|
||||
/>
|
||||
{searchBy === 'mobile' ? (
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
maxLength={11}
|
||||
value={mobile}
|
||||
onChange={e => onMobileChange(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
|
||||
placeholder="مثال: 09123456789"
|
||||
style={{ direction: 'ltr' }}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
lang="en"
|
||||
maxLength={10}
|
||||
value={nationalCode}
|
||||
onChange={e => onNationalSearchChange(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }}
|
||||
placeholder="کد ملی ۱۰ رقمی"
|
||||
style={{ direction: 'ltr' }}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn soft"
|
||||
onClick={() => search.mutate()}
|
||||
disabled={!mobileValid || search.isPending}
|
||||
disabled={!searchValid || search.isPending}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
<MagnifyingGlassIcon style={{ width: 16, height: 16 }} />
|
||||
@@ -305,7 +366,7 @@ export function NewAppointmentModal({
|
||||
fontSize: 12.5, color: 'var(--text-2)', marginBottom: 12,
|
||||
padding: '9px 12px', borderRadius: 'var(--r-sm)', background: 'var(--warning-bg)',
|
||||
}}>
|
||||
{lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'کاربری با این شماره یافت نشد — بیمار جدید:'}
|
||||
{lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'بیماری با این مشخصات یافت نشد — بیمار جدید:'}
|
||||
</div>
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label>نام و نام خانوادگی بیمار <span className="req">*</span></label>
|
||||
@@ -318,52 +379,98 @@ export function NewAppointmentModal({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label>کد ملی بیمار <span className="req">*</span></label>
|
||||
<div className="field">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
lang="en"
|
||||
maxLength={10}
|
||||
value={nationalCode}
|
||||
onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
|
||||
placeholder="کد ملی ۱۰ رقمی"
|
||||
style={{ direction: 'ltr' }}
|
||||
/>
|
||||
{/* در جستجو با کد ملی، موبایل هنوز نامعلوم است و برای ثبت لازم میشود. */}
|
||||
{searchBy === 'national' && (
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label>شماره موبایل بیمار <span className="req">*</span></label>
|
||||
<div className="field">
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
maxLength={11}
|
||||
value={mobile}
|
||||
onChange={e => setMobile(sanitizeMobileInput(e.target.value))}
|
||||
placeholder="مثال: 09123456789"
|
||||
style={{ direction: 'ltr' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* در جستجو با کد ملی، همان مقدار کلیدِ جستجو استفاده میشود و فیلد تکراری لازم نیست. */}
|
||||
{searchBy === 'mobile' && (
|
||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||
<label>کد ملی بیمار <span className="req">*</span></label>
|
||||
<div className="field">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
lang="en"
|
||||
maxLength={10}
|
||||
value={nationalCode}
|
||||
onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
|
||||
placeholder="کد ملی ۱۰ رقمی"
|
||||
style={{ direction: 'ltr' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="field-block">
|
||||
<label>
|
||||
هزینه ویزیت (تومان)
|
||||
{requireVisit ? <span className="req"> *</span> : <span className="opt">(اختیاری)</span>}
|
||||
</label>
|
||||
<div
|
||||
className="field"
|
||||
style={requireVisit && visitPriceToman <= 0 ? { borderColor: 'var(--danger)' } : undefined}
|
||||
>
|
||||
<PriceInput
|
||||
value={visitPriceToman}
|
||||
onChange={(v) => { setVisitPriceToman(v); setVisitPriceTouched(true); }}
|
||||
suffix="تومان"
|
||||
/>
|
||||
</div>
|
||||
{requireVisit && visitPriceToman <= 0
|
||||
? <span className="field-err">هزینه ویزیت الزامی است</span>
|
||||
: <span className="field-hint">{priceHint}</span>}
|
||||
{freeVisit > 0 && visitPriceToman !== rialToToman(freeVisit) && (
|
||||
{requireVisit ? (
|
||||
<label>
|
||||
هزینه ویزیت (تومان)<span className="req"> *</span>
|
||||
</label>
|
||||
) : (
|
||||
// سرِ کلپس — با کلیک باز/بسته میشود (فقط وقتی اختیاری است).
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost sm"
|
||||
style={{ marginTop: 8, alignSelf: 'flex-start' }}
|
||||
onClick={() => { setVisitPriceToman(rialToToman(freeVisit)); setVisitPriceTouched(true); }}
|
||||
onClick={() => setVisitPriceOpen(o => !o)}
|
||||
aria-expanded={visitPriceExpanded}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
||||
background: 'none', border: 'none', cursor: 'pointer', font: 'inherit',
|
||||
padding: 0, color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
استفاده از تعرفهٔ پزشک
|
||||
<span>هزینه ویزیت (تومان) <span className="opt">(اختیاری)</span></span>
|
||||
<ChevronDownIcon
|
||||
style={{
|
||||
width: 15, height: 15, marginInlineStart: 'auto', flexShrink: 0,
|
||||
transition: 'transform .2s var(--ease)',
|
||||
transform: visitPriceExpanded ? 'rotate(180deg)' : 'none',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{visitPriceExpanded && (
|
||||
<>
|
||||
<div
|
||||
className="field"
|
||||
style={requireVisit && visitPriceToman <= 0 ? { borderColor: 'var(--danger)' } : undefined}
|
||||
>
|
||||
<PriceInput
|
||||
value={visitPriceToman}
|
||||
onChange={(v) => { setVisitPriceToman(v); setVisitPriceTouched(true); }}
|
||||
suffix="تومان"
|
||||
/>
|
||||
</div>
|
||||
{requireVisit && visitPriceToman <= 0
|
||||
? <span className="field-err">هزینه ویزیت الزامی است</span>
|
||||
: <span className="field-hint">{priceHint}</span>}
|
||||
{freeVisit > 0 && visitPriceToman !== rialToToman(freeVisit) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost sm"
|
||||
style={{ marginTop: 8, alignSelf: 'flex-start' }}
|
||||
onClick={() => { setVisitPriceToman(rialToToman(freeVisit)); setVisitPriceTouched(true); }}
|
||||
>
|
||||
استفاده از تعرفهٔ پزشک
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user