feat(rules): add rules management UI

Adds list/create/edit pages for ruledef + ruleaction with SET_RESULT actions editor and expression tester, and links it from the sidebar.
This commit is contained in:
mahdahar 2026-03-12 07:36:08 +07:00
parent 39cdbb0464
commit 3dcfc379bd
12 changed files with 2119 additions and 43 deletions

316
docs/rules.yaml Normal file
View File

@ -0,0 +1,316 @@
/api/rules:
get:
tags: [Rules]
summary: List rules
security:
- bearerAuth: []
parameters:
- name: EventCode
in: query
schema:
type: string
description: Filter by event code
- name: Active
in: query
schema:
type: integer
enum: [0, 1]
description: Filter by active flag
- name: ScopeType
in: query
schema:
type: string
enum: [GLOBAL, TESTSITE]
description: Filter by scope type
- name: TestSiteID
in: query
schema:
type: integer
description: Filter by TestSiteID (for TESTSITE scope)
- name: search
in: query
schema:
type: string
description: Search by rule name
responses:
'200':
description: List of rules
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: array
items:
$ref: '../components/schemas/rules.yaml#/RuleDef'
post:
tags: [Rules]
summary: Create rule
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
Name:
type: string
Description:
type: string
EventCode:
type: string
example: ORDER_CREATED
ScopeType:
type: string
enum: [GLOBAL, TESTSITE]
TestSiteID:
type: integer
nullable: true
ConditionExpr:
type: string
nullable: true
Priority:
type: integer
Active:
type: integer
enum: [0, 1]
actions:
type: array
items:
type: object
properties:
Seq:
type: integer
ActionType:
type: string
ActionParams:
oneOf:
- type: string
- type: object
required: [Name, EventCode, ScopeType]
responses:
'201':
description: Rule created
/api/rules/{id}:
get:
tags: [Rules]
summary: Get rule (with actions)
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: Rule details
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/rules.yaml#/RuleWithActions'
'404':
description: Rule not found
patch:
tags: [Rules]
summary: Update rule
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
Name: { type: string }
Description: { type: string }
EventCode: { type: string }
ScopeType: { type: string, enum: [GLOBAL, TESTSITE] }
TestSiteID: { type: integer, nullable: true }
ConditionExpr: { type: string, nullable: true }
Priority: { type: integer }
Active: { type: integer, enum: [0, 1] }
responses:
'200':
description: Rule updated
'404':
description: Rule not found
delete:
tags: [Rules]
summary: Soft delete rule
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: Rule deleted
'404':
description: Rule not found
/api/rules/validate:
post:
tags: [Rules]
summary: Validate/evaluate an expression
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
expr:
type: string
context:
type: object
additionalProperties: true
required: [expr]
responses:
'200':
description: Validation result
/api/rules/{id}/actions:
get:
tags: [Rules]
summary: List actions for a rule
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: Actions list
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: array
items:
$ref: '../components/schemas/rules.yaml#/RuleAction'
post:
tags: [Rules]
summary: Create action for a rule
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
Seq:
type: integer
ActionType:
type: string
ActionParams:
oneOf:
- type: string
- type: object
required: [ActionType]
responses:
'201':
description: Action created
/api/rules/{id}/actions/{actionId}:
patch:
tags: [Rules]
summary: Update action
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: actionId
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
Seq: { type: integer }
ActionType: { type: string }
ActionParams:
oneOf:
- type: string
- type: object
responses:
'200':
description: Action updated
delete:
tags: [Rules]
summary: Soft delete action
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: actionId
in: path
required: true
schema:
type: integer
responses:
'200':
description: Action deleted

108
src/lib/api/rules.js Normal file
View File

@ -0,0 +1,108 @@
import { get, post, patch, del } from './client.js';
/**
* @typedef {import('$lib/types/rules.types.js').RuleDef} RuleDef
* @typedef {import('$lib/types/rules.types.js').RuleAction} RuleAction
* @typedef {import('$lib/types/rules.types.js').RuleWithActions} RuleWithActions
* @typedef {import('$lib/types/rules.types.js').RulesListResponse} RulesListResponse
* @typedef {import('$lib/types/rules.types.js').RuleDetailResponse} RuleDetailResponse
* @typedef {import('$lib/types/rules.types.js').RuleActionsListResponse} RuleActionsListResponse
* @typedef {import('$lib/types/rules.types.js').ValidateExprResponse} ValidateExprResponse
*/
/**
* List rules
* @param {{ EventCode?: string, Active?: 0|1|number, ScopeType?: string, TestSiteID?: number, search?: string }} [params]
* @returns {Promise<RulesListResponse>}
*/
export async function fetchRules(params = {}) {
const query = new URLSearchParams(params).toString();
return get(query ? `/api/rules?${query}` : '/api/rules');
}
/**
* Get rule (with actions)
* @param {number} id
* @returns {Promise<RuleDetailResponse>}
*/
export async function fetchRule(id) {
return get(`/api/rules/${id}`);
}
/**
* Create a rule; optionally include initial actions
* @param {Partial<RuleDef> & { actions?: Array<Partial<RuleAction>> }} payload
* @returns {Promise<any>}
*/
export async function createRule(payload) {
return post('/api/rules', payload);
}
/**
* Update a rule
* @param {number} id
* @param {Partial<RuleDef>} payload
* @returns {Promise<any>}
*/
export async function updateRule(id, payload) {
return patch(`/api/rules/${id}`, payload);
}
/**
* Soft delete a rule
* @param {number} id
* @returns {Promise<any>}
*/
export async function deleteRule(id) {
return del(`/api/rules/${id}`);
}
/**
* Validate/evaluate an expression
* @param {string} expr
* @param {Record<string, any>} [context]
* @returns {Promise<ValidateExprResponse>}
*/
export async function validateExpression(expr, context = {}) {
return post('/api/rules/validate', { expr, context });
}
/**
* List actions for a rule
* @param {number} id
* @returns {Promise<RuleActionsListResponse>}
*/
export async function fetchRuleActions(id) {
return get(`/api/rules/${id}/actions`);
}
/**
* Create action for a rule
* @param {number} id
* @param {Partial<RuleAction>} payload
* @returns {Promise<any>}
*/
export async function createRuleAction(id, payload) {
return post(`/api/rules/${id}/actions`, payload);
}
/**
* Update action
* @param {number} id
* @param {number} actionId
* @param {Partial<RuleAction>} payload
* @returns {Promise<any>}
*/
export async function updateRuleAction(id, actionId, payload) {
return patch(`/api/rules/${id}/actions/${actionId}`, payload);
}
/**
* Soft delete action
* @param {number} id
* @param {number} actionId
* @returns {Promise<any>}
*/
export async function deleteRuleAction(id, actionId) {
return del(`/api/rules/${id}/actions/${actionId}`);
}

View File

