feat(policy): rule builder and mandatory dry-run sandbox

Task 09 shipped a powerful API that a non-technical clinic owner could not
safely use. This closes that gap: activation now requires having seen what the
rule actually does.

- PolicySimulator runs a policy against real past appointments and writes
  nothing: evaluation works on facts (never entities), the whole run sits in a
  transaction rolled back and cleared in `finally`, and a test counts rows in
  five sensitive tables before and after
- activate() now demands a simulation of the *same version* — a report for
  version 1 does not unlock version 2
- PolicyTemplateRegistry: six ready-made rules, so the common case never
  touches a raw condition
- Severity from the affected ratio; 0% is a warning too, since a rule that
  changes nothing usually has a condition that never matches
- An empty clinic still succeeds with a warning, otherwise a new clinic could
  never activate anything

Admin: PoliciesPage, PolicyFormPage, PolicySimulationPage, and a
PolicyConditionBuilder built entirely from GET /policy-schema — a test proves a
field that exists only in the schema shows up with no frontend change, and that
operators are filtered per field type.

The schema response now carries per-field metadata (label, type, meaningful
operators) so the form has one source of truth instead of two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-31 10:44:28 +03:30
co-authored by Claude Opus 5
parent 56bd1b474a
commit bcfa87bfad
27 changed files with 2939 additions and 70 deletions
+147
View File
@@ -0,0 +1,147 @@
import React, { useMemo } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { BeakerIcon, PlusIcon } from '@heroicons/react/24/outline';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable';
import { ActiveBadge } from '../components/ui/StatusBadge';
import SearchableSelect from '../components/ui/SearchableSelect';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { usePolicies, usePolicySchema } from '../hooks/usePolicies';
import type { Policy, PolicyCategory } from '../types';
/**
* فهرست قوانین محیط.
*
* دکمهٔ «فعال‌سازی» عمداً اینجا نیست: فعال‌سازی بدون دیدن نتیجهٔ آزمایش همان چیزی است
* که این تسک آمده جلویش را بگیرد. مسیر از این صفحه به صفحهٔ آزمایش می‌رود.
*/
export default function PoliciesPage() {
const navigate = useNavigate();
const { schema } = usePolicySchema();
const [urlState, setUrlState] = useUrlState({ search: '', category: '' });
const category = urlState.category as PolicyCategory | '';
const { policies, loading, deactivate } = usePolicies(category);
const { can } = usePermissions();
const canManage = can('appointment_settings', 'update');
const categoryOptions = useMemo(
() =>
Object.entries(schema ?? {}).map(([key, value]) => ({
value: key,
label: value.label,
})),
[schema],
);
const rows = useMemo(() => {
const q = urlState.search.trim();
return policies.filter((p) => q === '' || p.name.includes(q));
}, [policies, urlState.search]);
const columns: Column<Policy>[] = [
{
key: 'name',
header: 'قانون',
render: (p) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontWeight: 600 }}>{p.name}</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{schema?.[p.category]?.label ?? p.category} · نسخهٔ {p.version}
</span>
</div>
),
},
{
key: 'effects',
header: 'اثر',
render: (p) => (
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
{p.effects
.map(
(e) =>
schema?.[p.category]?.effects.find((m) => m.type === e.type)?.label ?? e.type,
)
.join('، ') || '—'}
</span>
),
},
{
key: 'scope',
header: 'دامنه',
render: (p) => (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{p.address_uuid ? 'شعبه' : p.service_uuid ? 'سرویس' : p.catalog_category_uuid ? 'دسته' : 'کل محیط'}
</span>
),
},
{
key: 'priority',
header: 'اولویت',
render: (p) => <span style={{ fontSize: 13 }}>{p.priority}</span>,
},
{
key: 'active',
header: 'وضعیت',
render: (p) => <ActiveBadge active={p.active} />,
},
];
return (
<div className="fade-in">
<PageHeader
title="قوانین"
description="قانون‌ها رفتار نوبت‌دهی را بدون تغییر کد عوض می‌کنند. هر قانون پیش از فعال شدن باید آزمایش شود."
backTo="/admin/settings-menu"
action={
canManage ? (
<Link className="btn primary sm" to="/admin/policies/new">
<PlusIcon style={{ width: 15 }} /> قانون تازه
</Link>
) : undefined
}
/>
<DataTable
columns={columns}
data={rows}
loading={loading}
searchValue={urlState.search}
onSearchChange={(v) => setUrlState({ search: v })}
searchPlaceholder="جستجو در قوانین..."
emptyMessage="هنوز قانونی تعریف نشده است"
headerExtra={
<div style={{ minWidth: 200, marginRight: 'auto' }}>
<SearchableSelect
value={urlState.category}
onChange={(v) => setUrlState({ category: String(v ?? '') })}
options={[{ value: '', label: 'همهٔ دسته‌ها' }, ...categoryOptions]}
placeholder="دسته"
/>
</div>
}
actions={(p) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button
type="button"
className="btn secondary sm"
onClick={() => navigate(`/admin/policies/${p.uuid}/simulate`)}
>
<BeakerIcon style={{ width: 15 }} /> آزمایش
</button>
{canManage && p.active && (
<button
type="button"
className="btn secondary sm"
disabled={deactivate.isPending}
onClick={() => deactivate.mutate(p.uuid)}
>
غیرفعال
</button>
)}
</div>
)}
/>
</div>
);
}
@@ -0,0 +1,84 @@
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 {},
}));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { api } from '../lib/api';
import PolicyFormPage from './PolicyFormPage';
const get = api.get as ReturnType<typeof vi.fn>;
/**
* فیلدِ ساختگی: در هیچ کجای فرانت نوشته نشده. اگر در صفحه ظاهر شود، یعنی فرم واقعاً
* از schema ساخته می‌شود و فیلد تازهٔ بک‌اند بدون تغییر فرانت کار می‌کند.
*/
const schema = {
timing: {
label: 'مدت نوبت',
fields: ['made_up_field', 'patient_tags'],
operators: ['equals', 'not_equals', 'greater_than', 'less_than', 'in', 'contains'],
field_meta: [
{
key: 'made_up_field',
label: 'فیلد ساختگی آزمون',
type: 'int',
operators: ['equals', 'greater_than'],
},
{ key: 'patient_tags', label: 'برچسب‌های بیمار', type: 'list', operators: ['contains'] },
],
effects: [
{ type: 'min_duration_minutes', label: 'حداقل مدت (دقیقه)', value_type: 'int', combination: 'max' },
],
},
};
function mockApi() {
get.mockImplementation((path: string) => {
if (path === '/api/v1/policy-schema') return Promise.resolve({ success: true, data: schema });
if (path === '/api/v1/policy-templates') return Promise.resolve({ success: true, data: [] });
return Promise.resolve({ success: true, data: null });
});
}
async function openAdvanced() {
renderWithProviders(<PolicyFormPage />, { route: '/admin/policies/new' });
fireEvent.click(screen.getByText('پیشرفته'));
await waitFor(() => expect(screen.getByText('افزودن شرط')).toBeInTheDocument());
fireEvent.click(screen.getByText('افزودن شرط'));
}
describe('PolicyFormPage', () => {
beforeEach(() => {
vi.clearAllMocks();
mockApi();
});
it('renders a field that exists only in the schema, with no frontend change', async () => {
await openAdvanced();
await waitFor(() => expect(screen.getByText('فیلد ساختگی آزمون')).toBeInTheDocument());
});
it('offers only the operators that make sense for the field type', async () => {
await openAdvanced();
await waitFor(() => expect(screen.getByText('برابر است با')).toBeInTheDocument());
// فیلد از نوع `int` است، پس «شامل» — که فقط برای فهرست معنا دارد — نباید باشد.
expect(screen.queryByText('شامل')).not.toBeInTheDocument();
});
it('renders the effects of the selected category from the schema', async () => {
renderWithProviders(<PolicyFormPage />, { route: '/admin/policies/new' });
fireEvent.click(screen.getByText('پیشرفته'));
await waitFor(() => expect(screen.getByText('حداقل مدت (دقیقه)')).toBeInTheDocument());
expect(screen.getByText(/ترکیب: max/)).toBeInTheDocument();
});
});
+280
View File
@@ -0,0 +1,280 @@
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PageHeader from '../components/ui/PageHeader';
import SearchableSelect from '../components/ui/SearchableSelect';
import PolicyConditionBuilder from '../components/PolicyConditionBuilder';
import {
usePolicyMutations,
usePolicySchema,
usePolicyTemplates,
} from '../hooks/usePolicies';
import type { PolicyCategory, PolicyClause, PolicyEffect } from '../types';
type Mode = 'template' | 'advanced';
/**
* ساخت قانون — با الگو (راه ۹۰٪ کاربران) یا دستی.
*
* قانون تازه پیش‌نویس ذخیره می‌شود و کاربر مستقیم به صفحهٔ آزمایش می‌رود: فعال‌سازی
* بدون دیدن نتیجه ممکن نیست، پس بردنش به همان‌جا کوتاه‌ترین مسیر درست است.
*/
export default function PolicyFormPage() {
const navigate = useNavigate();
const { schema } = usePolicySchema();
const { templates } = usePolicyTemplates();
const { create } = usePolicyMutations();
const [mode, setMode] = useState<Mode>('template');
const [name, setName] = useState('');
const [priority, setPriority] = useState(0);
const [templateKey, setTemplateKey] = useState('');
const [values, setValues] = useState<Record<string, string>>({});
const [category, setCategory] = useState<PolicyCategory>('timing');
const [match, setMatch] = useState<'all' | 'any'>('all');
const [clauses, setClauses] = useState<PolicyClause[]>([]);
const [effects, setEffects] = useState<PolicyEffect[]>([]);
const template = templates.find((t) => t.key === templateKey);
const categorySchema = schema?.[category];
const categoryOptions = useMemo(
() => Object.entries(schema ?? {}).map(([key, v]) => ({ value: key, label: v.label })),
[schema],
);
const submit = async () => {
const body =
mode === 'template'
? { name, priority, template: templateKey, values }
: {
name,
priority,
category,
condition: clauses.length ? { match, conditions: clauses } : {},
effects,
};
const created = await create.mutateAsync(body);
navigate(`/admin/policies/${created.data.uuid}/simulate`);
};
const canSubmit =
name.trim() !== '' &&
(mode === 'template' ? templateKey !== '' : effects.length > 0);
return (
<div className="fade-in">
<PageHeader
title="قانون تازه"
description="قانون پیش‌نویس ذخیره می‌شود؛ برای فعال شدن باید یک بار آزمایش شود."
backTo="/admin/policies"
/>
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className={`btn sm ${mode === 'template' ? 'primary' : 'secondary'}`}
onClick={() => setMode('template')}
>
از الگو
</button>
<button
type="button"
className={`btn sm ${mode === 'advanced' ? 'primary' : 'secondary'}`}
onClick={() => setMode('advanced')}
>
پیشرفته
</button>
</div>
<div className="field">
<label htmlFor="policy-name">نام قانون</label>
<input
id="policy-name"
className="input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="مثلاً: حداقل ۲۱ روز فاصله بین جلسات لیزر"
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
این نام در پیام خطا به بیمار نشان داده میشود؛ قابل فهم بنویسید.
</span>
</div>
<div className="field" style={{ maxWidth: 200 }}>
<label htmlFor="policy-priority">اولویت</label>
<input
id="policy-priority"
className="input"
type="number"
value={priority}
onChange={(e) => setPriority(Number(e.target.value))}
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
در تناقض، عدد بزرگتر برنده است.
</span>
</div>
{mode === 'template' ? (
<>
<div className="field">
<label>الگو</label>
<SearchableSelect
value={templateKey}
onChange={(v) => {
setTemplateKey(String(v ?? ''));
setValues({});
}}
options={templates.map((t) => ({ value: t.key, label: t.title }))}
placeholder="یک الگو انتخاب کنید"
/>
{template && (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>{template.description}</span>
)}
</div>
{template?.inputs.map((input) => (
<div className="field" key={input.key} style={{ maxWidth: 260 }}>
<label htmlFor={`tpl-${input.key}`}>{input.label}</label>
<input
id={`tpl-${input.key}`}
className="input"
type={input.type === 'int' ? 'number' : 'text'}
min={input.min}
max={input.max}
value={values[input.key] ?? ''}
onChange={(e) => setValues({ ...values, [input.key]: e.target.value })}
/>
</div>
))}
</>
) : (
<>
<div className="field" style={{ maxWidth: 260 }}>
<label>دسته</label>
<SearchableSelect
value={category}
onChange={(v) => {
setCategory((v as PolicyCategory) ?? 'timing');
// فیلدها و اثرهای مجاز per دسته فرق دارند؛ نگه‌داشتنشان یعنی ۴۲۲.
setClauses([]);
setEffects([]);
}}
options={categoryOptions}
/>
</div>
{categorySchema && (
<>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 14 }}>
<h3 style={{ fontSize: 14, margin: '0 0 12px' }}>شرط</h3>
<PolicyConditionBuilder
schema={categorySchema}
match={match}
clauses={clauses}
onChange={(m, c) => {
setMatch(m);
setClauses(c);
}}
/>
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 14 }}>
<h3 style={{ fontSize: 14, margin: '0 0 12px' }}>اثر</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{categorySchema.effects.map((meta) => {
const current = effects.find((e) => e.type === meta.type);
return (
<div
key={meta.type}
style={{ display: 'flex', alignItems: 'center', gap: 10 }}
>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 200 }}>
<input
type="checkbox"
checked={!!current}
onChange={(e) =>
setEffects(
e.target.checked
? [...effects, { type: meta.type, value: meta.value_type === 'int' ? 0 : '' }]
: effects.filter((x) => x.type !== meta.type),
)
}
/>
{meta.label}
</label>
{current && meta.value_type !== 'none' && (
<input
className="input"
style={{ maxWidth: 180 }}
type={meta.value_type === 'int' ? 'number' : 'text'}
value={String(current.value ?? '')}
onChange={(e) =>
setEffects(
effects.map((x) =>
x.type === meta.type
? {
...x,
value:
meta.value_type === 'int'
? Number(e.target.value)
: e.target.value,
}
: x,
),
)
}
/>
)}
{current && meta.type === 'forbid' && (
<input
className="input"
style={{ flex: 1 }}
value={String(current.reason ?? '')}
placeholder="پیامی که بیمار می‌بیند"
onChange={(e) =>
setEffects(
effects.map((x) =>
x.type === 'forbid' ? { ...x, reason: e.target.value } : x,
),
)
}
/>
)}
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
ترکیب: {meta.combination}
</span>
</div>
);
})}
</div>
</div>
</>
)}
</>
)}
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="btn primary"
disabled={!canSubmit || create.isPending}
onClick={submit}
>
ذخیره و آزمایش
</button>
<button type="button" className="btn secondary" onClick={() => navigate('/admin/policies')}>
انصراف
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,104 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, 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 {},
}));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { Routes, Route } from 'react-router-dom';
import { api } from '../lib/api';
import PolicySimulationPage from './PolicySimulationPage';
const get = api.get as ReturnType<typeof vi.fn>;
const policy = {
uuid: 'p1',
category: 'pricing',
name: 'تخفیف وفاداری',
condition: {},
effects: [{ type: 'discount_percent', value: 25 }],
priority: 0,
version: 2,
active: false,
valid_from: null,
valid_to: null,
address_uuid: null,
service_uuid: null,
catalog_category_uuid: null,
specificity: 0,
};
function run(version: number, severity: string) {
return {
uuid: 'r1',
policy_uuid: 'p1',
policy_version: version,
sample_size: 4,
affected_count: 3,
affected_percent: 75,
severity,
created_at: 1_700_000_000,
warning: null,
rows: [
{
appointment_uuid: 'a1',
patient_name: 'ز. احمدی',
slot_start: 1_700_000_000,
before: '2,000,000 ریال',
after: '1,500,000 ریال',
reason: '500,000 ریال تخفیف',
},
],
};
}
function mockApi(runs: unknown[]) {
get.mockImplementation((path: string) => {
if (path === '/api/v1/policy/p1') return Promise.resolve({ success: true, data: policy });
if (path === '/api/v1/policy/p1/simulations') return Promise.resolve({ success: true, data: runs });
return Promise.resolve({ success: true, data: null });
});
}
function renderPage() {
return renderWithProviders(
<Routes>
<Route path="/admin/policies/:policyUuid/simulate" element={<PolicySimulationPage />} />
</Routes>,
{ route: '/admin/policies/p1/simulate' },
);
}
describe('PolicySimulationPage', () => {
beforeEach(() => vi.clearAllMocks());
it('shows the before/after change of each affected appointment', async () => {
mockApi([run(2, 'high')]);
renderPage();
await waitFor(() => expect(screen.getByText('ز. احمدی')).toBeInTheDocument());
expect(screen.getByText('2,000,000 ریال')).toBeInTheDocument();
expect(screen.getByText('1,500,000 ریال')).toBeInTheDocument();
expect(screen.getByText(/تحت تأثیر: 3 نوبت/)).toBeInTheDocument();
});
/** گزارشِ نسخهٔ قبلی نباید دکمهٔ فعال‌سازی را باز کند — همان قاعدهٔ بک‌اند. */
it('disables activation when the report belongs to an older version', async () => {
mockApi([run(1, 'low')]);
renderPage();
await waitFor(() => expect(screen.getByText(/فعال‌سازی قانون/)).toBeDisabled());
expect(screen.getByText(/دوباره آزمایش کنید/)).toBeInTheDocument();
});
it('enables activation when the report matches the current version', async () => {
mockApi([run(2, 'low')]);
renderPage();
await waitFor(() => expect(screen.getByText(/فعال‌سازی قانون/)).toBeEnabled());
});
});
+196
View File
@@ -0,0 +1,196 @@
import React, { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { BeakerIcon } from '@heroicons/react/24/outline';
import PageHeader from '../components/ui/PageHeader';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import DataTable, { type Column } from '../components/ui/DataTable';
import { formatDate } from '../lib/utils';
import { usePermissions } from '../hooks/usePermissions';
import { usePolicies, usePolicy, usePolicySimulation } from '../hooks/usePolicies';
import type { PolicySimulationRun, SimulationRow } from '../types';
const SEVERITY: Record<PolicySimulationRun['severity'], { label: string; className: string; note: string }> = {
none: {
label: 'بدون اثر',
className: 'badge',
note: 'این قانون روی هیچ نوبتی از نمونه اثر نداشت — احتمالاً شرطش هرگز برقرار نمی‌شود.',
},
low: { label: 'کم', className: 'badge green', note: 'اثر محدود و قابل انتظار.' },
medium: { label: 'متوسط', className: 'badge amber', note: 'بخش قابل‌توجهی از نوبت‌ها تغییر می‌کنند.' },
high: {
label: 'زیاد',
className: 'badge red',
note: 'بیشتر نوبت‌های نمونه تغییر می‌کنند. مطمئنید قانون درست نوشته شده؟',
},
};
/**
* آزمایش قانون روی نوبت‌های واقعی گذشته.
*
* ستون «وضعیت فعلی → با این قانون» تنها چیزی است که کاربر غیرفنی می‌فهمد؛ درصد و شدت
* هم لازم‌اند چون قانونی که ۹۸٪ نوبت‌ها را عوض می‌کند تقریباً همیشه اشتباه است.
*/
export default function PolicySimulationPage() {
const { policyUuid } = useParams<{ policyUuid: string }>();
const navigate = useNavigate();
const { policy } = usePolicy(policyUuid);
const { runs, loading, run } = usePolicySimulation(policyUuid);
const { activate } = usePolicies();
const { can } = usePermissions();
const canManage = can('appointment_settings', 'update');
const [latest, setLatest] = useState<PolicySimulationRun | null>(null);
const [confirming, setConfirming] = useState(false);
const current = latest ?? runs[0] ?? null;
const isCurrentVersion = !!policy && !!current && current.policy_version === policy.version;
const severity = current ? SEVERITY[current.severity] : null;
const columns: Column<SimulationRow>[] = [
{
key: 'patient_name',
header: 'بیمار',
render: (r) => <span style={{ fontSize: 13 }}>{r.patient_name}</span>,
},
{
key: 'slot_start',
header: 'تاریخ نوبت',
render: (r) => <span style={{ fontSize: 13 }}>{formatDate(r.slot_start)}</span>,
},
{
key: 'change',
header: 'وضعیت فعلی ← با این قانون',
render: (r) => (
<span style={{ fontSize: 13 }}>
<span style={{ color: 'var(--text-3)' }}>{r.before}</span>
{' ← '}
<span style={{ fontWeight: 600 }}>{r.after}</span>
</span>
),
},
{
key: 'reason',
header: 'توضیح',
render: (r) => <span style={{ fontSize: 12, color: 'var(--text-2)' }}>{r.reason}</span>,
},
];
return (
<div className="fade-in">
<PageHeader
title={policy ? `آزمایش قانون: ${policy.name}` : 'آزمایش قانون'}
description="اجرای قانون روی نوبت‌های واقعی گذشته. هیچ چیزی ثبت یا تغییر نمی‌شود."
backTo="/admin/policies"
action={
<button
type="button"
className="btn primary sm"
disabled={run.isPending}
onClick={async () => setLatest((await run.mutateAsync(50)).data)}
>
<BeakerIcon style={{ width: 15 }} /> اجرای آزمایش
</button>
}
/>
{current && severity && (
<div className="card" style={{ marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<span style={{ fontSize: 14 }}>
نمونه: {current.sample_size} نوبت · تحت تأثیر: {current.affected_count} نوبت (
{current.affected_percent}٪)
</span>
<span className={severity.className}>
<span className="bdot" /> شدت: {severity.label}
</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
نسخهٔ آزمایششده: {current.policy_version}
</span>
</div>
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-2)' }}>
{current.warning ?? severity.note}
</p>
{!isCurrentVersion && (
<p style={{ margin: 0, fontSize: 13, color: 'var(--warning)' }}>
این گزارش برای نسخهٔ {current.policy_version} است و قانون اکنون نسخهٔ{' '}
{policy?.version} است. برای فعالسازی، دوباره آزمایش کنید.
</p>
)}
{canManage && (
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button
type="button"
className="btn primary sm"
disabled={!isCurrentVersion || activate.isPending || policy?.active}
onClick={() => {
// شدت زیاد یعنی احتمالاً قانون اشتباه نوشته شده؛ یک قدم مکث لازم است.
if (current.severity === 'high') {
setConfirming(true);
return;
}
activate.mutate(policyUuid!, { onSuccess: () => navigate('/admin/policies') });
}}
>
{policy?.active ? 'قانون فعال است' : 'فعال‌سازی قانون'}
</button>
<button
type="button"
className="btn secondary sm"
onClick={() => navigate('/admin/policies')}
>
بازگشت به فهرست
</button>
</div>
)}
</div>
)}
<ConfirmDialog
open={confirming}
title="فعال‌سازی قانون پرتأثیر"
message={`این قانون ${current?.affected_percent ?? 0}٪ نوبت‌های نمونه را تغییر می‌دهد. مطمئنید می‌خواهید فعالش کنید؟`}
confirmLabel="بله، فعال کن"
danger
loading={activate.isPending}
onCancel={() => setConfirming(false)}
onConfirm={() => {
setConfirming(false);
activate.mutate(policyUuid!, { onSuccess: () => navigate('/admin/policies') });
}}
/>
{policy?.versions && policy.versions.length > 1 && (
<div className="card" style={{ marginBottom: 16 }}>
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>تاریخچهٔ نسخهها</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{policy.versions.map((v) => (
<div key={v.version} style={{ display: 'flex', gap: 10, fontSize: 12 }}>
<span style={{ fontWeight: 600, minWidth: 60 }}>نسخهٔ {v.version}</span>
<span style={{ color: 'var(--text-3)' }}>{formatDate(v.created_at)}</span>
<span style={{ color: 'var(--text-2)' }}>
{JSON.stringify((v.snapshot as { effects?: unknown }).effects)}
</span>
</div>
))}
</div>
</div>
)}
<div style={{ overflowX: 'auto' }}>
<DataTable
columns={columns}
data={current?.rows ?? []}
loading={loading || run.isPending}
emptyMessage={
current
? 'هیچ نوبتی از نمونه با این قانون تغییر نمی‌کرد'
: 'برای دیدن اثر این قانون، یک آزمایش اجرا کنید'
}
/>
</div>
</div>
);
}