feat: replace legacy rule builder with DSL page
This commit is contained in:
parent
3dcfc379bd
commit
134040fcb4
17
TODO.md
17
TODO.md
@ -1,15 +1,4 @@
|
||||
# its MVP so Keep It Simple STUPID
|
||||
# TODO
|
||||
|
||||
## todos
|
||||
- test single
|
||||
- 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
|
||||
- Investigate why calculated tests in `ResultEntryModal.svelte` are failing to run: confirm formula loading (FormulaCode vs testdefcal), member detection, and local compute validation rules.
|
||||
- Support `{CODE}` tokens and backend formula formats in the temporary `computeLocally` logic, then wire refresh button to compute the target calc directly.
|
||||
|
||||
166
docs/backend-api-calculation.md
Normal file
166
docs/backend-api-calculation.md
Normal 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
|
||||
@ -23,6 +23,7 @@
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-svelte": "^0.563.0"
|
||||
"lucide-svelte": "^0.563.0",
|
||||
"mathjs": "^15.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
62
pnpm-lock.yaml
generated
62
pnpm-lock.yaml
generated
@ -11,6 +11,9 @@ importers:
|
||||
lucide-svelte:
|
||||
specifier: ^0.563.0
|
||||
version: 0.563.0(svelte@5.50.0)
|
||||
mathjs:
|
||||
specifier: ^15.1.1
|
||||
version: 15.1.1
|
||||
devDependencies:
|
||||
'@sveltejs/adapter-auto':
|
||||
specifier: ^7.0.0
|
||||
@ -48,6 +51,10 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@babel/runtime@7.28.6':
|
||||
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.3':
|
||||
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
|
||||
engines: {node: '>=18'}
|
||||
@ -546,6 +553,9 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
complex.js@2.4.3:
|
||||
resolution: {integrity: sha512-UrQVSUur14tNX6tiP4y8T4w4FeJAX3bi2cIv0pu/DTLFNxoq7z2Yh83Vfzztj6Px3X/lubqQ9IrPp7Bpn6p4MQ==}
|
||||
|
||||
cookie@0.6.0:
|
||||
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@ -553,6 +563,9 @@ packages:
|
||||
daisyui@5.5.18:
|
||||
resolution: {integrity: sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og==}
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
deepmerge@4.3.1:
|
||||
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -580,6 +593,9 @@ packages:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
escape-latex@1.2.0:
|
||||
resolution: {integrity: sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==}
|
||||
|
||||
esm-env@1.2.2:
|
||||
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
|
||||
|
||||
@ -609,6 +625,9 @@ packages:
|
||||
is-reference@3.0.3:
|
||||
resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
|
||||
|
||||
javascript-natural-sort@0.7.1:
|
||||
resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==}
|
||||
|
||||
jiti@2.6.1:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
@ -702,6 +721,11 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
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:
|
||||
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
||||
engines: {node: '>=4'}
|
||||
@ -744,6 +768,9 @@ packages:
|
||||
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
seedrandom@3.0.5:
|
||||
resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==}
|
||||
|
||||
set-cookie-parser@3.0.1:
|
||||
resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==}
|
||||
|
||||
@ -766,6 +793,9 @@ packages:
|
||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tiny-emitter@2.1.0:
|
||||
resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@ -774,6 +804,10 @@ packages:
|
||||
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
typed-function@4.2.2:
|
||||
resolution: {integrity: sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
update-browserslist-db@1.2.3:
|
||||
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||
hasBin: true
|
||||
@ -833,6 +867,8 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/runtime@7.28.6': {}
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.3':
|
||||
optional: true
|
||||
|
||||
@ -1158,10 +1194,14 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
complex.js@2.4.3: {}
|
||||
|
||||
cookie@0.6.0: {}
|
||||
|
||||
daisyui@5.5.18: {}
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
deepmerge@4.3.1: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
@ -1206,6 +1246,8 @@ snapshots:
|
||||
|
||||
escalade@3.2.0: {}
|
||||
|
||||
escape-latex@1.2.0: {}
|
||||
|
||||
esm-env@1.2.2: {}
|
||||
|
||||
esrap@2.2.3:
|
||||
@ -1227,6 +1269,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
javascript-natural-sort@0.7.1: {}
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
kleur@4.1.5: {}
|
||||
@ -1290,6 +1334,18 @@ snapshots:
|
||||
dependencies:
|
||||
'@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: {}
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
@ -1347,6 +1403,8 @@ snapshots:
|
||||
dependencies:
|
||||
mri: 1.2.0
|
||||
|
||||
seedrandom@3.0.5: {}
|
||||
|
||||
set-cookie-parser@3.0.1: {}
|
||||
|
||||
sirv@3.0.2:
|
||||
@ -1379,6 +1437,8 @@ snapshots:
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
tiny-emitter@2.1.0: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
@ -1386,6 +1446,8 @@ snapshots:
|
||||
|
||||
totalist@3.0.1: {}
|
||||
|
||||
typed-function@4.2.2: {}
|
||||
|
||||
update-browserslist-db@1.2.3(browserslist@4.28.1):
|
||||
dependencies:
|
||||
browserslist: 4.28.1
|
||||
|
||||
@ -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').RuleAction} RuleAction
|
||||
* @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').RuleDetailResponse} RuleDetailResponse
|
||||
* @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').TestRuleMapping} TestRuleMapping
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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>}
|
||||
*/
|
||||
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
|
||||
* @returns {Promise<RuleDetailResponse>}
|
||||
*/
|
||||
@ -30,8 +34,8 @@ export async function fetchRule(id) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a rule; optionally include initial actions
|
||||
* @param {Partial<RuleDef> & { actions?: Array<Partial<RuleAction>> }} payload
|
||||
* Create a rule with test mappings and optional initial actions
|
||||
* @param {Partial<RuleDef> & { TestSiteIDs: number[], actions?: Array<Partial<RuleAction>> }} payload
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
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 {Partial<RuleDef>} payload
|
||||
* @param {Partial<RuleDef> & { TestSiteIDs?: number[] }} payload
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function updateRule(id, payload) {
|
||||
@ -57,6 +61,26 @@ export async function deleteRule(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
|
||||
* @param {string} expr
|
||||
@ -67,6 +91,15 @@ export async function validateExpression(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
|
||||
* @param {number} id
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
@ -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>
|
||||
@ -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 > 65 ? (order.priority > 5 ? true : false) : false</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
465
src/lib/components/rules/RuleFormPage.svelte
Normal file
465
src/lib/components/rules/RuleFormPage.svelte
Normal 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>
|
||||
@ -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>
|
||||
@ -1,22 +1,25 @@
|
||||
/**
|
||||
* Rules Engine Type Definitions
|
||||
* Based on CLQMS API Specification
|
||||
* Based on CLQMS API Specification - Refactored Rule System
|
||||
*/
|
||||
|
||||
export type RuleEventCode = 'ORDER_CREATED';
|
||||
export type RuleScopeType = 'GLOBAL' | 'TESTSITE';
|
||||
export type RuleActionType = 'SET_RESULT';
|
||||
|
||||
export interface RuleDef {
|
||||
RuleID: number;
|
||||
Name: string;
|
||||
RuleCode: string;
|
||||
RuleName: string;
|
||||
Description?: string;
|
||||
EventCode: RuleEventCode | string;
|
||||
ScopeType: RuleScopeType;
|
||||
TestSiteID?: number | null;
|
||||
ConditionExpr?: string | null;
|
||||
ConditionExprCompiled?: string | null;
|
||||
Priority?: number;
|
||||
Active: 0 | 1 | number;
|
||||
/** ISO timestamp strings */
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface RuleAction {
|
||||
@ -25,11 +28,9 @@ export interface RuleAction {
|
||||
Seq: number;
|
||||
ActionType: RuleActionType | string;
|
||||
ActionParams: string | Record<string, any>;
|
||||
}
|
||||
|
||||
export interface RuleWithActions {
|
||||
rule: RuleDef;
|
||||
actions: RuleAction[];
|
||||
/** ISO timestamp strings */
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
@ -39,11 +40,15 @@ export interface ApiResponse<T> {
|
||||
}
|
||||
|
||||
export interface RulesListResponse extends ApiResponse<RuleDef[]> {}
|
||||
export interface RuleDetailResponse extends ApiResponse<RuleWithActions> {}
|
||||
export interface RuleActionsListResponse extends ApiResponse<RuleAction[]> {}
|
||||
export interface RuleDetailResponse extends ApiResponse<RuleDef> {}
|
||||
|
||||
export interface ValidateExprResponse extends ApiResponse<{ valid: boolean; result?: any; error?: string }> {}
|
||||
|
||||
export interface CompileExprResponse {
|
||||
compiled: any;
|
||||
conditionExprCompiled: string;
|
||||
}
|
||||
|
||||
export type SetResultActionParams = {
|
||||
testSiteID?: number;
|
||||
testSiteCode?: string;
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
import MappingsTab from './tabs/MappingsTab.svelte';
|
||||
import RefNumTab from './tabs/RefNumTab.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();
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<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();
|
||||
|
||||
@ -23,6 +23,11 @@
|
||||
});
|
||||
|
||||
const availableTests = $derived.by(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const selectedIds = new Set(members.map((member) => Number(member.TestSiteID)));
|
||||
|
||||
let filtered = tests.filter((test) =>
|
||||
@ -32,19 +37,14 @@
|
||||
test.IsActive !== 0
|
||||
);
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter((test) =>
|
||||
test.TestSiteCode?.toLowerCase().includes(query) ||
|
||||
test.TestSiteName?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
filtered = filtered.filter((test) =>
|
||||
test.TestSiteCode?.toLowerCase().includes(query) ||
|
||||
test.TestSiteName?.toLowerCase().includes(query)
|
||||
);
|
||||
|
||||
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 missingMemberCodes = $derived.by(() =>
|
||||
@ -129,134 +129,151 @@
|
||||
|
||||
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>
|
||||
|
||||
<div class="space-y-4">
|
||||
<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>
|
||||
<div>
|
||||
<strong>Formula Syntax:</strong> Use curly braces to reference test codes, e.g., <code class="code">{'{HGB}'} + {'{MCV}'}</code>
|
||||
</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="p-3 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>
|
||||
<label class="input input-sm input-bordered flex items-center gap-2 w-full">
|
||||
<Search class="w-4 h-4 text-gray-400" />
|
||||
<div class="px-3 py-2 border-b border-base-300 bg-base-200 rounded-t-lg shrink-0">
|
||||
<h3 class="text-xs font-medium text-gray-700 mb-1">Available Tests</h3>
|
||||
<label class="input input-xs input-bordered flex items-center gap-2 w-full">
|
||||
<Search class="w-3.5 h-3.5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="grow bg-transparent outline-none text-sm"
|
||||
class="grow bg-transparent outline-none text-xs"
|
||||
placeholder="Search by code or name..."
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-1 min-h-0">
|
||||
{#if availableTests.length === 0}
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
<Box class="w-10 h-10 mx-auto mb-2 opacity-50" />
|
||||
<p class="text-sm">No tests available</p>
|
||||
<p class="text-xs opacity-70">
|
||||
{searchQuery ? 'Try a different search term' : 'All tests are already added'}
|
||||
</p>
|
||||
<div class="flex-1 overflow-y-auto p-1.5 space-y-0.5 min-h-0">
|
||||
{#if !searchQuery.trim()}
|
||||
<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">Start typing to search tests</p>
|
||||
<p class="text-[10px] opacity-70 mt-0.5">Search by test code or name</p>
|
||||
</div>
|
||||
{: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>
|
||||
{:else}
|
||||
{#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 items-center gap-2">
|
||||
<span class="font-mono text-xs text-gray-500 w-8">{test.SeqScr || '-'}</span>
|
||||
<span class="font-mono text-sm font-medium truncate">{test.TestSiteCode}</span>
|
||||
<span class="badge badge-xs badge-ghost">{test.TestType}</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-mono text-[10px] text-gray-500 w-6">{test.SeqScr || '-'}</span>
|
||||
<span class="font-mono text-xs font-medium truncate">{test.TestSiteCode}</span>
|
||||
<span class="badge badge-xs badge-ghost scale-90 origin-left">{test.TestType}</span>
|
||||
</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>
|
||||
<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)}
|
||||
title="Add to calculation"
|
||||
>
|
||||
<Plus class="w-4 h-4 text-primary" />
|
||||
<Plus class="w-3.5 h-3.5 text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<h3 class="text-sm font-medium text-gray-700">Selected Members</h3>
|
||||
<span class="badge badge-sm badge-ghost">{members.length} selected</span>
|
||||
<h3 class="text-xs font-medium text-gray-700">Selected Members</h3>
|
||||
<span class="badge badge-xs badge-ghost">{members.length} selected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto min-h-0">
|
||||
{#if members.length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-full text-gray-500 py-8">
|
||||
<Box class="w-12 h-12 mb-3 opacity-50" />
|
||||
<p class="text-sm font-medium">No members selected</p>
|
||||
<p class="text-xs opacity-70 mt-1">Click the + button on available tests to add them</p>
|
||||
<div class="flex flex-col items-center justify-center h-full text-gray-500 py-6">
|
||||
<Box class="w-8 h-8 mb-2 opacity-50" />
|
||||
<p class="text-xs font-medium">No members selected</p>
|
||||
<p class="text-[10px] opacity-70 mt-0.5">Search and add tests from the left panel</p>
|
||||
</div>
|
||||
{:else}
|
||||
<table class="table table-sm w-full">
|
||||
<table class="table table-xs w-full">
|
||||
<thead class="sticky top-0 bg-base-200">
|
||||
<tr>
|
||||
<th class="w-12 text-center text-xs">Seq</th>
|
||||
<th class="w-20 text-xs">Code</th>
|
||||
<th class="text-xs">Name</th>
|
||||
<th class="w-16 text-xs">Type</th>
|
||||
<th class="w-10 text-center text-xs"></th>
|
||||
<th class="w-10 text-center text-[10px] py-1">Seq</th>
|
||||
<th class="w-16 text-[10px] py-1">Code</th>
|
||||
<th class="text-[10px] py-1">Name</th>
|
||||
<th class="w-12 text-[10px] py-1">Type</th>
|
||||
<th class="w-8 text-center text-[10px] py-1"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each members as member (member.TestSiteID)}
|
||||
<tr class="hover:bg-base-200">
|
||||
<td class="text-center font-mono text-xs text-gray-600">{member.SeqScr}</td>
|
||||
<td class="font-mono text-xs">{member.TestSiteCode}</td>
|
||||
<td class="text-xs truncate max-w-[150px]" title={member.TestSiteName}>
|
||||
<td class="text-center font-mono text-[10px] text-gray-600 py-1">{member.SeqScr}</td>
|
||||
<td class="font-mono text-[10px] py-1">{member.TestSiteCode}</td>
|
||||
<td class="text-[10px] truncate max-w-[120px] py-1" title={member.TestSiteName}>
|
||||
{member.TestSiteName}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-xs badge-ghost">{member.TestType}</span>
|
||||
<td class="py-1">
|
||||
<span class="badge badge-xs badge-ghost scale-90 origin-left">{member.TestType}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<td class="text-center py-1">
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-error p-0 min-h-0 h-auto"
|
||||
onclick={() => removeMember(member.TestSiteID)}
|
||||
@ -272,59 +289,10 @@
|
||||
{/if}
|
||||
</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>
|
||||
{#if validationErrors.members}
|
||||
<p class="text-error mt-1">{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>
|
||||
<p class="text-error mt-0.5">{validationErrors.members}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,12 +1,9 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
FileText,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
User,
|
||||
@ -14,7 +11,7 @@
|
||||
Edit3,
|
||||
ClipboardList
|
||||
} 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 ResultEntryModal from './ResultEntryModal.svelte';
|
||||
|
||||
@ -33,14 +30,14 @@
|
||||
// Modal state
|
||||
let selectedOrder = $state(null);
|
||||
let showEntryModal = $state(false);
|
||||
let detailLoadingOrderId = $state('');
|
||||
|
||||
async function loadOrders() {
|
||||
loading = true;
|
||||
try {
|
||||
const params = {
|
||||
page: currentPage,
|
||||
perPage: perPage,
|
||||
include: 'details'
|
||||
perPage: perPage
|
||||
};
|
||||
|
||||
if (filterOrderId) params.OrderID = filterOrderId;
|
||||
@ -64,9 +61,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnterResults(order) {
|
||||
selectedOrder = order;
|
||||
showEntryModal = true;
|
||||
async function handleEnterResults(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;
|
||||
} else {
|
||||
toastError('Order details unavailable');
|
||||
}
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load order details');
|
||||
} finally {
|
||||
detailLoadingOrderId = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleResultsSaved() {
|
||||
@ -203,8 +214,7 @@
|
||||
{#each orders as order (order.OrderID)}
|
||||
{@const statusInfo = getStatusInfo(order.OrderStatus)}
|
||||
{@const priorityInfo = getPriorityInfo(order.Priority)}
|
||||
{@const testCount = order.Tests?.length || 0}
|
||||
{@const pendingCount = order.Tests?.filter(t => !t.Result || t.Result === '').length || 0}
|
||||
{@const hasTests = Array.isArray(order.Tests)}
|
||||
<tr class="hover:bg-base-200/50 transition-colors">
|
||||
<td class="text-xs font-mono font-medium">{order.OrderID}</td>
|
||||
<td class="text-xs">
|
||||
@ -223,23 +233,34 @@
|
||||
<span class="badge {priorityInfo.color} badge-sm">{priorityInfo.label}</span>
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="font-medium">{testCount}</span>
|
||||
{#if pendingCount > 0}
|
||||
<span class="text-warning">({pendingCount} pending)</span>
|
||||
{:else}
|
||||
<CheckCircle2 class="w-3 h-3 text-success" />
|
||||
{/if}
|
||||
</div>
|
||||
{#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">
|
||||
<span class="font-medium">{testCount}</span>
|
||||
{#if pendingCount > 0}
|
||||
<span class="text-warning">({pendingCount} pending)</span>
|
||||
{:else}
|
||||
<CheckCircle2 class="w-3 h-3 text-success" />
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-xs text-base-content/50">Details only</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
title="Enter Results"
|
||||
onclick={() => handleEnterResults(order)}
|
||||
disabled={detailLoadingOrderId === order.OrderID}
|
||||
>
|
||||
<Edit3 class="w-3 h-3" />
|
||||
Enter Results
|
||||
{#if detailLoadingOrderId === order.OrderID}
|
||||
Loading...
|
||||
{:else}
|
||||
Enter Results
|
||||
{/if}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -14,11 +14,14 @@
|
||||
MessageSquare,
|
||||
Copy,
|
||||
ChevronDown,
|
||||
ChevronUp
|
||||
ChevronUp,
|
||||
Calculator,
|
||||
RefreshCw
|
||||
} from 'lucide-svelte';
|
||||
import { updateResult } from '$lib/api/results.js';
|
||||
import { error as toastError, success as toastSuccess } from '$lib/utils/toast.js';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let {
|
||||
order = $bindable(),
|
||||
@ -30,6 +33,7 @@
|
||||
let results = $state([]);
|
||||
let formLoading = $state(false);
|
||||
let saveProgress = $state({ current: 0, total: 0 });
|
||||
let calcLoading = $state(false);
|
||||
|
||||
// Expandable rows for comments
|
||||
let expandedRows = $state(new Set());
|
||||
@ -37,39 +41,187 @@
|
||||
// Keyboard shortcuts enabled
|
||||
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
|
||||
$effect(() => {
|
||||
if (order && open) {
|
||||
// Map tests to editable result entries
|
||||
results = (order.Tests || []).map(test => ({
|
||||
ResultID: test.ResultID,
|
||||
TestSiteID: test.TestSiteID,
|
||||
TestSiteCode: test.TestSiteCode,
|
||||
TestSiteName: test.TestSiteName,
|
||||
Result: test.Result || '',
|
||||
SampleType: test.SampleType || '',
|
||||
WorkstationID: test.WorkstationID || '',
|
||||
EquipmentID: test.EquipmentID || '',
|
||||
Unit1: test.Unit1,
|
||||
Low: test.Low,
|
||||
High: test.High,
|
||||
LowSign: test.LowSign,
|
||||
HighSign: test.HighSign,
|
||||
RefDisplay: test.RefDisplay,
|
||||
RefNumID: test.RefNumID,
|
||||
ResultStatus: test.ResultStatus || 'PRE',
|
||||
EnteredBy: test.EnteredBy || '',
|
||||
EnteredDateTime: test.EnteredDateTime || '',
|
||||
Comment: test.Comment || '',
|
||||
flag: calculateFlag(test.Result, test.Low, test.High),
|
||||
saved: false,
|
||||
error: null
|
||||
}));
|
||||
saveProgress = { current: 0, total: 0 };
|
||||
expandedRows = new Set();
|
||||
// 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
|
||||
results = (order.Tests || []).map(test => ({
|
||||
ResultID: test.ResultID,
|
||||
TestSiteID: test.TestSiteID,
|
||||
TestSiteCode: test.TestSiteCode,
|
||||
TestSiteName: test.TestSiteName,
|
||||
Result: test.Result || '',
|
||||
SampleType: test.SampleType || '',
|
||||
WorkstationID: test.WorkstationID || '',
|
||||
EquipmentID: test.EquipmentID || '',
|
||||
Unit1: test.Unit1,
|
||||
Low: test.Low,
|
||||
High: test.High,
|
||||
LowSign: test.LowSign,
|
||||
HighSign: test.HighSign,
|
||||
RefDisplay: test.RefDisplay,
|
||||
RefNumID: test.RefNumID,
|
||||
ResultStatus: test.ResultStatus || 'PRE',
|
||||
EnteredBy: test.EnteredBy || '',
|
||||
EnteredDateTime: test.EnteredDateTime || '',
|
||||
Comment: test.Comment || '',
|
||||
flag: calculateFlag(test.Result, test.Low, test.High),
|
||||
saved: false,
|
||||
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 };
|
||||
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
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
@ -80,7 +232,7 @@
|
||||
// Ctrl+S: Save all
|
||||
if (event.ctrlKey && event.key === 's') {
|
||||
event.preventDefault();
|
||||
if (!formLoading && pendingCount !== results.length) {
|
||||
if (!formLoading) {
|
||||
handleSaveAll();
|
||||
}
|
||||
}
|
||||
@ -112,6 +264,234 @@
|
||||
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) {
|
||||
if (flag === 'H') return 'text-error';
|
||||
if (flag === 'L') return 'text-warning';
|
||||
@ -165,6 +545,8 @@
|
||||
|
||||
function copyPreviousResult(index) {
|
||||
if (index === 0) return;
|
||||
// Prevent copying to calculated tests
|
||||
if (results[index].isCalculated) return;
|
||||
const prevResult = results[index - 1].Result;
|
||||
if (prevResult) {
|
||||
results[index].Result = prevResult;
|
||||
@ -173,11 +555,15 @@
|
||||
}
|
||||
|
||||
async function handleSaveAll() {
|
||||
// Filter to only entries with values that haven't been saved yet
|
||||
const entriesToSave = results.filter(r => r.Result && r.Result.trim() !== '' && !r.saved);
|
||||
// Filter to dirty entries (user changed or auto-calc changed)
|
||||
// Include cleared results (empty values) that were modified
|
||||
const entriesToSave = results.filter(r =>
|
||||
!r.saved &&
|
||||
(r.changedByUser || r.changedByAutoCalc)
|
||||
);
|
||||
|
||||
if (entriesToSave.length === 0) {
|
||||
toastError('No results to save');
|
||||
toastSuccess('Nothing to save');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -202,11 +588,13 @@
|
||||
const response = await updateResult(entry.ResultID, data);
|
||||
|
||||
if (response.status === 'success') {
|
||||
// Mark as saved
|
||||
// Mark as saved and clear dirty flags
|
||||
const resultIndex = results.findIndex(r => r.ResultID === entry.ResultID);
|
||||
if (resultIndex !== -1) {
|
||||
results[resultIndex].saved = true;
|
||||
results[resultIndex].error = null;
|
||||
results[resultIndex].changedByUser = false;
|
||||
results[resultIndex].changedByAutoCalc = false;
|
||||
}
|
||||
} else {
|
||||
// Mark with error
|
||||
@ -260,10 +648,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Alt+C: Copy previous result
|
||||
// Alt+C: Copy previous result (disabled for calculated tests)
|
||||
if (event.key === 'c' && event.altKey) {
|
||||
event.preventDefault();
|
||||
copyPreviousResult(index);
|
||||
if (!results[index].isCalculated) {
|
||||
copyPreviousResult(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -366,7 +756,24 @@
|
||||
<td class="text-xs font-mono">{result.TestSiteCode}</td>
|
||||
<td class="text-xs">
|
||||
<div class="flex flex-col">
|
||||
<span>{result.TestSiteName}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<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}
|
||||
<div class="text-error text-xs">{result.error}</div>
|
||||
{/if}
|
||||
@ -378,24 +785,48 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-1">
|
||||
<label class="input input-sm input-bordered flex items-center gap-1 w-full {getInputBg(result.flag)}">
|
||||
{#if result.flag === 'H'}
|
||||
<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.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" />
|
||||
{:else if result.flag === 'L'}
|
||||
<AlertTriangle class="w-3 h-3 text-warning flex-shrink-0" />
|
||||
{:else if result.Result}
|
||||
<CheckCircle2 class="w-3 h-3 text-success flex-shrink-0" />
|
||||
{/if}
|
||||
<input
|
||||
id="result-input-{index}"
|
||||
type="text"
|
||||
class="grow bg-transparent outline-none text-sm font-mono"
|
||||
placeholder="..."
|
||||
bind:value={result.Result}
|
||||
oninput={() => updateResultFlag(index)}
|
||||
onkeydown={(e) => handleKeyDown(e, index)}
|
||||
disabled={result.saved || formLoading}
|
||||
/>
|
||||
{#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
|
||||
id="result-input-{index}"
|
||||
type="text"
|
||||
class="grow bg-transparent outline-none text-sm font-mono"
|
||||
placeholder="..."
|
||||
bind:value={result.Result}
|
||||
oninput={() => handleInputChange(index)}
|
||||
onkeydown={(e) => handleKeyDown(e, index)}
|
||||
disabled={result.saved || formLoading}
|
||||
/>
|
||||
{/if}
|
||||
{#if result.Unit1}
|
||||
<span class="text-xs text-base-content/50 flex-shrink-0">{result.Unit1}</span>
|
||||
{/if}
|
||||
@ -429,7 +860,7 @@
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-base-content/30 hover:text-primary"
|
||||
onclick={() => copyPreviousResult(index)}
|
||||
disabled={index === 0}
|
||||
disabled={index === 0 || result.isCalculated}
|
||||
title="Copy previous result (Alt+C)"
|
||||
>
|
||||
<Copy class="w-3 h-3" />
|
||||
@ -516,7 +947,7 @@
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
onclick={handleSaveAll}
|
||||
disabled={formLoading || pendingCount === results.length}
|
||||
disabled={formLoading}
|
||||
>
|
||||
{#if formLoading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { fetchRules, deleteRule } from '$lib/api/rules.js';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
import { fetchRules } from '$lib/api/rules.js';
|
||||
import { error as toastError } from '$lib/utils/toast.js';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import TestSiteSearch from '$lib/components/rules/TestSiteSearch.svelte';
|
||||
import { Plus, Search, Trash2, Edit2, ArrowLeft, Loader2, Filter, FileText } from 'lucide-svelte';
|
||||
import { Plus, Search, Edit2, ArrowLeft, Loader2, Filter, FileText } from 'lucide-svelte';
|
||||
|
||||
let loading = $state(false);
|
||||
let rules = $state([]);
|
||||
@ -15,21 +13,14 @@
|
||||
|
||||
let filterEventCode = $state('ORDER_CREATED');
|
||||
let filterActive = $state('ALL');
|
||||
let filterScopeType = $state('ALL');
|
||||
let filterTestSite = $state(null);
|
||||
|
||||
let deleteConfirmOpen = $state(false);
|
||||
let deleteItem = $state(null);
|
||||
let deleting = $state(false);
|
||||
|
||||
const columns = [
|
||||
{ key: 'Name', label: 'Name', class: 'font-medium min-w-[220px]' },
|
||||
{ key: 'EventCode', label: 'EventCode', class: 'w-36' },
|
||||
{ key: 'ScopeType', label: 'Scope', class: 'w-36' },
|
||||
{ key: 'TestSiteID', label: 'TestSiteID', class: 'w-28' },
|
||||
{ key: 'Priority', label: 'Priority', class: 'w-24 text-center' },
|
||||
{ key: 'Active', label: 'Active', class: 'w-24 text-center' },
|
||||
{ key: 'actions', label: 'Actions', class: 'w-24 text-center' },
|
||||
{ key: 'RuleCode', label: 'Code', class: 'font-medium w-36' },
|
||||
{ key: 'RuleName', label: 'Name', class: 'font-medium min-w-[200px]' },
|
||||
{ key: 'EventCode', label: 'Event', class: 'w-32' },
|
||||
{ key: 'Priority', label: 'Priority', class: 'w-20 text-center' },
|
||||
{ key: 'Active', label: 'Active', class: 'w-20 text-center' },
|
||||
{ key: 'actions', label: 'Actions', class: 'w-20 text-center' },
|
||||
];
|
||||
|
||||
function normalizeRulesListResponse(response) {
|
||||
@ -44,8 +35,6 @@
|
||||
|
||||
if (filterEventCode) params.EventCode = filterEventCode;
|
||||
if (filterActive !== 'ALL') params.Active = filterActive;
|
||||
if (filterScopeType !== 'ALL') params.ScopeType = filterScopeType;
|
||||
if (filterTestSite?.TestSiteID) params.TestSiteID = filterTestSite.TestSiteID;
|
||||
|
||||
const s = searchName.trim();
|
||||
if (s) params.search = s;
|
||||
@ -77,24 +66,12 @@
|
||||
|
||||
onMount(loadRules);
|
||||
|
||||
function confirmDelete(row) {
|
||||
deleteItem = row;
|
||||
deleteConfirmOpen = true;
|
||||
function openCreatePage() {
|
||||
goto('/rules/new');
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteItem?.RuleID) return;
|
||||
deleting = true;
|
||||
try {
|
||||
await deleteRule(deleteItem.RuleID);
|
||||
toastSuccess('Rule deleted successfully');
|
||||
deleteConfirmOpen = false;
|
||||
await loadRules();
|
||||
} catch (err) {
|
||||
toastError(err?.message || 'Failed to delete rule');
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
function openEditPage(row) {
|
||||
goto(`/rules/${row.RuleID}`);
|
||||
}
|
||||
|
||||
function isActive(v) {
|
||||
@ -112,12 +89,12 @@
|
||||
<FileText class="w-5 h-5 text-gray-500" />
|
||||
Rules
|
||||
</h1>
|
||||
<p class="text-sm text-gray-600">Manage rules and actions for ORDER_CREATED</p>
|
||||
<p class="text-sm text-gray-600">Manage global rules with DSL compilation</p>
|
||||
</div>
|
||||
<a class="btn btn-primary" href="/rules/new">
|
||||
<button class="btn btn-primary" onclick={openCreatePage}>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
New Rule
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4">
|
||||
@ -126,7 +103,7 @@
|
||||
Filters
|
||||
</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 class="text-xs font-semibold text-gray-600">EventCode</div>
|
||||
<select class="select select-sm select-bordered w-full" bind:value={filterEventCode} disabled={true}>
|
||||
@ -142,19 +119,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-gray-600">ScopeType</div>
|
||||
<select class="select select-sm select-bordered w-full" bind:value={filterScopeType} onchange={loadRules}>
|
||||
<option value="ALL">All</option>
|
||||
<option value="GLOBAL">GLOBAL</option>
|
||||
<option value="TESTSITE">TESTSITE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-gray-600">TestSite</div>
|
||||
<TestSiteSearch bind:value={filterTestSite} placeholder="Optional" onSelect={loadRules} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-gray-600">Rule Name</div>
|
||||
<div class="text-xs font-semibold text-gray-600">Search Rule</div>
|
||||
<div class="flex gap-2">
|
||||
<label class="input input-sm input-bordered flex items-center gap-2 w-full focus-within:input-primary">
|
||||
<Search class="w-4 h-4 text-gray-400" />
|
||||
@ -189,9 +154,7 @@
|
||||
bordered={false}
|
||||
>
|
||||
{#snippet cell({ column, row, value })}
|
||||
{#if column.key === 'ScopeType'}
|
||||
<span class="badge badge-ghost badge-sm">{row.ScopeType || '-'}</span>
|
||||
{:else if column.key === 'Active'}
|
||||
{#if column.key === 'Active'}
|
||||
<div class="flex justify-center">
|
||||
<span class="badge badge-sm {isActive(row.Active) ? 'badge-success' : 'badge-ghost'}">
|
||||
{isActive(row.Active) ? 'Yes' : 'No'}
|
||||
@ -203,20 +166,12 @@
|
||||
<div class="flex justify-center gap-2">
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
onclick={() => goto(`/rules/${row.RuleID}`)}
|
||||
onclick={() => openEditPage(row)}
|
||||
title="Edit rule"
|
||||
type="button"
|
||||
>
|
||||
<Edit2 class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost text-error"
|
||||
onclick={() => confirmDelete(row)}
|
||||
title="Delete rule"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{value || '-'}
|
||||
@ -225,22 +180,3 @@
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal bind:open={deleteConfirmOpen} title="Confirm Delete" size="sm">
|
||||
<div class="py-2">
|
||||
<p class="text-base-content/80">
|
||||
Are you sure you want to delete <strong class="text-base-content">{deleteItem?.Name}</strong>?
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mt-1">RuleID: {deleteItem?.RuleID}</p>
|
||||
<p class="text-sm text-error mt-3">This action cannot be undone.</p>
|
||||
</div>
|
||||
{#snippet footer()}
|
||||
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} type="button" disabled={deleting}>Cancel</button>
|
||||
<button class="btn btn-error" onclick={handleDelete} disabled={deleting} type="button">
|
||||
{#if deleting}
|
||||
<span class="loading loading-spinner loading-sm mr-2"></span>
|
||||
{/if}
|
||||
{deleting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
8
src/routes/(app)/rules/[id]/+page.svelte
Normal file
8
src/routes/(app)/rules/[id]/+page.svelte
Normal 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} />
|
||||
@ -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>
|
||||
@ -1,166 +1,5 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { createRule } from '$lib/api/rules.js';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
import RuleForm from '$lib/components/rules/RuleForm.svelte';
|
||||
import ActionsEditor from '$lib/components/rules/ActionsEditor.svelte';
|
||||
import ExprTesterModal from '$lib/components/rules/ExprTesterModal.svelte';
|
||||
import { ArrowLeft, Save, Play } from 'lucide-svelte';
|
||||
|
||||
let saving = $state(false);
|
||||
let errors = $state({});
|
||||
|
||||
let ruleForm = $state({
|
||||
Name: '',
|
||||
Description: '',
|
||||
EventCode: 'ORDER_CREATED',
|
||||
ScopeType: 'GLOBAL',
|
||||
TestSiteID: null,
|
||||
ConditionExpr: '',
|
||||
Priority: 0,
|
||||
Active: 1,
|
||||
});
|
||||
|
||||
let actions = $state([
|
||||
{
|
||||
Seq: 1,
|
||||
ActionType: 'SET_RESULT',
|
||||
ActionParams: {},
|
||||
},
|
||||
]);
|
||||
|
||||
let conditionTesterOpen = $state(false);
|
||||
let conditionTesterContext = $state('{}');
|
||||
|
||||
function stringifyActionParams(actionParams) {
|
||||
if (!actionParams) return '{}';
|
||||
|
||||
/** @type {any} */
|
||||
let p = actionParams;
|
||||
if (typeof p === 'string') {
|
||||
try {
|
||||
p = JSON.parse(p);
|
||||
} catch {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
if (!p || typeof p !== 'object') return '{}';
|
||||
|
||||
const out = {};
|
||||
if (p.testSiteID != null && p.testSiteID !== '') out.testSiteID = Number(p.testSiteID);
|
||||
if (p.testSiteCode) out.testSiteCode = String(p.testSiteCode);
|
||||
if (p.valueExpr) out.valueExpr = String(p.valueExpr);
|
||||
if (!out.valueExpr && p.value !== undefined) out.value = p.value;
|
||||
|
||||
return JSON.stringify(out);
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
const next = {};
|
||||
if (!ruleForm.Name?.trim()) next.Name = 'Name is required';
|
||||
if (ruleForm.ScopeType === 'TESTSITE' && !ruleForm.TestSiteID) next.TestSiteID = 'TestSite is required for TESTSITE scope';
|
||||
errors = next;
|
||||
return Object.keys(next).length === 0;
|
||||
}
|
||||
|
||||
function extractRuleId(response) {
|
||||
const candidates = [
|
||||
response?.data?.RuleID,
|
||||
response?.data?.data?.RuleID,
|
||||
response?.data?.rule?.RuleID,
|
||||
response?.data?.data?.rule?.RuleID,
|
||||
response?.RuleID,
|
||||
];
|
||||
for (const c of candidates) {
|
||||
const n = Number(c);
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!validateForm()) return;
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
const payload = {
|
||||
Name: ruleForm.Name?.trim(),
|
||||
Description: ruleForm.Description || '',
|
||||
EventCode: 'ORDER_CREATED',
|
||||
ScopeType: ruleForm.ScopeType,
|
||||
TestSiteID: ruleForm.ScopeType === 'TESTSITE' ? (ruleForm.TestSiteID ?? null) : null,
|
||||
ConditionExpr: ruleForm.ConditionExpr?.trim() ? ruleForm.ConditionExpr.trim() : null,
|
||||
Priority: Number(ruleForm.Priority) || 0,
|
||||
Active: ruleForm.Active === 1 || ruleForm.Active === '1' || ruleForm.Active === true ? 1 : 0,
|
||||
actions: (actions || []).map(a => ({
|
||||
Seq: Number(a.Seq) || 0,
|
||||
ActionType: a.ActionType || 'SET_RESULT',
|
||||
ActionParams: stringifyActionParams(a.ActionParams),
|
||||
})),
|
||||
};
|
||||
|
||||
const response = await createRule(payload);
|
||||
toastSuccess('Rule created successfully');
|
||||
|
||||
const newId = extractRuleId(response);
|
||||
if (newId) {
|
||||
goto(`/rules/${newId}`);
|
||||
} else {
|
||||
goto('/rules');
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err?.message || 'Failed to create rule';
|
||||
toastError(message);
|
||||
if (err?.messages && typeof err.messages === 'object') {
|
||||
errors = err.messages;
|
||||
}
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
import RuleFormPage from '$lib/components/rules/RuleFormPage.svelte';
|
||||
</script>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<a href="/rules" class="btn btn-ghost btn-circle">
|
||||
<ArrowLeft class="w-5 h-5" />
|
||||
</a>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-xl font-bold text-gray-800">New Rule</h1>
|
||||
<p class="text-sm text-gray-600">Create a rule and its initial actions</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick={handleSave} disabled={saving} type="button">
|
||||
<Save class="w-4 h-4 mr-2" />
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4">
|
||||
<RuleForm bind:value={ruleForm} {errors} disabled={saving} />
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
onclick={() => (conditionTesterOpen = true)}
|
||||
disabled={!ruleForm.ConditionExpr?.trim()}
|
||||
type="button"
|
||||
title="Test condition expression"
|
||||
>
|
||||
<Play class="w-4 h-4 mr-2" />
|
||||
Test Condition
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4">
|
||||
<ActionsEditor bind:actions={actions} disabled={saving} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ExprTesterModal
|
||||
bind:open={conditionTesterOpen}
|
||||
bind:expr={ruleForm.ConditionExpr}
|
||||
bind:context={conditionTesterContext}
|
||||
title="Test Condition Expression"
|
||||
/>
|
||||
<RuleFormPage mode="create" />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user