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:
parent
39cdbb0464
commit
3dcfc379bd
316
docs/rules.yaml
Normal file
316
docs/rules.yaml
Normal 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
108
src/lib/api/rules.js
Normal 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}`);
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import {
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Database,
|
||||
Printer,
|
||||
@ -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
|
||||
@ -101,11 +100,12 @@ import {
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
function toggleLaboratory() {
|
||||
function toggleLaboratory() {
|
||||
if (!isOpen) {
|
||||
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;
|
||||
|
||||
282
src/lib/components/rules/ActionsEditor.svelte
Normal file
282
src/lib/components/rules/ActionsEditor.svelte
Normal 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}
|
||||
/>
|
||||
116
src/lib/components/rules/ExprTesterModal.svelte
Normal file
116
src/lib/components/rules/ExprTesterModal.svelte
Normal 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>
|
||||
224
src/lib/components/rules/RuleForm.svelte
Normal file
224
src/lib/components/rules/RuleForm.svelte
Normal 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 > 65 ? (order.priority > 5 ? true : false) : false</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
177
src/lib/components/rules/TestSiteSearch.svelte
Normal file
177
src/lib/components/rules/TestSiteSearch.svelte
Normal 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>
|
||||
@ -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';
|
||||
|
||||
52
src/lib/types/rules.types.ts
Normal file
52
src/lib/types/rules.types.ts
Normal 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;
|
||||
};
|
||||
246
src/routes/(app)/rules/+page.svelte
Normal file
246
src/routes/(app)/rules/+page.svelte
Normal 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>
|
||||
368
src/routes/(app)/rules/[ruleId]/+page.svelte
Normal file
368
src/routes/(app)/rules/[ruleId]/+page.svelte
Normal 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>
|
||||
166
src/routes/(app)/rules/new/+page.svelte
Normal file
166
src/routes/(app)/rules/new/+page.svelte
Normal 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"
|
||||
/>
|
||||
Loading…
x
Reference in New Issue
Block a user