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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user