@ -1,4 +1,5 @@
<script>
import { onMount } from 'svelte';
import {
LayoutDashboard,
Database,
@ -43,8 +44,27 @@ import {
let usersContactsExpanded = $state(false);
let referenceDataExpanded = $state(false);
// Load states from localStorage on mount
$effect(() => {
let lastSaved = '';
function getSectionStates() {
return {
laboratory: laboratoryExpanded,
organization: organizationExpanded,
labSetup: labSetupExpanded,
usersContacts: usersContactsExpanded,
referenceData: referenceDataExpanded,
};
}
function persistSectionStates() {
if (!browser) return;
const json = JSON.stringify(getSectionStates());
if (json === lastSaved) return;
lastSaved = json;
localStorage.setItem('sidebar_section_states', json);
}
onMount(() => {
if (browser) {
const savedStates = localStorage.getItem('sidebar_section_states');
if (savedStates) {
@ -55,35 +75,14 @@ import {
labSetupExpanded = parsed.labSetup ?? false;
usersContactsExpanded = parsed.usersContacts ?? false;
referenceDataExpanded = parsed.referenceData ?? false;
} catch (e) {
} catch {
// Keep defaults if parsing fails
}
}
}
});
// Save states to localStorage when they change
$effect(() => {
if (browser) {
localStorage.setItem('sidebar_section_states', JSON.stringify({
laboratory: laboratoryExpanded,
organization: organizationExpanded,
labSetup: labSetupExpanded,
usersContacts: usersContactsExpanded,
referenceData: referenceDataExpanded
}));
}
});
// Close all sections when sidebar collapses
$effect(() => {
if (!isOpen) {
laboratoryExpanded = false;
organizationExpanded = false;
labSetupExpanded = false;
usersContactsExpanded = false;
referenceDataExpanded = false;
}
lastSaved = JSON.stringify(getSectionStates());
persistSectionStates();
});
// Function to expand sidebar when clicking dropdown in collapsed mode
@ -106,6 +105,7 @@ function toggleLaboratory() {
expandSidebar();
}
laboratoryExpanded = !laboratoryExpanded;
persistSectionStates();
}
function toggleOrganization() {
@ -113,6 +113,7 @@ function toggleLaboratory() {
expandSidebar();
}
organizationExpanded = !organizationExpanded;
persistSectionStates();
}
function toggleLabSetup() {
@ -120,6 +121,7 @@ function toggleLaboratory() {
expandSidebar();
}
labSetupExpanded = !labSetupExpanded;
persistSectionStates();
}
function toggleUsersContacts() {
@ -127,6 +129,7 @@ function toggleLaboratory() {
expandSidebar();
}
usersContactsExpanded = !usersContactsExpanded;
persistSectionStates();
}
function toggleReferenceData() {
@ -134,17 +137,18 @@ function toggleLaboratory() {
expandSidebar();
}
referenceDataExpanded = !referenceDataExpanded;
persistSectionStates();
}
</script>
<!-- Mobile Overlay Backdrop -->
{#if isOpen}
<div
<button
type="button"
class="sidebar-backdrop lg:hidden"
onclick={closeSidebar}
role="button"
aria-label="Close sidebar"
></div>
></button>
{/if}
<!-- Sidebar -->
@ -199,7 +203,9 @@ function toggleLaboratory() {
<FlaskConical size={20} class="text-secondary flex-shrink-0" />
{#if isOpen}
<span class="nav-text">Laboratory</span>
<ChevronDown size={16} class="chevron {laboratoryExpanded ? 'expanded' : ''}" />
<span class="chevron" class:expanded={laboratoryExpanded}>
<ChevronDown size={16} />
</span>
{/if}
</button>
@ -249,7 +255,9 @@ function toggleLaboratory() {
<Building2 size={20} class="text-secondary flex-shrink-0" />
{#if isOpen}
<span class="nav-text">Organization</span>
<ChevronDown size={16} class="chevron {organizationExpanded ? 'expanded' : ''}" />
<span class="chevron" class:expanded={organizationExpanded}>
<ChevronDown size={16} />
</span>
{/if}
</button>
@ -279,7 +287,9 @@ function toggleLaboratory() {
<FlaskConical size={20} class="text-secondary flex-shrink-0" />
{#if isOpen}
<span class="nav-text">Lab Setup</span>
<ChevronDown size={16} class="chevron {labSetupExpanded ? 'expanded' : ''}" />
<span class="chevron" class:expanded={labSetupExpanded}>
<ChevronDown size={16} />
</span>
{/if}
</button>
@ -288,6 +298,7 @@ function toggleLaboratory() {
<li><a href="/master-data/containers" class="submenu-link"><FlaskConical size={16} /> Containers</a></li>
<li><a href="/master-data/tests" class="submenu-link"><TestTube size={16} /> Test Definitions</a></li>
<li><a href="/master-data/testmap" class="submenu-link"><Link size={16} /> Test Mapping</a></li>
<li><a href="/rules" class="submenu-link"><FileText size={16} /> Rules</a></li>
</ul>
{/if}
</li>
@ -303,7 +314,9 @@ function toggleLaboratory() {
<Users size={20} class="text-secondary flex-shrink-0" />
{#if isOpen}
<span class="nav-text">Users & Contacts</span>
<ChevronDown size={16} class="chevron {usersContactsExpanded ? 'expanded' : ''}" />
<span class="chevron" class:expanded={usersContactsExpanded}>
<ChevronDown size={16} />
</span>
{/if}
</button>
@ -328,7 +341,9 @@ function toggleLaboratory() {
<Database size={20} class="text-secondary flex-shrink-0" />
{#if isOpen}
<span class="nav-text">Reference Data</span>
<ChevronDown size={16} class="chevron {referenceDataExpanded ? 'expanded' : ''}" />
<span class="chevron" class:expanded={referenceDataExpanded}>
<ChevronDown size={16} />
</span>
{/if}
</button>
@ -486,13 +501,6 @@ function toggleLaboratory() {
animation: slideDown 0.2s ease-out;
}
.submenu.nested {
margin-left: 1rem;
margin-top: 0.25rem;
padding-left: 0.5rem;
border-left: 1px solid hsl(var(--bc) / 0.1);
}
.submenu-link {
display: flex;
align-items: center;

View File

@ -0,0 +1,282 @@
<script>
import { Plus, Trash2, Wrench, Hash, Code, Play } from 'lucide-svelte';
import TestSiteSearch from './TestSiteSearch.svelte';
import ExprTesterModal from './ExprTesterModal.svelte';
/** @type {{ actions?: any[], errors?: any[], disabled?: boolean }} */
let {
actions = $bindable([]),
errors = [],
disabled = false,
} = $props();
let testerOpen = $state(false);
let testerExpr = $state('');
let testerTitle = $state('Test Expression');
let testerContext = $state('{}');
function ensureParams(action) {
if (!action.ActionParams || typeof action.ActionParams !== 'object') {
action.ActionParams = {};
}
const p = action.ActionParams;
if (!p._uiTargetMode) {
p._uiTargetMode = p.testSiteCode ? 'CODE' : (p.testSiteID != null && p.testSiteID !== '' ? 'ID' : 'CODE');
}
if (!p._uiValueMode) {
p._uiValueMode = p.valueExpr ? 'EXPR' : 'STATIC';
}
if (p._uiTargetMode === 'CODE' && !p._uiTestSite && (p.testSiteCode || p.testSiteName || p.testSiteID != null)) {
p._uiTestSite = {
TestSiteID: Number(p.testSiteID) || 0,
TestSiteCode: p.testSiteCode || '',
TestSiteName: p.testSiteName || '',
};
}
return action.ActionParams;
}
function getTargetMode(action) {
const p = action?.ActionParams;
if (p?._uiTargetMode) return p._uiTargetMode;
if (p?.testSiteCode) return 'CODE';
if (p?.testSiteID != null && p.testSiteID !== '') return 'ID';
return 'CODE';
}
function setTargetMode(action, mode) {
const p = ensureParams(action);
p._uiTargetMode = mode;
if (mode === 'CODE') {
p._uiTestSite = p._uiTestSite ?? null;
p.testSiteID = p.testSiteID ?? null;
p.testSiteCode = p.testSiteCode || '';
p.testSiteName = p.testSiteName || '';
} else {
p._uiTestSite = null;
p.testSiteCode = '';
p.testSiteName = '';
p.testSiteID = p.testSiteID ?? null;
}
}
function handleTargetSelect(action, selected) {
const p = ensureParams(action);
p._uiTargetMode = 'CODE';
p._uiTestSite = selected;
if (selected) {
p.testSiteID = selected.TestSiteID ?? null;
p.testSiteCode = selected.TestSiteCode || '';
p.testSiteName = selected.TestSiteName || '';
} else {
p.testSiteID = null;
p.testSiteCode = '';
p.testSiteName = '';
}
}
function getValueMode(action) {
const p = action?.ActionParams;
if (p?._uiValueMode) return p._uiValueMode;
if (p?.valueExpr) return 'EXPR';
return 'STATIC';
}
function setValueMode(action, mode) {
const p = ensureParams(action);
p._uiValueMode = mode;
if (mode === 'EXPR') {
p.valueExpr = p.valueExpr || '';
p.value = '';
} else {
p.value = p.value ?? '';
p.valueExpr = '';
}
}
function addAction() {
const nextSeq = actions.length > 0
? Math.max(...actions.map(a => Number(a.Seq) || 0)) + 1
: 1;
actions = [
...actions,
{
Seq: nextSeq,
ActionType: 'SET_RESULT',
ActionParams: {
_uiTargetMode: 'CODE',
_uiValueMode: 'STATIC',
_uiTestSite: null,
testSiteID: null,
testSiteCode: '',
testSiteName: '',
value: '',
valueExpr: '',
},
},
];
}
function removeAction(index) {
actions = actions.filter((_, i) => i !== index);
}
function openExprTester(index) {
testerTitle = `Test Action #${index + 1} Expression`;
testerExpr = actions[index]?.ActionParams?.valueExpr || '';
testerOpen = true;
}
</script>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-bold text-gray-800 flex items-center gap-2">
<Wrench class="w-4 h-4 text-gray-500" />
Actions
</h3>
<p class="text-xs text-gray-500">SET_RESULT actions executed in sequence</p>
</div>
<button class="btn btn-sm btn-primary" onclick={addAction} disabled={disabled} type="button">
<Plus class="w-4 h-4 mr-2" />
Add Action
</button>
</div>
<div class="overflow-x-auto border border-base-200 rounded-lg bg-base-100">
<table class="table table-compact w-full">
<thead>
<tr class="bg-base-200">
<th class="w-20">Seq</th>
<th class="w-40">Type</th>
<th class="min-w-[260px]">Target</th>
<th class="min-w-[320px]">Value</th>
<th class="w-16"></th>
</tr>
</thead>
<tbody>
{#if actions.length === 0}
<tr>
<td colspan="5" class="text-sm text-gray-500 text-center py-6">No actions yet</td>
</tr>
{:else}
{#each actions as action, index (action.RuleActionID ?? index)}
{@const p = ensureParams(action)}
{@const targetMode = getTargetMode(action)}
{@const valueMode = getValueMode(action)}
<tr>
<td>
<label class="input input-sm input-bordered flex items-center gap-2 w-20">
<Hash class="w-4 h-4 text-gray-400" />
<input type="number" class="grow bg-transparent outline-none" bind:value={action.Seq} disabled={disabled} />
</label>
</td>
<td>
<select class="select select-sm select-bordered w-full" bind:value={action.ActionType} disabled={disabled}>
<option value="SET_RESULT">SET_RESULT</option>
</select>
</td>
<td>
<div class="space-y-2">
<select
class="select select-sm select-bordered w-full"
value={targetMode}
onchange={(e) => setTargetMode(action, e.currentTarget.value)}
disabled={disabled}
>
<option value="CODE">By TestSiteCode</option>
<option value="ID">By TestSiteID</option>
</select>
{#if targetMode === 'CODE'}
<TestSiteSearch
bind:value={p._uiTestSite}
onSelect={(sel) => handleTargetSelect(action, sel)}
placeholder="Search test site..."
disabled={disabled}
/>
{:else}
<input
type="number"
class="input input-sm input-bordered w-full"
placeholder="TestSiteID"
bind:value={p.testSiteID}
disabled={disabled}
/>
{/if}
</div>
</td>
<td>
<div class="space-y-2">
<select
class="select select-sm select-bordered w-full"
value={valueMode}
onchange={(e) => setValueMode(action, e.currentTarget.value)}
disabled={disabled}
>
<option value="STATIC">Static</option>
<option value="EXPR">Expression</option>
</select>
{#if valueMode === 'EXPR'}
<div>
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-gray-500 flex items-center gap-1">
<Code class="w-3.5 h-3.5" />
ExpressionLanguage (ternary)
</span>
<button class="btn btn-xs btn-ghost" onclick={() => openExprTester(index)} disabled={disabled} type="button">
<Play class="w-3.5 h-3.5 mr-1" />
Test
</button>
</div>
<textarea
class="textarea textarea-bordered w-full font-mono text-xs"
rows="3"
bind:value={p.valueExpr}
disabled={disabled}
placeholder="e.g. order.isStat ? 'POS' : 'NEG'"
></textarea>
<p class="text-xs text-gray-500 mt-1">Example: `cond ? a : (cond2 ? b : c)`</p>
</div>
{:else}
<input
type="text"
class="input input-sm input-bordered w-full"
bind:value={p.value}
disabled={disabled}
placeholder="Value"
/>
{/if}
</div>
</td>
<td class="text-right">
<button class="btn btn-sm btn-ghost text-error" onclick={() => removeAction(index)} disabled={disabled} type="button" title="Remove">
<Trash2 class="w-4 h-4" />
</button>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
{#if errors && errors.length}
<div class="alert alert-warning">
<div class="text-sm">Some actions have validation errors.</div>
</div>
{/if}
</div>
<ExprTesterModal
bind:open={testerOpen}
bind:expr={testerExpr}
bind:context={testerContext}
title={testerTitle}
/>

View File

@ -0,0 +1,116 @@
<script>
import Modal from '$lib/components/Modal.svelte';
import { validateExpression } from '$lib/api/rules.js';
import { Play, AlertTriangle, CheckCircle2 } from 'lucide-svelte';
let {
open = $bindable(false),
expr = $bindable(''),
context = $bindable('{}'),
title = 'Test Expression',
} = $props();
const uid = `expr-tester-${Math.random().toString(36).slice(2, 10)}`;
let running = $state(false);
let result = $state(null);
let errorMessage = $state('');
function handleClose() {
running = false;
result = null;
errorMessage = '';
}
function normalizeValidateResponse(response) {
const payload = response?.data?.data ?? response?.data ?? response;
if (payload && typeof payload === 'object') {
return payload;
}
return null;
}
async function handleRun() {
running = true;
result = null;
errorMessage = '';
try {
const parsedContext = context?.trim() ? JSON.parse(context) : {};
const response = await validateExpression(expr || '', parsedContext);
result = normalizeValidateResponse(response);
if (!result) {
errorMessage = 'Unexpected response from API';
}
} catch (err) {
errorMessage = err?.message || 'Failed to validate expression';
} finally {
running = false;
}
}
</script>
<Modal bind:open={open} {title} size="lg" onClose={handleClose}>
<div class="space-y-4">
<div>
<div class="flex items-center justify-between gap-3">
<label class="text-sm font-semibold text-gray-700" for={`${uid}-expr`}>Expression</label>
<button class="btn btn-sm btn-primary" onclick={handleRun} disabled={running || !expr?.trim()} type="button">
{#if running}
<span class="loading loading-spinner loading-sm mr-2"></span>
{:else}
<Play class="w-4 h-4 mr-2" />
{/if}
Run
</button>
</div>
<textarea
id={`${uid}-expr`}
class="textarea textarea-bordered w-full font-mono text-xs"
rows="4"
bind:value={expr}
placeholder="e.g. order.priority > 10 ? true : false"
></textarea>
</div>
<div>
<label class="text-sm font-semibold text-gray-700" for={`${uid}-context`}>Context (JSON)</label>
<textarea
id={`${uid}-context`}
class="textarea textarea-bordered w-full font-mono text-xs"
rows="6"
bind:value={context}
placeholder={'{"order": {"priority": 10}}'}
></textarea>
<p class="text-xs text-gray-500 mt-1">Use a JSON object; it will be passed as <code>context</code>.</p>
</div>
{#if errorMessage}
<div class="alert alert-error">
<AlertTriangle class="w-4 h-4" />
<div class="text-sm">{errorMessage}</div>
</div>
{/if}
{#if result}
<div class={"alert " + (result.valid ? 'alert-success' : 'alert-warning')}>
{#if result.valid}
<CheckCircle2 class="w-4 h-4" />
{:else}
<AlertTriangle class="w-4 h-4" />
{/if}
<div class="text-sm">
{#if result.valid}
Expression is valid.
{:else}
Expression is invalid.
{/if}
</div>
</div>
<div class="bg-base-200 rounded-md p-3">
<div class="text-xs font-semibold text-gray-700 mb-2">Response</div>
<pre class="text-xs overflow-auto">{JSON.stringify(result, null, 2)}</pre>
</div>
{/if}
</div>
</Modal>

View File

@ -0,0 +1,224 @@
<script>
import { FileText, AlignLeft, Zap, Hash, Globe, MapPin, Code, ToggleLeft } from 'lucide-svelte';
import TestSiteSearch from './TestSiteSearch.svelte';
/** @type {{ value?: any, errors?: Record<string, any>, disabled?: boolean }} */
let {
value = $bindable({}),
errors = {},
disabled = false,
} = $props();
let selectedTestSite = $state(null);
$effect(() => {
if (!value) return;
if (value.ConditionExpr == null) {
value.ConditionExpr = '';
}
if (value.EventCode == null) {
value.EventCode = 'ORDER_CREATED';
}
if (value.ScopeType == null) {
value.ScopeType = 'GLOBAL';
}
if (value.Active == null) {
value.Active = 1;
}
});
$effect(() => {
if (!value) return;
if (value.ScopeType !== 'TESTSITE') {
value.TestSiteID = null;
}
});
$effect(() => {
if (!value) return;
if (value.ScopeType !== 'TESTSITE') return;
value.TestSiteID = selectedTestSite?.TestSiteID ?? null;
});
function isActive(v) {
return v === 1 || v === '1' || v === true;
}
function handleActiveToggle(e) {
value.Active = e.currentTarget.checked ? 1 : 0;
}
function handleScopeChange(e) {
value.ScopeType = e.currentTarget.value;
if (value.ScopeType !== 'TESTSITE') {
value.TestSiteID = null;
selectedTestSite = null;
}
}
function fieldError(key) {
return errors?.[key] || errors?.[key?.toLowerCase?.()] || '';
}
</script>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="space-y-4">
<div>
<div class="text-sm font-semibold text-gray-700">Name<span class="text-error"> *</span></div>
<label class="input input-sm input-bordered flex items-center gap-2 w-full focus-within:input-primary">
<FileText class="w-4 h-4 text-gray-400" />
<input
type="text"
class="grow bg-transparent outline-none"
placeholder="e.g. Auto-set STAT results"
bind:value={value.Name}
disabled={disabled}
/>
</label>
{#if fieldError('Name')}
<p class="text-xs text-error mt-1">{fieldError('Name')}</p>
{/if}
</div>
<div>
<div class="text-sm font-semibold text-gray-700">Description</div>
<label class="input input-sm input-bordered flex items-center gap-2 w-full focus-within:input-primary">
<AlignLeft class="w-4 h-4 text-gray-400" />
<input
type="text"
class="grow bg-transparent outline-none"
placeholder="Optional"
bind:value={value.Description}
disabled={disabled}
/>
</label>
{#if fieldError('Description')}
<p class="text-xs text-error mt-1">{fieldError('Description')}</p>
{/if}
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<div class="text-sm font-semibold text-gray-700">Event</div>
<label class="input input-sm input-bordered flex items-center gap-2 w-full">
<Zap class="w-4 h-4 text-gray-400" />
<select
class="select select-sm select-ghost grow bg-transparent outline-none"
bind:value={value.EventCode}
disabled={true}
>
<option value="ORDER_CREATED">ORDER_CREATED</option>
</select>
</label>
</div>
<div>
<div class="text-sm font-semibold text-gray-700">Priority</div>
<label class="input input-sm input-bordered flex items-center gap-2 w-full focus-within:input-primary">
<Hash class="w-4 h-4 text-gray-400" />
<input
type="number"
class="grow bg-transparent outline-none"
min="0"
step="1"
bind:value={value.Priority}
disabled={disabled}
/>
</label>
{#if fieldError('Priority')}
<p class="text-xs text-error mt-1">{fieldError('Priority')}</p>
{/if}
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<div class="text-sm font-semibold text-gray-700">Scope</div>
<label class="input input-sm input-bordered flex items-center gap-2 w-full focus-within:input-primary">
<Globe class="w-4 h-4 text-gray-400" />
<select
class="select select-sm select-ghost grow bg-transparent outline-none"
value={value.ScopeType}
onchange={handleScopeChange}
disabled={disabled}
>
<option value="GLOBAL">GLOBAL</option>
<option value="TESTSITE">TESTSITE</option>
</select>
</label>
{#if fieldError('ScopeType')}
<p class="text-xs text-error mt-1">{fieldError('ScopeType')}</p>
{/if}
</div>
<div>
<div class="text-sm font-semibold text-gray-700">Active</div>
<label class="input input-sm input-bordered flex items-center justify-between gap-2 w-full">
<div class="flex items-center gap-2">
<ToggleLeft class="w-4 h-4 text-gray-400" />
<span class="text-sm text-gray-700">{isActive(value.Active) ? 'Enabled' : 'Disabled'}</span>
</div>
<input
type="checkbox"
class="toggle toggle-success toggle-sm"
checked={isActive(value.Active)}
onchange={handleActiveToggle}
disabled={disabled}
/>
</label>
{#if fieldError('Active')}
<p class="text-xs text-error mt-1">{fieldError('Active')}</p>
{/if}
</div>
</div>
{#if value.ScopeType === 'TESTSITE'}
<div>
<div class="text-sm font-semibold text-gray-700">Test Site</div>
<div class="flex items-start gap-2">
<div class="flex-1">
<TestSiteSearch bind:value={selectedTestSite} placeholder="Search test site..." disabled={disabled} />
{#if value.TestSiteID && !selectedTestSite}
<p class="text-xs text-gray-500 mt-1">Current TestSiteID: {value.TestSiteID}</p>
{/if}
{#if fieldError('TestSiteID')}
<p class="text-xs text-error mt-1">{fieldError('TestSiteID')}</p>
{/if}
</div>
<div class="pt-2">
<MapPin class="w-4 h-4 text-gray-400" />
</div>
</div>
</div>
{/if}
</div>
<div class="space-y-4">
<div>
<div class="flex items-center justify-between">
<div class="text-sm font-semibold text-gray-700">Condition Expression</div>
<span class="text-xs text-gray-500">ExpressionLanguage (ternary)</span>
</div>
<div class="mt-1">
<textarea
class="textarea textarea-bordered w-full font-mono text-xs"
rows="10"
bind:value={value.ConditionExpr}
disabled={disabled}
placeholder="e.g. order.priority > 10 ? true : false"
></textarea>
</div>
{#if fieldError('ConditionExpr')}
<p class="text-xs text-error mt-1">{fieldError('ConditionExpr')}</p>
{/if}
<div class="text-xs text-gray-500 mt-2">
<div class="font-semibold text-gray-700 mb-1 flex items-center gap-2">
<Code class="w-3.5 h-3.5 text-gray-500" />
Examples
</div>
<pre class="bg-base-200 rounded p-2 overflow-auto">order.isStat ? true : false
order.patient.age &gt; 65 ? (order.priority &gt; 5 ? true : false) : false</pre>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,177 @@
<script>
import { fetchTests } from '$lib/api/tests.js';
import { Search, X } from 'lucide-svelte';
/**
* @typedef {{ TestSiteID: number, TestSiteCode?: string, TestSiteName?: string }} TestSiteOption
*/
/** @type {{ value?: TestSiteOption | null, placeholder?: string, disabled?: boolean, onSelect?: (value: TestSiteOption | null) => void }} */
let {
value = $bindable(null),
placeholder = 'Search test site...',
disabled = false,
onSelect = null,
} = $props();
let inputValue = $state('');
let loading = $state(false);
let open = $state(false);
let options = $state([]);
let errorMessage = $state('');
let debounceTimer = $state(null);
const displayValue = $derived.by(() => {
return value ? formatValue(value) : inputValue;
});
function formatValue(v) {
if (!v) return '';
const code = v.TestSiteCode || '';
const name = v.TestSiteName || '';
if (code && name) return `${code} - ${name}`;
if (code) return code;
if (name) return name;
if (v.TestSiteID != null) return `#${v.TestSiteID}`;
return '';
}
async function search(term) {
const q = term.trim();
if (!q) {
options = [];
loading = false;
errorMessage = '';
return;
}
loading = true;
errorMessage = '';
try {
const response = await fetchTests({
page: 1,
perPage: 10,
TestSiteCode: q,
TestSiteName: q,
});
/** @type {any[]} */
const list = Array.isArray(response?.data?.data)
? response.data.data
: Array.isArray(response?.data)
? response.data
: [];
options = list
.filter(t => t && t.IsActive !== '0' && t.IsActive !== 0)
.map(t => ({
TestSiteID: t.TestSiteID,
TestSiteCode: t.TestSiteCode,
TestSiteName: t.TestSiteName,
}));
} catch (err) {
options = [];
errorMessage = err?.message || 'Failed to search test sites';
} finally {
loading = false;
}
}
function handleInput(e) {
inputValue = e.currentTarget.value;
value = null;
open = true;
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
search(inputValue);
}, 300);
}
function handleSelect(item) {
value = item;
inputValue = '';
open = false;
options = [];
errorMessage = '';
if (onSelect) {
onSelect(item);
}
}
function clear() {
value = null;
inputValue = '';
options = [];
errorMessage = '';
open = false;
if (onSelect) {
onSelect(null);
}
}
function handleBlur() {
setTimeout(() => {
open = false;
}, 120);
}
</script>
<div class="dropdown w-full" class:dropdown-open={open && (options.length > 0 || loading || !!errorMessage)}>
<label class="input input-sm input-bordered flex items-center gap-2 w-full focus-within:input-primary">
<Search class="w-4 h-4 text-gray-400" />
<input
type="text"
class="grow bg-transparent outline-none"
{placeholder}
value={displayValue}
disabled={disabled}
oninput={handleInput}
onfocus={() => (open = true)}
onblur={handleBlur}
/>
{#if (inputValue || value) && !disabled}
<button class="btn btn-ghost btn-xs btn-circle" onclick={clear} type="button" aria-label="Clear">
<X class="w-3.5 h-3.5" />
</button>
{/if}
</label>
<div tabindex="-1" class="dropdown-content z-[60] mt-1 w-full rounded-box bg-base-100 shadow border border-base-200">
{#if loading}
<div class="p-3 text-sm text-gray-600 flex items-center gap-2">
<span class="loading loading-spinner loading-sm text-primary"></span>
Searching...
</div>
{:else if errorMessage}
<div class="p-3 text-sm text-error flex items-center gap-2">
{errorMessage}
</div>
{:else if options.length === 0}
<div class="p-3 text-sm text-gray-500">No matches</div>
{:else}
<ul class="menu menu-sm">
{#each options as item (item.TestSiteID)}
<li>
<button
type="button"
class="justify-start"
onmousedown={(e) => {
e.preventDefault();
handleSelect(item);
}}
>
<span class="font-medium">{item.TestSiteCode || `#${item.TestSiteID}`}</span>
{#if item.TestSiteName}
<span class="text-gray-500">{item.TestSiteName}</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>

View File

@ -6,6 +6,9 @@
// Test Types
export * from './test.types.js';
// Rules Types
export * from './rules.types.js';
// Re-export specific types for convenience
export type {
TestType,
@ -24,3 +27,13 @@ export type {
TestFormState,
TabConfig
} from './test.types.js';
export type {
RuleEventCode,
RuleScopeType,
RuleActionType,
RuleDef,
RuleAction,
RuleWithActions,
SetResultActionParams
} from './rules.types.js';

View File

@ -0,0 +1,52 @@
/**
* Rules Engine Type Definitions
* Based on CLQMS API Specification
*/
export type RuleEventCode = 'ORDER_CREATED';
export type RuleScopeType = 'GLOBAL' | 'TESTSITE';
export type RuleActionType = 'SET_RESULT';
export interface RuleDef {
RuleID: number;
Name: string;
Description?: string;
EventCode: RuleEventCode | string;
ScopeType: RuleScopeType;
TestSiteID?: number | null;
ConditionExpr?: string | null;
Priority?: number;
Active: 0 | 1 | number;
}
export interface RuleAction {
RuleActionID: number;
RuleID: number;
Seq: number;
ActionType: RuleActionType | string;
ActionParams: string | Record<string, any>;
}
export interface RuleWithActions {
rule: RuleDef;
actions: RuleAction[];
}
export interface ApiResponse<T> {
status: 'success' | 'created' | 'error' | string;
message: string;
data: T;
}
export interface RulesListResponse extends ApiResponse<RuleDef[]> {}
export interface RuleDetailResponse extends ApiResponse<RuleWithActions> {}
export interface RuleActionsListResponse extends ApiResponse<RuleAction[]> {}
export interface ValidateExprResponse extends ApiResponse<{ valid: boolean; result?: any; error?: string }> {}
export type SetResultActionParams = {
testSiteID?: number;
testSiteCode?: string;
value?: any;
valueExpr?: string;
};

View File

@ -0,0 +1,246 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { fetchRules, deleteRule } from '$lib/api/rules.js';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import DataTable from '$lib/components/DataTable.svelte';
import Modal from '$lib/components/Modal.svelte';
import TestSiteSearch from '$lib/components/rules/TestSiteSearch.svelte';
import { Plus, Search, Trash2, Edit2, ArrowLeft, Loader2, Filter, FileText } from 'lucide-svelte';
let loading = $state(false);
let rules = $state([]);
let searchName = $state('');
let searchDebounceTimer = $state(null);
let filterEventCode = $state('ORDER_CREATED');
let filterActive = $state('ALL');
let filterScopeType = $state('ALL');
let filterTestSite = $state(null);
let deleteConfirmOpen = $state(false);
let deleteItem = $state(null);
let deleting = $state(false);
const columns = [
{ key: 'Name', label: 'Name', class: 'font-medium min-w-[220px]' },
{ key: 'EventCode', label: 'EventCode', class: 'w-36' },
{ key: 'ScopeType', label: 'Scope', class: 'w-36' },
{ key: 'TestSiteID', label: 'TestSiteID', class: 'w-28' },
{ key: 'Priority', label: 'Priority', class: 'w-24 text-center' },
{ key: 'Active', label: 'Active', class: 'w-24 text-center' },
{ key: 'actions', label: 'Actions', class: 'w-24 text-center' },
];
function normalizeRulesListResponse(response) {
if (Array.isArray(response?.data?.data)) return response.data.data;
if (Array.isArray(response?.data)) return response.data;
if (Array.isArray(response?.data?.rules)) return response.data.rules;
return [];
}
function getParams() {
const params = {};
if (filterEventCode) params.EventCode = filterEventCode;
if (filterActive !== 'ALL') params.Active = filterActive;
if (filterScopeType !== 'ALL') params.ScopeType = filterScopeType;
if (filterTestSite?.TestSiteID) params.TestSiteID = filterTestSite.TestSiteID;
const s = searchName.trim();
if (s) params.search = s;
return params;
}
async function loadRules() {
loading = true;
try {
const response = await fetchRules(getParams());
rules = normalizeRulesListResponse(response);
} catch (err) {
toastError(err?.message || 'Failed to load rules');
rules = [];
} finally {
loading = false;
}
}
function handleSearch() {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer);
}
searchDebounceTimer = setTimeout(() => {
loadRules();
}, 300);
}
onMount(loadRules);
function confirmDelete(row) {
deleteItem = row;
deleteConfirmOpen = true;
}
async function handleDelete() {
if (!deleteItem?.RuleID) return;
deleting = true;
try {
await deleteRule(deleteItem.RuleID);
toastSuccess('Rule deleted successfully');
deleteConfirmOpen = false;
await loadRules();
} catch (err) {
toastError(err?.message || 'Failed to delete rule');
} finally {
deleting = false;
}
}
function isActive(v) {
return v === 1 || v === '1' || v === true;
}
</script>
<div class="p-4">
<div class="flex items-center gap-4 mb-6">
<a href="/dashboard" class="btn btn-ghost btn-circle">
<ArrowLeft class="w-5 h-5" />
</a>
<div class="flex-1">
<h1 class="text-xl font-bold text-gray-800 flex items-center gap-2">
<FileText class="w-5 h-5 text-gray-500" />
Rules
</h1>
<p class="text-sm text-gray-600">Manage rules and actions for ORDER_CREATED</p>
</div>
<a class="btn btn-primary" href="/rules/new">
<Plus class="w-4 h-4 mr-2" />
New Rule
</a>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4">
<div class="flex items-center gap-2 mb-3 text-sm font-semibold text-gray-700">
<Filter class="w-4 h-4" />
Filters
</div>
<div class="grid grid-cols-1 lg:grid-cols-5 gap-3">
<div>
<div class="text-xs font-semibold text-gray-600">EventCode</div>
<select class="select select-sm select-bordered w-full" bind:value={filterEventCode} disabled={true}>
<option value="ORDER_CREATED">ORDER_CREATED</option>
</select>
</div>
<div>
<div class="text-xs font-semibold text-gray-600">Active</div>
<select class="select select-sm select-bordered w-full" bind:value={filterActive} onchange={loadRules}>
<option value="ALL">All</option>
<option value="1">Active</option>
<option value="0">Inactive</option>
</select>
</div>
<div>
<div class="text-xs font-semibold text-gray-600">ScopeType</div>
<select class="select select-sm select-bordered w-full" bind:value={filterScopeType} onchange={loadRules}>
<option value="ALL">All</option>
<option value="GLOBAL">GLOBAL</option>
<option value="TESTSITE">TESTSITE</option>
</select>
</div>
<div>
<div class="text-xs font-semibold text-gray-600">TestSite</div>
<TestSiteSearch bind:value={filterTestSite} placeholder="Optional" onSelect={loadRules} />
</div>
<div>
<div class="text-xs font-semibold text-gray-600">Rule Name</div>
<div class="flex gap-2">
<label class="input input-sm input-bordered flex items-center gap-2 w-full focus-within:input-primary">
<Search class="w-4 h-4 text-gray-400" />
<input
type="text"
class="grow bg-transparent outline-none"
placeholder="Search..."
bind:value={searchName}
oninput={handleSearch}
onkeydown={(e) => e.key === 'Enter' && loadRules()}
/>
</label>
<button class="btn btn-sm btn-primary" onclick={loadRules} disabled={loading} type="button">
{#if loading}
<Loader2 class="w-4 h-4 animate-spin" />
{:else}
<Search class="w-4 h-4" />
{/if}
</button>
</div>
</div>
</div>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
<DataTable
{columns}
data={rules}
loading={loading}
emptyMessage="No rules found"
hover={true}
bordered={false}
>
{#snippet cell({ column, row, value })}
{#if column.key === 'ScopeType'}
<span class="badge badge-ghost badge-sm">{row.ScopeType || '-'}</span>
{:else if column.key === 'Active'}
<div class="flex justify-center">
<span class="badge badge-sm {isActive(row.Active) ? 'badge-success' : 'badge-ghost'}">
{isActive(row.Active) ? 'Yes' : 'No'}
</span>
</div>
{:else if column.key === 'Priority'}
<div class="text-center">{value ?? 0}</div>
{:else if column.key === 'actions'}
<div class="flex justify-center gap-2">
<button
class="btn btn-sm btn-ghost"
onclick={() => goto(`/rules/${row.RuleID}`)}
title="Edit rule"
type="button"
>
<Edit2 class="w-4 h-4" />
</button>
<button
class="btn btn-sm btn-ghost text-error"
onclick={() => confirmDelete(row)}
title="Delete rule"
type="button"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else}
{value || '-'}
{/if}
{/snippet}
</DataTable>
</div>
</div>
<Modal bind:open={deleteConfirmOpen} title="Confirm Delete" size="sm">
<div class="py-2">
<p class="text-base-content/80">
Are you sure you want to delete <strong class="text-base-content">{deleteItem?.Name}</strong>?
</p>
<p class="text-sm text-gray-500 mt-1">RuleID: {deleteItem?.RuleID}</p>
<p class="text-sm text-error mt-3">This action cannot be undone.</p>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} type="button" disabled={deleting}>Cancel</button>
<button class="btn btn-error" onclick={handleDelete} disabled={deleting} type="button">
{#if deleting}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{deleting ? 'Deleting...' : 'Delete'}
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,368 @@
<script>
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { afterNavigate, goto } from '$app/navigation';
import {
fetchRule,
updateRule,
deleteRule,
createRuleAction,
updateRuleAction,
deleteRuleAction,
} from '$lib/api/rules.js';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import Modal from '$lib/components/Modal.svelte';
import RuleForm from '$lib/components/rules/RuleForm.svelte';
import ActionsEditor from '$lib/components/rules/ActionsEditor.svelte';
import ExprTesterModal from '$lib/components/rules/ExprTesterModal.svelte';
import { ArrowLeft, Save, Trash2, Play } from 'lucide-svelte';
const ruleId = $derived.by(() => {
const raw = $page?.params?.ruleId;
const id = parseInt(raw, 10);
return Number.isFinite(id) ? id : null;
});
let loading = $state(false);
let savingRule = $state(false);
let savingActions = $state(false);
let errors = $state({});
let ruleForm = $state({
Name: '',
Description: '',
EventCode: 'ORDER_CREATED',
ScopeType: 'GLOBAL',
TestSiteID: null,
ConditionExpr: '',
Priority: 0,
Active: 1,
});
let actions = $state([]);
let originalActions = $state([]);
let deleteConfirmOpen = $state(false);
let deleting = $state(false);
let conditionTesterOpen = $state(false);
let conditionTesterContext = $state('{}');
let loadedId = $state(null);
function parseParams(v) {
if (!v) return {};
if (typeof v === 'string') {
try {
const parsed = JSON.parse(v);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
if (typeof v === 'object') return v;
return {};
}
function stableStringify(value) {
if (value == null) return 'null';
if (typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
const keys = Object.keys(value).sort();
return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
}
function normalizeActionForCompare(a) {
const params = parseParams(a.ActionParams);
const cleaned = {};
for (const [k, v] of Object.entries(params || {})) {
if (k.startsWith('_ui')) continue;
cleaned[k] = v;
}
return {
RuleActionID: a.RuleActionID ?? null,
Seq: Number(a.Seq) || 0,
ActionType: a.ActionType || '',
ActionParams: cleaned,
};
}
function stringifyActionParams(actionParams) {
if (!actionParams) return '{}';
/** @type {any} */
let p = actionParams;
if (typeof p === 'string') {
try {
p = JSON.parse(p);
} catch {
return p;
}
}
if (!p || typeof p !== 'object') return '{}';
const out = {};
if (p.testSiteID != null && p.testSiteID !== '') out.testSiteID = Number(p.testSiteID);
if (p.testSiteCode) out.testSiteCode = String(p.testSiteCode);
if (p.valueExpr) out.valueExpr = String(p.valueExpr);
if (!out.valueExpr && p.value !== undefined) out.value = p.value;
return JSON.stringify(out);
}
function normalizeRuleDetailResponse(response) {
const payload = response?.data?.data ?? response?.data ?? response;
if (payload?.rule && payload?.actions) return payload;
if (payload?.data?.rule && payload?.data?.actions) return payload.data;
return null;
}
async function loadRule(id) {
if (!id) return;
loading = true;
errors = {};
try {
const response = await fetchRule(id);
const detail = normalizeRuleDetailResponse(response);
const rule = detail?.rule || {};
const list = Array.isArray(detail?.actions) ? detail.actions : [];
ruleForm = {
...ruleForm,
...rule,
ConditionExpr: rule.ConditionExpr ?? '',
Priority: rule.Priority ?? 0,
Active: rule.Active ?? 1,
EventCode: rule.EventCode ?? 'ORDER_CREATED',
};
actions = list.map(a => ({
...a,
ActionParams: parseParams(a.ActionParams),
}));
originalActions = actions.map(a => {
const p = parseParams(a.ActionParams);
let cloned = {};
try {
cloned = JSON.parse(JSON.stringify(p));
} catch {
cloned = p;
}
return {
...a,
ActionParams: cloned,
};
});
loadedId = id;
} catch (err) {
toastError(err?.message || 'Failed to load rule');
} finally {
loading = false;
}
}
onMount(() => {
loadRule(ruleId);
});
afterNavigate(() => {
if (ruleId && ruleId !== loadedId) {
loadRule(ruleId);
}
});
function validateForm() {
const next = {};
if (!ruleForm.Name?.trim()) next.Name = 'Name is required';
if (ruleForm.ScopeType === 'TESTSITE' && !ruleForm.TestSiteID) next.TestSiteID = 'TestSite is required for TESTSITE scope';
errors = next;
return Object.keys(next).length === 0;
}
async function handleSaveRule() {
if (!ruleId) return;
if (!validateForm()) return;
savingRule = true;
try {
const payload = {
Name: ruleForm.Name?.trim(),
Description: ruleForm.Description || '',
EventCode: 'ORDER_CREATED',
ScopeType: ruleForm.ScopeType,
TestSiteID: ruleForm.ScopeType === 'TESTSITE' ? (ruleForm.TestSiteID ?? null) : null,
ConditionExpr: ruleForm.ConditionExpr?.trim() ? ruleForm.ConditionExpr.trim() : null,
Priority: Number(ruleForm.Priority) || 0,
Active: ruleForm.Active === 1 || ruleForm.Active === '1' || ruleForm.Active === true ? 1 : 0,
};
await updateRule(ruleId, payload);
toastSuccess('Rule saved');
await loadRule(ruleId);
} catch (err) {
toastError(err?.message || 'Failed to save rule');
if (err?.messages && typeof err.messages === 'object') {
errors = err.messages;
}
} finally {
savingRule = false;
}
}
async function handleSaveActions() {
if (!ruleId) return;
savingActions = true;
try {
const currentById = {};
for (const a of actions) {
if (a.RuleActionID) currentById[String(a.RuleActionID)] = normalizeActionForCompare(a);
}
const originalById = {};
for (const a of originalActions) {
if (a.RuleActionID) originalById[String(a.RuleActionID)] = normalizeActionForCompare(a);
}
const toDelete = [];
for (const id of Object.keys(originalById)) {
if (!currentById[id]) toDelete.push(Number(id));
}
const toCreate = actions
.filter(a => !a.RuleActionID)
.map(a => normalizeActionForCompare(a));
const toUpdate = [];
for (const id of Object.keys(currentById)) {
const cur = currentById[id];
const orig = originalById[id];
if (!orig) continue;
const changed = stableStringify(cur) !== stableStringify(orig);
if (changed) toUpdate.push(cur);
}
const createPromises = toCreate.map(a => createRuleAction(ruleId, {
Seq: a.Seq,
ActionType: a.ActionType,
ActionParams: stringifyActionParams(a.ActionParams),
}));
const updatePromises = toUpdate.map(a => updateRuleAction(ruleId, Number(a.RuleActionID), {
Seq: a.Seq,
ActionType: a.ActionType,
ActionParams: stringifyActionParams(a.ActionParams),
}));
const deletePromises = toDelete.map(id => deleteRuleAction(ruleId, id));
await Promise.all([...createPromises, ...updatePromises, ...deletePromises]);
toastSuccess('Actions saved');
await loadRule(ruleId);
} catch (err) {
toastError(err?.message || 'Failed to save actions');
} finally {
savingActions = false;
}
}
function confirmDelete() {
deleteConfirmOpen = true;
}
async function handleDeleteRule() {
if (!ruleId) return;
deleting = true;
try {
await deleteRule(ruleId);
toastSuccess('Rule deleted');
deleteConfirmOpen = false;
goto('/rules');
} catch (err) {
toastError(err?.message || 'Failed to delete rule');
} finally {
deleting = false;
}
}
</script>
<div class="p-4">
<div class="flex items-center gap-4 mb-6">
<a href="/rules" class="btn btn-ghost btn-circle">
<ArrowLeft class="w-5 h-5" />
</a>
<div class="flex-1">
<h1 class="text-xl font-bold text-gray-800">Edit Rule</h1>
<p class="text-sm text-gray-600">RuleID: {ruleId ?? '-'}</p>
</div>
<button class="btn btn-error btn-outline" onclick={confirmDelete} disabled={loading || savingRule || deleting} type="button">
<Trash2 class="w-4 h-4 mr-2" />
Delete
</button>
<button class="btn btn-primary" onclick={handleSaveRule} disabled={loading || savingRule} type="button">
<Save class="w-4 h-4 mr-2" />
{savingRule ? 'Saving...' : 'Save Rule'}
</button>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4">
<RuleForm bind:value={ruleForm} {errors} disabled={loading || savingRule} />
<div class="flex justify-end mt-4">
<button
class="btn btn-sm btn-ghost"
onclick={() => (conditionTesterOpen = true)}
disabled={!ruleForm.ConditionExpr?.trim()}
type="button"
title="Test condition expression"
>
<Play class="w-4 h-4 mr-2" />
Test Condition
</button>
</div>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4">
<div class="flex items-center justify-between mb-3">
<div>
<h2 class="text-base font-bold text-gray-800">Actions</h2>
<p class="text-xs text-gray-500">Diff-based save (create/update/delete)</p>
</div>
<button class="btn btn-sm btn-primary" onclick={handleSaveActions} disabled={loading || savingActions} type="button">
<Save class="w-4 h-4 mr-2" />
{savingActions ? 'Saving...' : 'Save Actions'}
</button>
</div>
<ActionsEditor bind:actions={actions} disabled={loading || savingActions} />
</div>
</div>
<ExprTesterModal
bind:open={conditionTesterOpen}
bind:expr={ruleForm.ConditionExpr}
bind:context={conditionTesterContext}
title="Test Condition Expression"
/>
<Modal bind:open={deleteConfirmOpen} title="Confirm Delete" size="sm">
<div class="py-2">
<p class="text-base-content/80">
Are you sure you want to delete <strong class="text-base-content">{ruleForm?.Name}</strong>?
</p>
<p class="text-sm text-gray-500 mt-1">RuleID: {ruleId}</p>
<p class="text-sm text-error mt-3">This action cannot be undone.</p>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} type="button" disabled={deleting}>Cancel</button>
<button class="btn btn-error" onclick={handleDeleteRule} disabled={deleting} type="button">
{#if deleting}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{deleting ? 'Deleting...' : 'Delete'}
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,166 @@
<script>
import { goto } from '$app/navigation';
import { createRule } from '$lib/api/rules.js';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import RuleForm from '$lib/components/rules/RuleForm.svelte';
import ActionsEditor from '$lib/components/rules/ActionsEditor.svelte';
import ExprTesterModal from '$lib/components/rules/ExprTesterModal.svelte';
import { ArrowLeft, Save, Play } from 'lucide-svelte';
let saving = $state(false);
let errors = $state({});
let ruleForm = $state({
Name: '',
Description: '',
EventCode: 'ORDER_CREATED',
ScopeType: 'GLOBAL',
TestSiteID: null,
ConditionExpr: '',
Priority: 0,
Active: 1,
});
let actions = $state([
{
Seq: 1,
ActionType: 'SET_RESULT',
ActionParams: {},
},
]);
let conditionTesterOpen = $state(false);
let conditionTesterContext = $state('{}');
function stringifyActionParams(actionParams) {
if (!actionParams) return '{}';
/** @type {any} */
let p = actionParams;
if (typeof p === 'string') {
try {
p = JSON.parse(p);
} catch {
return p;
}
}
if (!p || typeof p !== 'object') return '{}';
const out = {};
if (p.testSiteID != null && p.testSiteID !== '') out.testSiteID = Number(p.testSiteID);
if (p.testSiteCode) out.testSiteCode = String(p.testSiteCode);
if (p.valueExpr) out.valueExpr = String(p.valueExpr);
if (!out.valueExpr && p.value !== undefined) out.value = p.value;
return JSON.stringify(out);
}
function validateForm() {
const next = {};
if (!ruleForm.Name?.trim()) next.Name = 'Name is required';
if (ruleForm.ScopeType === 'TESTSITE' && !ruleForm.TestSiteID) next.TestSiteID = 'TestSite is required for TESTSITE scope';
errors = next;
return Object.keys(next).length === 0;
}
function extractRuleId(response) {
const candidates = [
response?.data?.RuleID,
response?.data?.data?.RuleID,
response?.data?.rule?.RuleID,
response?.data?.data?.rule?.RuleID,
response?.RuleID,
];
for (const c of candidates) {
const n = Number(c);
if (Number.isFinite(n) && n > 0) return n;
}
return null;
}
async function handleSave() {
if (!validateForm()) return;
saving = true;
try {
const payload = {
Name: ruleForm.Name?.trim(),
Description: ruleForm.Description || '',
EventCode: 'ORDER_CREATED',
ScopeType: ruleForm.ScopeType,
TestSiteID: ruleForm.ScopeType === 'TESTSITE' ? (ruleForm.TestSiteID ?? null) : null,
ConditionExpr: ruleForm.ConditionExpr?.trim() ? ruleForm.ConditionExpr.trim() : null,
Priority: Number(ruleForm.Priority) || 0,
Active: ruleForm.Active === 1 || ruleForm.Active === '1' || ruleForm.Active === true ? 1 : 0,
actions: (actions || []).map(a => ({
Seq: Number(a.Seq) || 0,
ActionType: a.ActionType || 'SET_RESULT',
ActionParams: stringifyActionParams(a.ActionParams),
})),
};
const response = await createRule(payload);
toastSuccess('Rule created successfully');
const newId = extractRuleId(response);
if (newId) {
goto(`/rules/${newId}`);
} else {
goto('/rules');
}
} catch (err) {
const message = err?.message || 'Failed to create rule';
toastError(message);
if (err?.messages && typeof err.messages === 'object') {
errors = err.messages;
}
} finally {
saving = false;
}
}
</script>
<div class="p-4">
<div class="flex items-center gap-4 mb-6">
<a href="/rules" class="btn btn-ghost btn-circle">
<ArrowLeft class="w-5 h-5" />
</a>
<div class="flex-1">
<h1 class="text-xl font-bold text-gray-800">New Rule</h1>
<p class="text-sm text-gray-600">Create a rule and its initial actions</p>
</div>
<button class="btn btn-primary" onclick={handleSave} disabled={saving} type="button">
<Save class="w-4 h-4 mr-2" />
{saving ? 'Saving...' : 'Save'}
</button>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4">
<RuleForm bind:value={ruleForm} {errors} disabled={saving} />
<div class="flex justify-end mt-4">
<button
class="btn btn-sm btn-ghost"
onclick={() => (conditionTesterOpen = true)}
disabled={!ruleForm.ConditionExpr?.trim()}
type="button"
title="Test condition expression"
>
<Play class="w-4 h-4 mr-2" />
Test Condition
</button>
</div>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4">
<ActionsEditor bind:actions={actions} disabled={saving} />
</div>
</div>
<ExprTesterModal
bind:open={conditionTesterOpen}
bind:expr={ruleForm.ConditionExpr}
bind:context={conditionTesterContext}
title="Test Condition Expression"
/>