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:
@@ -75,6 +75,9 @@ import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage
|
||||
import PatientsListPage from './pages/PatientsListPage';
|
||||
import InventoryPage from './pages/InventoryPage';
|
||||
import BranchesPage from './pages/BranchesPage';
|
||||
import PoliciesPage from './pages/PoliciesPage';
|
||||
import PolicyFormPage from './pages/PolicyFormPage';
|
||||
import PolicySimulationPage from './pages/PolicySimulationPage';
|
||||
import BranchWorkingHoursPage from './pages/BranchWorkingHoursPage';
|
||||
import BranchRoomsPage from './pages/BranchRoomsPage';
|
||||
import ResourcesPage from './pages/ResourcesPage';
|
||||
@@ -287,6 +290,9 @@ export default function App() {
|
||||
<Route path="branches" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchesPage /></RoleRoute>} />
|
||||
<Route path="branches/:branchUuid/working-hours" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchWorkingHoursPage /></RoleRoute>} />
|
||||
<Route path="branches/:branchUuid/rooms" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><BranchRoomsPage /></RoleRoute>} />
|
||||
<Route path="policies" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PoliciesPage /></RoleRoute>} />
|
||||
<Route path="policies/new" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'update']}><PolicyFormPage /></RoleRoute>} />
|
||||
<Route path="policies/:policyUuid/simulate" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PolicySimulationPage /></RoleRoute>} />
|
||||
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
|
||||
<Route path="resources/types" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceTypesPage /></RoleRoute>} />
|
||||
<Route path="resources/skills" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><SkillsPage /></RoleRoute>} />
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import React from 'react';
|
||||
import { TrashIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import type { PolicyCategorySchema, PolicyClause } from '../types';
|
||||
|
||||
const OPERATOR_LABELS: Record<string, string> = {
|
||||
equals: 'برابر است با',
|
||||
not_equals: 'برابر نیست با',
|
||||
greater_than: 'بیشتر از',
|
||||
less_than: 'کمتر از',
|
||||
in: 'یکی از',
|
||||
contains: 'شامل',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
schema: PolicyCategorySchema;
|
||||
match: 'all' | 'any';
|
||||
clauses: PolicyClause[];
|
||||
onChange: (match: 'all' | 'any', clauses: PolicyClause[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* شرطساز — کاملاً از `policy-schema` ساخته میشود.
|
||||
*
|
||||
* فیلدها، عملگرهای **مجاز برای همان فیلد**، و نوع ورودی مقدار، همه از سرور میآیند.
|
||||
* اگر اینجا فهرست دستی مینوشتیم، هر فیلد تازه در بکاند نیاز به تغییر فرانت داشت و
|
||||
* بعد از دو ماه دو فهرست ناهمگام میداشتیم.
|
||||
*/
|
||||
export default function PolicyConditionBuilder({ schema, match, clauses, onChange }: Props) {
|
||||
const metaOf = (field: string) => schema.field_meta.find((m) => m.key === field);
|
||||
|
||||
const update = (index: number, patch: Partial<PolicyClause>) => {
|
||||
onChange(
|
||||
match,
|
||||
clauses.map((c, i) => (i === index ? { ...c, ...patch } : c)),
|
||||
);
|
||||
};
|
||||
|
||||
const addClause = () => {
|
||||
const first = schema.field_meta[0];
|
||||
if (!first) return;
|
||||
onChange(match, [...clauses, { field: first.key, operator: first.operators[0], value: '' }]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>شرطها با هم:</span>
|
||||
<div style={{ minWidth: 160 }}>
|
||||
<SearchableSelect
|
||||
value={match}
|
||||
onChange={(v) => onChange((v as 'all' | 'any') ?? 'all', clauses)}
|
||||
options={[
|
||||
{ value: 'all', label: 'همه برقرار باشند' },
|
||||
{ value: 'any', label: 'یکی برقرار باشد' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{clauses.length === 0 && (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
|
||||
بدون شرط، این قانون روی همهٔ نوبتهای دامنهاش اعمال میشود.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{clauses.map((clause, index) => {
|
||||
const meta = metaOf(clause.field);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}
|
||||
>
|
||||
<div style={{ minWidth: 200 }}>
|
||||
<SearchableSelect
|
||||
value={clause.field}
|
||||
onChange={(v) => {
|
||||
const next = metaOf(String(v ?? ''));
|
||||
update(index, {
|
||||
field: String(v ?? ''),
|
||||
// عملگر قبلی ممکن است برای فیلد تازه بیمعنا باشد؛ به اولین
|
||||
// عملگرِ مجاز برمیگردد تا کاربر ۴۲۲ نگیرد.
|
||||
operator: next?.operators[0] ?? 'equals',
|
||||
value: '',
|
||||
});
|
||||
}}
|
||||
options={schema.field_meta.map((m) => ({ value: m.key, label: m.label }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ minWidth: 160 }}>
|
||||
<SearchableSelect
|
||||
value={clause.operator}
|
||||
onChange={(v) => update(index, { operator: String(v ?? 'equals') })}
|
||||
options={(meta?.operators ?? []).map((op) => ({
|
||||
value: op,
|
||||
label: OPERATOR_LABELS[op] ?? op,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ minWidth: 160, flex: 1 }}>
|
||||
{meta?.type === 'enum' ? (
|
||||
<SearchableSelect
|
||||
value={String(clause.value ?? '')}
|
||||
onChange={(v) => update(index, { value: String(v ?? '') })}
|
||||
options={(meta.values ?? []).map((val) => ({
|
||||
value: val,
|
||||
label: val === 'male' ? 'آقا' : val === 'female' ? 'خانم' : val,
|
||||
}))}
|
||||
/>
|
||||
) : meta?.type === 'bool' ? (
|
||||
<SearchableSelect
|
||||
value={String(clause.value ?? '')}
|
||||
onChange={(v) => update(index, { value: v === 'true' })}
|
||||
options={[
|
||||
{ value: 'true', label: 'بله' },
|
||||
{ value: 'false', label: 'خیر' },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="input"
|
||||
type={meta?.type === 'int' ? 'number' : 'text'}
|
||||
value={String(clause.value ?? '')}
|
||||
onChange={(e) =>
|
||||
update(index, {
|
||||
value: meta?.type === 'int' ? Number(e.target.value) : e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="مقدار"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() => onChange(match, clauses.filter((_, i) => i !== index))}
|
||||
aria-label="حذف شرط"
|
||||
>
|
||||
<TrashIcon style={{ width: 15 }} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div>
|
||||
<button type="button" className="btn secondary sm" onClick={addClause}>
|
||||
<PlusIcon style={{ width: 15 }} /> افزودن شرط
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import {
|
||||
CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon,
|
||||
BanknotesIcon, UsersIcon, ShieldCheckIcon,
|
||||
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon,
|
||||
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import PurchaseSubscriptionSidebar from './PurchaseSubscriptionSidebar';
|
||||
|
||||
@@ -32,6 +32,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
|
||||
{ key: 'branches', label: 'شعبهها و اتاقها', icon: MapPinIcon, to: '/admin/branches', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'resources', label: 'منابع', icon: CubeIcon, to: '/admin/resources', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'policies', label: 'قوانین', icon: ScaleIcon, to: '/admin/policies', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
|
||||
{ key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] },
|
||||
{ key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' },
|
||||
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] },
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
||||
import type {
|
||||
Policy,
|
||||
PolicyCategory,
|
||||
PolicySchema,
|
||||
PolicySimulationRun,
|
||||
PolicyTemplate,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* قوانین کلینیک.
|
||||
*
|
||||
* دو نکتهٔ قراردادی که فرم به آنها وابسته است:
|
||||
* ۱. schema و الگوها از سرور میآیند، نه از فهرستی در فرانت — یک حقیقت، نه دو تا.
|
||||
* ۲. فعالسازی بدون آزمایشِ همان نسخه ۴۲۲ میگیرد؛ UI باید کاربر را اول به آزمایش ببرد.
|
||||
*/
|
||||
const POLICIES_KEY = ['policies'];
|
||||
|
||||
function fail(e: unknown, fallback: string) {
|
||||
toast.error(e instanceof ApiError ? e.message : fallback);
|
||||
}
|
||||
|
||||
/** schema تقریباً هرگز عوض نمیشود؛ نگهداشتن طولانیاش هزینهای ندارد. */
|
||||
export function usePolicySchema() {
|
||||
const query = useQuery({
|
||||
queryKey: ['policy-schema'],
|
||||
queryFn: () => api.get<ApiResponse<PolicySchema>>('/api/v1/policy-schema'),
|
||||
staleTime: 300_000,
|
||||
});
|
||||
|
||||
return { schema: query.data?.data, loading: query.isLoading };
|
||||
}
|
||||
|
||||
export function usePolicyTemplates() {
|
||||
const query = useQuery({
|
||||
queryKey: ['policy-templates'],
|
||||
queryFn: () => api.get<ApiResponse<PolicyTemplate[]>>('/api/v1/policy-templates'),
|
||||
staleTime: 300_000,
|
||||
});
|
||||
|
||||
return { templates: query.data?.data ?? [], loading: query.isLoading };
|
||||
}
|
||||
|
||||
export function usePolicies(category?: PolicyCategory | '') {
|
||||
const qc = useQueryClient();
|
||||
const key = [...POLICIES_KEY, category ?? ''];
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () =>
|
||||
api.get<ApiResponse<Policy[]>>(
|
||||
`/api/v1/policies${category ? `?category=${category}` : ''}`,
|
||||
),
|
||||
});
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: POLICIES_KEY });
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: (uuid: string) => api.post<ApiResponse<Policy>>(`/api/v1/policy/${uuid}/activate`, {}),
|
||||
onSuccess: () => {
|
||||
toast.success('قانون فعال شد');
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => fail(e, 'فعالسازی ناموفق بود'),
|
||||
});
|
||||
|
||||
const deactivate = useMutation({
|
||||
mutationFn: (uuid: string) => api.post<ApiResponse<Policy>>(`/api/v1/policy/${uuid}/deactivate`, {}),
|
||||
onSuccess: () => {
|
||||
toast.success('قانون غیرفعال شد');
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => fail(e, 'غیرفعالسازی ناموفق بود'),
|
||||
});
|
||||
|
||||
return {
|
||||
policies: query.data?.data ?? [],
|
||||
loading: query.isLoading,
|
||||
activate,
|
||||
deactivate,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePolicy(uuid: string | undefined) {
|
||||
const query = useQuery({
|
||||
queryKey: ['policy', uuid],
|
||||
queryFn: () => api.get<ApiResponse<Policy>>(`/api/v1/policy/${uuid}`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
|
||||
return { policy: query.data?.data, loading: query.isLoading };
|
||||
}
|
||||
|
||||
export function usePolicyMutations() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
api.post<ApiResponse<Policy>>('/api/v1/policy', body),
|
||||
onSuccess: () => {
|
||||
toast.success('قانون ساخته شد — حالا آزمایشش کنید');
|
||||
qc.invalidateQueries({ queryKey: POLICIES_KEY });
|
||||
},
|
||||
onError: (e) => fail(e, 'ساخت قانون ناموفق بود'),
|
||||
});
|
||||
|
||||
const newVersion = useMutation({
|
||||
mutationFn: ({ uuid, body }: { uuid: string; body: Record<string, unknown> }) =>
|
||||
api.post<ApiResponse<Policy>>(`/api/v1/policy/${uuid}/version`, body),
|
||||
onSuccess: (_d, v) => {
|
||||
toast.success('نسخهٔ تازه ثبت شد');
|
||||
qc.invalidateQueries({ queryKey: POLICIES_KEY });
|
||||
qc.invalidateQueries({ queryKey: ['policy', v.uuid] });
|
||||
},
|
||||
onError: (e) => fail(e, 'ثبت نسخه ناموفق بود'),
|
||||
});
|
||||
|
||||
return { create, newVersion };
|
||||
}
|
||||
|
||||
export function usePolicySimulation(uuid: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
const key = ['policy-simulations', uuid];
|
||||
|
||||
const history = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => api.get<ApiResponse<PolicySimulationRun[]>>(`/api/v1/policy/${uuid}/simulations`),
|
||||
enabled: !!uuid,
|
||||
});
|
||||
|
||||
const run = useMutation({
|
||||
mutationFn: (sampleSize?: number) =>
|
||||
api.post<ApiResponse<PolicySimulationRun>>(`/api/v1/policy/${uuid}/simulate`, {
|
||||
sample_size: sampleSize ?? 50,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: key });
|
||||
qc.invalidateQueries({ queryKey: POLICIES_KEY });
|
||||
},
|
||||
onError: (e) => fail(e, 'اجرای آزمایشی ناموفق بود'),
|
||||
});
|
||||
|
||||
return { runs: history.data?.data ?? [], loading: history.isLoading, run };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1089,3 +1089,118 @@ export interface HolidayOverride {
|
||||
note: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
// ── قوانین (تسک ۰۹/۱۰) ────────────────────────────────────────────────────────
|
||||
|
||||
export type PolicyCategory =
|
||||
| 'selection'
|
||||
| 'eligibility'
|
||||
| 'resource'
|
||||
| 'timing'
|
||||
| 'spacing'
|
||||
| 'pricing';
|
||||
|
||||
export interface PolicyClause {
|
||||
field: string;
|
||||
operator: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface PolicyCondition {
|
||||
match?: 'all' | 'any';
|
||||
conditions?: PolicyClause[];
|
||||
}
|
||||
|
||||
export interface PolicyEffect {
|
||||
type: string;
|
||||
value?: unknown;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface Policy {
|
||||
uuid: string;
|
||||
category: PolicyCategory;
|
||||
name: string;
|
||||
condition: PolicyCondition;
|
||||
effects: PolicyEffect[];
|
||||
priority: number;
|
||||
version: number;
|
||||
active: boolean;
|
||||
valid_from: number | null;
|
||||
valid_to: number | null;
|
||||
address_uuid: string | null;
|
||||
service_uuid: string | null;
|
||||
catalog_category_uuid: string | null;
|
||||
specificity: number;
|
||||
versions?: PolicyVersionLog[];
|
||||
}
|
||||
|
||||
export interface PolicyVersionLog {
|
||||
version: number;
|
||||
snapshot: Record<string, unknown>;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface PolicyFieldMeta {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'int' | 'uuid' | 'enum' | 'bool' | 'list';
|
||||
/** فقط عملگرهایی که برای این نوع معنا دارند — فرم باید همین را نشان دهد */
|
||||
operators: string[];
|
||||
values?: string[];
|
||||
}
|
||||
|
||||
export interface PolicyEffectMeta {
|
||||
type: string;
|
||||
label: string;
|
||||
value_type: 'none' | 'int' | 'string';
|
||||
combination: string;
|
||||
}
|
||||
|
||||
export interface PolicyCategorySchema {
|
||||
label: string;
|
||||
fields: string[];
|
||||
operators: string[];
|
||||
field_meta: PolicyFieldMeta[];
|
||||
effects: PolicyEffectMeta[];
|
||||
}
|
||||
|
||||
export type PolicySchema = Record<PolicyCategory, PolicyCategorySchema>;
|
||||
|
||||
export interface PolicyTemplateInput {
|
||||
key: string;
|
||||
type: string;
|
||||
label: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export interface PolicyTemplate {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: PolicyCategory;
|
||||
inputs: PolicyTemplateInput[];
|
||||
}
|
||||
|
||||
export interface SimulationRow {
|
||||
appointment_uuid: string;
|
||||
patient_name: string;
|
||||
slot_start: number;
|
||||
before: string;
|
||||
after: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface PolicySimulationRun {
|
||||
uuid: string;
|
||||
policy_uuid: string;
|
||||
policy_version: number;
|
||||
sample_size: number;
|
||||
affected_count: number;
|
||||
affected_percent: number;
|
||||
severity: 'none' | 'low' | 'medium' | 'high';
|
||||
created_at: number;
|
||||
rows: SimulationRow[];
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user