feat: replace legacy rule builder with DSL page

This commit is contained in:
mahdahar 2026-03-12 16:55:12 +07:00
parent 3dcfc379bd
commit 134040fcb4
19 changed files with 1405 additions and 1648 deletions

17
TODO.md
View File

@ -1,15 +1,4 @@
# its MVP so Keep It Simple STUPID # TODO
## todos - Investigate why calculated tests in `ResultEntryModal.svelte` are failing to run: confirm formula loading (FormulaCode vs testdefcal), member detection, and local compute validation rules.
- test single - Support `{CODE}` tokens and backend formula formats in the temporary `computeLocally` logic, then wire refresh button to compute the target calc directly.
- test group
- test calc
- test param
### backend
- testgroup detail is wrong
## done
- patient page -> add edit patient button on patient list
- patient page -> add view order modal
- visit page -> on visit modal, show visitid when create
- visit page -> on visit modal, make uniform design when create and edit

View File

@ -0,0 +1,166 @@
# Backend API Specification: Calculation Engine
## Overview
Endpoint to evaluate calculated test formulas and return computed values with proper rounding and error handling.
## Endpoint
```
POST /api/calculate/evaluate
```
## Request Body
```typescript
{
// The formula expression using test codes as variables
// Example: "CHOL - HDL - (TG/5)"
formula: string;
// Map of test codes to their current numeric values
// Example: { "CHOL": 180, "HDL": 45, "TG": 150 }
values: Record<string, number>;
// Decimal precision for rounding (0-6)
// Default: 2
decimal?: number;
}
```
## Response Body
### Success (200)
```typescript
{
status: "success";
data: {
// The computed result value
result: number;
// The result rounded to specified decimal places
resultRounded: number;
// Formula that was evaluated (for verification)
evaluatedFormula: string;
}
}
```
### Error (400/422)
```typescript
{
status: "error";
message: string;
error: {
// Error type for frontend handling
type: "MISSING_VALUE" | "INVALID_EXPRESSION" | "DIVISION_BY_ZERO" | "SYNTAX_ERROR";
// Missing variable names if applicable
missingVars?: string[];
// Position of syntax error if applicable
position?: number;
}
}
```
## Formula Syntax
### Supported Operators
- Arithmetic: `+`, `-`, `*`, `/`, `^` (power)
- Parentheses: `(` `)` for grouping
- Functions: `abs()`, `round()`, `floor()`, `ceil()`, `min()`, `max()`, `sqrt()`
### Variable Names
- Test codes are used as variable names directly
- Case-sensitive (CHOL ≠ chol)
- Must match exactly (word boundaries)
### Examples
**Simple subtraction:**
```
Formula: "CHOL - HDL"
Values: { "CHOL": 180, "HDL": 45 }
Result: 135
```
**Complex with division:**
```
Formula: "CHOL - HDL - (TG/5)"
Values: { "CHOL": 180, "HDL": 45, "TG": 150 }
Result: 105
```
**With decimal rounding:**
```
Formula: "(HGB * MCV) / 100"
Values: { "HGB": 14.2, "MCV": 87.5 }
Decimal: 2
Result: 12.43
```
## Validation Rules
1. **Missing Values**: If any variable in formula is not provided in values, return MISSING_VALUE error
2. **Division by Zero**: Return DIVISION_BY_ZERO error if encountered
3. **Invalid Syntax**: Return SYNTAX_ERROR with position if formula cannot be parsed
4. **Non-numeric Values**: Return MISSING_VALUE if any value is not a valid number
## Batch Endpoint (Optional)
For efficiency when recalculating multiple CALC tests:
```
POST /api/calculate/evaluate-batch
```
```typescript
// Request
{
calculations: [
{
testSiteId: number;
formula: string;
values: Record<string, number>;
decimal?: number;
}
]
}
// Response
{
status: "success";
data: {
results: [
{
testSiteId: number;
result: number;
resultRounded: number;
error?: {
type: string;
message: string;
}
}
]
}
}
```
## Frontend Integration
The frontend will:
1. Build dependency graph from test definitions
2. Detect when member test values change
3. Call this API to compute dependent CALC tests
4. Update UI with computed values
5. Mark CALC tests as `changedByAutoCalc` for save tracking
## Security Considerations
1. Never use `eval()` or similar unsafe evaluation
2. Use a proper expression parser (mathjs, muparser, or custom parser)
3. Sanitize/validate formula input before parsing
4. Limit computation time to prevent DoS

View File

@ -23,6 +23,7 @@
"vite": "^7.3.1" "vite": "^7.3.1"
}, },
"dependencies": { "dependencies": {
"lucide-svelte": "^0.563.0" "lucide-svelte": "^0.563.0",
"mathjs": "^15.1.1"
} }
} }

62
pnpm-lock.yaml generated
View File

@ -11,6 +11,9 @@ importers:
lucide-svelte: lucide-svelte:
specifier: ^0.563.0 specifier: ^0.563.0
version: 0.563.0(svelte@5.50.0) version: 0.563.0(svelte@5.50.0)
mathjs:
specifier: ^15.1.1
version: 15.1.1
devDependencies: devDependencies:
'@sveltejs/adapter-auto': '@sveltejs/adapter-auto':
specifier: ^7.0.0 specifier: ^7.0.0
@ -48,6 +51,10 @@ importers:
packages: packages:
'@babel/runtime@7.28.6':
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
'@esbuild/aix-ppc64@0.27.3': '@esbuild/aix-ppc64@0.27.3':
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -546,6 +553,9 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} engines: {node: '>=6'}
complex.js@2.4.3:
resolution: {integrity: sha512-UrQVSUur14tNX6tiP4y8T4w4FeJAX3bi2cIv0pu/DTLFNxoq7z2Yh83Vfzztj6Px3X/lubqQ9IrPp7Bpn6p4MQ==}
cookie@0.6.0: cookie@0.6.0:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@ -553,6 +563,9 @@ packages:
daisyui@5.5.18: daisyui@5.5.18:
resolution: {integrity: sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og==} resolution: {integrity: sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og==}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
deepmerge@4.3.1: deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -580,6 +593,9 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'} engines: {node: '>=6'}
escape-latex@1.2.0:
resolution: {integrity: sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==}
esm-env@1.2.2: esm-env@1.2.2:
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
@ -609,6 +625,9 @@ packages:
is-reference@3.0.3: is-reference@3.0.3:
resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
javascript-natural-sort@0.7.1:
resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==}
jiti@2.6.1: jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
@ -702,6 +721,11 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
mathjs@15.1.1:
resolution: {integrity: sha512-rM668DTtpSzMVoh/cKAllyQVEbBApM5g//IMGD8vD7YlrIz9ITRr3SrdhjaDxcBNTdyETWwPebj2unZyHD7ZdA==}
engines: {node: '>= 18'}
hasBin: true
mri@1.2.0: mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -744,6 +768,9 @@ packages:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'} engines: {node: '>=6'}
seedrandom@3.0.5:
resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==}
set-cookie-parser@3.0.1: set-cookie-parser@3.0.1:
resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==} resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==}
@ -766,6 +793,9 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'} engines: {node: '>=6'}
tiny-emitter@2.1.0:
resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==}
tinyglobby@0.2.15: tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@ -774,6 +804,10 @@ packages:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
typed-function@4.2.2:
resolution: {integrity: sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==}
engines: {node: '>= 18'}
update-browserslist-db@1.2.3: update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true hasBin: true
@ -833,6 +867,8 @@ packages:
snapshots: snapshots:
'@babel/runtime@7.28.6': {}
'@esbuild/aix-ppc64@0.27.3': '@esbuild/aix-ppc64@0.27.3':
optional: true optional: true
@ -1158,10 +1194,14 @@ snapshots:
clsx@2.1.1: {} clsx@2.1.1: {}
complex.js@2.4.3: {}
cookie@0.6.0: {} cookie@0.6.0: {}
daisyui@5.5.18: {} daisyui@5.5.18: {}
decimal.js@10.6.0: {}
deepmerge@4.3.1: {} deepmerge@4.3.1: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
@ -1206,6 +1246,8 @@ snapshots:
escalade@3.2.0: {} escalade@3.2.0: {}
escape-latex@1.2.0: {}
esm-env@1.2.2: {} esm-env@1.2.2: {}
esrap@2.2.3: esrap@2.2.3:
@ -1227,6 +1269,8 @@ snapshots:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
javascript-natural-sort@0.7.1: {}
jiti@2.6.1: {} jiti@2.6.1: {}
kleur@4.1.5: {} kleur@4.1.5: {}
@ -1290,6 +1334,18 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
mathjs@15.1.1:
dependencies:
'@babel/runtime': 7.28.6
complex.js: 2.4.3
decimal.js: 10.6.0
escape-latex: 1.2.0
fraction.js: 5.3.4
javascript-natural-sort: 0.7.1
seedrandom: 3.0.5
tiny-emitter: 2.1.0
typed-function: 4.2.2
mri@1.2.0: {} mri@1.2.0: {}
mrmime@2.0.1: {} mrmime@2.0.1: {}
@ -1347,6 +1403,8 @@ snapshots:
dependencies: dependencies:
mri: 1.2.0 mri: 1.2.0
seedrandom@3.0.5: {}
set-cookie-parser@3.0.1: {} set-cookie-parser@3.0.1: {}
sirv@3.0.2: sirv@3.0.2:
@ -1379,6 +1437,8 @@ snapshots:
tapable@2.3.0: {} tapable@2.3.0: {}
tiny-emitter@2.1.0: {}
tinyglobby@0.2.15: tinyglobby@0.2.15:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
@ -1386,6 +1446,8 @@ snapshots:
totalist@3.0.1: {} totalist@3.0.1: {}
typed-function@4.2.2: {}
update-browserslist-db@1.2.3(browserslist@4.28.1): update-browserslist-db@1.2.3(browserslist@4.28.1):
dependencies: dependencies:
browserslist: 4.28.1 browserslist: 4.28.1

