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>
|
<script>
|
||||||
import {
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Database,
|
Database,
|
||||||
Printer,
|
Printer,
|
||||||
@ -43,8 +44,27 @@ import {
|
|||||||
let usersContactsExpanded = $state(false);
|
let usersContactsExpanded = $state(false);
|
||||||
let referenceDataExpanded = $state(false);
|
let referenceDataExpanded = $state(false);
|
||||||
|
|
||||||
// Load states from localStorage on mount
|
let lastSaved = '';
|
||||||
$effect(() => {
|
|
||||||
|
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) {
|
if (browser) {
|
||||||
const savedStates = localStorage.getItem('sidebar_section_states');
|
const savedStates = localStorage.getItem('sidebar_section_states');
|
||||||
if (savedStates) {
|
if (savedStates) {
|
||||||
@ -55,35 +75,14 @@ import {
|
|||||||
labSetupExpanded = parsed.labSetup ?? false;
|
labSetupExpanded = parsed.labSetup ?? false;
|
||||||
usersContactsExpanded = parsed.usersContacts ?? false;
|
usersContactsExpanded = parsed.usersContacts ?? false;
|
||||||
referenceDataExpanded = parsed.referenceData ?? false;
|
referenceDataExpanded = parsed.referenceData ?? false;
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Keep defaults if parsing fails
|
// Keep defaults if parsing fails
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Save states to localStorage when they change
|
lastSaved = JSON.stringify(getSectionStates());
|
||||||
$effect(() => {
|
persistSectionStates();
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Function to expand sidebar when clicking dropdown in collapsed mode
|
// Function to expand sidebar when clicking dropdown in collapsed mode
|
||||||
@ -101,11 +100,12 @@ import {
|
|||||||
goto('/login');
|
goto('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLaboratory() {
|
function toggleLaboratory() {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
expandSidebar();
|
expandSidebar();
|
||||||
}
|
}
|
||||||
laboratoryExpanded = !laboratoryExpanded;
|
laboratoryExpanded = !laboratoryExpanded;
|
||||||
|
persistSectionStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleOrganization() {
|
function toggleOrganization() {
|
||||||
@ -113,6 +113,7 @@ function toggleLaboratory() {
|
|||||||
expandSidebar();
|
expandSidebar();
|
||||||
}
|
}
|
||||||
organizationExpanded = !organizationExpanded;
|
organizationExpanded = !organizationExpanded;
|
||||||
|
persistSectionStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLabSetup() {
|
function toggleLabSetup() {
|
||||||
@ -120,6 +121,7 @@ function toggleLaboratory() {
|
|||||||
expandSidebar();
|
expandSidebar();
|
||||||
}
|
}
|
||||||
labSetupExpanded = !labSetupExpanded;
|
labSetupExpanded = !labSetupExpanded;
|
||||||
|
persistSectionStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleUsersContacts() {
|
function toggleUsersContacts() {
|
||||||
@ -127,6 +129,7 @@ function toggleLaboratory() {
|
|||||||
expandSidebar();
|
expandSidebar();
|
||||||
}
|
}
|
||||||
usersContactsExpanded = !usersContactsExpanded;
|
usersContactsExpanded = !usersContactsExpanded;
|
||||||
|
persistSectionStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleReferenceData() {
|
function toggleReferenceData() {
|
||||||
@ -134,17 +137,18 @@ function toggleLaboratory() {
|
|||||||
expandSidebar();
|
expandSidebar();
|
||||||
}
|
}
|
||||||
referenceDataExpanded = !referenceDataExpanded;
|
referenceDataExpanded = !referenceDataExpanded;
|
||||||
|
persistSectionStates();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Mobile Overlay Backdrop -->
|
<!-- Mobile Overlay Backdrop -->
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<div
|
<button
|
||||||
|
type="button"
|
||||||
class="sidebar-backdrop lg:hidden"
|
class="sidebar-backdrop lg:hidden"
|
||||||
onclick={closeSidebar}
|
onclick={closeSidebar}
|
||||||
role="button"
|
|
||||||
aria-label="Close sidebar"
|
aria-label="Close sidebar"
|
||||||
></div>
|
></button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
@ -199,7 +203,9 @@ function toggleLaboratory() {
|
|||||||
<FlaskConical size={20} class="text-secondary flex-shrink-0" />
|
<FlaskConical size={20} class="text-secondary flex-shrink-0" />
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<span class="nav-text">Laboratory</span>
|
<span class="nav-text">Laboratory</span>
|
||||||
<ChevronDown size={16} class="chevron {laboratoryExpanded ? 'expanded' : ''}" />
|
<span class="chevron" class:expanded={laboratoryExpanded}>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -249,7 +255,9 @@ function toggleLaboratory() {
|
|||||||
<Building2 size={20} class="text-secondary flex-shrink-0" />
|
<Building2 size={20} class="text-secondary flex-shrink-0" />
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<span class="nav-text">Organization</span>
|
<span class="nav-text">Organization</span>
|
||||||
<ChevronDown size={16} class="chevron {organizationExpanded ? 'expanded' : ''}" />
|
<span class="chevron" class:expanded={organizationExpanded}>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -279,7 +287,9 @@ function toggleLaboratory() {
|
|||||||
<FlaskConical size={20} class="text-secondary flex-shrink-0" />
|
<FlaskConical size={20} class="text-secondary flex-shrink-0" />
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<span class="nav-text">Lab Setup</span>
|
<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}
|
{/if}
|
||||||
</button>
|
</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/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/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="/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>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
@ -303,7 +314,9 @@ function toggleLaboratory() {
|
|||||||
<Users size={20} class="text-secondary flex-shrink-0" />
|
<Users size={20} class="text-secondary flex-shrink-0" />
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<span class="nav-text">Users & Contacts</span>
|
<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}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -328,7 +341,9 @@ function toggleLaboratory() {
|
|||||||
<Database size={20} class="text-secondary flex-shrink-0" />
|
<Database size={20} class="text-secondary flex-shrink-0" />
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<span class="nav-text">Reference Data</span>
|
<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}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -486,13 +501,6 @@ function toggleLaboratory() {
|
|||||||
animation: slideDown 0.2s ease-out;
|
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 {
|
.submenu-link {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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
|
// Test Types
|
||||||
export * from './test.types.js';
|
export * from './test.types.js';
|
||||||
|
|
||||||
|
// Rules Types
|
||||||
|
export * from './rules.types.js';
|
||||||
|
|
||||||
// Re-export specific types for convenience
|
// Re-export specific types for convenience
|
||||||
export type {
|
export type {
|
||||||
TestType,
|
TestType,
|
||||||
@ -24,3 +27,13 @@ export type {
|
|||||||
TestFormState,
|
TestFormState,
|
||||||
TabConfig
|
TabConfig
|
||||||
} from './test.types.js';
|
} 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