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
|
- Investigate why calculated tests in `ResultEntryModal.svelte` are failing to run: confirm formula loading (FormulaCode vs testdefcal), member detection, and local compute validation rules.
|
||||||
- test single
|
- Support `{CODE}` tokens and backend formula formats in the temporary `computeLocally` logic, then wire refresh button to compute the target calc directly.
|
||||||
- test group
|
|
||||||
- test calc
|
|
||||||
- test param
|
|
||||||
### backend
|
|
||||||
- testgroup detail is wrong
|
|
||||||
|
|
||||||
## done
|
|
||||||
- patient page -> add edit patient button on patient list
|
|
||||||
- patient page -> add view order modal
|
|
||||||
- visit page -> on visit modal, show visitid when create
|
|
||||||
- visit page -> on visit modal, make uniform design when create and edit
|
|
||||||
|
|||||||
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"
|
"vite": "^7.3.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-svelte": "^0.563.0"
|
"lucide-svelte": "^0.563.0",
|
||||||
|
"mathjs": "^15.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
62
pnpm-lock.yaml
generated
62
pnpm-lock.yaml
generated
@ -11,6 +11,9 @@ importers:
|
|||||||
lucide-svelte:
|
lucide-svelte:
|
||||||
specifier: ^0.563.0
|
specifier: ^0.563.0
|
||||||
version: 0.563.0(svelte@5.50.0)
|
version: 0.563.0(svelte@5.50.0)
|
||||||
|
mathjs:
|
||||||
|
specifier: ^15.1.1
|
||||||
|
version: 15.1.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@sveltejs/adapter-auto':
|
'@sveltejs/adapter-auto':
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
@ -48,6 +51,10 @@ importers:
|
|||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
|
'@babel/runtime@7.28.6':
|
||||||
|
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.27.3':
|
'@esbuild/aix-ppc64@0.27.3':
|
||||||
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
|
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -546,6 +553,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
complex.js@2.4.3:
|
||||||
|
resolution: {integrity: sha512-UrQVSUur14tNX6tiP4y8T4w4FeJAX3bi2cIv0pu/DTLFNxoq7z2Yh83Vfzztj6Px3X/lubqQ9IrPp7Bpn6p4MQ==}
|
||||||
|
|
||||||
cookie@0.6.0:
|
cookie@0.6.0:
|
||||||
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
|
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@ -553,6 +563,9 @@ packages:
|
|||||||
daisyui@5.5.18:
|
daisyui@5.5.18:
|
||||||
resolution: {integrity: sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og==}
|
resolution: {integrity: sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og==}
|
||||||
|
|
||||||
|
decimal.js@10.6.0:
|
||||||
|
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||||
|
|
||||||
deepmerge@4.3.1:
|
deepmerge@4.3.1:
|
||||||
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -580,6 +593,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
escape-latex@1.2.0:
|
||||||
|
resolution: {integrity: sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==}
|
||||||
|
|
||||||
esm-env@1.2.2:
|
esm-env@1.2.2:
|
||||||
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
|
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
|
||||||
|
|
||||||
@ -609,6 +625,9 @@ packages:
|
|||||||
is-reference@3.0.3:
|
is-reference@3.0.3:
|
||||||
resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
|
resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
|
||||||
|
|
||||||
|
javascript-natural-sort@0.7.1:
|
||||||
|
resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==}
|
||||||
|
|
||||||
jiti@2.6.1:
|
jiti@2.6.1:
|
||||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@ -702,6 +721,11 @@ packages:
|
|||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
|
mathjs@15.1.1:
|
||||||
|
resolution: {integrity: sha512-rM668DTtpSzMVoh/cKAllyQVEbBApM5g//IMGD8vD7YlrIz9ITRr3SrdhjaDxcBNTdyETWwPebj2unZyHD7ZdA==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
mri@1.2.0:
|
mri@1.2.0:
|
||||||
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@ -744,6 +768,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
seedrandom@3.0.5:
|
||||||
|
resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==}
|
||||||
|
|
||||||
set-cookie-parser@3.0.1:
|
set-cookie-parser@3.0.1:
|
||||||
resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==}
|
resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==}
|
||||||
|
|
||||||
@ -766,6 +793,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
tiny-emitter@2.1.0:
|
||||||
|
resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==}
|
||||||
|
|
||||||
tinyglobby@0.2.15:
|
tinyglobby@0.2.15:
|
||||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@ -774,6 +804,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
typed-function@4.2.2:
|
||||||
|
resolution: {integrity: sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
update-browserslist-db@1.2.3:
|
update-browserslist-db@1.2.3:
|
||||||
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@ -833,6 +867,8 @@ packages:
|
|||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
|
'@babel/runtime@7.28.6': {}
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.27.3':
|
'@esbuild/aix-ppc64@0.27.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@ -1158,10 +1194,14 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
|
complex.js@2.4.3: {}
|
||||||
|
|
||||||
cookie@0.6.0: {}
|
cookie@0.6.0: {}
|
||||||
|
|
||||||
daisyui@5.5.18: {}
|
daisyui@5.5.18: {}
|
||||||
|
|
||||||
|
decimal.js@10.6.0: {}
|
||||||
|
|
||||||
deepmerge@4.3.1: {}
|
deepmerge@4.3.1: {}
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
@ -1206,6 +1246,8 @@ snapshots:
|
|||||||
|
|
||||||
escalade@3.2.0: {}
|
escalade@3.2.0: {}
|
||||||
|
|
||||||
|
escape-latex@1.2.0: {}
|
||||||
|
|
||||||
esm-env@1.2.2: {}
|
esm-env@1.2.2: {}
|
||||||
|
|
||||||
esrap@2.2.3:
|
esrap@2.2.3:
|
||||||
@ -1227,6 +1269,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
|
|
||||||
|
javascript-natural-sort@0.7.1: {}
|
||||||
|
|
||||||
jiti@2.6.1: {}
|
jiti@2.6.1: {}
|
||||||
|
|
||||||
kleur@4.1.5: {}
|
kleur@4.1.5: {}
|
||||||
@ -1290,6 +1334,18 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
mathjs@15.1.1:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.28.6
|
||||||
|
complex.js: 2.4.3
|
||||||
|
decimal.js: 10.6.0
|
||||||
|
escape-latex: 1.2.0
|
||||||
|
fraction.js: 5.3.4
|
||||||
|
javascript-natural-sort: 0.7.1
|
||||||
|
seedrandom: 3.0.5
|
||||||
|
tiny-emitter: 2.1.0
|
||||||
|
typed-function: 4.2.2
|
||||||
|
|
||||||
mri@1.2.0: {}
|
mri@1.2.0: {}
|
||||||
|
|
||||||
mrmime@2.0.1: {}
|
mrmime@2.0.1: {}
|
||||||
@ -1347,6 +1403,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
mri: 1.2.0
|
mri: 1.2.0
|
||||||
|
|
||||||
|
seedrandom@3.0.5: {}
|
||||||
|
|
||||||
set-cookie-parser@3.0.1: {}
|
set-cookie-parser@3.0.1: {}
|
||||||
|
|
||||||
sirv@3.0.2:
|
sirv@3.0.2:
|
||||||
@ -1379,6 +1437,8 @@ snapshots:
|
|||||||
|
|
||||||
tapable@2.3.0: {}
|
tapable@2.3.0: {}
|
||||||
|
|
||||||
|
tiny-emitter@2.1.0: {}
|
||||||
|
|
||||||
tinyglobby@0.2.15:
|
tinyglobby@0.2.15:
|
||||||
dependencies:
|
dependencies:
|
||||||
fdir: 6.5.0(picomatch@4.0.3)
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
@ -1386,6 +1446,8 @@ snapshots:
|
|||||||
|
|
||||||
totalist@3.0.1: {}
|
totalist@3.0.1: {}
|
||||||
|
|
||||||
|
typed-function@4.2.2: {}
|
||||||
|
|
||||||
update-browserslist-db@1.2.3(browserslist@4.28.1):
|
update-browserslist-db@1.2.3(browserslist@4.28.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.28.1
|
browserslist: 4.28.1
|
||||||
|
|||||||
@ -4,15 +4,19 @@ import { get, post, patch, del } from './client.js';
|
|||||||
* @typedef {import('$lib/types/rules.types.js').RuleDef} RuleDef
|
* @typedef {import('$lib/types/rules.types.js').RuleDef} RuleDef
|
||||||
* @typedef {import('$lib/types/rules.types.js').RuleAction} RuleAction
|
* @typedef {import('$lib/types/rules.types.js').RuleAction} RuleAction
|
||||||
* @typedef {import('$lib/types/rules.types.js').RuleWithActions} RuleWithActions
|
* @typedef {import('$lib/types/rules.types.js').RuleWithActions} RuleWithActions
|
||||||
|
* @typedef {import('$lib/types/rules.types.js').RuleWithLinks} RuleWithLinks
|
||||||
|
* @typedef {import('$lib/types/rules.types.js').LinkedTestSummary} LinkedTestSummary
|
||||||
* @typedef {import('$lib/types/rules.types.js').RulesListResponse} RulesListResponse
|
* @typedef {import('$lib/types/rules.types.js').RulesListResponse} RulesListResponse
|
||||||
* @typedef {import('$lib/types/rules.types.js').RuleDetailResponse} RuleDetailResponse
|
* @typedef {import('$lib/types/rules.types.js').RuleDetailResponse} RuleDetailResponse
|
||||||
* @typedef {import('$lib/types/rules.types.js').RuleActionsListResponse} RuleActionsListResponse
|
* @typedef {import('$lib/types/rules.types.js').RuleActionsListResponse} RuleActionsListResponse
|
||||||
* @typedef {import('$lib/types/rules.types.js').ValidateExprResponse} ValidateExprResponse
|
* @typedef {import('$lib/types/rules.types.js').ValidateExprResponse} ValidateExprResponse
|
||||||
|
* @typedef {import('$lib/types/rules.types.js').TestRuleMapping} TestRuleMapping
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List rules
|
* List rules
|
||||||
* @param {{ EventCode?: string, Active?: 0|1|number, ScopeType?: string, TestSiteID?: number, search?: string }} [params]
|
* Filter by TestSiteID to see rules linked to a specific test
|
||||||
|
* @param {{ EventCode?: string, Active?: 0|1|number, TestSiteID?: number, search?: string }} [params]
|
||||||
* @returns {Promise<RulesListResponse>}
|
* @returns {Promise<RulesListResponse>}
|
||||||
*/
|
*/
|
||||||
export async function fetchRules(params = {}) {
|
export async function fetchRules(params = {}) {
|
||||||
@ -21,7 +25,7 @@ export async function fetchRules(params = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get rule (with actions)
|
* Get rule (with actions and linked tests)
|
||||||
* @param {number} id
|
* @param {number} id
|
||||||
* @returns {Promise<RuleDetailResponse>}
|
* @returns {Promise<RuleDetailResponse>}
|
||||||
*/
|
*/
|
||||||
@ -30,8 +34,8 @@ export async function fetchRule(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a rule; optionally include initial actions
|
* Create a rule with test mappings and optional initial actions
|
||||||
* @param {Partial<RuleDef> & { actions?: Array<Partial<RuleAction>> }} payload
|
* @param {Partial<RuleDef> & { TestSiteIDs: number[], actions?: Array<Partial<RuleAction>> }} payload
|
||||||
* @returns {Promise<any>}
|
* @returns {Promise<any>}
|
||||||
*/
|
*/
|
||||||
export async function createRule(payload) {
|
export async function createRule(payload) {
|
||||||
@ -39,9 +43,9 @@ export async function createRule(payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a rule
|
* Update a rule and its test mappings
|
||||||
* @param {number} id
|
* @param {number} id
|
||||||
* @param {Partial<RuleDef>} payload
|
* @param {Partial<RuleDef> & { TestSiteIDs?: number[] }} payload
|
||||||
* @returns {Promise<any>}
|
* @returns {Promise<any>}
|
||||||
*/
|
*/
|
||||||
export async function updateRule(id, payload) {
|
export async function updateRule(id, payload) {
|
||||||
@ -57,6 +61,26 @@ export async function deleteRule(id) {
|
|||||||
return del(`/api/rules/${id}`);
|
return del(`/api/rules/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link a test to a rule (many-to-many mapping)
|
||||||
|
* @param {number} ruleId
|
||||||
|
* @param {number} testSiteId
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
export async function linkTestToRule(ruleId, testSiteId) {
|
||||||
|
return post(`/api/rules/${ruleId}/link`, { TestSiteID: testSiteId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlink a test from a rule
|
||||||
|
* @param {number} ruleId
|
||||||
|
* @param {number} testSiteId
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
export async function unlinkTestFromRule(ruleId, testSiteId) {
|
||||||
|
return post(`/api/rules/${ruleId}/unlink`, { TestSiteID: testSiteId });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate/evaluate an expression
|
* Validate/evaluate an expression
|
||||||
* @param {string} expr
|
* @param {string} expr
|
||||||
@ -67,6 +91,15 @@ export async function validateExpression(expr, context = {}) {
|
|||||||
return post('/api/rules/validate', { expr, context });
|
return post('/api/rules/validate', { expr, context });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile a DSL expression into compiled payload
|
||||||
|
* @param {string} expr
|
||||||
|
* @returns {Promise<{ compiled: any; conditionExprCompiled: string }>}
|
||||||
|
*/
|
||||||
|
export async function compileRuleExpr(expr) {
|
||||||
|
return post('/api/rules/compile', { expr });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List actions for a rule
|
* List actions for a rule
|
||||||
* @param {number} id
|
* @param {number} id
|
||||||
|
|||||||
@ -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
|
* Rules Engine Type Definitions
|
||||||
* Based on CLQMS API Specification
|
* Based on CLQMS API Specification - Refactored Rule System
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type RuleEventCode = 'ORDER_CREATED';
|
export type RuleEventCode = 'ORDER_CREATED';
|
||||||
export type RuleScopeType = 'GLOBAL' | 'TESTSITE';
|
|
||||||
export type RuleActionType = 'SET_RESULT';
|
export type RuleActionType = 'SET_RESULT';
|
||||||
|
|
||||||
export interface RuleDef {
|
export interface RuleDef {
|
||||||
RuleID: number;
|
RuleID: number;
|
||||||
Name: string;
|
RuleCode: string;
|
||||||
|
RuleName: string;
|
||||||
Description?: string;
|
Description?: string;
|
||||||
EventCode: RuleEventCode | string;
|
EventCode: RuleEventCode | string;
|
||||||
ScopeType: RuleScopeType;
|
|
||||||
TestSiteID?: number | null;
|
|
||||||
ConditionExpr?: string | null;
|
ConditionExpr?: string | null;
|
||||||
|
ConditionExprCompiled?: string | null;
|
||||||
Priority?: number;
|
Priority?: number;
|
||||||
Active: 0 | 1 | number;
|
Active: 0 | 1 | number;
|
||||||
|
/** ISO timestamp strings */
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
deletedAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuleAction {
|
export interface RuleAction {
|
||||||
@ -25,11 +28,9 @@ export interface RuleAction {
|
|||||||
Seq: number;
|
Seq: number;
|
||||||
ActionType: RuleActionType | string;
|
ActionType: RuleActionType | string;
|
||||||
ActionParams: string | Record<string, any>;
|
ActionParams: string | Record<string, any>;
|
||||||
}
|
/** ISO timestamp strings */
|
||||||
|
createdAt?: string;
|
||||||
export interface RuleWithActions {
|
updatedAt?: string;
|
||||||
rule: RuleDef;
|
|
||||||
actions: RuleAction[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiResponse<T> {
|
export interface ApiResponse<T> {
|
||||||
@ -39,11 +40,15 @@ export interface ApiResponse<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RulesListResponse extends ApiResponse<RuleDef[]> {}
|
export interface RulesListResponse extends ApiResponse<RuleDef[]> {}
|
||||||
export interface RuleDetailResponse extends ApiResponse<RuleWithActions> {}
|
export interface RuleDetailResponse extends ApiResponse<RuleDef> {}
|
||||||
export interface RuleActionsListResponse extends ApiResponse<RuleAction[]> {}
|
|
||||||
|
|
||||||
export interface ValidateExprResponse extends ApiResponse<{ valid: boolean; result?: any; error?: string }> {}
|
export interface ValidateExprResponse extends ApiResponse<{ valid: boolean; result?: any; error?: string }> {}
|
||||||
|
|
||||||
|
export interface CompileExprResponse {
|
||||||
|
compiled: any;
|
||||||
|
conditionExprCompiled: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type SetResultActionParams = {
|
export type SetResultActionParams = {
|
||||||
testSiteID?: number;
|
testSiteID?: number;
|
||||||
testSiteCode?: string;
|
testSiteCode?: string;
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
import MappingsTab from './tabs/MappingsTab.svelte';
|
import MappingsTab from './tabs/MappingsTab.svelte';
|
||||||
import RefNumTab from './tabs/RefNumTab.svelte';
|
import RefNumTab from './tabs/RefNumTab.svelte';
|
||||||
import RefTxtTab from './tabs/RefTxtTab.svelte';
|
import RefTxtTab from './tabs/RefTxtTab.svelte';
|
||||||
import ThresholdTab from './tabs/ThresholdTab.svelte';
|
import ThresholdTab from './tabs/ThresholdTab.svelte';
|
||||||
|
|
||||||
let { open = $bindable(false), mode = 'create', testId = null, initialTestType = 'TEST', disciplines = [], departments = [], tests = [], onsave = null } = $props();
|
let { open = $bindable(false), mode = 'create', testId = null, initialTestType = 'TEST', disciplines = [], departments = [], tests = [], onsave = null } = $props();
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { Plus, Trash2, Box, Search, WandSparkles } from 'lucide-svelte';
|
import { Plus, Trash2, Box, Search } from 'lucide-svelte';
|
||||||
|
|
||||||
let { formData = $bindable(), tests = [], isDirty = $bindable(false), validationErrors = {} } = $props();
|
let { formData = $bindable(), tests = [], isDirty = $bindable(false), validationErrors = {} } = $props();
|
||||||
|
|
||||||
@ -23,6 +23,11 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const availableTests = $derived.by(() => {
|
const availableTests = $derived.by(() => {
|
||||||
|
const query = searchQuery.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const selectedIds = new Set(members.map((member) => Number(member.TestSiteID)));
|
const selectedIds = new Set(members.map((member) => Number(member.TestSiteID)));
|
||||||
|
|
||||||
let filtered = tests.filter((test) =>
|
let filtered = tests.filter((test) =>
|
||||||
@ -32,19 +37,14 @@
|
|||||||
test.IsActive !== 0
|
test.IsActive !== 0
|
||||||
);
|
);
|
||||||
|
|
||||||
if (searchQuery.trim()) {
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
filtered = filtered.filter((test) =>
|
filtered = filtered.filter((test) =>
|
||||||
test.TestSiteCode?.toLowerCase().includes(query) ||
|
test.TestSiteCode?.toLowerCase().includes(query) ||
|
||||||
test.TestSiteName?.toLowerCase().includes(query)
|
test.TestSiteName?.toLowerCase().includes(query)
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return filtered.sort((a, b) => parseInt(a.SeqScr || 0) - parseInt(b.SeqScr || 0));
|
return filtered.sort((a, b) => parseInt(a.SeqScr || 0) - parseInt(b.SeqScr || 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
const formulaTokens = $derived(tokenizeBraceRefs(formData?.details?.FormulaCode || ''));
|
|
||||||
|
|
||||||
const formulaSyntax = $derived.by(() => getFormulaSyntaxStatus(formData?.details?.FormulaCode || ''));
|
const formulaSyntax = $derived.by(() => getFormulaSyntaxStatus(formData?.details?.FormulaCode || ''));
|
||||||
|
|
||||||
const missingMemberCodes = $derived.by(() =>
|
const missingMemberCodes = $derived.by(() =>
|
||||||
@ -129,134 +129,151 @@
|
|||||||
|
|
||||||
handleFieldChange();
|
handleFieldChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
function prettifyFormula() {
|
|
||||||
const original = formData?.details?.FormulaCode || '';
|
|
||||||
if (!original.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = [];
|
|
||||||
let normalized = original.replace(/\{[^{}]+\}/g, (match) => {
|
|
||||||
const index = tokens.push(match) - 1;
|
|
||||||
return `__TOKEN_${index}__`;
|
|
||||||
});
|
|
||||||
|
|
||||||
normalized = normalized
|
|
||||||
.replace(/[\r\n\t]+/g, ' ')
|
|
||||||
.replace(/\s*([+\-*/])\s*/g, ' $1 ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
normalized = normalized.replace(/__TOKEN_(\d+)__/g, (_, index) => tokens[Number(index)]);
|
|
||||||
|
|
||||||
if (normalized !== original) {
|
|
||||||
formData.details.FormulaCode = normalized;
|
|
||||||
handleFieldChange();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h2 class="text-lg font-semibold text-gray-800">Calculated Test Formula</h2>
|
<h2 class="text-lg font-semibold text-gray-800">Calculated Test Formula</h2>
|
||||||
|
|
||||||
<div class="alert alert-info text-sm">
|
<div class="alert alert-info text-sm py-2">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-4 w-4" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-4 w-4" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||||
<div>
|
<div>
|
||||||
<strong>Formula Syntax:</strong> Use curly braces to reference test codes, e.g., <code class="code">{'{HGB}'} + {'{MCV}'}</code>
|
<strong>Formula Syntax:</strong> Use curly braces to reference test codes, e.g., <code class="code">{'{HGB}'} + {'{MCV}'}</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 h-[520px] overflow-hidden">
|
<!-- Formula Code -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<label for="formulaCode" class="text-sm font-medium text-gray-700">
|
||||||
|
Formula Code <span class="text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="text-xs">
|
||||||
|
{#if formulaSyntax.tone === 'success'}
|
||||||
|
<span class="badge badge-success badge-outline badge-sm">{formulaSyntax.text}</span>
|
||||||
|
{:else if formulaSyntax.tone === 'warning'}
|
||||||
|
<span class="badge badge-warning badge-outline badge-sm">{formulaSyntax.text}</span>
|
||||||
|
{:else if formulaSyntax.tone === 'error'}
|
||||||
|
<span class="badge badge-error badge-outline badge-sm">{formulaSyntax.text}</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-gray-500">{formulaSyntax.text}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="formulaCode"
|
||||||
|
class="textarea textarea-sm textarea-bordered w-full font-mono text-sm"
|
||||||
|
bind:value={formData.details.FormulaCode}
|
||||||
|
placeholder="e.g., {'{HGB}'} + {'{MCV}'} + {'{MCHC}'}"
|
||||||
|
rows="2"
|
||||||
|
oninput={handleFieldChange}
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
{#if validationErrors.FormulaCode}
|
||||||
|
<p class="text-error text-xs">{validationErrors.FormulaCode}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if members.length > 0 && missingMemberCodes.length > 0}
|
||||||
|
<p class="text-warning text-xs">
|
||||||
|
Missing member references: {missingMemberCodes.map((code) => `{${code}}`).join(', ')}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 h-[380px] overflow-hidden">
|
||||||
<div class="flex flex-col border border-base-300 rounded-lg bg-base-100 overflow-hidden">
|
<div class="flex flex-col border border-base-300 rounded-lg bg-base-100 overflow-hidden">
|
||||||
<div class="p-3 border-b border-base-300 bg-base-200 rounded-t-lg shrink-0">
|
<div class="px-3 py-2 border-b border-base-300 bg-base-200 rounded-t-lg shrink-0">
|
||||||
<h3 class="text-sm font-medium text-gray-700 mb-2">Available Tests</h3>
|
<h3 class="text-xs font-medium text-gray-700 mb-1">Available Tests</h3>
|
||||||
<label class="input input-sm input-bordered flex items-center gap-2 w-full">
|
<label class="input input-xs input-bordered flex items-center gap-2 w-full">
|
||||||
<Search class="w-4 h-4 text-gray-400" />
|
<Search class="w-3.5 h-3.5 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="grow bg-transparent outline-none text-sm"
|
class="grow bg-transparent outline-none text-xs"
|
||||||
placeholder="Search by code or name..."
|
placeholder="Search by code or name..."
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-2 space-y-1 min-h-0">
|
<div class="flex-1 overflow-y-auto p-1.5 space-y-0.5 min-h-0">
|
||||||
{#if availableTests.length === 0}
|
{#if !searchQuery.trim()}
|
||||||
<div class="text-center py-8 text-gray-500">
|
<div class="text-center py-6 text-gray-500">
|
||||||
<Box class="w-10 h-10 mx-auto mb-2 opacity-50" />
|
<Box class="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||||
<p class="text-sm">No tests available</p>
|
<p class="text-xs">Start typing to search tests</p>
|
||||||
<p class="text-xs opacity-70">
|
<p class="text-[10px] opacity-70 mt-0.5">Search by test code or name</p>
|
||||||
{searchQuery ? 'Try a different search term' : 'All tests are already added'}
|
</div>
|
||||||
</p>
|
{:else if availableTests.length === 0}
|
||||||
|
<div class="text-center py-6 text-gray-500">
|
||||||
|
<Box class="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p class="text-xs">No tests found</p>
|
||||||
|
<p class="text-[10px] opacity-70 mt-0.5">Try a different search term</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each availableTests as test (test.TestSiteID)}
|
{#each availableTests as test (test.TestSiteID)}
|
||||||
<div class="flex items-center justify-between p-2 hover:bg-base-200 rounded-md group">
|
<div class="flex items-center justify-between px-1.5 py-1 hover:bg-base-200 rounded-md group">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-1.5">
|
||||||
<span class="font-mono text-xs text-gray-500 w-8">{test.SeqScr || '-'}</span>
|
<span class="font-mono text-[10px] text-gray-500 w-6">{test.SeqScr || '-'}</span>
|
||||||
<span class="font-mono text-sm font-medium truncate">{test.TestSiteCode}</span>
|
<span class="font-mono text-xs font-medium truncate">{test.TestSiteCode}</span>
|
||||||
<span class="badge badge-xs badge-ghost">{test.TestType}</span>
|
<span class="badge badge-xs badge-ghost scale-90 origin-left">{test.TestType}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-600 truncate pl-10">{test.TestSiteName}</p>
|
<p class="text-[11px] text-gray-600 truncate pl-7">{test.TestSiteName}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
class="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 transition-opacity h-6 min-h-0 px-1"
|
||||||
onclick={() => addMember(test)}
|
onclick={() => addMember(test)}
|
||||||
title="Add to calculation"
|
title="Add to calculation"
|
||||||
>
|
>
|
||||||
<Plus class="w-4 h-4 text-primary" />
|
<Plus class="w-3.5 h-3.5 text-primary" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-2 border-t border-base-300 text-xs text-gray-500 text-center shrink-0">
|
<div class="px-2 py-1 border-t border-base-300 text-[10px] text-gray-500 text-center shrink-0">
|
||||||
{availableTests.length} tests available
|
{availableTests.length} tests available
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col border border-base-300 rounded-lg bg-base-100 overflow-hidden">
|
<div class="flex flex-col border border-base-300 rounded-lg bg-base-100 overflow-hidden">
|
||||||
<div class="p-3 border-b border-base-300 bg-base-200 rounded-t-lg shrink-0">
|
<div class="px-3 py-2 border-b border-base-300 bg-base-200 rounded-t-lg shrink-0">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<h3 class="text-sm font-medium text-gray-700">Selected Members</h3>
|
<h3 class="text-xs font-medium text-gray-700">Selected Members</h3>
|
||||||
<span class="badge badge-sm badge-ghost">{members.length} selected</span>
|
<span class="badge badge-xs badge-ghost">{members.length} selected</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto min-h-0">
|
<div class="flex-1 overflow-y-auto min-h-0">
|
||||||
{#if members.length === 0}
|
{#if members.length === 0}
|
||||||
<div class="flex flex-col items-center justify-center h-full text-gray-500 py-8">
|
<div class="flex flex-col items-center justify-center h-full text-gray-500 py-6">
|
||||||
<Box class="w-12 h-12 mb-3 opacity-50" />
|
<Box class="w-8 h-8 mb-2 opacity-50" />
|
||||||
<p class="text-sm font-medium">No members selected</p>
|
<p class="text-xs font-medium">No members selected</p>
|
||||||
<p class="text-xs opacity-70 mt-1">Click the + button on available tests to add them</p>
|
<p class="text-[10px] opacity-70 mt-0.5">Search and add tests from the left panel</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<table class="table table-sm w-full">
|
<table class="table table-xs w-full">
|
||||||
<thead class="sticky top-0 bg-base-200">
|
<thead class="sticky top-0 bg-base-200">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-12 text-center text-xs">Seq</th>
|
<th class="w-10 text-center text-[10px] py-1">Seq</th>
|
||||||
<th class="w-20 text-xs">Code</th>
|
<th class="w-16 text-[10px] py-1">Code</th>
|
||||||
<th class="text-xs">Name</th>
|
<th class="text-[10px] py-1">Name</th>
|
||||||
<th class="w-16 text-xs">Type</th>
|
<th class="w-12 text-[10px] py-1">Type</th>
|
||||||
<th class="w-10 text-center text-xs"></th>
|
<th class="w-8 text-center text-[10px] py-1"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each members as member (member.TestSiteID)}
|
{#each members as member (member.TestSiteID)}
|
||||||
<tr class="hover:bg-base-200">
|
<tr class="hover:bg-base-200">
|
||||||
<td class="text-center font-mono text-xs text-gray-600">{member.SeqScr}</td>
|
<td class="text-center font-mono text-[10px] text-gray-600 py-1">{member.SeqScr}</td>
|
||||||
<td class="font-mono text-xs">{member.TestSiteCode}</td>
|
<td class="font-mono text-[10px] py-1">{member.TestSiteCode}</td>
|
||||||
<td class="text-xs truncate max-w-[150px]" title={member.TestSiteName}>
|
<td class="text-[10px] truncate max-w-[120px] py-1" title={member.TestSiteName}>
|
||||||
{member.TestSiteName}
|
{member.TestSiteName}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="py-1">
|
||||||
<span class="badge badge-xs badge-ghost">{member.TestType}</span>
|
<span class="badge badge-xs badge-ghost scale-90 origin-left">{member.TestType}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center py-1">
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-xs text-error p-0 min-h-0 h-auto"
|
class="btn btn-ghost btn-xs text-error p-0 min-h-0 h-auto"
|
||||||
onclick={() => removeMember(member.TestSiteID)}
|
onclick={() => removeMember(member.TestSiteID)}
|
||||||
@ -272,59 +289,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-2 border-t border-base-300 text-xs text-gray-500 shrink-0">
|
<div class="px-2 py-1 border-t border-base-300 text-[10px] text-gray-500 shrink-0">
|
||||||
<p>Use {'{CODE}'} tokens from these members in your formula.</p>
|
<p>Use {'{CODE}'} tokens from these members in your formula.</p>
|
||||||
{#if validationErrors.members}
|
{#if validationErrors.members}
|
||||||
<p class="text-error mt-1">{validationErrors.members}</p>
|
<p class="text-error mt-0.5">{validationErrors.members}</p>
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Formula Definition -->
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">Formula Definition</h3>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<label for="formulaCode" class="block text-sm font-medium text-gray-700">
|
|
||||||
Formula Code <span class="text-error">*</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="formulaCode"
|
|
||||||
class="textarea textarea-sm textarea-bordered w-full font-mono text-sm"
|
|
||||||
bind:value={formData.details.FormulaCode}
|
|
||||||
placeholder="e.g., {'{HGB}'} + {'{MCV}'} + {'{MCHC}'}"
|
|
||||||
rows="4"
|
|
||||||
oninput={handleFieldChange}
|
|
||||||
required
|
|
||||||
></textarea>
|
|
||||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
|
||||||
{#if formulaSyntax.tone === 'success'}
|
|
||||||
<span class="badge badge-success badge-outline">{formulaSyntax.text}</span>
|
|
||||||
{:else if formulaSyntax.tone === 'warning'}
|
|
||||||
<span class="badge badge-warning badge-outline">{formulaSyntax.text}</span>
|
|
||||||
{:else if formulaSyntax.tone === 'error'}
|
|
||||||
<span class="badge badge-error badge-outline">{formulaSyntax.text}</span>
|
|
||||||
{:else}
|
|
||||||
<span class="text-gray-500">{formulaSyntax.text}</span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<span class="badge badge-ghost badge-outline">{formulaTokens.length} token(s)</span>
|
|
||||||
|
|
||||||
<button class="btn btn-ghost btn-xs" type="button" onclick={prettifyFormula}>
|
|
||||||
<WandSparkles class="w-3.5 h-3.5" />
|
|
||||||
Prettify
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if validationErrors.FormulaCode}
|
|
||||||
<p class="text-error text-xs mt-1">{validationErrors.FormulaCode}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if members.length > 0 && missingMemberCodes.length > 0}
|
|
||||||
<p class="text-warning text-xs mt-1">
|
|
||||||
Missing member references: {missingMemberCodes.map((code) => `{${code}}`).join(', ')}
|
|
||||||
</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,12 +1,9 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import {
|
import {
|
||||||
Search,
|
|
||||||
Filter,
|
Filter,
|
||||||
FileText,
|
FileText,
|
||||||
AlertTriangle,
|
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
User,
|
User,
|
||||||
@ -14,7 +11,7 @@
|
|||||||
Edit3,
|
Edit3,
|
||||||
ClipboardList
|
ClipboardList
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { fetchOrders, getStatusInfo, getPriorityInfo } from '$lib/api/orders.js';
|
import { fetchOrders, fetchOrderById, getStatusInfo, getPriorityInfo } from '$lib/api/orders.js';
|
||||||
import { error as toastError, success as toastSuccess } from '$lib/utils/toast.js';
|
import { error as toastError, success as toastSuccess } from '$lib/utils/toast.js';
|
||||||
import ResultEntryModal from './ResultEntryModal.svelte';
|
import ResultEntryModal from './ResultEntryModal.svelte';
|
||||||
|
|
||||||
@ -33,14 +30,14 @@
|
|||||||
// Modal state
|
// Modal state
|
||||||
let selectedOrder = $state(null);
|
let selectedOrder = $state(null);
|
||||||
let showEntryModal = $state(false);
|
let showEntryModal = $state(false);
|
||||||
|
let detailLoadingOrderId = $state('');
|
||||||
|
|
||||||
async function loadOrders() {
|
async function loadOrders() {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
const params = {
|
const params = {
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
perPage: perPage,
|
perPage: perPage
|
||||||
include: 'details'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (filterOrderId) params.OrderID = filterOrderId;
|
if (filterOrderId) params.OrderID = filterOrderId;
|
||||||
@ -64,9 +61,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEnterResults(order) {
|
async function handleEnterResults(order) {
|
||||||
selectedOrder = order;
|
detailLoadingOrderId = order.OrderID;
|
||||||
|
selectedOrder = null;
|
||||||
|
showEntryModal = false;
|
||||||
|
try {
|
||||||
|
const response = await fetchOrderById(order.OrderID);
|
||||||
|
if (response.status === 'success' && response.data) {
|
||||||
|
selectedOrder = response.data;
|
||||||
showEntryModal = true;
|
showEntryModal = true;
|
||||||
|
} else {
|
||||||
|
toastError('Order details unavailable');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err.message || 'Failed to load order details');
|
||||||
|
} finally {
|
||||||
|
detailLoadingOrderId = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleResultsSaved() {
|
function handleResultsSaved() {
|
||||||
@ -203,8 +214,7 @@
|
|||||||
{#each orders as order (order.OrderID)}
|
{#each orders as order (order.OrderID)}
|
||||||
{@const statusInfo = getStatusInfo(order.OrderStatus)}
|
{@const statusInfo = getStatusInfo(order.OrderStatus)}
|
||||||
{@const priorityInfo = getPriorityInfo(order.Priority)}
|
{@const priorityInfo = getPriorityInfo(order.Priority)}
|
||||||
{@const testCount = order.Tests?.length || 0}
|
{@const hasTests = Array.isArray(order.Tests)}
|
||||||
{@const pendingCount = order.Tests?.filter(t => !t.Result || t.Result === '').length || 0}
|
|
||||||
<tr class="hover:bg-base-200/50 transition-colors">
|
<tr class="hover:bg-base-200/50 transition-colors">
|
||||||
<td class="text-xs font-mono font-medium">{order.OrderID}</td>
|
<td class="text-xs font-mono font-medium">{order.OrderID}</td>
|
||||||
<td class="text-xs">
|
<td class="text-xs">
|
||||||
@ -223,6 +233,9 @@
|
|||||||
<span class="badge {priorityInfo.color} badge-sm">{priorityInfo.label}</span>
|
<span class="badge {priorityInfo.color} badge-sm">{priorityInfo.label}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-xs">
|
<td class="text-xs">
|
||||||
|
{#if hasTests}
|
||||||
|
{@const testCount = order.Tests.length}
|
||||||
|
{@const pendingCount = order.Tests.filter(t => !t.Result || t.Result === '').length}
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span class="font-medium">{testCount}</span>
|
<span class="font-medium">{testCount}</span>
|
||||||
{#if pendingCount > 0}
|
{#if pendingCount > 0}
|
||||||
@ -231,15 +244,23 @@
|
|||||||
<CheckCircle2 class="w-3 h-3 text-success" />
|
<CheckCircle2 class="w-3 h-3 text-success" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<span class="text-xs text-base-content/50">Details only</span>
|
||||||
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
title="Enter Results"
|
title="Enter Results"
|
||||||
onclick={() => handleEnterResults(order)}
|
onclick={() => handleEnterResults(order)}
|
||||||
|
disabled={detailLoadingOrderId === order.OrderID}
|
||||||
>
|
>
|
||||||
<Edit3 class="w-3 h-3" />
|
<Edit3 class="w-3 h-3" />
|
||||||
|
{#if detailLoadingOrderId === order.OrderID}
|
||||||
|
Loading...
|
||||||
|
{:else}
|
||||||
Enter Results
|
Enter Results
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -14,11 +14,14 @@
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Copy,
|
Copy,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp
|
ChevronUp,
|
||||||
|
Calculator,
|
||||||
|
RefreshCw
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { updateResult } from '$lib/api/results.js';
|
import { updateResult } from '$lib/api/results.js';
|
||||||
import { error as toastError, success as toastSuccess } from '$lib/utils/toast.js';
|
import { error as toastError, success as toastSuccess } from '$lib/utils/toast.js';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
order = $bindable(),
|
order = $bindable(),
|
||||||
@ -30,6 +33,7 @@
|
|||||||
let results = $state([]);
|
let results = $state([]);
|
||||||
let formLoading = $state(false);
|
let formLoading = $state(false);
|
||||||
let saveProgress = $state({ current: 0, total: 0 });
|
let saveProgress = $state({ current: 0, total: 0 });
|
||||||
|
let calcLoading = $state(false);
|
||||||
|
|
||||||
// Expandable rows for comments
|
// Expandable rows for comments
|
||||||
let expandedRows = $state(new Set());
|
let expandedRows = $state(new Set());
|
||||||
@ -37,9 +41,38 @@
|
|||||||
// Keyboard shortcuts enabled
|
// Keyboard shortcuts enabled
|
||||||
let shortcutsEnabled = $state(true);
|
let shortcutsEnabled = $state(true);
|
||||||
|
|
||||||
|
// Guard to prevent repeated initialization
|
||||||
|
let initKey = $state('');
|
||||||
|
|
||||||
|
// Calculation engine state
|
||||||
|
/** @type {Map<number, string>} TestSiteID -> Result value */
|
||||||
|
let resultMapById = $state(new Map());
|
||||||
|
/** @type {Map<string, string>} TestSiteCode -> Result value */
|
||||||
|
let resultMapByCode = $state(new Map());
|
||||||
|
/** @type {Map<number, CalcDef>} TestSiteID -> Calculation definition */
|
||||||
|
let calcDefs = $state(new Map());
|
||||||
|
/** @type {Map<number, Set<number>>} Member TestSiteID -> Set of dependent CALC TestSiteIDs */
|
||||||
|
let dependencyGraph = $state(new Map());
|
||||||
|
|
||||||
|
/** @typedef {{ id: number, code: string, formula: string, members: Array<{id: number, code: string}>, decimal: number }} CalcDef */
|
||||||
|
|
||||||
// Initialize results when order changes
|
// Initialize results when order changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (order && open) {
|
if (order && open) {
|
||||||
|
// Guard: only initialize once per order/open combination
|
||||||
|
const currentKey = `${order.OrderID}-${open}`;
|
||||||
|
if (initKey === currentKey) return;
|
||||||
|
initKey = currentKey;
|
||||||
|
|
||||||
|
// Use untrack to prevent the effect from tracking internal state mutations
|
||||||
|
untrack(() => initializeResults());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize results and build calculation context
|
||||||
|
*/
|
||||||
|
function initializeResults() {
|
||||||
// Map tests to editable result entries
|
// Map tests to editable result entries
|
||||||
results = (order.Tests || []).map(test => ({
|
results = (order.Tests || []).map(test => ({
|
||||||
ResultID: test.ResultID,
|
ResultID: test.ResultID,
|
||||||
@ -63,12 +96,131 @@
|
|||||||
Comment: test.Comment || '',
|
Comment: test.Comment || '',
|
||||||
flag: calculateFlag(test.Result, test.Low, test.High),
|
flag: calculateFlag(test.Result, test.Low, test.High),
|
||||||
saved: false,
|
saved: false,
|
||||||
error: null
|
error: null,
|
||||||
|
warning: null,
|
||||||
|
// Dirty tracking
|
||||||
|
changedByUser: false,
|
||||||
|
changedByAutoCalc: false,
|
||||||
|
lastAutoCalcAt: null,
|
||||||
|
// Calc metadata
|
||||||
|
TestType: test.TestType,
|
||||||
|
FormulaCode: test.FormulaCode || null,
|
||||||
|
Decimal: test.Decimal ?? 2,
|
||||||
|
isCalculated: test.TestType === 'CALC' || (test.FormulaCode && test.FormulaCode.trim() !== ''),
|
||||||
|
// Group members for dependency tracking
|
||||||
|
testdefgrp: test.testdefgrp || []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
saveProgress = { current: 0, total: 0 };
|
saveProgress = { current: 0, total: 0 };
|
||||||
expandedRows = new Set();
|
expandedRows = new Set();
|
||||||
|
|
||||||
|
// Build calculation context
|
||||||
|
buildCalcContext();
|
||||||
|
|
||||||
|
console.log('Initialized results:', results.length, 'tests');
|
||||||
|
console.log('Calc defs:', calcDefs.size, 'calculated tests');
|
||||||
|
console.log('Calc test IDs:', Array.from(calcDefs.keys()));
|
||||||
|
|
||||||
|
// Trigger initial calculation for CALC tests
|
||||||
|
if (calcDefs.size > 0) {
|
||||||
|
console.log('Triggering initial calculation...');
|
||||||
|
setTimeout(() => recalculateAll(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build calculation context: result maps, calc definitions, dependency graph
|
||||||
|
*/
|
||||||
|
function buildCalcContext() {
|
||||||
|
resultMapById = new Map();
|
||||||
|
resultMapByCode = new Map();
|
||||||
|
calcDefs = new Map();
|
||||||
|
dependencyGraph = new Map();
|
||||||
|
|
||||||
|
// Build result lookup maps
|
||||||
|
for (const row of results) {
|
||||||
|
resultMapById.set(row.TestSiteID, row.Result ?? '');
|
||||||
|
if (row.TestSiteCode) {
|
||||||
|
resultMapByCode.set(row.TestSiteCode, row.Result ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build calc definitions and dependency graph
|
||||||
|
console.log('buildCalcContext: checking', results.length, 'results');
|
||||||
|
for (const row of results) {
|
||||||
|
console.log(' Result:', row.TestSiteCode, 'TestType:', row.TestType, 'isCalculated:', row.isCalculated, 'FormulaCode:', row.FormulaCode);
|
||||||
|
if (!row.isCalculated) continue;
|
||||||
|
|
||||||
|
// Parse members from testdefgrp or extract from formula
|
||||||
|
const members = parseCalcMembers(row);
|
||||||
|
console.log(' Parsed members:', members);
|
||||||
|
|
||||||
|
const def = {
|
||||||
|
id: row.TestSiteID,
|
||||||
|
code: row.TestSiteCode,
|
||||||
|
formula: row.FormulaCode || '',
|
||||||
|
members,
|
||||||
|
decimal: Number.isFinite(+row.Decimal) ? +row.Decimal : 2
|
||||||
|
};
|
||||||
|
|
||||||
|
calcDefs.set(def.id, def);
|
||||||
|
console.log(' Added calc def for:', row.TestSiteCode);
|
||||||
|
|
||||||
|
// Build reverse dependency graph: member -> [calcs that depend on it]
|
||||||
|
for (const member of members) {
|
||||||
|
if (!dependencyGraph.has(member.id)) {
|
||||||
|
dependencyGraph.set(member.id, new Set());
|
||||||
|
}
|
||||||
|
dependencyGraph.get(member.id).add(def.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('buildCalcContext complete, calcDefs size:', calcDefs.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse calculation members from row data
|
||||||
|
* @param {Object} row - Result row
|
||||||
|
* @returns {Array<{id: number, code: string}>}
|
||||||
|
*/
|
||||||
|
function parseCalcMembers(row) {
|
||||||
|
// First try to get from testdefgrp
|
||||||
|
if (row.testdefgrp && row.testdefgrp.length > 0) {
|
||||||
|
return row.testdefgrp.map(m => ({
|
||||||
|
id: Number(m.TestSiteID),
|
||||||
|
code: m.TestSiteCode
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: extract from formula by finding matching test codes
|
||||||
|
const members = [];
|
||||||
|
const formula = row.FormulaCode || '';
|
||||||
|
const codePattern = /\b([A-Z][A-Z0-9_]*)\b/g;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = codePattern.exec(formula)) !== null) {
|
||||||
|
const code = match[1];
|
||||||
|
// Find test with this code in results
|
||||||
|
const test = results.find(r => r.TestSiteCode === code);
|
||||||
|
if (test) {
|
||||||
|
members.push({ id: test.TestSiteID, code: test.TestSiteCode });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get dependency hint text for a CALC test
|
||||||
|
* @param {Object} row - Result row
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
function getDependencyHint(row) {
|
||||||
|
if (!row.isCalculated) return null;
|
||||||
|
const def = calcDefs.get(row.TestSiteID);
|
||||||
|
if (!def || def.members.length === 0) return null;
|
||||||
|
const codes = def.members.map(m => m.code).join(', ');
|
||||||
|
return `from ${codes}`;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@ -80,7 +232,7 @@
|
|||||||
// Ctrl+S: Save all
|
// Ctrl+S: Save all
|
||||||
if (event.ctrlKey && event.key === 's') {
|
if (event.ctrlKey && event.key === 's') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!formLoading && pendingCount !== results.length) {
|
if (!formLoading) {
|
||||||
handleSaveAll();
|
handleSaveAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -112,6 +264,234 @@
|
|||||||
results[index].flag = calculateFlag(entry.Result, entry.Low, entry.High);
|
results[index].flag = calculateFlag(entry.Result, entry.Low, entry.High);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate all CALC tests (used for initial load)
|
||||||
|
*/
|
||||||
|
async function recalculateAll() {
|
||||||
|
console.log('recalculateAll called, calcDefs size:', calcDefs.size);
|
||||||
|
|
||||||
|
// Find all CALC tests that need computation
|
||||||
|
const calcsToCompute = [];
|
||||||
|
for (const [calcId, def] of calcDefs) {
|
||||||
|
const row = results.find(r => r.TestSiteID === calcId);
|
||||||
|
if (!row) {
|
||||||
|
console.log(' Row not found for calcId:', calcId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(' Checking calc test:', row.TestSiteCode, 'Result:', row.Result, 'changedByAutoCalc:', row.changedByAutoCalc);
|
||||||
|
|
||||||
|
// Only auto-fill if empty or was previously auto-calculated
|
||||||
|
if (!row.Result || row.Result === '' || row.changedByAutoCalc) {
|
||||||
|
calcsToCompute.push({ calcId, def, row });
|
||||||
|
console.log(' -> Will compute');
|
||||||
|
} else {
|
||||||
|
console.log(' -> Skipping (has manual value)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Total calcs to compute:', calcsToCompute.length);
|
||||||
|
|
||||||
|
if (calcsToCompute.length === 0) return;
|
||||||
|
|
||||||
|
await computeCalculations(calcsToCompute);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate dependent CALC tests starting from a changed test
|
||||||
|
* @param {number} changedTestSiteId - The test that changed
|
||||||
|
*/
|
||||||
|
async function recalculateFrom(changedTestSiteId) {
|
||||||
|
const queue = [changedTestSiteId];
|
||||||
|
const visited = new Set();
|
||||||
|
const calcsToCompute = [];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const currentId = queue.shift();
|
||||||
|
|
||||||
|
// Get calcs that depend on this test
|
||||||
|
const dependentCalcs = dependencyGraph.get(currentId);
|
||||||
|
if (!dependentCalcs) continue;
|
||||||
|
|
||||||
|
for (const calcId of dependentCalcs) {
|
||||||
|
if (visited.has(calcId)) continue; // Loop protection
|
||||||
|
visited.add(calcId);
|
||||||
|
|
||||||
|
const def = calcDefs.get(calcId);
|
||||||
|
const row = results.find(r => r.TestSiteID === calcId);
|
||||||
|
if (!def || !row) continue;
|
||||||
|
|
||||||
|
calcsToCompute.push({ calcId, def, row });
|
||||||
|
|
||||||
|
// This calc might be a dependency for other calcs, add to queue
|
||||||
|
queue.push(calcId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (calcsToCompute.length > 0) {
|
||||||
|
await computeCalculations(calcsToCompute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute multiple calculations via backend API
|
||||||
|
* @param {Array} calcsToCompute - Array of {calcId, def, row}
|
||||||
|
*/
|
||||||
|
async function computeCalculations(calcsToCompute) {
|
||||||
|
calcLoading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build batch request
|
||||||
|
const calculations = calcsToCompute.map(({ def }) => {
|
||||||
|
// Collect member values
|
||||||
|
const values = {};
|
||||||
|
let incomplete = false;
|
||||||
|
|
||||||
|
for (const member of def.members) {
|
||||||
|
const rawValue = resultMapById.get(member.id);
|
||||||
|
if (rawValue === '' || rawValue == null) {
|
||||||
|
incomplete = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const numValue = parseFloat(rawValue);
|
||||||
|
if (!Number.isFinite(numValue)) {
|
||||||
|
incomplete = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
values[member.code] = numValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
testSiteId: def.id,
|
||||||
|
formula: def.formula,
|
||||||
|
values,
|
||||||
|
decimal: def.decimal,
|
||||||
|
incomplete
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter out incomplete calculations
|
||||||
|
const validCalculations = calculations.filter(c => !c.incomplete);
|
||||||
|
|
||||||
|
if (validCalculations.length === 0) {
|
||||||
|
// Clear results for incomplete calculations
|
||||||
|
for (const { row } of calcsToCompute) {
|
||||||
|
const index = results.findIndex(r => r.TestSiteID === row.TestSiteID);
|
||||||
|
if (index !== -1) {
|
||||||
|
results[index].Result = '';
|
||||||
|
results[index].changedByAutoCalc = true;
|
||||||
|
results[index].lastAutoCalcAt = Date.now();
|
||||||
|
results[index].warning = 'Missing dependency values';
|
||||||
|
updateResultFlag(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Replace with actual backend API call
|
||||||
|
// const response = await evaluateCalculations(validCalculations);
|
||||||
|
// For now, compute locally until backend is ready
|
||||||
|
const calcOutputs = computeLocally(validCalculations);
|
||||||
|
|
||||||
|
// Update results
|
||||||
|
for (const calcResult of calcOutputs) {
|
||||||
|
const index = results.findIndex(r => r.TestSiteID === calcResult.testSiteId);
|
||||||
|
if (index === -1) continue;
|
||||||
|
|
||||||
|
if (calcResult.error) {
|
||||||
|
results[index].warning = calcResult.error.message;
|
||||||
|
} else {
|
||||||
|
results[index].Result = String(calcResult.resultRounded);
|
||||||
|
results[index].changedByAutoCalc = true;
|
||||||
|
results[index].lastAutoCalcAt = Date.now();
|
||||||
|
results[index].warning = null;
|
||||||
|
updateResultFlag(index);
|
||||||
|
|
||||||
|
// Update lookup maps
|
||||||
|
resultMapById.set(calcResult.testSiteId, results[index].Result);
|
||||||
|
resultMapByCode.set(results[index].TestSiteCode, results[index].Result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Calculation error:', err);
|
||||||
|
toastError('Failed to compute calculated values');
|
||||||
|
} finally {
|
||||||
|
calcLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporary local computation until backend API is ready
|
||||||
|
* @param {Array} calculations - Calculations to compute
|
||||||
|
* @returns {Array} Computation results
|
||||||
|
*/
|
||||||
|
function computeLocally(calculations) {
|
||||||
|
return calculations.map(calc => {
|
||||||
|
try {
|
||||||
|
// Replace variable names with values (word boundary matching)
|
||||||
|
let expression = calc.formula;
|
||||||
|
for (const [code, value] of Object.entries(calc.values)) {
|
||||||
|
// Use word boundary regex to match exact variable names
|
||||||
|
const regex = new RegExp(`\\b${code}\\b`, 'g');
|
||||||
|
expression = expression.replace(regex, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate expression characters
|
||||||
|
if (!/^[\d\s+\-*/.()]+$/.test(expression)) {
|
||||||
|
return {
|
||||||
|
testSiteId: calc.testSiteId,
|
||||||
|
error: { type: 'INVALID_EXPRESSION', message: 'Invalid characters in formula' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate (temporary - will be replaced by backend)
|
||||||
|
const result = Function('return ' + expression)();
|
||||||
|
|
||||||
|
if (!Number.isFinite(result)) {
|
||||||
|
return {
|
||||||
|
testSiteId: calc.testSiteId,
|
||||||
|
error: { type: 'NON_FINITE', message: 'Result is not a valid number' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round to specified decimal places
|
||||||
|
const factor = Math.pow(10, calc.decimal);
|
||||||
|
const resultRounded = Math.round(result * factor) / factor;
|
||||||
|
|
||||||
|
return {
|
||||||
|
testSiteId: calc.testSiteId,
|
||||||
|
result,
|
||||||
|
resultRounded
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
testSiteId: calc.testSiteId,
|
||||||
|
error: { type: 'EVAL_ERROR', message: err.message || 'Formula evaluation failed' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle input change - update flag and trigger recalculation of dependent fields
|
||||||
|
*/
|
||||||
|
function handleInputChange(index) {
|
||||||
|
const row = results[index];
|
||||||
|
|
||||||
|
// Update dirty flag
|
||||||
|
results[index].changedByUser = true;
|
||||||
|
results[index].saved = false;
|
||||||
|
|
||||||
|
// Update lookup maps
|
||||||
|
resultMapById.set(row.TestSiteID, row.Result);
|
||||||
|
resultMapByCode.set(row.TestSiteCode, row.Result);
|
||||||
|
|
||||||
|
updateResultFlag(index);
|
||||||
|
|
||||||
|
// Recalculate dependent CALC fields
|
||||||
|
recalculateFrom(row.TestSiteID);
|
||||||
|
}
|
||||||
|
|
||||||
function getFlagColor(flag) {
|
function getFlagColor(flag) {
|
||||||
if (flag === 'H') return 'text-error';
|
if (flag === 'H') return 'text-error';
|
||||||
if (flag === 'L') return 'text-warning';
|
if (flag === 'L') return 'text-warning';
|
||||||
@ -165,6 +545,8 @@
|
|||||||
|
|
||||||
function copyPreviousResult(index) {
|
function copyPreviousResult(index) {
|
||||||
if (index === 0) return;
|
if (index === 0) return;
|
||||||
|
// Prevent copying to calculated tests
|
||||||
|
if (results[index].isCalculated) return;
|
||||||
const prevResult = results[index - 1].Result;
|
const prevResult = results[index - 1].Result;
|
||||||
if (prevResult) {
|
if (prevResult) {
|
||||||
results[index].Result = prevResult;
|
results[index].Result = prevResult;
|
||||||
@ -173,11 +555,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveAll() {
|
async function handleSaveAll() {
|
||||||
// Filter to only entries with values that haven't been saved yet
|
// Filter to dirty entries (user changed or auto-calc changed)
|
||||||
const entriesToSave = results.filter(r => r.Result && r.Result.trim() !== '' && !r.saved);
|
// Include cleared results (empty values) that were modified
|
||||||
|
const entriesToSave = results.filter(r =>
|
||||||
|
!r.saved &&
|
||||||
|
(r.changedByUser || r.changedByAutoCalc)
|
||||||
|
);
|
||||||
|
|
||||||
if (entriesToSave.length === 0) {
|
if (entriesToSave.length === 0) {
|
||||||
toastError('No results to save');
|
toastSuccess('Nothing to save');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -202,11 +588,13 @@
|
|||||||
const response = await updateResult(entry.ResultID, data);
|
const response = await updateResult(entry.ResultID, data);
|
||||||
|
|
||||||
if (response.status === 'success') {
|
if (response.status === 'success') {
|
||||||
// Mark as saved
|
// Mark as saved and clear dirty flags
|
||||||
const resultIndex = results.findIndex(r => r.ResultID === entry.ResultID);
|
const resultIndex = results.findIndex(r => r.ResultID === entry.ResultID);
|
||||||
if (resultIndex !== -1) {
|
if (resultIndex !== -1) {
|
||||||
results[resultIndex].saved = true;
|
results[resultIndex].saved = true;
|
||||||
results[resultIndex].error = null;
|
results[resultIndex].error = null;
|
||||||
|
results[resultIndex].changedByUser = false;
|
||||||
|
results[resultIndex].changedByAutoCalc = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Mark with error
|
// Mark with error
|
||||||
@ -260,12 +648,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alt+C: Copy previous result
|
// Alt+C: Copy previous result (disabled for calculated tests)
|
||||||
if (event.key === 'c' && event.altKey) {
|
if (event.key === 'c' && event.altKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (!results[index].isCalculated) {
|
||||||
copyPreviousResult(index);
|
copyPreviousResult(index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const pendingCount = $derived(results.filter(r => !r.Result || r.Result === '').length);
|
const pendingCount = $derived(results.filter(r => !r.Result || r.Result === '').length);
|
||||||
const savedCount = $derived(results.filter(r => r.saved).length);
|
const savedCount = $derived(results.filter(r => r.saved).length);
|
||||||
@ -366,7 +756,24 @@
|
|||||||
<td class="text-xs font-mono">{result.TestSiteCode}</td>
|
<td class="text-xs font-mono">{result.TestSiteCode}</td>
|
||||||
<td class="text-xs">
|
<td class="text-xs">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
<span>{result.TestSiteName}</span>
|
<span>{result.TestSiteName}</span>
|
||||||
|
{#if result.isCalculated}
|
||||||
|
<span class="badge badge-xs badge-primary" title="Auto-calculated: {result.FormulaCode}">CALC</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if result.isCalculated}
|
||||||
|
{@const hint = getDependencyHint(result)}
|
||||||
|
{#if hint}
|
||||||
|
<div class="text-xs text-primary/70">{hint}</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if result.warning}
|
||||||
|
<div class="text-warning text-xs flex items-center gap-1">
|
||||||
|
<AlertTriangle class="w-3 h-3" />
|
||||||
|
{result.warning}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if result.error}
|
{#if result.error}
|
||||||
<div class="text-error text-xs">{result.error}</div>
|
<div class="text-error text-xs">{result.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
@ -378,24 +785,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-1">
|
<td class="p-1">
|
||||||
<label class="input input-sm input-bordered flex items-center gap-1 w-full {getInputBg(result.flag)}">
|
<label class="input input-sm input-bordered flex items-center gap-1 w-full {getInputBg(result.flag)} {result.isCalculated ? 'border-primary/50 bg-primary/5' : ''}">
|
||||||
{#if result.flag === 'H'}
|
{#if result.isCalculated}
|
||||||
|
<Calculator class="w-3 h-3 text-primary flex-shrink-0" title="Auto-calculated" />
|
||||||
|
{:else if result.flag === 'H'}
|
||||||
<AlertTriangle class="w-3 h-3 text-error flex-shrink-0" />
|
<AlertTriangle class="w-3 h-3 text-error flex-shrink-0" />
|
||||||
{:else if result.flag === 'L'}
|
{:else if result.flag === 'L'}
|
||||||
<AlertTriangle class="w-3 h-3 text-warning flex-shrink-0" />
|
<AlertTriangle class="w-3 h-3 text-warning flex-shrink-0" />
|
||||||
{:else if result.Result}
|
{:else if result.Result}
|
||||||
<CheckCircle2 class="w-3 h-3 text-success flex-shrink-0" />
|
<CheckCircle2 class="w-3 h-3 text-success flex-shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if result.isCalculated}
|
||||||
|
<!-- CALC tests: read-only display with refresh button -->
|
||||||
|
<input
|
||||||
|
id="result-input-{index}"
|
||||||
|
type="text"
|
||||||
|
class="grow bg-transparent outline-none text-sm font-mono cursor-not-allowed"
|
||||||
|
placeholder="Auto"
|
||||||
|
value={result.Result}
|
||||||
|
readonly
|
||||||
|
disabled={formLoading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs p-0 min-h-0 h-auto"
|
||||||
|
onclick={() => recalculateFrom(result.TestSiteID)}
|
||||||
|
disabled={calcLoading || formLoading}
|
||||||
|
title="Recalculate"
|
||||||
|
>
|
||||||
|
<RefreshCw class="w-3 h-3 text-primary {calcLoading ? 'animate-spin' : ''}" />
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<!-- Regular tests: editable -->
|
||||||
<input
|
<input
|
||||||
id="result-input-{index}"
|
id="result-input-{index}"
|
||||||
type="text"
|
type="text"
|
||||||
class="grow bg-transparent outline-none text-sm font-mono"
|
class="grow bg-transparent outline-none text-sm font-mono"
|
||||||
placeholder="..."
|
placeholder="..."
|
||||||
bind:value={result.Result}
|
bind:value={result.Result}
|
||||||
oninput={() => updateResultFlag(index)}
|
oninput={() => handleInputChange(index)}
|
||||||
onkeydown={(e) => handleKeyDown(e, index)}
|
onkeydown={(e) => handleKeyDown(e, index)}
|
||||||
disabled={result.saved || formLoading}
|
disabled={result.saved || formLoading}
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
{#if result.Unit1}
|
{#if result.Unit1}
|
||||||
<span class="text-xs text-base-content/50 flex-shrink-0">{result.Unit1}</span>
|
<span class="text-xs text-base-content/50 flex-shrink-0">{result.Unit1}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@ -429,7 +860,7 @@
|
|||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-xs text-base-content/30 hover:text-primary"
|
class="btn btn-ghost btn-xs text-base-content/30 hover:text-primary"
|
||||||
onclick={() => copyPreviousResult(index)}
|
onclick={() => copyPreviousResult(index)}
|
||||||
disabled={index === 0}
|
disabled={index === 0 || result.isCalculated}
|
||||||
title="Copy previous result (Alt+C)"
|
title="Copy previous result (Alt+C)"
|
||||||
>
|
>
|
||||||
<Copy class="w-3 h-3" />
|
<Copy class="w-3 h-3" />
|
||||||
@ -516,7 +947,7 @@
|
|||||||
<button
|
<button
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
onclick={handleSaveAll}
|
onclick={handleSaveAll}
|
||||||
disabled={formLoading || pendingCount === results.length}
|
disabled={formLoading}
|
||||||
>
|
>
|
||||||
{#if formLoading}
|
{#if formLoading}
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { fetchRules, deleteRule } from '$lib/api/rules.js';
|
import { fetchRules } from '$lib/api/rules.js';
|
||||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
import { error as toastError } from '$lib/utils/toast.js';
|
||||||
import DataTable from '$lib/components/DataTable.svelte';
|
import DataTable from '$lib/components/DataTable.svelte';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import { Plus, Search, Edit2, ArrowLeft, Loader2, Filter, FileText } from 'lucide-svelte';
|
||||||
import TestSiteSearch from '$lib/components/rules/TestSiteSearch.svelte';
|
|
||||||
import { Plus, Search, Trash2, Edit2, ArrowLeft, Loader2, Filter, FileText } from 'lucide-svelte';
|
|
||||||
|
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let rules = $state([]);
|
let rules = $state([]);
|
||||||
@ -15,21 +13,14 @@
|
|||||||
|
|
||||||
let filterEventCode = $state('ORDER_CREATED');
|
let filterEventCode = $state('ORDER_CREATED');
|
||||||
let filterActive = $state('ALL');
|
let filterActive = $state('ALL');
|
||||||
let filterScopeType = $state('ALL');
|
|
||||||
let filterTestSite = $state(null);
|
|
||||||
|
|
||||||
let deleteConfirmOpen = $state(false);
|
|
||||||
let deleteItem = $state(null);
|
|
||||||
let deleting = $state(false);
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'Name', label: 'Name', class: 'font-medium min-w-[220px]' },
|
{ key: 'RuleCode', label: 'Code', class: 'font-medium w-36' },
|
||||||
{ key: 'EventCode', label: 'EventCode', class: 'w-36' },
|
{ key: 'RuleName', label: 'Name', class: 'font-medium min-w-[200px]' },
|
||||||
{ key: 'ScopeType', label: 'Scope', class: 'w-36' },
|
{ key: 'EventCode', label: 'Event', class: 'w-32' },
|
||||||
{ key: 'TestSiteID', label: 'TestSiteID', class: 'w-28' },
|
{ key: 'Priority', label: 'Priority', class: 'w-20 text-center' },
|
||||||
{ key: 'Priority', label: 'Priority', class: 'w-24 text-center' },
|
{ key: 'Active', label: 'Active', class: 'w-20 text-center' },
|
||||||
{ key: 'Active', label: 'Active', class: 'w-24 text-center' },
|
{ key: 'actions', label: 'Actions', class: 'w-20 text-center' },
|
||||||
{ key: 'actions', label: 'Actions', class: 'w-24 text-center' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function normalizeRulesListResponse(response) {
|
function normalizeRulesListResponse(response) {
|
||||||
@ -44,8 +35,6 @@
|
|||||||
|
|
||||||
if (filterEventCode) params.EventCode = filterEventCode;
|
if (filterEventCode) params.EventCode = filterEventCode;
|
||||||
if (filterActive !== 'ALL') params.Active = filterActive;
|
if (filterActive !== 'ALL') params.Active = filterActive;
|
||||||
if (filterScopeType !== 'ALL') params.ScopeType = filterScopeType;
|
|
||||||
if (filterTestSite?.TestSiteID) params.TestSiteID = filterTestSite.TestSiteID;
|
|
||||||
|
|
||||||
const s = searchName.trim();
|
const s = searchName.trim();
|
||||||
if (s) params.search = s;
|
if (s) params.search = s;
|
||||||
@ -77,24 +66,12 @@
|
|||||||
|
|
||||||
onMount(loadRules);
|
onMount(loadRules);
|
||||||
|
|
||||||
function confirmDelete(row) {
|
function openCreatePage() {
|
||||||
deleteItem = row;
|
goto('/rules/new');
|
||||||
deleteConfirmOpen = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
function openEditPage(row) {
|
||||||
if (!deleteItem?.RuleID) return;
|
goto(`/rules/${row.RuleID}`);
|
||||||
deleting = true;
|
|
||||||
try {
|
|
||||||
await deleteRule(deleteItem.RuleID);
|
|
||||||
toastSuccess('Rule deleted successfully');
|
|
||||||
deleteConfirmOpen = false;
|
|
||||||
await loadRules();
|
|
||||||
} catch (err) {
|
|
||||||
toastError(err?.message || 'Failed to delete rule');
|
|
||||||
} finally {
|
|
||||||
deleting = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActive(v) {
|
function isActive(v) {
|
||||||
@ -112,12 +89,12 @@
|
|||||||
<FileText class="w-5 h-5 text-gray-500" />
|
<FileText class="w-5 h-5 text-gray-500" />
|
||||||
Rules
|
Rules
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm text-gray-600">Manage rules and actions for ORDER_CREATED</p>
|
<p class="text-sm text-gray-600">Manage global rules with DSL compilation</p>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-primary" href="/rules/new">
|
<button class="btn btn-primary" onclick={openCreatePage}>
|
||||||
<Plus class="w-4 h-4 mr-2" />
|
<Plus class="w-4 h-4 mr-2" />
|
||||||
New Rule
|
New Rule
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4">
|
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4">
|
||||||
@ -126,7 +103,7 @@
|
|||||||
Filters
|
Filters
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-5 gap-3">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-semibold text-gray-600">EventCode</div>
|
<div class="text-xs font-semibold text-gray-600">EventCode</div>
|
||||||
<select class="select select-sm select-bordered w-full" bind:value={filterEventCode} disabled={true}>
|
<select class="select select-sm select-bordered w-full" bind:value={filterEventCode} disabled={true}>
|
||||||
@ -142,19 +119,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-semibold text-gray-600">ScopeType</div>
|
<div class="text-xs font-semibold text-gray-600">Search Rule</div>
|
||||||
<select class="select select-sm select-bordered w-full" bind:value={filterScopeType} onchange={loadRules}>
|
|
||||||
<option value="ALL">All</option>
|
|
||||||
<option value="GLOBAL">GLOBAL</option>
|
|
||||||
<option value="TESTSITE">TESTSITE</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-xs font-semibold text-gray-600">TestSite</div>
|
|
||||||
<TestSiteSearch bind:value={filterTestSite} placeholder="Optional" onSelect={loadRules} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-xs font-semibold text-gray-600">Rule Name</div>
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<label class="input input-sm input-bordered flex items-center gap-2 w-full focus-within:input-primary">
|
<label class="input input-sm input-bordered flex items-center gap-2 w-full focus-within:input-primary">
|
||||||
<Search class="w-4 h-4 text-gray-400" />
|
<Search class="w-4 h-4 text-gray-400" />
|
||||||
@ -189,9 +154,7 @@
|
|||||||
bordered={false}
|
bordered={false}
|
||||||
>
|
>
|
||||||
{#snippet cell({ column, row, value })}
|
{#snippet cell({ column, row, value })}
|
||||||
{#if column.key === 'ScopeType'}
|
{#if column.key === 'Active'}
|
||||||
<span class="badge badge-ghost badge-sm">{row.ScopeType || '-'}</span>
|
|
||||||
{:else if column.key === 'Active'}
|
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<span class="badge badge-sm {isActive(row.Active) ? 'badge-success' : 'badge-ghost'}">
|
<span class="badge badge-sm {isActive(row.Active) ? 'badge-success' : 'badge-ghost'}">
|
||||||
{isActive(row.Active) ? 'Yes' : 'No'}
|
{isActive(row.Active) ? 'Yes' : 'No'}
|
||||||
@ -203,20 +166,12 @@
|
|||||||
<div class="flex justify-center gap-2">
|
<div class="flex justify-center gap-2">
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm btn-ghost"
|
class="btn btn-sm btn-ghost"
|
||||||
onclick={() => goto(`/rules/${row.RuleID}`)}
|
onclick={() => openEditPage(row)}
|
||||||
title="Edit rule"
|
title="Edit rule"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Edit2 class="w-4 h-4" />
|
<Edit2 class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
class="btn btn-sm btn-ghost text-error"
|
|
||||||
onclick={() => confirmDelete(row)}
|
|
||||||
title="Delete rule"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Trash2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{value || '-'}
|
{value || '-'}
|
||||||
@ -225,22 +180,3 @@
|
|||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal bind:open={deleteConfirmOpen} title="Confirm Delete" size="sm">
|
|
||||||
<div class="py-2">
|
|
||||||
<p class="text-base-content/80">
|
|
||||||
Are you sure you want to delete <strong class="text-base-content">{deleteItem?.Name}</strong>?
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-500 mt-1">RuleID: {deleteItem?.RuleID}</p>
|
|
||||||
<p class="text-sm text-error mt-3">This action cannot be undone.</p>
|
|
||||||
</div>
|
|
||||||
{#snippet footer()}
|
|
||||||
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} type="button" disabled={deleting}>Cancel</button>
|
|
||||||
<button class="btn btn-error" onclick={handleDelete} disabled={deleting} type="button">
|
|
||||||
{#if deleting}
|
|
||||||
<span class="loading loading-spinner loading-sm mr-2"></span>
|
|
||||||
{/if}
|
|
||||||
{deleting ? 'Deleting...' : 'Delete'}
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
</Modal>
|
|
||||||
|
|||||||
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>
|
<script>
|
||||||
import { goto } from '$app/navigation';
|
import RuleFormPage from '$lib/components/rules/RuleFormPage.svelte';
|
||||||
import { createRule } from '$lib/api/rules.js';
|
|
||||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
|
||||||
import RuleForm from '$lib/components/rules/RuleForm.svelte';
|
|
||||||
import ActionsEditor from '$lib/components/rules/ActionsEditor.svelte';
|
|
||||||
import ExprTesterModal from '$lib/components/rules/ExprTesterModal.svelte';
|
|
||||||
import { ArrowLeft, Save, Play } from 'lucide-svelte';
|
|
||||||
|
|
||||||
let saving = $state(false);
|
|
||||||
let errors = $state({});
|
|
||||||
|
|
||||||
let ruleForm = $state({
|
|
||||||
Name: '',
|
|
||||||
Description: '',
|
|
||||||
EventCode: 'ORDER_CREATED',
|
|
||||||
ScopeType: 'GLOBAL',
|
|
||||||
TestSiteID: null,
|
|
||||||
ConditionExpr: '',
|
|
||||||
Priority: 0,
|
|
||||||
Active: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
let actions = $state([
|
|
||||||
{
|
|
||||||
Seq: 1,
|
|
||||||
ActionType: 'SET_RESULT',
|
|
||||||
ActionParams: {},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
let conditionTesterOpen = $state(false);
|
|
||||||
let conditionTesterContext = $state('{}');
|
|
||||||
|
|
||||||
function stringifyActionParams(actionParams) {
|
|
||||||
if (!actionParams) return '{}';
|
|
||||||
|
|
||||||
/** @type {any} */
|
|
||||||
let p = actionParams;
|
|
||||||
if (typeof p === 'string') {
|
|
||||||
try {
|
|
||||||
p = JSON.parse(p);
|
|
||||||
} catch {
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!p || typeof p !== 'object') return '{}';
|
|
||||||
|
|
||||||
const out = {};
|
|
||||||
if (p.testSiteID != null && p.testSiteID !== '') out.testSiteID = Number(p.testSiteID);
|
|
||||||
if (p.testSiteCode) out.testSiteCode = String(p.testSiteCode);
|
|
||||||
if (p.valueExpr) out.valueExpr = String(p.valueExpr);
|
|
||||||
if (!out.valueExpr && p.value !== undefined) out.value = p.value;
|
|
||||||
|
|
||||||
return JSON.stringify(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateForm() {
|
|
||||||
const next = {};
|
|
||||||
if (!ruleForm.Name?.trim()) next.Name = 'Name is required';
|
|
||||||
if (ruleForm.ScopeType === 'TESTSITE' && !ruleForm.TestSiteID) next.TestSiteID = 'TestSite is required for TESTSITE scope';
|
|
||||||
errors = next;
|
|
||||||
return Object.keys(next).length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractRuleId(response) {
|
|
||||||
const candidates = [
|
|
||||||
response?.data?.RuleID,
|
|
||||||
response?.data?.data?.RuleID,
|
|
||||||
response?.data?.rule?.RuleID,
|
|
||||||
response?.data?.data?.rule?.RuleID,
|
|
||||||
response?.RuleID,
|
|
||||||
];
|
|
||||||
for (const c of candidates) {
|
|
||||||
const n = Number(c);
|
|
||||||
if (Number.isFinite(n) && n > 0) return n;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
if (!validateForm()) return;
|
|
||||||
|
|
||||||
saving = true;
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
Name: ruleForm.Name?.trim(),
|
|
||||||
Description: ruleForm.Description || '',
|
|
||||||
EventCode: 'ORDER_CREATED',
|
|
||||||
ScopeType: ruleForm.ScopeType,
|
|
||||||
TestSiteID: ruleForm.ScopeType === 'TESTSITE' ? (ruleForm.TestSiteID ?? null) : null,
|
|
||||||
ConditionExpr: ruleForm.ConditionExpr?.trim() ? ruleForm.ConditionExpr.trim() : null,
|
|
||||||
Priority: Number(ruleForm.Priority) || 0,
|
|
||||||
Active: ruleForm.Active === 1 || ruleForm.Active === '1' || ruleForm.Active === true ? 1 : 0,
|
|
||||||
actions: (actions || []).map(a => ({
|
|
||||||
Seq: Number(a.Seq) || 0,
|
|
||||||
ActionType: a.ActionType || 'SET_RESULT',
|
|
||||||
ActionParams: stringifyActionParams(a.ActionParams),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await createRule(payload);
|
|
||||||
toastSuccess('Rule created successfully');
|
|
||||||
|
|
||||||
const newId = extractRuleId(response);
|
|
||||||
if (newId) {
|
|
||||||
goto(`/rules/${newId}`);
|
|
||||||
} else {
|
|
||||||
goto('/rules');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const message = err?.message || 'Failed to create rule';
|
|
||||||
toastError(message);
|
|
||||||
if (err?.messages && typeof err.messages === 'object') {
|
|
||||||
errors = err.messages;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-4">
|
<RuleFormPage mode="create" />
|
||||||
<div class="flex items-center gap-4 mb-6">
|
|
||||||
<a href="/rules" class="btn btn-ghost btn-circle">
|
|
||||||
<ArrowLeft class="w-5 h-5" />
|
|
||||||
</a>
|
|
||||||
<div class="flex-1">
|
|
||||||
<h1 class="text-xl font-bold text-gray-800">New Rule</h1>
|
|
||||||
<p class="text-sm text-gray-600">Create a rule and its initial actions</p>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary" onclick={handleSave} disabled={saving} type="button">
|
|
||||||
<Save class="w-4 h-4 mr-2" />
|
|
||||||
{saving ? 'Saving...' : 'Save'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4">
|
|
||||||
<RuleForm bind:value={ruleForm} {errors} disabled={saving} />
|
|
||||||
|
|
||||||
<div class="flex justify-end mt-4">
|
|
||||||
<button
|
|
||||||
class="btn btn-sm btn-ghost"
|
|
||||||
onclick={() => (conditionTesterOpen = true)}
|
|
||||||
disabled={!ruleForm.ConditionExpr?.trim()}
|
|
||||||
type="button"
|
|
||||||
title="Test condition expression"
|
|
||||||
>
|
|
||||||
<Play class="w-4 h-4 mr-2" />
|
|
||||||
Test Condition
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4">
|
|
||||||
<ActionsEditor bind:actions={actions} disabled={saving} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ExprTesterModal
|
|
||||||
bind:open={conditionTesterOpen}
|
|
||||||
bind:expr={ruleForm.ConditionExpr}
|
|
||||||
bind:context={conditionTesterContext}
|
|
||||||
title="Test Condition Expression"
|
|
||||||
/>
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user