View File

@ -4,15 +4,19 @@ import { get, post, patch, del } from './client.js';
* @typedef {import('$lib/types/rules.types.js').RuleDef} RuleDef * @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').RuleAction} RuleAction
* @typedef {import('$lib/types/rules.types.js').RuleWithActions} RuleWithActions * @typedef {import('$lib/types/rules.types.js').RuleWithActions} RuleWithActions
* @typedef {import('$lib/types/rules.types.js').RuleWithLinks} RuleWithLinks
* @typedef {import('$lib/types/rules.types.js').LinkedTestSummary} LinkedTestSummary
* @typedef {import('$lib/types/rules.types.js').RulesListResponse} RulesListResponse * @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').RuleDetailResponse} RuleDetailResponse
* @typedef {import('$lib/types/rules.types.js').RuleActionsListResponse} RuleActionsListResponse * @typedef {import('$lib/types/rules.types.js').RuleActionsListResponse} RuleActionsListResponse
* @typedef {import('$lib/types/rules.types.js').ValidateExprResponse} ValidateExprResponse * @typedef {import('$lib/types/rules.types.js').ValidateExprResponse} ValidateExprResponse
* @typedef {import('$lib/types/rules.types.js').TestRuleMapping} TestRuleMapping
*/ */
/** /**
* List rules * List rules
* @param {{ EventCode?: string, Active?: 0|1|number, ScopeType?: string, TestSiteID?: number, search?: string }} [params] * Filter by TestSiteID to see rules linked to a specific test
* @param {{ EventCode?: string, Active?: 0|1|number, TestSiteID?: number, search?: string }} [params]
* @returns {Promise<RulesListResponse>} * @returns {Promise<RulesListResponse>}
*/ */
export async function fetchRules(params = {}) { export async function fetchRules(params = {}) {
@ -21,7 +25,7 @@ export async function fetchRules(params = {}) {
} }
/** /**
* Get rule (with actions) * Get rule (with actions and linked tests)
* @param {number} id * @param {number} id
* @returns {Promise<RuleDetailResponse>} * @returns {Promise<RuleDetailResponse>}
*/ */
@ -30,8 +34,8 @@ export async function fetchRule(id) {
} }
/** /**
* Create a rule; optionally include initial actions * Create a rule with test mappings and optional initial actions
* @param {Partial<RuleDef> & { actions?: Array<Partial<RuleAction>> }} payload * @param {Partial<RuleDef> & { TestSiteIDs: number[], actions?: Array<Partial<RuleAction>> }} payload
* @returns {Promise<any>} * @returns {Promise<any>}
*/ */
export async function createRule(payload) { export async function createRule(payload) {
@ -39,9 +43,9 @@ export async function createRule(payload) {
} }
/** /**
* Update a rule * Update a rule and its test mappings
* @param {number} id * @param {number} id
* @param {Partial<RuleDef>} payload * @param {Partial<RuleDef> & { TestSiteIDs?: number[] }} payload
* @returns {Promise<any>} * @returns {Promise<any>}
*/ */
export async function updateRule(id, payload) { export async function updateRule(id, payload) {
@ -57,6 +61,26 @@ export async function deleteRule(id) {
return del(`/api/rules/${id}`); return del(`/api/rules/${id}`);
} }
/**
* Link a test to a rule (many-to-many mapping)
* @param {number} ruleId
* @param {number} testSiteId
* @returns {Promise<any>}
*/
export async function linkTestToRule(ruleId, testSiteId) {
return post(`/api/rules/${ruleId}/link`, { TestSiteID: testSiteId });
}
/**
* Unlink a test from a rule
* @param {number} ruleId
* @param {number} testSiteId
* @returns {Promise<any>}
*/
export async function unlinkTestFromRule(ruleId, testSiteId) {
return post(`/api/rules/${ruleId}/unlink`, { TestSiteID: testSiteId });
}
/** /**
* Validate/evaluate an expression * Validate/evaluate an expression
* @param {string} expr * @param {string} expr
@ -67,6 +91,15 @@ export async function validateExpression(expr, context = {}) {
return post('/api/rules/validate', { expr, context }); return post('/api/rules/validate', { expr, context });
} }
/**
* Compile a DSL expression into compiled payload
* @param {string} expr
* @returns {Promise<{ compiled: any; conditionExprCompiled: string }>}
*/
export async function compileRuleExpr(expr) {
return post('/api/rules/compile', { expr });
}
/** /**
* List actions for a rule * List actions for a rule
* @param {number} id * @param {number} id

View File

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

View File

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

View File

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

View File

@ -0,0 +1,465 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { compileRuleExpr, createRule, updateRule, fetchRule } from '$lib/api/rules.js';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import { ArrowLeft, Save, Loader2, AlertCircle, CheckCircle2, RefreshCw, Code } from 'lucide-svelte';
/** @typedef {'idle' | 'success' | 'error' | 'stale' | 'loading'} CompileStatus */
/** @type {{ ruleId?: number | null, mode?: 'create' | 'edit' }} */
let { ruleId = null, mode = 'create' } = $props();
// Form state
let ruleCode = $state('');
let ruleName = $state('');
let eventCode = $state('ORDER_CREATED');
let rawExpr = $state('');
// Compile state
let compiledExprJson = $state(null);
let compiledExprObject = $state(null);
let compileStatus = $state(/** @type {CompileStatus} */ ('idle'));
let compileError = $state(null);
let lastCompiledRawExpr = $state(null);
// UI state
let isSaving = $state(false);
let formErrors = $state(/** @type {Record<string, string>} */ ({}));
// Load existing rule in edit mode
onMount(async () => {
if (mode === 'edit' && ruleId) {
await loadRule(ruleId);
}
});
/**
* Load rule data for editing
* @param {number} id
*/
async function loadRule(id) {
try {
const response = await fetchRule(id);
const rule = response?.data?.data ?? response?.data ?? response;
if (rule) {
ruleCode = rule.RuleCode ?? '';
ruleName = rule.RuleName ?? '';
eventCode = rule.EventCode ?? 'ORDER_CREATED';
rawExpr = rule.ConditionExpr ?? '';
// If already has compiled expression, restore that state
if (rule.ConditionExprCompiled) {
compiledExprJson = rule.ConditionExprCompiled;
try {
compiledExprObject = JSON.parse(rule.ConditionExprCompiled);
} catch {
compiledExprObject = null;
}
compileStatus = 'success';
lastCompiledRawExpr = rawExpr;
} else {
compileStatus = 'idle';
lastCompiledRawExpr = null;
compiledExprJson = null;
compiledExprObject = null;
}
compileError = null;
}
} catch (err) {
toastError(err?.message || 'Failed to load rule');
goto('/rules');
}
}
/**
* Handle raw expression change - mark stale if different from last compiled
* @param {string} value
*/
function onRawExprChange(value) {
rawExpr = value;
if (lastCompiledRawExpr !== null && lastCompiledRawExpr !== value) {
compileStatus = 'stale';
}
}
/**
* Compile the raw DSL expression
*/
async function onCompileClick() {
// Validate non-empty raw if DSL is required
// For now, DSL is optional but if provided must be compiled
if (!rawExpr.trim()) {
compileError = 'Expression cannot be empty';
compileStatus = 'error';
return;
}
compileStatus = 'loading';
compileError = null;
try {
const response = await compileRuleExpr(rawExpr);
const data = response?.data?.data ?? response?.data ?? response;
if (data?.conditionExprCompiled) {
compiledExprJson = data.conditionExprCompiled;
compiledExprObject = data.compiled ?? null;
compileStatus = 'success';
lastCompiledRawExpr = rawExpr;
} else {
compileError = 'Invalid compile response';
compileStatus = 'error';
}
} catch (err) {
compileError = err?.message || 'Compilation failed';
compileStatus = 'error';
}
}
/**
* Validate required fields
* @returns {boolean}
*/
function validateForm() {
const errors = {};
if (!ruleCode.trim()) {
errors.RuleCode = 'Rule Code is required';
}
if (!ruleName.trim()) {
errors.RuleName = 'Rule Name is required';
}
if (!eventCode.trim()) {
errors.EventCode = 'Event Code is required';
}
formErrors = errors;
return Object.keys(errors).length === 0;
}
/**
* Check if save should be disabled
* @returns {boolean}
*/
function isSaveDisabled() {
if (isSaving || compileStatus === 'loading') return true;
// If raw is empty, save is allowed (optional DSL)
if (!rawExpr.trim()) return false;
// If raw exists, must be compiled and fresh
if (compileStatus !== 'success') return true;
if (lastCompiledRawExpr !== rawExpr) return true;
return false;
}
/**
* Get save disabled message
* @returns {string | null}
*/
function getSaveDisabledMessage() {
if (isSaving) return 'Saving...';
if (compileStatus === 'loading') return 'Compiling...';
if (!rawExpr.trim()) return null; // Empty is allowed
if (compileStatus === 'error') return 'Fix compilation error before saving';
if (compileStatus === 'stale') return 'Recompile required before saving';
if (compileStatus !== 'success') return 'Compile required before saving';
if (lastCompiledRawExpr !== rawExpr) return 'Expression changed - recompile required';
return null;
}
/**
* Save the rule
*/
async function onSaveClick() {
if (!validateForm()) return;
if (isSaveDisabled()) return;
isSaving = true;
try {
const payload = {
RuleCode: ruleCode.trim(),
RuleName: ruleName.trim(),
EventCode: eventCode,
ConditionExpr: rawExpr.trim() || null,
ConditionExprCompiled: rawExpr.trim() ? compiledExprJson : null,
};
if (mode === 'create') {
await createRule(payload);
toastSuccess('Rule created successfully');
} else {
await updateRule(ruleId, payload);
toastSuccess('Rule updated successfully');
}
goto('/rules');
} catch (err) {
const message = err?.message || `Failed to ${mode === 'create' ? 'create' : 'update'} rule`;
toastError(message);
if (err?.messages && typeof err.messages === 'object') {
formErrors = { ...formErrors, ...err.messages };
}
} finally {
isSaving = false;
}
}
/**
* Get compile status badge UI
* @returns {{ text: string; class: string; icon: any }}
*/
function getCompileStatusBadge() {
switch (compileStatus) {
case 'idle':
return { text: 'Not compiled', class: 'badge-ghost', icon: null };
case 'loading':
return { text: 'Compiling...', class: 'badge-info', icon: Loader2 };
case 'success':
return { text: 'Compiled', class: 'badge-success', icon: CheckCircle2 };
case 'error':
return { text: 'Compile failed', class: 'badge-error', icon: AlertCircle };
case 'stale':
return { text: 'Needs recompile', class: 'badge-warning', icon: RefreshCw };
default:
return { text: 'Unknown', class: 'badge-ghost', icon: null };
}
}
function handleCancel() {
goto('/rules');
}
const statusBadge = $derived(getCompileStatusBadge());
const saveDisabled = $derived(isSaveDisabled());
const saveMessage = $derived(getSaveDisabledMessage());
</script>
<div class="p-4 max-w-6xl mx-auto">
<!-- Page Header -->
<div class="flex items-center gap-4 mb-6">
<button class="btn btn-ghost btn-circle" onclick={handleCancel} type="button">
<ArrowLeft class="w-5 h-5" />
</button>
<div class="flex-1">
<h1 class="text-xl font-bold text-gray-800">
{mode === 'create' ? 'New Rule' : 'Edit Rule'}
</h1>
<p class="text-sm text-gray-600">Global rule with DSL compilation</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column: Fields + DSL Editor -->
<div class="space-y-6">
<!-- RuleFieldsCard -->
<div class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body compact-y">
<h2 class="card-title text-sm font-semibold text-gray-800 mb-4">Rule Information</h2>
<div class="space-y-4">
<!-- RuleCode -->
<div>
<label class="form-control">
<div class="label">
<span class="label-text font-medium">Rule Code <span class="text-error">*</span></span>
</div>
<label class="input input-sm input-bordered flex items-center gap-2 w-full focus-within:input-primary">
<Code class="w-4 h-4 text-gray-400" />
<input
type="text"
class="grow bg-transparent outline-none"
placeholder="e.g. AUTO_STAT_001"
bind:value={ruleCode}
disabled={isSaving}
/>
</label>
{#if formErrors.RuleCode}
<p class="text-xs text-error mt-1">{formErrors.RuleCode}</p>
{/if}
</label>
</div>
<!-- RuleName -->
<div>
<label class="form-control">
<div class="label">
<span class="label-text font-medium">Rule Name <span class="text-error">*</span></span>
</div>
<label class="input input-sm input-bordered flex items-center gap-2 w-full focus-within:input-primary">
<Code 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={ruleName}
disabled={isSaving}
/>
</label>
{#if formErrors.RuleName}
<p class="text-xs text-error mt-1">{formErrors.RuleName}</p>
{/if}
</label>
</div>
<!-- EventCode -->
<div>
<label class="form-control">
<div class="label">
<span class="label-text font-medium">Event Code <span class="text-error">*</span></span>
</div>
<select class="select select-sm select-bordered w-full" bind:value={eventCode} disabled={isSaving}>
<option value="ORDER_CREATED">ORDER_CREATED</option>
</select>
{#if formErrors.EventCode}
<p class="text-xs text-error mt-1">{formErrors.EventCode}</p>
{/if}
</label>
</div>
</div>
</div>
</div>
<!-- DslEditorCard -->
<div class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body compact-y">
<div class="flex items-center justify-between mb-4">
<h2 class="card-title text-sm font-semibold text-gray-800">Condition Expression (DSL)</h2>
<div class="flex items-center gap-2">
<span class="badge badge-sm {statusBadge.class}">
{#if statusBadge.icon}
<statusBadge.icon class="w-3 h-3 mr-1 inline" />
{/if}
{statusBadge.text}
</span>
</div>
</div>
<div class="space-y-4">
<!-- Expression Textarea -->
<textarea
class="textarea textarea-bordered w-full font-mono text-sm"
rows="6"
placeholder="Enter DSL expression..."
value={rawExpr}
oninput={(e) => onRawExprChange(e.currentTarget.value)}
disabled={isSaving}
></textarea>
<!-- Compile Button -->
<div class="flex items-center justify-between">
<button
class="btn btn-sm btn-primary"
onclick={onCompileClick}
disabled={compileStatus === 'loading' || isSaving || !rawExpr.trim()}
type="button"
>
{#if compileStatus === 'loading'}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Compiling...
{:else}
<RefreshCw class="w-4 h-4 mr-2" />
Compile
{/if}
</button>
{#if compileStatus === 'stale'}
<span class="text-xs text-warning">Expression changed - needs recompile</span>
{/if}
</div>
<!-- Inline Compile Error -->
{#if compileError}
<div class="alert alert-error alert-sm">
<AlertCircle class="w-4 h-4" />
<span class="text-sm">{compileError}</span>
</div>
{/if}
<!-- DSL Helper Examples -->
<div class="bg-base-200 rounded-lg p-3">
<div class="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-2">
<Code class="w-3.5 h-3.5" />
DSL Examples
</div>
<pre class="text-xs text-gray-600 overflow-auto">if(sex('F') ? set_result(0.7) : set_result(1))
order.isStat ? set_result('URGENT') : set_result('NORMAL')
patient.age > 65 ? set_result(true) : skip()</pre>
</div>
</div>
</div>
</div>
</div>
<!-- Right Column: Compiled Preview + Actions -->
<div class="space-y-6">
<!-- CompiledPreviewCard -->
<div class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body compact-y">
<h2 class="card-title text-sm font-semibold text-gray-800 mb-4">Compiled Output</h2>
{#if compiledExprObject && compileStatus === 'success'}
<div class="bg-base-200 rounded-lg p-3">
<pre class="text-xs font-mono overflow-auto whitespace-pre-wrap break-all">{JSON.stringify(
compiledExprObject,
null,
2
)}</pre>
</div>
{:else}
<div class="text-center py-8 text-gray-500">
<Code class="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p class="text-sm">Compile expression to see output</p>
</div>
{/if}
</div>
</div>
<!-- FormActions -->
<div class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body compact-y">
<h2 class="card-title text-sm font-semibold text-gray-800 mb-4">Actions</h2>
<div class="space-y-3">
{#if saveMessage}
<div class="alert alert-warning alert-sm">
<AlertCircle class="w-4 h-4" />
<span class="text-sm">{saveMessage}</span>
</div>
{/if}
<div class="flex gap-3">
<button
class="btn btn-ghost flex-1"
onclick={handleCancel}
disabled={isSaving || compileStatus === 'loading'}
type="button"
>
Cancel
</button>
<button
class="btn btn-primary flex-1"
onclick={onSaveClick}
disabled={saveDisabled}
type="button"
>
{#if isSaving}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Saving...
{:else}
<Save class="w-4 h-4 mr-2" />
{mode === 'create' ? 'Create Rule' : 'Update Rule'}
{/if}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

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

View File

@ -1,22 +1,25 @@
/** /**
* Rules Engine Type Definitions * Rules Engine Type Definitions
* Based on CLQMS API Specification * Based on CLQMS API Specification - Refactored Rule System
*/ */
export type RuleEventCode = 'ORDER_CREATED'; export type RuleEventCode = 'ORDER_CREATED';
export type RuleScopeType = 'GLOBAL' | 'TESTSITE';
export type RuleActionType = 'SET_RESULT'; export type RuleActionType = 'SET_RESULT';
export interface RuleDef { export interface RuleDef {
RuleID: number; RuleID: number;
Name: string; RuleCode: string;
RuleName: string;
Description?: string; Description?: string;
EventCode: RuleEventCode | string; EventCode: RuleEventCode | string;
ScopeType: RuleScopeType;
TestSiteID?: number | null;
ConditionExpr?: string | null; ConditionExpr?: string | null;
ConditionExprCompiled?: string | null;
Priority?: number; Priority?: number;
Active: 0 | 1 | number; Active: 0 | 1 | number;
/** ISO timestamp strings */
createdAt?: string;
updatedAt?: string;
deletedAt?: string | null;
} }
export interface RuleAction { export interface RuleAction {
@ -25,11 +28,9 @@ export interface RuleAction {
Seq: number; Seq: number;
ActionType: RuleActionType | string; ActionType: RuleActionType | string;
ActionParams: string | Record<string, any>; ActionParams: string | Record<string, any>;
} /** ISO timestamp strings */
createdAt?: string;
export interface RuleWithActions { updatedAt?: string;
rule: RuleDef;
actions: RuleAction[];
} }
export interface ApiResponse<T> { export interface ApiResponse<T> {
@ -39,11 +40,15 @@ export interface ApiResponse<T> {
} }
export interface RulesListResponse extends ApiResponse<RuleDef[]> {} export interface RulesListResponse extends ApiResponse<RuleDef[]> {}
export interface RuleDetailResponse extends ApiResponse<RuleWithActions> {} export interface RuleDetailResponse extends ApiResponse<RuleDef> {}
export interface RuleActionsListResponse extends ApiResponse<RuleAction[]> {}
export interface ValidateExprResponse extends ApiResponse<{ valid: boolean; result?: any; error?: string }> {} export interface ValidateExprResponse extends ApiResponse<{ valid: boolean; result?: any; error?: string }> {}
export interface CompileExprResponse {
compiled: any;
conditionExprCompiled: string;
}
export type SetResultActionParams = { export type SetResultActionParams = {
testSiteID?: number; testSiteID?: number;
testSiteCode?: string; testSiteCode?: string;

View File

@ -11,7 +11,7 @@
import MappingsTab from './tabs/MappingsTab.svelte'; import MappingsTab from './tabs/MappingsTab.svelte';
import RefNumTab from './tabs/RefNumTab.svelte'; import RefNumTab from './tabs/RefNumTab.svelte';
import RefTxtTab from './tabs/RefTxtTab.svelte'; import RefTxtTab from './tabs/RefTxtTab.svelte';
import ThresholdTab from './tabs/ThresholdTab.svelte'; import ThresholdTab from './tabs/ThresholdTab.svelte';
let { open = $bindable(false), mode = 'create', testId = null, initialTestType = 'TEST', disciplines = [], departments = [], tests = [], onsave = null } = $props(); let { open = $bindable(false), mode = 'create', testId = null, initialTestType = 'TEST', disciplines = [], departments = [], tests = [], onsave = null } = $props();

View File

@ -1,5 +1,5 @@
<script> <script>
import { Plus, Trash2, Box, Search, WandSparkles } from 'lucide-svelte'; import { Plus, Trash2, Box, Search } from 'lucide-svelte';
let { formData = $bindable(), tests = [], isDirty = $bindable(false), validationErrors = {} } = $props(); let { formData = $bindable(), tests = [], isDirty = $bindable(false), validationErrors = {} } = $props();
@ -23,6 +23,11 @@
}); });
const availableTests = $derived.by(() => { const availableTests = $derived.by(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) {
return [];
}
const selectedIds = new Set(members.map((member) => Number(member.TestSiteID))); const selectedIds = new Set(members.map((member) => Number(member.TestSiteID)));
let filtered = tests.filter((test) => let filtered = tests.filter((test) =>
@ -32,19 +37,14 @@
test.IsActive !== 0 test.IsActive !== 0
); );
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter((test) => filtered = filtered.filter((test) =>
test.TestSiteCode?.toLowerCase().includes(query) || test.TestSiteCode?.toLowerCase().includes(query) ||
test.TestSiteName?.toLowerCase().includes(query) test.TestSiteName?.toLowerCase().includes(query)
); );
}
return filtered.sort((a, b) => parseInt(a.SeqScr || 0) - parseInt(b.SeqScr || 0)); return filtered.sort((a, b) => parseInt(a.SeqScr || 0) - parseInt(b.SeqScr || 0));
}); });
const formulaTokens = $derived(tokenizeBraceRefs(formData?.details?.FormulaCode || ''));
const formulaSyntax = $derived.by(() => getFormulaSyntaxStatus(formData?.details?.FormulaCode || '')); const formulaSyntax = $derived.by(() => getFormulaSyntaxStatus(formData?.details?.FormulaCode || ''));
const missingMemberCodes = $derived.by(() => const missingMemberCodes = $derived.by(() =>
@ -129,134 +129,151 @@
handleFieldChange(); handleFieldChange();
} }
function prettifyFormula() {
const original = formData?.details?.FormulaCode || '';
if (!original.trim()) {
return;
}
const tokens = [];
let normalized = original.replace(/\{[^{}]+\}/g, (match) => {
const index = tokens.push(match) - 1;
return `__TOKEN_${index}__`;
});
normalized = normalized
.replace(/[\r\n\t]+/g, ' ')
.replace(/\s*([+\-*/])\s*/g, ' $1 ')
.replace(/\s+/g, ' ')
.trim();
normalized = normalized.replace(/__TOKEN_(\d+)__/g, (_, index) => tokens[Number(index)]);
if (normalized !== original) {
formData.details.FormulaCode = normalized;
handleFieldChange();
}
}
</script> </script>
<div class="space-y-4"> <div class="space-y-4">
<h2 class="text-lg font-semibold text-gray-800">Calculated Test Formula</h2> <h2 class="text-lg font-semibold text-gray-800">Calculated Test Formula</h2>
<div class="alert alert-info text-sm"> <div class="alert alert-info text-sm py-2">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-4 w-4" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-4 w-4" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<div> <div>
<strong>Formula Syntax:</strong> Use curly braces to reference test codes, e.g., <code class="code">{'{HGB}'} + {'{MCV}'}</code> <strong>Formula Syntax:</strong> Use curly braces to reference test codes, e.g., <code class="code">{'{HGB}'} + {'{MCV}'}</code>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 h-[520px] overflow-hidden"> <!-- Formula Code -->
<div class="space-y-2">
<div class="flex items-start justify-between gap-2">
<label for="formulaCode" class="text-sm font-medium text-gray-700">
Formula Code <span class="text-error">*</span>
</label>
<div class="text-xs">
{#if formulaSyntax.tone === 'success'}
<span class="badge badge-success badge-outline badge-sm">{formulaSyntax.text}</span>
{:else if formulaSyntax.tone === 'warning'}
<span class="badge badge-warning badge-outline badge-sm">{formulaSyntax.text}</span>
{:else if formulaSyntax.tone === 'error'}
<span class="badge badge-error badge-outline badge-sm">{formulaSyntax.text}</span>
{:else}
<span class="text-gray-500">{formulaSyntax.text}</span>
{/if}
</div>
</div>
<textarea
id="formulaCode"
class="textarea textarea-sm textarea-bordered w-full font-mono text-sm"
bind:value={formData.details.FormulaCode}
placeholder="e.g., {'{HGB}'} + {'{MCV}'} + {'{MCHC}'}"
rows="2"
oninput={handleFieldChange}
required
></textarea>
{#if validationErrors.FormulaCode}
<p class="text-error text-xs">{validationErrors.FormulaCode}</p>
{/if}
{#if members.length > 0 && missingMemberCodes.length > 0}
<p class="text-warning text-xs">
Missing member references: {missingMemberCodes.map((code) => `{${code}}`).join(', ')}
</p>
{/if}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 h-[380px] overflow-hidden">
<div class="flex flex-col border border-base-300 rounded-lg bg-base-100 overflow-hidden"> <div class="flex flex-col border border-base-300 rounded-lg bg-base-100 overflow-hidden">
<div class="p-3 border-b border-base-300 bg-base-200 rounded-t-lg shrink-0"> <div class="px-3 py-2 border-b border-base-300 bg-base-200 rounded-t-lg shrink-0">
<h3 class="text-sm font-medium text-gray-700 mb-2">Available Tests</h3> <h3 class="text-xs font-medium text-gray-700 mb-1">Available Tests</h3>
<label class="input input-sm input-bordered flex items-center gap-2 w-full"> <label class="input input-xs input-bordered flex items-center gap-2 w-full">
<Search class="w-4 h-4 text-gray-400" /> <Search class="w-3.5 h-3.5 text-gray-400" />
<input <input
type="text" type="text"
class="grow bg-transparent outline-none text-sm" class="grow bg-transparent outline-none text-xs"
placeholder="Search by code or name..." placeholder="Search by code or name..."
bind:value={searchQuery} bind:value={searchQuery}
/> />
</label> </label>
</div> </div>
<div class="flex-1 overflow-y-auto p-2 space-y-1 min-h-0"> <div class="flex-1 overflow-y-auto p-1.5 space-y-0.5 min-h-0">
{#if availableTests.length === 0} {#if !searchQuery.trim()}
<div class="text-center py-8 text-gray-500"> <div class="text-center py-6 text-gray-500">
<Box class="w-10 h-10 mx-auto mb-2 opacity-50" /> <Box class="w-8 h-8 mx-auto mb-2 opacity-50" />
<p class="text-sm">No tests available</p> <p class="text-xs">Start typing to search tests</p>
<p class="text-xs opacity-70"> <p class="text-[10px] opacity-70 mt-0.5">Search by test code or name</p>
{searchQuery ? 'Try a different search term' : 'All tests are already added'} </div>
</p> {:else if availableTests.length === 0}
<div class="text-center py-6 text-gray-500">
<Box class="w-8 h-8 mx-auto mb-2 opacity-50" />
<p class="text-xs">No tests found</p>
<p class="text-[10px] opacity-70 mt-0.5">Try a different search term</p>
</div> </div>
{:else} {:else}
{#each availableTests as test (test.TestSiteID)} {#each availableTests as test (test.TestSiteID)}
<div class="flex items-center justify-between p-2 hover:bg-base-200 rounded-md group"> <div class="flex items-center justify-between px-1.5 py-1 hover:bg-base-200 rounded-md group">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-1.5">
<span class="font-mono text-xs text-gray-500 w-8">{test.SeqScr || '-'}</span> <span class="font-mono text-[10px] text-gray-500 w-6">{test.SeqScr || '-'}</span>
<span class="font-mono text-sm font-medium truncate">{test.TestSiteCode}</span> <span class="font-mono text-xs font-medium truncate">{test.TestSiteCode}</span>
<span class="badge badge-xs badge-ghost">{test.TestType}</span> <span class="badge badge-xs badge-ghost scale-90 origin-left">{test.TestType}</span>
</div> </div>
<p class="text-xs text-gray-600 truncate pl-10">{test.TestSiteName}</p> <p class="text-[11px] text-gray-600 truncate pl-7">{test.TestSiteName}</p>
</div> </div>
<button <button
class="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 transition-opacity" class="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 transition-opacity h-6 min-h-0 px-1"
onclick={() => addMember(test)} onclick={() => addMember(test)}
title="Add to calculation" title="Add to calculation"
> >
<Plus class="w-4 h-4 text-primary" /> <Plus class="w-3.5 h-3.5 text-primary" />
</button> </button>
</div> </div>
{/each} {/each}
{/if} {/if}
</div> </div>
<div class="p-2 border-t border-base-300 text-xs text-gray-500 text-center shrink-0"> <div class="px-2 py-1 border-t border-base-300 text-[10px] text-gray-500 text-center shrink-0">
{availableTests.length} tests available {availableTests.length} tests available
</div> </div>
</div> </div>
<div class="flex flex-col border border-base-300 rounded-lg bg-base-100 overflow-hidden"> <div class="flex flex-col border border-base-300 rounded-lg bg-base-100 overflow-hidden">
<div class="p-3 border-b border-base-300 bg-base-200 rounded-t-lg shrink-0"> <div class="px-3 py-2 border-b border-base-300 bg-base-200 rounded-t-lg shrink-0">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-medium text-gray-700">Selected Members</h3> <h3 class="text-xs font-medium text-gray-700">Selected Members</h3>
<span class="badge badge-sm badge-ghost">{members.length} selected</span> <span class="badge badge-xs badge-ghost">{members.length} selected</span>
</div> </div>
</div> </div>
<div class="flex-1 overflow-y-auto min-h-0"> <div class="flex-1 overflow-y-auto min-h-0">
{#if members.length === 0} {#if members.length === 0}
<div class="flex flex-col items-center justify-center h-full text-gray-500 py-8"> <div class="flex flex-col items-center justify-center h-full text-gray-500 py-6">
<Box class="w-12 h-12 mb-3 opacity-50" /> <Box class="w-8 h-8 mb-2 opacity-50" />
<p class="text-sm font-medium">No members selected</p> <p class="text-xs font-medium">No members selected</p>
<p class="text-xs opacity-70 mt-1">Click the + button on available tests to add them</p> <p class="text-[10px] opacity-70 mt-0.5">Search and add tests from the left panel</p>
</div> </div>
{:else} {:else}
<table class="table table-sm w-full"> <table class="table table-xs w-full">
<thead class="sticky top-0 bg-base-200"> <thead class="sticky top-0 bg-base-200">
<tr> <tr>
<th class="w-12 text-center text-xs">Seq</th> <th class="w-10 text-center text-[10px] py-1">Seq</th>
<th class="w-20 text-xs">Code</th> <th class="w-16 text-[10px] py-1">Code</th>
<th class="text-xs">Name</th> <th class="text-[10px] py-1">Name</th>
<th class="w-16 text-xs">Type</th> <th class="w-12 text-[10px] py-1">Type</th>
<th class="w-10 text-center text-xs"></th> <th class="w-8 text-center text-[10px] py-1"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each members as member (member.TestSiteID)} {#each members as member (member.TestSiteID)}
<tr class="hover:bg-base-200"> <tr class="hover:bg-base-200">
<td class="text-center font-mono text-xs text-gray-600">{member.SeqScr}</td> <td class="text-center font-mono text-[10px] text-gray-600 py-1">{member.SeqScr}</td>
<td class="font-mono text-xs">{member.TestSiteCode}</td> <td class="font-mono text-[10px] py-1">{member.TestSiteCode}</td>
<td class="text-xs truncate max-w-[150px]" title={member.TestSiteName}> <td class="text-[10px] truncate max-w-[120px] py-1" title={member.TestSiteName}>
{member.TestSiteName} {member.TestSiteName}
</td> </td>
<td> <td class="py-1">
<span class="badge badge-xs badge-ghost">{member.TestType}</span> <span class="badge badge-xs badge-ghost scale-90 origin-left">{member.TestType}</span>
</td> </td>
<td class="text-center"> <td class="text-center py-1">
<button <button
class="btn btn-ghost btn-xs text-error p-0 min-h-0 h-auto" class="btn btn-ghost btn-xs text-error p-0 min-h-0 h-auto"
onclick={() => removeMember(member.TestSiteID)} onclick={() => removeMember(member.TestSiteID)}
@ -272,59 +289,10 @@
{/if} {/if}
</div> </div>
<div class="p-2 border-t border-base-300 text-xs text-gray-500 shrink-0"> <div class="px-2 py-1 border-t border-base-300 text-[10px] text-gray-500 shrink-0">
<p>Use {'{CODE}'} tokens from these members in your formula.</p> <p>Use {'{CODE}'} tokens from these members in your formula.</p>
{#if validationErrors.members} {#if validationErrors.members}
<p class="text-error mt-1">{validationErrors.members}</p> <p class="text-error mt-0.5">{validationErrors.members}</p>
{/if}
</div>
</div>
</div>
<!-- Formula Definition -->
<div>
<h3 class="text-sm font-semibold text-gray-700 mb-3">Formula Definition</h3>
<div class="space-y-4">
<div class="space-y-1">
<label for="formulaCode" class="block text-sm font-medium text-gray-700">
Formula Code <span class="text-error">*</span>
</label>
<textarea
id="formulaCode"
class="textarea textarea-sm textarea-bordered w-full font-mono text-sm"
bind:value={formData.details.FormulaCode}
placeholder="e.g., {'{HGB}'} + {'{MCV}'} + {'{MCHC}'}"
rows="4"
oninput={handleFieldChange}
required
></textarea>
<div class="flex flex-wrap items-center gap-2 text-xs">
{#if formulaSyntax.tone === 'success'}
<span class="badge badge-success badge-outline">{formulaSyntax.text}</span>
{:else if formulaSyntax.tone === 'warning'}
<span class="badge badge-warning badge-outline">{formulaSyntax.text}</span>
{:else if formulaSyntax.tone === 'error'}
<span class="badge badge-error badge-outline">{formulaSyntax.text}</span>
{:else}
<span class="text-gray-500">{formulaSyntax.text}</span>
{/if}
<span class="badge badge-ghost badge-outline">{formulaTokens.length} token(s)</span>
<button class="btn btn-ghost btn-xs" type="button" onclick={prettifyFormula}>
<WandSparkles class="w-3.5 h-3.5" />
Prettify
</button>
</div>
{#if validationErrors.FormulaCode}
<p class="text-error text-xs mt-1">{validationErrors.FormulaCode}</p>
{/if}
{#if members.length > 0 && missingMemberCodes.length > 0}
<p class="text-warning text-xs mt-1">
Missing member references: {missingMemberCodes.map((code) => `{${code}}`).join(', ')}
</p>
{/if} {/if}
</div> </div>
</div> </div>

View File

@ -1,12 +1,9 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { import {
Search,
Filter, Filter,
FileText, FileText,
AlertTriangle,
CheckCircle2, CheckCircle2,
Clock,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
User, User,
@ -14,7 +11,7 @@
Edit3, Edit3,
ClipboardList ClipboardList
} from 'lucide-svelte'; } from 'lucide-svelte';
import { fetchOrders, getStatusInfo, getPriorityInfo } from '$lib/api/orders.js'; import { fetchOrders, fetchOrderById, getStatusInfo, getPriorityInfo } from '$lib/api/orders.js';
import { error as toastError, success as toastSuccess } from '$lib/utils/toast.js'; import { error as toastError, success as toastSuccess } from '$lib/utils/toast.js';
import ResultEntryModal from './ResultEntryModal.svelte'; import ResultEntryModal from './ResultEntryModal.svelte';
@ -33,14 +30,14 @@
// Modal state // Modal state
let selectedOrder = $state(null); let selectedOrder = $state(null);
let showEntryModal = $state(false); let showEntryModal = $state(false);
let detailLoadingOrderId = $state('');
async function loadOrders() { async function loadOrders() {
loading = true; loading = true;
try { try {
const params = { const params = {
page: currentPage, page: currentPage,
perPage: perPage, perPage: perPage
include: 'details'
}; };
if (filterOrderId) params.OrderID = filterOrderId; if (filterOrderId) params.OrderID = filterOrderId;
@ -64,9 +61,23 @@
} }
} }
function handleEnterResults(order) { async function handleEnterResults(order) {
selectedOrder = order; detailLoadingOrderId = order.OrderID;
selectedOrder = null;
showEntryModal = false;
try {
const response = await fetchOrderById(order.OrderID);
if (response.status === 'success' && response.data) {
selectedOrder = response.data;
showEntryModal = true; showEntryModal = true;
} else {
toastError('Order details unavailable');
}
} catch (err) {
toastError(err.message || 'Failed to load order details');
} finally {
detailLoadingOrderId = '';
}
} }
function handleResultsSaved() { function handleResultsSaved() {
@ -203,8 +214,7 @@
{#each orders as order (order.OrderID)} {#each orders as order (order.OrderID)}
{@const statusInfo = getStatusInfo(order.OrderStatus)} {@const statusInfo = getStatusInfo(order.OrderStatus)}
{@const priorityInfo = getPriorityInfo(order.Priority)} {@const priorityInfo = getPriorityInfo(order.Priority)}
{@const testCount = order.Tests?.length || 0} {@const hasTests = Array.isArray(order.Tests)}
{@const pendingCount = order.Tests?.filter(t => !t.Result || t.Result === '').length || 0}
<tr class="hover:bg-base-200/50 transition-colors"> <tr class="hover:bg-base-200/50 transition-colors">
<td class="text-xs font-mono font-medium">{order.OrderID}</td> <td class="text-xs font-mono font-medium">{order.OrderID}</td>
<td class="text-xs"> <td class="text-xs">
@ -223,6 +233,9 @@
<span class="badge {priorityInfo.color} badge-sm">{priorityInfo.label}</span> <span class="badge {priorityInfo.color} badge-sm">{priorityInfo.label}</span>
</td> </td>
<td class="text-xs"> <td class="text-xs">
{#if hasTests}
{@const testCount = order.Tests.length}
{@const pendingCount = order.Tests.filter(t => !t.Result || t.Result === '').length}
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="font-medium">{testCount}</span> <span class="font-medium">{testCount}</span>
{#if pendingCount > 0} {#if pendingCount > 0}
@ -231,15 +244,23 @@
<CheckCircle2 class="w-3 h-3 text-success" /> <CheckCircle2 class="w-3 h-3 text-success" />
{/if} {/if}
</div> </div>
{:else}
<span class="text-xs text-base-content/50">Details only</span>
{/if}
</td> </td>
<td class="text-right"> <td class="text-right">
<button <button
class="btn btn-primary btn-sm" class="btn btn-primary btn-sm"
title="Enter Results" title="Enter Results"
onclick={() => handleEnterResults(order)} onclick={() => handleEnterResults(order)}
disabled={detailLoadingOrderId === order.OrderID}
> >
<Edit3 class="w-3 h-3" /> <Edit3 class="w-3 h-3" />
{#if detailLoadingOrderId === order.OrderID}
Loading...
{:else}
Enter Results Enter Results
{/if}
</button> </button>
</td> </td>
</tr> </tr>

View File

@ -14,11 +14,14 @@
MessageSquare, MessageSquare,
Copy, Copy,
ChevronDown, ChevronDown,
ChevronUp ChevronUp,
Calculator,
RefreshCw
} from 'lucide-svelte'; } from 'lucide-svelte';
import { updateResult } from '$lib/api/results.js'; import { updateResult } from '$lib/api/results.js';
import { error as toastError, success as toastSuccess } from '$lib/utils/toast.js'; import { error as toastError, success as toastSuccess } from '$lib/utils/toast.js';
import Modal from '$lib/components/Modal.svelte'; import Modal from '$lib/components/Modal.svelte';
import { untrack } from 'svelte';
let { let {
order = $bindable(), order = $bindable(),
@ -30,6 +33,7 @@
let results = $state([]); let results = $state([]);
let formLoading = $state(false); let formLoading = $state(false);
let saveProgress = $state({ current: 0, total: 0 }); let saveProgress = $state({ current: 0, total: 0 });
let calcLoading = $state(false);
// Expandable rows for comments // Expandable rows for comments
let expandedRows = $state(new Set()); let expandedRows = $state(new Set());
@ -37,9 +41,38 @@
// Keyboard shortcuts enabled // Keyboard shortcuts enabled
let shortcutsEnabled = $state(true); let shortcutsEnabled = $state(true);
// Guard to prevent repeated initialization
let initKey = $state('');
// Calculation engine state
/** @type {Map<number, string>} TestSiteID -> Result value */
let resultMapById = $state(new Map());
/** @type {Map<string, string>} TestSiteCode -> Result value */
let resultMapByCode = $state(new Map());
/** @type {Map<number, CalcDef>} TestSiteID -> Calculation definition */
let calcDefs = $state(new Map());
/** @type {Map<number, Set<number>>} Member TestSiteID -> Set of dependent CALC TestSiteIDs */
let dependencyGraph = $state(new Map());
/** @typedef {{ id: number, code: string, formula: string, members: Array<{id: number, code: string}>, decimal: number }} CalcDef */
// Initialize results when order changes // Initialize results when order changes
$effect(() => { $effect(() => {
if (order && open) { if (order && open) {
// Guard: only initialize once per order/open combination
const currentKey = `${order.OrderID}-${open}`;
if (initKey === currentKey) return;
initKey = currentKey;
// Use untrack to prevent the effect from tracking internal state mutations
untrack(() => initializeResults());
}
});
/**
* Initialize results and build calculation context
*/
function initializeResults() {
// Map tests to editable result entries // Map tests to editable result entries
results = (order.Tests || []).map(test => ({ results = (order.Tests || []).map(test => ({
ResultID: test.ResultID, ResultID: test.ResultID,
@ -63,12 +96,131 @@
Comment: test.Comment || '', Comment: test.Comment || '',
flag: calculateFlag(test.Result, test.Low, test.High), flag: calculateFlag(test.Result, test.Low, test.High),
saved: false, saved: false,
error: null error: null,
warning: null,
// Dirty tracking
changedByUser: false,
changedByAutoCalc: false,
lastAutoCalcAt: null,
// Calc metadata
TestType: test.TestType,
FormulaCode: test.FormulaCode || null,
Decimal: test.Decimal ?? 2,
isCalculated: test.TestType === 'CALC' || (test.FormulaCode && test.FormulaCode.trim() !== ''),
// Group members for dependency tracking
testdefgrp: test.testdefgrp || []
})); }));
saveProgress = { current: 0, total: 0 }; saveProgress = { current: 0, total: 0 };
expandedRows = new Set(); expandedRows = new Set();
// Build calculation context
buildCalcContext();
console.log('Initialized results:', results.length, 'tests');
console.log('Calc defs:', calcDefs.size, 'calculated tests');
console.log('Calc test IDs:', Array.from(calcDefs.keys()));
// Trigger initial calculation for CALC tests
if (calcDefs.size > 0) {
console.log('Triggering initial calculation...');
setTimeout(() => recalculateAll(), 0);
}
}
/**
* Build calculation context: result maps, calc definitions, dependency graph
*/
function buildCalcContext() {
resultMapById = new Map();
resultMapByCode = new Map();
calcDefs = new Map();
dependencyGraph = new Map();
// Build result lookup maps
for (const row of results) {
resultMapById.set(row.TestSiteID, row.Result ?? '');
if (row.TestSiteCode) {
resultMapByCode.set(row.TestSiteCode, row.Result ?? '');
}
}
// Build calc definitions and dependency graph
console.log('buildCalcContext: checking', results.length, 'results');
for (const row of results) {
console.log(' Result:', row.TestSiteCode, 'TestType:', row.TestType, 'isCalculated:', row.isCalculated, 'FormulaCode:', row.FormulaCode);
if (!row.isCalculated) continue;
// Parse members from testdefgrp or extract from formula
const members = parseCalcMembers(row);
console.log(' Parsed members:', members);
const def = {
id: row.TestSiteID,
code: row.TestSiteCode,
formula: row.FormulaCode || '',
members,
decimal: Number.isFinite(+row.Decimal) ? +row.Decimal : 2
};
calcDefs.set(def.id, def);
console.log(' Added calc def for:', row.TestSiteCode);
// Build reverse dependency graph: member -> [calcs that depend on it]
for (const member of members) {
if (!dependencyGraph.has(member.id)) {
dependencyGraph.set(member.id, new Set());
}
dependencyGraph.get(member.id).add(def.id);
}
}
console.log('buildCalcContext complete, calcDefs size:', calcDefs.size);
}
/**
* Parse calculation members from row data
* @param {Object} row - Result row
* @returns {Array<{id: number, code: string}>}
*/
function parseCalcMembers(row) {
// First try to get from testdefgrp
if (row.testdefgrp && row.testdefgrp.length > 0) {
return row.testdefgrp.map(m => ({
id: Number(m.TestSiteID),
code: m.TestSiteCode
}));
}
// Fallback: extract from formula by finding matching test codes
const members = [];
const formula = row.FormulaCode || '';
const codePattern = /\b([A-Z][A-Z0-9_]*)\b/g;
let match;
while ((match = codePattern.exec(formula)) !== null) {
const code = match[1];
// Find test with this code in results
const test = results.find(r => r.TestSiteCode === code);
if (test) {
members.push({ id: test.TestSiteID, code: test.TestSiteCode });
}
}
return members;
}
/**
* Get dependency hint text for a CALC test
* @param {Object} row - Result row
* @returns {string|null}
*/
function getDependencyHint(row) {
if (!row.isCalculated) return null;
const def = calcDefs.get(row.TestSiteID);
if (!def || def.members.length === 0) return null;
const codes = def.members.map(m => m.code).join(', ');
return `from ${codes}`;
} }
});
// Keyboard shortcuts // Keyboard shortcuts
$effect(() => { $effect(() => {
@ -80,7 +232,7 @@
// Ctrl+S: Save all // Ctrl+S: Save all
if (event.ctrlKey && event.key === 's') { if (event.ctrlKey && event.key === 's') {
event.preventDefault(); event.preventDefault();
if (!formLoading && pendingCount !== results.length) { if (!formLoading) {
handleSaveAll(); handleSaveAll();
} }
} }
@ -112,6 +264,234 @@
results[index].flag = calculateFlag(entry.Result, entry.Low, entry.High); results[index].flag = calculateFlag(entry.Result, entry.Low, entry.High);
} }
/**
* Recalculate all CALC tests (used for initial load)
*/
async function recalculateAll() {
console.log('recalculateAll called, calcDefs size:', calcDefs.size);
// Find all CALC tests that need computation
const calcsToCompute = [];
for (const [calcId, def] of calcDefs) {
const row = results.find(r => r.TestSiteID === calcId);
if (!row) {
console.log(' Row not found for calcId:', calcId);
continue;
}
console.log(' Checking calc test:', row.TestSiteCode, 'Result:', row.Result, 'changedByAutoCalc:', row.changedByAutoCalc);
// Only auto-fill if empty or was previously auto-calculated
if (!row.Result || row.Result === '' || row.changedByAutoCalc) {
calcsToCompute.push({ calcId, def, row });
console.log(' -> Will compute');
} else {
console.log(' -> Skipping (has manual value)');
}
}
console.log('Total calcs to compute:', calcsToCompute.length);
if (calcsToCompute.length === 0) return;
await computeCalculations(calcsToCompute);
}
/**
* Recalculate dependent CALC tests starting from a changed test
* @param {number} changedTestSiteId - The test that changed
*/
async function recalculateFrom(changedTestSiteId) {
const queue = [changedTestSiteId];
const visited = new Set();
const calcsToCompute = [];
while (queue.length > 0) {
const currentId = queue.shift();
// Get calcs that depend on this test
const dependentCalcs = dependencyGraph.get(currentId);
if (!dependentCalcs) continue;
for (const calcId of dependentCalcs) {
if (visited.has(calcId)) continue; // Loop protection
visited.add(calcId);
const def = calcDefs.get(calcId);
const row = results.find(r => r.TestSiteID === calcId);
if (!def || !row) continue;
calcsToCompute.push({ calcId, def, row });
// This calc might be a dependency for other calcs, add to queue
queue.push(calcId);
}
}
if (calcsToCompute.length > 0) {
await computeCalculations(calcsToCompute);
}
}
/**
* Compute multiple calculations via backend API
* @param {Array} calcsToCompute - Array of {calcId, def, row}
*/
async function computeCalculations(calcsToCompute) {
calcLoading = true;
try {
// Build batch request
const calculations = calcsToCompute.map(({ def }) => {
// Collect member values
const values = {};
let incomplete = false;
for (const member of def.members) {
const rawValue = resultMapById.get(member.id);
if (rawValue === '' || rawValue == null) {
incomplete = true;
break;
}
const numValue = parseFloat(rawValue);
if (!Number.isFinite(numValue)) {
incomplete = true;
break;
}
values[member.code] = numValue;
}
return {
testSiteId: def.id,
formula: def.formula,
values,
decimal: def.decimal,
incomplete
};
});
// Filter out incomplete calculations
const validCalculations = calculations.filter(c => !c.incomplete);
if (validCalculations.length === 0) {
// Clear results for incomplete calculations
for (const { row } of calcsToCompute) {
const index = results.findIndex(r => r.TestSiteID === row.TestSiteID);
if (index !== -1) {
results[index].Result = '';
results[index].changedByAutoCalc = true;
results[index].lastAutoCalcAt = Date.now();
results[index].warning = 'Missing dependency values';
updateResultFlag(index);
}
}
return;
}
// TODO: Replace with actual backend API call
// const response = await evaluateCalculations(validCalculations);
// For now, compute locally until backend is ready
const calcOutputs = computeLocally(validCalculations);
// Update results
for (const calcResult of calcOutputs) {
const index = results.findIndex(r => r.TestSiteID === calcResult.testSiteId);
if (index === -1) continue;
if (calcResult.error) {
results[index].warning = calcResult.error.message;
} else {
results[index].Result = String(calcResult.resultRounded);
results[index].changedByAutoCalc = true;
results[index].lastAutoCalcAt = Date.now();
results[index].warning = null;
updateResultFlag(index);
// Update lookup maps
resultMapById.set(calcResult.testSiteId, results[index].Result);
resultMapByCode.set(results[index].TestSiteCode, results[index].Result);
}
}
} catch (err) {
console.error('Calculation error:', err);
toastError('Failed to compute calculated values');
} finally {
calcLoading = false;
}
}
/**
* Temporary local computation until backend API is ready
* @param {Array} calculations - Calculations to compute
* @returns {Array} Computation results
*/
function computeLocally(calculations) {
return calculations.map(calc => {
try {
// Replace variable names with values (word boundary matching)
let expression = calc.formula;
for (const [code, value] of Object.entries(calc.values)) {
// Use word boundary regex to match exact variable names
const regex = new RegExp(`\\b${code}\\b`, 'g');
expression = expression.replace(regex, value);
}
// Validate expression characters
if (!/^[\d\s+\-*/.()]+$/.test(expression)) {
return {
testSiteId: calc.testSiteId,
error: { type: 'INVALID_EXPRESSION', message: 'Invalid characters in formula' }
};
}
// Evaluate (temporary - will be replaced by backend)
const result = Function('return ' + expression)();
if (!Number.isFinite(result)) {
return {
testSiteId: calc.testSiteId,
error: { type: 'NON_FINITE', message: 'Result is not a valid number' }
};
}
// Round to specified decimal places
const factor = Math.pow(10, calc.decimal);
const resultRounded = Math.round(result * factor) / factor;
return {
testSiteId: calc.testSiteId,
result,
resultRounded
};
} catch (err) {
return {
testSiteId: calc.testSiteId,
error: { type: 'EVAL_ERROR', message: err.message || 'Formula evaluation failed' }
};
}
});
}
/**
* Handle input change - update flag and trigger recalculation of dependent fields
*/
function handleInputChange(index) {
const row = results[index];
// Update dirty flag
results[index].changedByUser = true;
results[index].saved = false;
// Update lookup maps
resultMapById.set(row.TestSiteID, row.Result);
resultMapByCode.set(row.TestSiteCode, row.Result);
updateResultFlag(index);
// Recalculate dependent CALC fields
recalculateFrom(row.TestSiteID);
}
function getFlagColor(flag) { function getFlagColor(flag) {
if (flag === 'H') return 'text-error'; if (flag === 'H') return 'text-error';
if (flag === 'L') return 'text-warning'; if (flag === 'L') return 'text-warning';
@ -165,6 +545,8 @@
function copyPreviousResult(index) { function copyPreviousResult(index) {
if (index === 0) return; if (index === 0) return;
// Prevent copying to calculated tests
if (results[index].isCalculated) return;
const prevResult = results[index - 1].Result; const prevResult = results[index - 1].Result;
if (prevResult) { if (prevResult) {
results[index].Result = prevResult; results[index].Result = prevResult;
@ -173,11 +555,15 @@
} }
async function handleSaveAll() { async function handleSaveAll() {
// Filter to only entries with values that haven't been saved yet // Filter to dirty entries (user changed or auto-calc changed)
const entriesToSave = results.filter(r => r.Result && r.Result.trim() !== '' && !r.saved); // Include cleared results (empty values) that were modified
const entriesToSave = results.filter(r =>
!r.saved &&
(r.changedByUser || r.changedByAutoCalc)
);
if (entriesToSave.length === 0) { if (entriesToSave.length === 0) {
toastError('No results to save'); toastSuccess('Nothing to save');
return; return;
} }
@ -202,11 +588,13 @@
const response = await updateResult(entry.ResultID, data); const response = await updateResult(entry.ResultID, data);
if (response.status === 'success') { if (response.status === 'success') {
// Mark as saved // Mark as saved and clear dirty flags
const resultIndex = results.findIndex(r => r.ResultID === entry.ResultID); const resultIndex = results.findIndex(r => r.ResultID === entry.ResultID);
if (resultIndex !== -1) { if (resultIndex !== -1) {
results[resultIndex].saved = true; results[resultIndex].saved = true;
results[resultIndex].error = null; results[resultIndex].error = null;
results[resultIndex].changedByUser = false;
results[resultIndex].changedByAutoCalc = false;
} }
} else { } else {
// Mark with error // Mark with error
@ -260,12 +648,14 @@
} }
} }
// Alt+C: Copy previous result // Alt+C: Copy previous result (disabled for calculated tests)
if (event.key === 'c' && event.altKey) { if (event.key === 'c' && event.altKey) {
event.preventDefault(); event.preventDefault();
if (!results[index].isCalculated) {
copyPreviousResult(index); copyPreviousResult(index);
} }
} }
}
const pendingCount = $derived(results.filter(r => !r.Result || r.Result === '').length); const pendingCount = $derived(results.filter(r => !r.Result || r.Result === '').length);
const savedCount = $derived(results.filter(r => r.saved).length); const savedCount = $derived(results.filter(r => r.saved).length);
@ -366,7 +756,24 @@
<td class="text-xs font-mono">{result.TestSiteCode}</td> <td class="text-xs font-mono">{result.TestSiteCode}</td>
<td class="text-xs"> <td class="text-xs">
<div class="flex flex-col"> <div class="flex flex-col">
<div class="flex items-center gap-1">
<span>{result.TestSiteName}</span> <span>{result.TestSiteName}</span>
{#if result.isCalculated}
<span class="badge badge-xs badge-primary" title="Auto-calculated: {result.FormulaCode}">CALC</span>
{/if}
</div>
{#if result.isCalculated}
{@const hint = getDependencyHint(result)}
{#if hint}
<div class="text-xs text-primary/70">{hint}</div>
{/if}
{/if}
{#if result.warning}
<div class="text-warning text-xs flex items-center gap-1">
<AlertTriangle class="w-3 h-3" />
{result.warning}
</div>
{/if}
{#if result.error} {#if result.error}
<div class="text-error text-xs">{result.error}</div> <div class="text-error text-xs">{result.error}</div>
{/if} {/if}
@ -378,24 +785,48 @@
</div> </div>
</td> </td>
<td class="p-1"> <td class="p-1">
<label class="input input-sm input-bordered flex items-center gap-1 w-full {getInputBg(result.flag)}"> <label class="input input-sm input-bordered flex items-center gap-1 w-full {getInputBg(result.flag)} {result.isCalculated ? 'border-primary/50 bg-primary/5' : ''}">
{#if result.flag === 'H'} {#if result.isCalculated}
<Calculator class="w-3 h-3 text-primary flex-shrink-0" title="Auto-calculated" />
{:else if result.flag === 'H'}
<AlertTriangle class="w-3 h-3 text-error flex-shrink-0" /> <AlertTriangle class="w-3 h-3 text-error flex-shrink-0" />
{:else if result.flag === 'L'} {:else if result.flag === 'L'}
<AlertTriangle class="w-3 h-3 text-warning flex-shrink-0" /> <AlertTriangle class="w-3 h-3 text-warning flex-shrink-0" />
{:else if result.Result} {:else if result.Result}
<CheckCircle2 class="w-3 h-3 text-success flex-shrink-0" /> <CheckCircle2 class="w-3 h-3 text-success flex-shrink-0" />
{/if} {/if}
{#if result.isCalculated}
<!-- CALC tests: read-only display with refresh button -->
<input
id="result-input-{index}"
type="text"
class="grow bg-transparent outline-none text-sm font-mono cursor-not-allowed"
placeholder="Auto"
value={result.Result}
readonly
disabled={formLoading}
/>
<button
class="btn btn-ghost btn-xs p-0 min-h-0 h-auto"
onclick={() => recalculateFrom(result.TestSiteID)}
disabled={calcLoading || formLoading}
title="Recalculate"
>
<RefreshCw class="w-3 h-3 text-primary {calcLoading ? 'animate-spin' : ''}" />
</button>
{:else}
<!-- Regular tests: editable -->
<input <input
id="result-input-{index}" id="result-input-{index}"
type="text" type="text"
class="grow bg-transparent outline-none text-sm font-mono" class="grow bg-transparent outline-none text-sm font-mono"
placeholder="..." placeholder="..."
bind:value={result.Result} bind:value={result.Result}
oninput={() => updateResultFlag(index)} oninput={() => handleInputChange(index)}
onkeydown={(e) => handleKeyDown(e, index)} onkeydown={(e) => handleKeyDown(e, index)}
disabled={result.saved || formLoading} disabled={result.saved || formLoading}
/> />
{/if}
{#if result.Unit1} {#if result.Unit1}
<span class="text-xs text-base-content/50 flex-shrink-0">{result.Unit1}</span> <span class="text-xs text-base-content/50 flex-shrink-0">{result.Unit1}</span>
{/if} {/if}
@ -429,7 +860,7 @@
<button <button
class="btn btn-ghost btn-xs text-base-content/30 hover:text-primary" class="btn btn-ghost btn-xs text-base-content/30 hover:text-primary"
onclick={() => copyPreviousResult(index)} onclick={() => copyPreviousResult(index)}
disabled={index === 0} disabled={index === 0 || result.isCalculated}
title="Copy previous result (Alt+C)" title="Copy previous result (Alt+C)"
> >
<Copy class="w-3 h-3" /> <Copy class="w-3 h-3" />
@ -516,7 +947,7 @@
<button <button
class="btn btn-primary btn-sm" class="btn btn-primary btn-sm"
onclick={handleSaveAll} onclick={handleSaveAll}
disabled={formLoading || pendingCount === results.length} disabled={formLoading}
> >
{#if formLoading} {#if formLoading}
<span class="loading loading-spinner loading-xs"></span> <span class="loading loading-spinner loading-xs"></span>

View File

@ -1,12 +1,10 @@
<script> <script>
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { fetchRules, deleteRule } from '$lib/api/rules.js'; import { fetchRules } from '$lib/api/rules.js';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js'; import { error as toastError } from '$lib/utils/toast.js';
import DataTable from '$lib/components/DataTable.svelte'; import DataTable from '$lib/components/DataTable.svelte';
import Modal from '$lib/components/Modal.svelte'; import { Plus, Search, Edit2, ArrowLeft, Loader2, Filter, FileText } from 'lucide-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 loading = $state(false);
let rules = $state([]); let rules = $state([]);
@ -15,21 +13,14 @@
let filterEventCode = $state('ORDER_CREATED'); let filterEventCode = $state('ORDER_CREATED');
let filterActive = $state('ALL'); 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 = [ const columns = [
{ key: 'Name', label: 'Name', class: 'font-medium min-w-[220px]' }, { key: 'RuleCode', label: 'Code', class: 'font-medium w-36' },
{ key: 'EventCode', label: 'EventCode', class: 'w-36' }, { key: 'RuleName', label: 'Name', class: 'font-medium min-w-[200px]' },
{ key: 'ScopeType', label: 'Scope', class: 'w-36' }, { key: 'EventCode', label: 'Event', class: 'w-32' },
{ key: 'TestSiteID', label: 'TestSiteID', class: 'w-28' }, { key: 'Priority', label: 'Priority', class: 'w-20 text-center' },
{ key: 'Priority', label: 'Priority', class: 'w-24 text-center' }, { key: 'Active', label: 'Active', class: 'w-20 text-center' },
{ key: 'Active', label: 'Active', class: 'w-24 text-center' }, { key: 'actions', label: 'Actions', class: 'w-20 text-center' },
{ key: 'actions', label: 'Actions', class: 'w-24 text-center' },
]; ];
function normalizeRulesListResponse(response) { function normalizeRulesListResponse(response) {
@ -44,8 +35,6 @@
if (filterEventCode) params.EventCode = filterEventCode; if (filterEventCode) params.EventCode = filterEventCode;
if (filterActive !== 'ALL') params.Active = filterActive; if (filterActive !== 'ALL') params.Active = filterActive;
if (filterScopeType !== 'ALL') params.ScopeType = filterScopeType;
if (filterTestSite?.TestSiteID) params.TestSiteID = filterTestSite.TestSiteID;
const s = searchName.trim(); const s = searchName.trim();
if (s) params.search = s; if (s) params.search = s;
@ -77,24 +66,12 @@
onMount(loadRules); onMount(loadRules);
function confirmDelete(row) { function openCreatePage() {
deleteItem = row; goto('/rules/new');
deleteConfirmOpen = true;
} }
async function handleDelete() { function openEditPage(row) {
if (!deleteItem?.RuleID) return; goto(`/rules/${row.RuleID}`);
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) { function isActive(v) {
@ -112,12 +89,12 @@
<FileText class="w-5 h-5 text-gray-500" /> <FileText class="w-5 h-5 text-gray-500" />
Rules Rules
</h1> </h1>
<p class="text-sm text-gray-600">Manage rules and actions for ORDER_CREATED</p> <p class="text-sm text-gray-600">Manage global rules with DSL compilation</p>
</div> </div>
<a class="btn btn-primary" href="/rules/new"> <button class="btn btn-primary" onclick={openCreatePage}>
<Plus class="w-4 h-4 mr-2" /> <Plus class="w-4 h-4 mr-2" />
New Rule New Rule
</a> </button>
</div> </div>
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4"> <div class="bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4">
@ -126,7 +103,7 @@
Filters Filters
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-5 gap-3"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-3">
<div> <div>
<div class="text-xs font-semibold text-gray-600">EventCode</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}> <select class="select select-sm select-bordered w-full" bind:value={filterEventCode} disabled={true}>
@ -142,19 +119,7 @@
</select> </select>
</div> </div>
<div> <div>
<div class="text-xs font-semibold text-gray-600">ScopeType</div> <div class="text-xs font-semibold text-gray-600">Search Rule</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"> <div class="flex gap-2">
<label class="input input-sm input-bordered flex items-center gap-2 w-full focus-within:input-primary"> <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" /> <Search class="w-4 h-4 text-gray-400" />
@ -189,9 +154,7 @@
bordered={false} bordered={false}
> >
{#snippet cell({ column, row, value })} {#snippet cell({ column, row, value })}
{#if column.key === 'ScopeType'} {#if column.key === 'Active'}
<span class="badge badge-ghost badge-sm">{row.ScopeType || '-'}</span>
{:else if column.key === 'Active'}
<div class="flex justify-center"> <div class="flex justify-center">
<span class="badge badge-sm {isActive(row.Active) ? 'badge-success' : 'badge-ghost'}"> <span class="badge badge-sm {isActive(row.Active) ? 'badge-success' : 'badge-ghost'}">
{isActive(row.Active) ? 'Yes' : 'No'} {isActive(row.Active) ? 'Yes' : 'No'}
@ -203,20 +166,12 @@
<div class="flex justify-center gap-2"> <div class="flex justify-center gap-2">
<button <button
class="btn btn-sm btn-ghost" class="btn btn-sm btn-ghost"
onclick={() => goto(`/rules/${row.RuleID}`)} onclick={() => openEditPage(row)}
title="Edit rule" title="Edit rule"
type="button" type="button"
> >
<Edit2 class="w-4 h-4" /> <Edit2 class="w-4 h-4" />
</button> </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> </div>
{:else} {:else}
{value || '-'} {value || '-'}
@ -225,22 +180,3 @@
</DataTable> </DataTable>
</div> </div>
</div> </div>
<Modal bind:open={deleteConfirmOpen} title="Confirm Delete" size="sm">
<div class="py-2">
<p class="text-base-content/80">
Are you sure you want to delete <strong class="text-base-content">{deleteItem?.Name}</strong>?
</p>
<p class="text-sm text-gray-500 mt-1">RuleID: {deleteItem?.RuleID}</p>
<p class="text-sm text-error mt-3">This action cannot be undone.</p>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} type="button" disabled={deleting}>Cancel</button>
<button class="btn btn-error" onclick={handleDelete} disabled={deleting} type="button">
{#if deleting}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{deleting ? 'Deleting...' : 'Delete'}
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,8 @@
<script>
import { page } from '$app/stores';
import RuleFormPage from '$lib/components/rules/RuleFormPage.svelte';
const ruleId = $derived(Number($page.params.id) || null);
</script>
<RuleFormPage mode="edit" {ruleId} />

View File

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

View File

@ -1,166 +1,5 @@
<script> <script>
import { goto } from '$app/navigation'; import RuleFormPage from '$lib/components/rules/RuleFormPage.svelte';
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> </script>
<div class="p-4"> <RuleFormPage mode="create" />
<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"
/>