feat(patient): implement record number pattern management
- Add RecordNumberSettingsController for managing patient record number patterns. - Create RecordNumberPattern entity to represent the pattern configuration. - Implement RecordNumberPatternRepository for database interactions. - Develop RecordNumberGenerator service for generating and validating record numbers. - Add tests for record number generation, backfilling, and API interactions. - Ensure proper access control for viewing and updating patterns based on user roles.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import PatientRecordFormPage from './PatientRecordFormPage';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
|
||||
/** الگوی شمارهٔ پرونده؛ `enabled=false` یعنی رفتار قدیمیِ ورود دستی. */
|
||||
function mockPattern(settings: Record<string, unknown>) {
|
||||
get.mockImplementation((url: string) =>
|
||||
url.includes('patient-record-number-settings')
|
||||
? Promise.resolve({ success: true, data: { pattern: '{YY}-{SEQ:4}', reset_policy: 'none', counter: 0, next_preview: '05-0001', ...settings } })
|
||||
: Promise.resolve({ success: true, data: [] }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* پرکردن فیلدهای الزامیِ دیگر تا فقط شمارهٔ پرونده متغیر بماند.
|
||||
*
|
||||
* اول منتظر رسیدن تنظیمات میماند: رندرِ بعد از پاسخ، منوی باز react-select را
|
||||
* میبندد و انتخاب جنسیت را میخورد.
|
||||
*/
|
||||
async function fillRequiredFields() {
|
||||
await waitFor(() => expect(get).toHaveBeenCalledWith(expect.stringContaining('patient-record-number-settings')));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /ثبت|ذخیره/ })).toBeEnabled());
|
||||
|
||||
fireEvent.change(await screen.findByPlaceholderText('نام و نام خانوادگی را وارد نمایید'), { target: { value: 'علی محمدی' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('کد ملی را وارد نمایید'), { target: { value: '0012345675' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('شماره تماس را وارد نمایید'), { target: { value: '09121234567' } });
|
||||
const gender = document.getElementById('patient-gender-select') as HTMLInputElement;
|
||||
fireEvent.focus(gender);
|
||||
fireEvent.keyDown(gender, { key: 'ArrowDown' });
|
||||
fireEvent.click(await screen.findByText('مرد'));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
post.mockReset();
|
||||
post.mockResolvedValue({ success: true, data: { uuid: 'rec-new' } });
|
||||
(api.patch as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true, data: {} });
|
||||
});
|
||||
|
||||
describe('PatientRecordFormPage — شمارهٔ پرونده', () => {
|
||||
/** ⚠️ مرزی: بدون الگو، فیلد الزامی میماند (رفتار امروز). */
|
||||
it('بدون الگو شماره پرونده الزامی است', async () => {
|
||||
mockPattern({ enabled: false, can_edit: true });
|
||||
renderWithProviders(<PatientRecordFormPage />, { route: '/admin/patients/new' });
|
||||
|
||||
await fillRequiredFields();
|
||||
fireEvent.click(screen.getByText('ثبت اطلاعات') ?? screen.getByRole('button', { name: /ثبت|ذخیره/ }));
|
||||
|
||||
expect(await screen.findByText('شماره پرونده الزامی است')).toBeInTheDocument();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** ✅ موفق: با الگوی فعال و بدون اجازهٔ دستی، فیلد read-only است و ارسال نمیشود. */
|
||||
it('با الگوی فعال و بدون can_edit، شماره فرستاده نمیشود', async () => {
|
||||
mockPattern({ enabled: true, can_edit: false });
|
||||
renderWithProviders(<PatientRecordFormPage />, { route: '/admin/patients/new' });
|
||||
|
||||
expect(await screen.findByText('شماره از الگوی مجموعه ساخته میشود')).toBeInTheDocument();
|
||||
|
||||
await fillRequiredFields();
|
||||
fireEvent.click(screen.getByRole('button', { name: /ثبت|ذخیره/ }));
|
||||
|
||||
await waitFor(() => expect(post).toHaveBeenCalled());
|
||||
expect(post.mock.calls[0][1]).not.toHaveProperty('record_number');
|
||||
});
|
||||
|
||||
/** ✅ موفق: صاحب مجموعه میتواند خالی بگذارد تا سرور بسازد. */
|
||||
it('صاحب مجموعه میتواند شماره را خالی بگذارد', async () => {
|
||||
mockPattern({ enabled: true, can_edit: true });
|
||||
renderWithProviders(<PatientRecordFormPage />, { route: '/admin/patients/new' });
|
||||
|
||||
expect(await screen.findByText(/خالی بماند تا از الگو ساخته شود/)).toBeInTheDocument();
|
||||
|
||||
await fillRequiredFields();
|
||||
fireEvent.click(screen.getByRole('button', { name: /ثبت|ذخیره/ }));
|
||||
|
||||
await waitFor(() => expect(post).toHaveBeenCalled());
|
||||
expect(post.mock.calls[0][1].record_number).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -11,21 +11,30 @@ vi.mock('../lib/api', () => ({
|
||||
import { api } from '../lib/api';
|
||||
import PatientRecordFormPage from './PatientRecordFormPage';
|
||||
|
||||
/** react-select (SearchableSelect) را با placeholder پیدا و گزینه را با متن انتخاب میکند. */
|
||||
async function pickSelect(placeholder: string, optionLabel: string) {
|
||||
const ph = await screen.findByText(placeholder);
|
||||
const control = ph.closest('div[class*="control"]') as HTMLElement;
|
||||
const input = control.querySelector('input') as HTMLInputElement;
|
||||
/** جنسیت را از SearchableSelect انتخاب میکند (شناسهٔ ورودی، نه placeholder). */
|
||||
async function pickGender(optionLabel: string) {
|
||||
// فلاشِ رندرهای در صف (کوئریِ تنظیمات)؛ رندرِ وسط تعامل، منوی react-select را میبندد.
|
||||
await waitFor(() => expect(document.getElementById('patient-gender-select')).toBeInTheDocument());
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
const input = document.getElementById('patient-gender-select') as HTMLInputElement;
|
||||
fireEvent.focus(input);
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||
fireEvent.click(await screen.findByText(optionLabel));
|
||||
}
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
post.mockReset(); patch.mockReset();
|
||||
get.mockReset(); post.mockReset(); patch.mockReset();
|
||||
// صفحه الگوی شمارهٔ پرونده را میخواند؛ بدون mock، ردِ همان کوئری وسط تعامل
|
||||
// یک رندر میآورد و منوی باز react-select را میبندد.
|
||||
get.mockResolvedValue({
|
||||
success: true,
|
||||
data: { enabled: false, pattern: '{YY}-{SEQ:4}', reset_policy: 'none', counter: 0, next_preview: '05-0001', can_edit: true },
|
||||
});
|
||||
post.mockResolvedValue({ success: true, data: { uuid: 'new-1' } });
|
||||
patch.mockResolvedValue({ success: true, data: {} });
|
||||
});
|
||||
@@ -33,10 +42,12 @@ beforeEach(() => {
|
||||
describe('PatientRecordFormPage (تشکیل پرونده)', () => {
|
||||
it('creates a record then patches the demographic fields', async () => {
|
||||
renderWithProviders(<PatientRecordFormPage />, { route: '/admin/patients/new' });
|
||||
// اول تنظیمات الگو برسد؛ رندرِ بعد از پاسخ، منوی باز react-select را میبندد.
|
||||
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی را وارد نمایید'), { target: { value: 'بیمار نمونه' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('شماره پرونده'), { target: { value: 'P-1001' } });
|
||||
await pickSelect('انتخاب...', 'زن'); // gender = female
|
||||
await pickGender('زن'); // gender = female
|
||||
fireEvent.change(screen.getByPlaceholderText('کد ملی را وارد نمایید'), { target: { value: '1234567890' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('شماره تماس را وارد نمایید'), { target: { value: '09120000000' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' }));
|
||||
@@ -49,10 +60,12 @@ describe('PatientRecordFormPage (تشکیل پرونده)', () => {
|
||||
|
||||
it('blocks submit and shows a validation error for a bad national code', async () => {
|
||||
renderWithProviders(<PatientRecordFormPage />, { route: '/admin/patients/new' });
|
||||
// اول تنظیمات الگو برسد؛ رندرِ بعد از پاسخ، منوی باز react-select را میبندد.
|
||||
await waitFor(() => expect(get).toHaveBeenCalled());
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی را وارد نمایید'), { target: { value: 'ب' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('شماره پرونده'), { target: { value: 'P-1' } });
|
||||
await pickSelect('انتخاب...', 'مرد');
|
||||
await pickGender('مرد');
|
||||
fireEvent.change(screen.getByPlaceholderText('کد ملی را وارد نمایید'), { target: { value: '12' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('شماره تماس را وارد نمایید'), { target: { value: '09120000000' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' }));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -14,12 +14,15 @@ import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import { numericField } from '../lib/forms';
|
||||
import BackButton from '../components/ui/BackButton';
|
||||
import { iranNationalCodeSchema, iranMobileSchema, unixToIso } from '../lib/utils';
|
||||
import { useRecordNumberSettings } from '../hooks/useRecordNumberSettings';
|
||||
|
||||
const REFERRAL_OPTIONS = ['اینستاگرام', 'معرفی دوستان و آشنایان', 'جستجوی اینترنتی', 'تابلو مطب', 'سایر'];
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, 'نام و نام خانوادگی الزامی است'),
|
||||
record_number: z.string().min(1, 'شماره پرونده الزامی است'),
|
||||
// الزامیبودنش شرطی است: با الگوی فعال، سرور شماره را میسازد و این فیلد اصلاً
|
||||
// فرستاده نمیشود. اعتبارسنجیِ شرطی در `superRefine` پایینتر است.
|
||||
record_number: z.string(),
|
||||
gender: z.enum(['male', 'female'], { errorMap: () => ({ message: 'جنسیت را انتخاب کنید' }) }),
|
||||
national_code: iranNationalCodeSchema,
|
||||
mobile: iranMobileSchema,
|
||||
@@ -39,8 +42,25 @@ export default function PatientRecordFormPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { settings } = useRecordNumberSettings();
|
||||
/** الگو روشن است ⇒ شماره را سرور میسازد. */
|
||||
const autoNumbered = !!settings?.enabled;
|
||||
/** ورود دستی: وقتی الگویی نیست، یا کاربر صاحب مجموعه است (سرور هم همین را میسنجد). */
|
||||
const manualAllowed = !autoNumbered || !!settings?.can_edit;
|
||||
|
||||
const formSchema = useMemo(
|
||||
() => schema.superRefine((v, ctx) => {
|
||||
// فقط وقتی شماره دستی است الزامی میماند؛ وگرنه کاربر با فیلدی که نمیتواند
|
||||
// پرش کند پشت فرمِ قفلشده میماند.
|
||||
if (!autoNumbered && !(v.record_number ?? '').trim()) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['record_number'], message: 'شماره پرونده الزامی است' });
|
||||
}
|
||||
}),
|
||||
[autoNumbered],
|
||||
);
|
||||
|
||||
const form = useForm<Form>({
|
||||
resolver: zodResolver(schema),
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { name: '', record_number: '', gender: undefined as any, national_code: '', mobile: '', birth_date: '', referral_source: '', description: '' },
|
||||
});
|
||||
|
||||
@@ -74,14 +94,18 @@ export default function PatientRecordFormPage() {
|
||||
referral_source: d.referral_source || null,
|
||||
description: d.description || null,
|
||||
};
|
||||
// شمارهٔ پرونده فقط وقتی فرستاده میشود که ورود دستی مجاز باشد؛ وگرنه سرور
|
||||
// ۴۰۳ میدهد و سرورست که شماره را از الگو میسازد.
|
||||
const numberPayload = manualAllowed ? { record_number: d.record_number ?? '' } : {};
|
||||
|
||||
if (isEdit) {
|
||||
return api.patch(`/api/v1/patient/${uuid}`, {
|
||||
name: d.name, national_code: d.national_code, mobile: d.mobile, record_number: d.record_number, ...profilePayload,
|
||||
name: d.name, national_code: d.national_code, mobile: d.mobile, ...numberPayload, ...profilePayload,
|
||||
});
|
||||
}
|
||||
// create: POST creates the record + identity, then PATCH applies the profile demographics
|
||||
const created = await api.post<ApiResponse<PatientRecord>>('/api/v1/patient', {
|
||||
name: d.name, mobile: d.mobile, national_code: d.national_code, record_number: d.record_number,
|
||||
name: d.name, mobile: d.mobile, national_code: d.national_code, ...numberPayload,
|
||||
});
|
||||
const newUuid = (created as any)?.data?.uuid;
|
||||
if (newUuid) await api.patch(`/api/v1/patient/${newUuid}`, profilePayload);
|
||||
@@ -116,11 +140,34 @@ export default function PatientRecordFormPage() {
|
||||
<Field label="نام و نام خانوادگی مراجعه کننده" required error={form.formState.errors.name?.message}>
|
||||
<div className="field"><input {...form.register('name')} placeholder="نام و نام خانوادگی را وارد نمایید" /></div>
|
||||
</Field>
|
||||
<Field label="شماره پرونده" required error={form.formState.errors.record_number?.message}>
|
||||
<div className="field"><input {...form.register('record_number')} placeholder="شماره پرونده" /></div>
|
||||
<Field
|
||||
label="شماره پرونده"
|
||||
required={!autoNumbered}
|
||||
error={form.formState.errors.record_number?.message}
|
||||
>
|
||||
{manualAllowed ? (
|
||||
<div className="field">
|
||||
<input
|
||||
{...form.register('record_number')}
|
||||
placeholder={autoNumbered ? (settings?.next_preview ?? 'شماره پرونده') : 'شماره پرونده'}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="field" style={{ opacity: 0.7 }}>
|
||||
<input value={isEdit ? (recordData?.data?.record_number ?? '') : (settings?.next_preview ?? '')} readOnly dir="ltr" />
|
||||
</div>
|
||||
)}
|
||||
{autoNumbered && (
|
||||
<span style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'block', marginTop: 4 }}>
|
||||
{manualAllowed
|
||||
? `خالی بماند تا از الگو ساخته شود (شمارهٔ بعدی: ${settings?.next_preview ?? '—'})`
|
||||
: 'شماره از الگوی مجموعه ساخته میشود'}
|
||||
</span>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="جنسیت" required error={form.formState.errors.gender?.message}>
|
||||
<SearchableSelect
|
||||
inputId="patient-gender-select"
|
||||
options={[{ value: 'female', label: 'زن' }, { value: 'male', label: 'مرد' }]}
|
||||
value={form.watch('gender') ?? null}
|
||||
onChange={(v) => form.setValue('gender', v as Form['gender'], { shouldValidate: true, shouldDirty: true })}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import RecordNumberSettingsPage from './RecordNumberSettingsPage';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const put = api.put as ReturnType<typeof vi.fn>;
|
||||
|
||||
function mockSettings(overrides: Record<string, unknown> = {}) {
|
||||
get.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
enabled: false, pattern: '{YY}-{SEQ:4}', reset_policy: 'none',
|
||||
counter: 0, next_preview: '05-0001', can_edit: true, ...overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
put.mockReset();
|
||||
put.mockResolvedValue({ success: true, data: {} });
|
||||
});
|
||||
|
||||
describe('RecordNumberSettingsPage', () => {
|
||||
it('الگو و پیشنمایش سرور را نشان میدهد', async () => {
|
||||
mockSettings({ enabled: true, pattern: 'MD-{YY}-{SEQ:4}', reset_policy: 'yearly', counter: 12, next_preview: 'MD-05-0013' });
|
||||
renderWithProviders(<RecordNumberSettingsPage />);
|
||||
|
||||
expect(await screen.findByDisplayValue('MD-{YY}-{SEQ:4}')).toBeInTheDocument();
|
||||
expect(screen.getByText('MD-05-0013')).toBeInTheDocument();
|
||||
expect(screen.getByText(/12 پرونده را شمارهگذاری کرده/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/** ✅ موفق: تغییر الگو و ذخیره با همان سه فیلد. */
|
||||
it('الگوی تغییریافته را ذخیره میکند', async () => {
|
||||
mockSettings();
|
||||
renderWithProviders(<RecordNumberSettingsPage />);
|
||||
|
||||
const input = await screen.findByLabelText('الگوی شماره');
|
||||
fireEvent.change(input, { target: { value: 'P-{SEQ:3}' } });
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'شمارهگذاری خودکار پرونده' }));
|
||||
|
||||
fireEvent.click(screen.getByText('ذخیره تنظیمات'));
|
||||
await waitFor(() => expect(put).toHaveBeenCalled());
|
||||
|
||||
expect(put.mock.calls[0][0]).toBe('/api/v1/patient-record-number-settings');
|
||||
expect(put.mock.calls[0][1]).toEqual({ enabled: true, pattern: 'P-{SEQ:3}', reset_policy: 'none' });
|
||||
});
|
||||
|
||||
/** ⚠️ مرزی: بدون تغییر، دکمهٔ ذخیره غیرفعال است. */
|
||||
it('تا تغییری نباشد دکمهٔ ذخیره غیرفعال است', async () => {
|
||||
mockSettings();
|
||||
renderWithProviders(<RecordNumberSettingsPage />);
|
||||
|
||||
const btn = await screen.findByText('ذخیره تنظیمات');
|
||||
expect(btn).toBeDisabled();
|
||||
});
|
||||
|
||||
/** ❌ خطا: کاربری که صاحب مجموعه نیست فقط میبیند. */
|
||||
it('بدون can_edit فرم فقط خواندنی است', async () => {
|
||||
mockSettings({ can_edit: false, enabled: true });
|
||||
renderWithProviders(<RecordNumberSettingsPage />);
|
||||
|
||||
expect(await screen.findByText(/تغییر الگو فقط با حساب صاحب مجموعه/)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('الگوی شماره')).toHaveAttribute('readonly');
|
||||
expect(screen.queryByText('ذخیره تنظیمات')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import SettingsLayout from '../components/layout/SettingsLayout';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import { useRecordNumberSettings } from '../hooks/useRecordNumberSettings';
|
||||
import type { RecordNumberResetPolicy } from '../hooks/useRecordNumberSettings';
|
||||
|
||||
const RESET_OPTIONS: { value: RecordNumberResetPolicy; label: string }[] = [
|
||||
{ value: 'none', label: 'هرگز — شمارنده پیوسته جلو میرود' },
|
||||
{ value: 'yearly', label: 'هر سال شمسی از ۱ شروع شود' },
|
||||
{ value: 'monthly', label: 'هر ماه شمسی از ۱ شروع شود' },
|
||||
];
|
||||
|
||||
const TOKENS: [string, string][] = [
|
||||
['{SEQ}', 'شمارنده — {SEQ:4} یعنی چهار رقمی با صفرِ ابتدایی'],
|
||||
['{YY}', 'دو رقم آخر سال شمسی'],
|
||||
['{YYYY}', 'سال شمسی کامل'],
|
||||
['{MM}', 'ماه شمسی دو رقمی'],
|
||||
];
|
||||
|
||||
/**
|
||||
* شمارهٔ پرونده — الگوی شمارهگذاری خودکار پروندههای همین مجموعه.
|
||||
*
|
||||
* پیشنمایش سمت کلاینت ساخته نمیشود: همان `next_preview` سرور نشان داده میشود تا
|
||||
* دو پیادهسازیِ رندر (اینجا و در `RecordNumberGenerator`) از هم واگرا نشوند.
|
||||
*/
|
||||
export default function RecordNumberSettingsPage() {
|
||||
const { settings, loading, save } = useRecordNumberSettings();
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [pattern, setPattern] = useState('');
|
||||
const [resetPolicy, setResetPolicy] = useState<RecordNumberResetPolicy>('none');
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
setEnabled(settings.enabled);
|
||||
setPattern(settings.pattern);
|
||||
setResetPolicy(settings.reset_policy);
|
||||
}, [settings]);
|
||||
|
||||
const readOnly = !settings?.can_edit;
|
||||
const dirty = !!settings && (
|
||||
enabled !== settings.enabled || pattern !== settings.pattern || resetPolicy !== settings.reset_policy
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsLayout active="record-number">
|
||||
<div
|
||||
style={{ background: 'var(--surface)', minWidth: 0 }}
|
||||
className="px-4 py-4 md:px-6 md:py-6 rounded-[var(--r-lg)]"
|
||||
>
|
||||
<h1 className="section-title" style={{ marginBottom: 6 }}>شماره پرونده</h1>
|
||||
<p style={{ fontSize: 12.5, color: 'var(--text-3)', marginBottom: 18, lineHeight: 2 }}>
|
||||
با روشنکردن این گزینه، شمارهٔ هر پروندهٔ تازه — چه از فرم تشکیل پرونده و چه از
|
||||
نوبتی که قطعی میشود — بر اساس همین الگو ساخته میشود.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-2">{Array.from({ length: 3 }).map((_, i) => <div key={i} className="h-12 rounded-xl skeleton" />)}</div>
|
||||
) : !settings ? (
|
||||
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 13.5 }}>
|
||||
دسترسی به تنظیمات شمارهٔ پرونده ندارید.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{readOnly && (
|
||||
<div style={{
|
||||
marginBottom: 16, padding: '10px 14px', borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--warning-bg)', color: 'var(--text-2)', fontSize: 12.5,
|
||||
}}>
|
||||
تغییر الگو فقط با حساب صاحب مجموعه ممکن است؛ اینجا فقط مقدار فعلی را میبینید.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 10, fontSize: 14, cursor: readOnly ? 'not-allowed' : 'pointer' }}>
|
||||
<span style={{
|
||||
position: 'relative', width: 42, height: 22, borderRadius: 999, flexShrink: 0,
|
||||
background: enabled ? 'var(--primary)' : 'var(--border-2)', transition: 'background .2s',
|
||||
opacity: readOnly ? 0.6 : 1,
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-label="شمارهگذاری خودکار پرونده"
|
||||
checked={enabled}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', margin: 0, opacity: 0, cursor: 'inherit' }}
|
||||
/>
|
||||
<span style={{
|
||||
position: 'absolute', top: 2, insetInlineStart: enabled ? 22 : 2, width: 18, height: 18,
|
||||
borderRadius: 999, background: 'var(--surface)', transition: 'inset-inline-start .2s',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,.2)',
|
||||
}} />
|
||||
</span>
|
||||
شمارهگذاری خودکار پرونده
|
||||
</label>
|
||||
|
||||
<div style={{ maxWidth: 420, marginTop: 18 }}>
|
||||
<label className="field-label" htmlFor="record-number-pattern">الگوی شماره</label>
|
||||
<div className="field" style={{ marginTop: 6 }}>
|
||||
<input
|
||||
id="record-number-pattern"
|
||||
value={pattern}
|
||||
dir="ltr"
|
||||
readOnly={readOnly}
|
||||
onChange={(e) => setPattern(e.target.value)}
|
||||
placeholder="MD-{YY}-{SEQ:4}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul style={{ margin: '10px 0 0', padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{TOKENS.map(([token, hint]) => (
|
||||
<li key={token} style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
<code dir="ltr" style={{ color: 'var(--primary)' }}>{token}</code> — {hint}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: 420, marginTop: 18 }}>
|
||||
<label className="field-label" id="reset-policy-label">ریست شمارنده</label>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<SearchableSelect
|
||||
options={RESET_OPTIONS}
|
||||
value={resetPolicy}
|
||||
onChange={(v) => setResetPolicy((v ?? 'none') as RecordNumberResetPolicy)}
|
||||
ariaLabelledBy="reset-policy-label"
|
||||
isDisabled={readOnly}
|
||||
height={44}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: 18, padding: '12px 14px', borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--surface-2)', border: '1px solid var(--border)', maxWidth: 420,
|
||||
}}>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-2)' }}>شمارهٔ بعدی: </span>
|
||||
<b dir="ltr" style={{ fontSize: 14, color: 'var(--text)' }}>{settings.next_preview}</b>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: 6 }}>
|
||||
{dirty
|
||||
? 'پیشنمایش پس از ذخیره بهروز میشود.'
|
||||
: `شمارنده تا اینجا ${settings.counter} پرونده را شمارهگذاری کرده است.`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 22 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={!dirty || save.isPending}
|
||||
onClick={() => save.mutate({ enabled, pattern, reset_policy: resetPolicy })}
|
||||
>
|
||||
{save.isPending ? 'در حال ذخیره…' : 'ذخیره تنظیمات'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user