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:
hamed
2026-08-04 12:30:17 +03:30
parent 9b8eef9598
commit 85a27812c7
24 changed files with 2009 additions and 19 deletions
+2
View File
@@ -70,6 +70,7 @@ import AdminSubscriptionPage from './pages/AdminSubscriptionPage';
import SettingsMenuPage from './pages/SettingsMenuPage';
import AccountSettingsPage from './pages/AccountSettingsPage';
import TagsSettingsPage from './pages/TagsSettingsPage';
import RecordNumberSettingsPage from './pages/RecordNumberSettingsPage';
import AppointmentSettingsPage from './pages/AppointmentSettingsPage';
import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage';
import PatientsListPage from './pages/PatientsListPage';
@@ -283,6 +284,7 @@ export default function App() {
<Route path="settings-menu" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SettingsMenuPage /></RoleRoute>} />
<Route path="account-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']}><AccountSettingsPage /></RoleRoute>} />
<Route path="tags-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['tags', 'view']}><TagsSettingsPage /></RoleRoute>} />
<Route path="record-number-settings" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['patients', 'view']}><RecordNumberSettingsPage /></RoleRoute>} />
<Route path="appointment-settings" element={<RoleRoute roles={['doctor', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><AppointmentSettingsPage /></RoleRoute>} />
<Route path="subscription" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['subscription', 'view']}><SubscriptionPage /></RoleRoute>} />
<Route path="discounts" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['discounts', 'view']}><DiscountsPage /></RoleRoute>} />
@@ -3,7 +3,7 @@ import {
CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon,
BanknotesIcon, UsersIcon, ShieldCheckIcon,
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon,
RectangleStackIcon,
RectangleStackIcon, HashtagIcon,
} from '@heroicons/react/24/outline';
/**
@@ -44,6 +44,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
{ key: 'insurance', label: 'مدیریت بیمه', icon: ShieldCheckIcon, to: '/admin/insurance-pricing', perm: ['insurances', 'view'] },
{ key: 'discounts', label: 'مدیریت تخفیف‌ها', icon: ReceiptPercentIcon, to: '/admin/discounts', roles: ['doctor', 'clinic'], perm: ['discounts', 'view'] },
{ key: 'tags', label: 'برچسب‌ها', icon: TagIcon, to: '/admin/tags-settings', perm: ['tags', 'view'] },
{ key: 'record-number', label: 'شماره پرونده', icon: HashtagIcon, to: '/admin/record-number-settings', roles: ['doctor', 'clinic'], perm: ['patients', 'view'] },
{ key: 'sms', label: 'پیامک‌ها', icon: ChatBubbleLeftRightIcon, to: '/admin/sms-wallet', perm: ['sms', 'view'] },
{ key: 'account', label: 'حساب کاربری', icon: UserCircleIcon, to: '/admin/account-settings', alwaysOpen: true },
];
@@ -0,0 +1,48 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api, ApiError, type ApiResponse } from '../lib/api';
export type RecordNumberResetPolicy = 'none' | 'yearly' | 'monthly';
export interface RecordNumberSettings {
enabled: boolean;
pattern: string;
reset_policy: RecordNumberResetPolicy;
counter: number;
/** شمارهٔ بعدی، بدون مصرف‌کردن شمارنده. */
next_preview: string;
/** فقط صاحب مجموعه (و ادمین) اجازهٔ ذخیره دارد؛ گیت اصلی سمت سرور است. */
can_edit: boolean;
}
const KEY = ['record-number-settings'];
/**
* الگوی شمارهٔ پروندهٔ محیط جاری.
*
* هم صفحهٔ تنظیمات از آن می‌خواند، هم فرم پرونده — فرم باید بداند شماره خودکار
* ساخته می‌شود یا هنوز دستی است.
*/
export function useRecordNumberSettings() {
const qc = useQueryClient();
const query = useQuery({
queryKey: KEY,
queryFn: () => api.get<ApiResponse<RecordNumberSettings>>('/api/v1/patient-record-number-settings'),
staleTime: 60_000,
// منشیِ بدون مجوز `patients.view` اینجا ۴۰۳ می‌گیرد؛ تلاش دوباره چیزی عوض نمی‌کند.
retry: false,
});
const save = useMutation({
mutationFn: (d: Pick<RecordNumberSettings, 'enabled' | 'pattern' | 'reset_policy'>) =>
api.put<ApiResponse<RecordNumberSettings>>('/api/v1/patient-record-number-settings', d),
onSuccess: () => {
toast.success('الگوی شماره پرونده ذخیره شد');
qc.invalidateQueries({ queryKey: KEY });
},
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'ذخیرهٔ الگو ناموفق بود'),
});
return { settings: query.data?.data ?? null, loading: query.isLoading, save };
}
@@ -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: 'ثبت اطلاعات' }));
+54 -7
View File
@@ -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>
);
}