@svgrid/enterprise 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +95 -81
  2. package/dist/cdn/svgrid-enterprise.svelte-external.js +22479 -10466
  3. package/dist/node/studio.js +11590 -2907
  4. package/package.json +31 -10
  5. package/src/SvAlertRuleEditor.svelte +294 -0
  6. package/src/SvAlertsManager.svelte +210 -0
  7. package/src/SvAlertsPanel.svelte +129 -0
  8. package/src/SvBoard.svelte +3 -1
  9. package/src/SvExpressionEditor.svelte +341 -0
  10. package/src/SvGridAlerts.dom.test.ts +113 -0
  11. package/src/SvGridAlerts.svelte +265 -0
  12. package/src/SvGridBoard.svelte +2355 -0
  13. package/src/SvGridEditPanel.svelte +849 -794
  14. package/src/SvGridScheduler.svelte +5334 -4410
  15. package/src/SvPivotDesigner.svelte +3 -3
  16. package/src/SvRecordDetail.svelte +6 -2
  17. package/src/SvSchedule.svelte +3 -1
  18. package/src/{ai-export-pdf.test.ts → ai-export-pdf.dom.test.ts} +4 -1
  19. package/src/{ai-export-xlsx.test.ts → ai-export-xlsx.dom.test.ts} +4 -1
  20. package/src/{ai-export.test.ts → ai-export.dom.test.ts} +5 -1
  21. package/src/alerts/alert-engine-attach.ts +165 -0
  22. package/src/alerts/alert-engine.test.ts +135 -0
  23. package/src/alerts/alert-engine.ts +260 -0
  24. package/src/alerts/alert-formats.test.ts +87 -0
  25. package/src/alerts/alert-formats.ts +77 -0
  26. package/src/alerts/alert-observer.test.ts +189 -0
  27. package/src/alerts/alert-observer.ts +208 -0
  28. package/src/alerts/alert-scheduler.ts +96 -0
  29. package/src/alerts/alert-storage.test.ts +54 -0
  30. package/src/alerts/alert-storage.ts +116 -0
  31. package/src/alerts/alert-store.svelte.ts +80 -0
  32. package/src/alerts/alert-types.ts +94 -0
  33. package/src/alerts.ts +28 -0
  34. package/src/board.dom.test.ts +941 -0
  35. package/src/board.ts +36 -0
  36. package/src/export.ts +13 -1
  37. package/src/expressions/evaluate.test.ts +111 -0
  38. package/src/expressions/evaluate.ts +228 -0
  39. package/src/expressions/expression-columns.ts +133 -0
  40. package/src/expressions/expression-types.ts +84 -0
  41. package/src/expressions/parse.test.ts +106 -0
  42. package/src/expressions/parse.ts +610 -0
  43. package/src/import.ts +2 -0
  44. package/src/index.ts +180 -46
  45. package/src/install.ts +12 -2
  46. package/src/pivot-enable.ts +44 -0
  47. package/src/scheduler-assignments.test.ts +97 -0
  48. package/src/scheduler-assignments.ts +134 -0
  49. package/src/scheduler-axis.test.ts +108 -0
  50. package/src/scheduler-axis.ts +238 -0
  51. package/src/scheduler-booking.test.ts +57 -0
  52. package/src/scheduler-booking.ts +63 -0
  53. package/src/scheduler-config.ts +179 -0
  54. package/src/scheduler-dependencies.test.ts +155 -0
  55. package/src/scheduler-dependencies.ts +223 -0
  56. package/src/scheduler-freebusy.test.ts +41 -0
  57. package/src/scheduler-freebusy.ts +36 -0
  58. package/src/scheduler-heatmap.test.ts +39 -0
  59. package/src/scheduler-heatmap.ts +55 -0
  60. package/src/scheduler-resource-tree.test.ts +84 -0
  61. package/src/scheduler-resource-tree.ts +107 -0
  62. package/src/scheduler-slots.test.ts +53 -0
  63. package/src/scheduler-slots.ts +94 -0
  64. package/src/scheduler-summary.test.ts +47 -0
  65. package/src/scheduler-summary.ts +81 -0
  66. package/src/sources/index.ts +1 -1
  67. package/src/sources/introspect-supabase.test.ts +13 -1
  68. package/src/sources/introspect-supabase.ts +20 -0
  69. package/src/studio/copilot-core.test.ts +45 -0
  70. package/src/studio/copilot-core.ts +65 -0
  71. package/src/studio/deploy-cli.test.ts +56 -0
  72. package/src/studio/deploy-cli.ts +100 -0
  73. package/src/studio/emit-project.test.ts +503 -18
  74. package/src/studio/emit-project.ts +829 -111
  75. package/src/studio/emit-schema.test.ts +17 -0
  76. package/src/studio/emit-schema.ts +166 -35
  77. package/src/studio/index.ts +25 -1
  78. package/src/studio/introspect-openapi.test.ts +84 -0
  79. package/src/studio/introspect-openapi.ts +252 -0
  80. package/src/studio/project.test.ts +57 -0
  81. package/src/studio/project.ts +193 -6
  82. package/src/studio/samples/crm.ts +282 -258
  83. package/src/studio/samples/fleet.ts +213 -186
  84. package/src/studio/samples/insurance.ts +223 -195
  85. package/src/studio/samples/inventory.ts +213 -182
  86. package/src/studio/samples/live-data.test.ts +99 -98
  87. package/src/studio/samples/live-data.ts +8 -10
  88. package/src/studio/samples/projects.ts +212 -190
  89. package/src/studio/samples/samples.test.ts +52 -0
  90. package/src/studio/samples/shared.ts +26 -6
  91. package/src/studio/samples/starter.ts +251 -0
  92. package/src/studio/samples/support.ts +209 -184
  93. package/src/studio/ui-components-surface.test.ts +185 -0
  94. package/src/studio/ui-components.generated.ts +5617 -0
  95. package/src/studio/ui-components.ts +641 -472
  96. package/src/sveltekit/sql-source.test.ts +13 -0
  97. package/src/sveltekit/sql-source.ts +11 -6
  98. package/src/upgrade-prompt.ts +2 -2
  99. package/src/watermark.ts +2 -2
  100. package/src/ai.test.ts +0 -522
  101. package/src/ai.ts +0 -1388
package/package.json CHANGED
@@ -4,8 +4,8 @@
4
4
  "type": "commercial",
5
5
  "url": "https://svgrid.com/pricing"
6
6
  },
7
- "version": "2.2.0",
8
- "description": "Pro feature pack for sv-grid: data export (Excel, PDF, CSV, TSV, HTML), import (xlsx, CSV, TSV, JSON), pivot table + designer, AI assistant (BYO model), and printing. Requires a paid license key.",
7
+ "version": "2.3.0",
8
+ "description": "Enterprise feature pack for SvGrid: Excel (xlsx), PDF, CSV, TSV and HTML export, Excel/CSV/JSON import, pivot tables with a drag-and-drop designer, paginated print, Kanban board and scheduler views. Requires a paid license key.",
9
9
  "license": "SEE LICENSE IN LICENSE",
10
10
  "author": "jQWidgets <sales@jqwidgets.com>",
11
11
  "homepage": "https://svgrid.com/pricing",
@@ -26,11 +26,6 @@
26
26
  "svelte": "./src/print.ts",
27
27
  "import": "./src/print.ts"
28
28
  },
29
- "./ai": {
30
- "types": "./src/ai.ts",
31
- "svelte": "./src/ai.ts",
32
- "import": "./src/ai.ts"
33
- },
34
29
  "./import": {
35
30
  "types": "./src/import.ts",
36
31
  "svelte": "./src/import.ts",
@@ -69,7 +64,7 @@
69
64
  "svelte": "^5.0.0",
70
65
  "jszip": "^3.10.0",
71
66
  "pdfmake": "^0.2.0",
72
- "@svgrid/grid": "^2.2.0"
67
+ "@svgrid/grid": "^2.2.27"
73
68
  },
74
69
  "peerDependenciesMeta": {
75
70
  "jszip": {
@@ -86,13 +81,39 @@
86
81
  "svelte": "^5.55.5",
87
82
  "typescript": "6.0.3",
88
83
  "vite": "^8.0.10",
89
- "@svgrid/grid": "^2.2.0"
84
+ "@svgrid/grid": "^2.2.27"
85
+ },
86
+ "repository": {
87
+ "type": "git",
88
+ "url": "git+https://github.com/sv-grid/sv-grid.git",
89
+ "directory": "packages/enterprise"
90
90
  },
91
+ "keywords": [
92
+ "svelte",
93
+ "svelte5",
94
+ "sveltekit",
95
+ "data grid",
96
+ "datagrid",
97
+ "data table",
98
+ "excel export",
99
+ "xlsx",
100
+ "pdf export",
101
+ "csv",
102
+ "pivot table",
103
+ "pivot",
104
+ "import",
105
+ "print",
106
+ "kanban",
107
+ "scheduler",
108
+ "svgrid",
109
+ "sv-grid"
110
+ ],
91
111
  "scripts": {
92
112
  "test": "vitest run",
93
113
  "test:types": "tsc -p tsconfig.json --noEmit",
94
114
  "build": "node ./scripts/build-cdn.mjs && node ./scripts/build-node.mjs",
95
115
  "build:cdn": "node ./scripts/build-cdn.mjs",
96
- "build:node": "node ./scripts/build-node.mjs"
116
+ "build:node": "node ./scripts/build-node.mjs",
117
+ "extract:ui-props": "node scripts/extract-ui-props.mjs"
97
118
  }
98
119
  }
@@ -0,0 +1,294 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvAlertRuleEditor - a modal that authors a single alert rule: name /
4
+ * severity / scope, the trigger (data change, relative change, validation,
5
+ * scheduled), the predicate (embedded SvExpressionEditor), and the actions
6
+ * (toast / badge / highlight / flash / prevent-edit / log). Reuses the grid
7
+ * UI kit so it matches the rest of the app.
8
+ */
9
+ import { SvModal, SvField, SvTextInput, SvDropDownList, SvColorInput, SvTagsInput } from '@svgrid/grid'
10
+ import SvExpressionEditor from './SvExpressionEditor.svelte'
11
+ import { CRON_PRESETS } from './scheduling'
12
+ import type { ExprColumn } from './expressions/expression-columns'
13
+ import type { PredicateExpr } from './expressions/expression-types'
14
+ import type {
15
+ AlertAction,
16
+ AlertActionKind,
17
+ AlertRule,
18
+ AlertScope,
19
+ AlertSeverity,
20
+ AlertTrigger,
21
+ } from './alerts/alert-types'
22
+
23
+ type Props = {
24
+ open?: boolean
25
+ columns: ReadonlyArray<ExprColumn>
26
+ rule?: AlertRule | null
27
+ rows?: ReadonlyArray<Record<string, unknown>>
28
+ onSave?: (rule: AlertRule) => void
29
+ onClose?: () => void
30
+ }
31
+
32
+ let {
33
+ open = $bindable(false),
34
+ columns,
35
+ rule = null,
36
+ rows = [],
37
+ onSave,
38
+ onClose,
39
+ }: Props = $props()
40
+
41
+ const ACTION_KINDS: { kind: AlertActionKind; label: string }[] = [
42
+ { kind: 'toast', label: 'Toast notification' },
43
+ { kind: 'highlight', label: 'Highlight row/cell' },
44
+ { kind: 'badge', label: 'Colour badge' },
45
+ { kind: 'cellFlash', label: 'Flash the cell' },
46
+ { kind: 'preventEdit', label: 'Prevent the edit' },
47
+ { kind: 'log', label: 'Log only' },
48
+ ]
49
+
50
+ const TRIGGERS = [
51
+ { value: 'dataChange', label: 'When data matches' },
52
+ { value: 'relativeChange', label: 'When a value moves' },
53
+ { value: 'validation', label: 'On edit (validation)' },
54
+ { value: 'scheduled', label: 'On a schedule' },
55
+ ]
56
+
57
+ const CHANGE_KINDS = [
58
+ { value: 'changed', label: 'changed at all' },
59
+ { value: 'delta', label: 'moved by (absolute)' },
60
+ { value: 'percentChange', label: 'moved by (percent)' },
61
+ { value: 'crossed', label: 'crossed a threshold' },
62
+ ]
63
+
64
+ // --- Draft state ---------------------------------------------------------
65
+
66
+ let name = $state('')
67
+ let severity = $state<AlertSeverity>('warning')
68
+ let scope = $state<AlertScope>('row')
69
+ let triggerType = $state<AlertTrigger['type']>('dataChange')
70
+ let predicate = $state<PredicateExpr>({ kind: 'const', value: true })
71
+ let selected = $state<Set<AlertActionKind>>(new Set(['toast']))
72
+ let message = $state('')
73
+ let background = $state('#fee2e2')
74
+ let color = $state('#991b1b')
75
+ let targetColumns = $state<string[]>([])
76
+
77
+ // relativeChange draft
78
+ let changeKind = $state<'changed' | 'delta' | 'percentChange' | 'crossed'>('percentChange')
79
+ let changeColumn = $state('')
80
+ let changeOp = $state<'>' | '<' | '>=' | '<='>('>')
81
+ let changeValue = $state('5')
82
+ let changeAbs = $state(true)
83
+ let changeDirection = $state<'above' | 'below'>('above')
84
+
85
+ // scheduled draft
86
+ let cron = $state(CRON_PRESETS[3].cron)
87
+
88
+ let createdAt = $state(0)
89
+ let ruleId = $state('')
90
+
91
+ // Re-seed the draft each time the modal opens.
92
+ let wasOpen = $state(false)
93
+ $effect(() => {
94
+ if (open && !wasOpen) seed()
95
+ wasOpen = open
96
+ })
97
+
98
+ function seed() {
99
+ name = rule?.name ?? ''
100
+ severity = rule?.severity ?? 'warning'
101
+ scope = rule?.scope ?? 'row'
102
+ triggerType = rule?.trigger.type ?? 'dataChange'
103
+ predicate = rule?.predicate ?? { kind: 'const', value: true }
104
+ selected = new Set(rule?.actions.map((a) => a.kind) ?? ['toast'])
105
+ message = rule?.actions.find((a) => a.message)?.message ?? ''
106
+ const styled = rule?.actions.find((a) => a.style)
107
+ background = styled?.style?.background ?? '#fee2e2'
108
+ color = styled?.style?.color ?? '#991b1b'
109
+ targetColumns = rule?.columns ?? []
110
+ createdAt = rule?.createdAt ?? Date.now()
111
+ ruleId = rule?.id ?? `alert-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
112
+ changeColumn = columns[0]?.id ?? ''
113
+ if (rule?.trigger.type === 'relativeChange') {
114
+ const e = rule.trigger.expr
115
+ changeKind = e.kind
116
+ changeColumn = e.column
117
+ if (e.kind === 'delta' || e.kind === 'percentChange') {
118
+ changeOp = e.op as '>' | '<' | '>=' | '<='
119
+ changeValue = String(e.value)
120
+ changeAbs = e.abs ?? false
121
+ } else if (e.kind === 'crossed') {
122
+ changeValue = String(e.threshold)
123
+ changeDirection = e.direction
124
+ }
125
+ }
126
+ if (rule?.trigger.type === 'scheduled') cron = rule.trigger.schedule.cron ?? CRON_PRESETS[3].cron
127
+ }
128
+
129
+ function toggle(kind: AlertActionKind, on: boolean) {
130
+ const next = new Set(selected)
131
+ if (on) next.add(kind)
132
+ else next.delete(kind)
133
+ selected = next
134
+ }
135
+
136
+ const showStyle = $derived(selected.has('highlight') || selected.has('badge'))
137
+ const showTargets = $derived(showStyle || selected.has('cellFlash') || scope === 'cell')
138
+
139
+ function buildTrigger(): AlertTrigger {
140
+ if (triggerType === 'relativeChange') {
141
+ const column = changeColumn
142
+ const value = Number(changeValue)
143
+ if (changeKind === 'changed') return { type: 'relativeChange', expr: { kind: 'changed', column } }
144
+ if (changeKind === 'crossed')
145
+ return { type: 'relativeChange', expr: { kind: 'crossed', column, threshold: value, direction: changeDirection } }
146
+ return { type: 'relativeChange', expr: { kind: changeKind, column, op: changeOp, value, abs: changeAbs } }
147
+ }
148
+ if (triggerType === 'scheduled') return { type: 'scheduled', schedule: { id: ruleId, cron } }
149
+ if (triggerType === 'validation') return { type: 'validation' }
150
+ return { type: 'dataChange' }
151
+ }
152
+
153
+ function buildActions(): AlertAction[] {
154
+ const cols = targetColumns.length ? targetColumns : undefined
155
+ return [...selected].map((kind) => {
156
+ const action: AlertAction = { kind }
157
+ if (message && (kind === 'toast' || kind === 'log' || kind === 'preventEdit')) action.message = message
158
+ if (kind === 'highlight' || kind === 'badge') {
159
+ action.style = { background, color }
160
+ if (cols) action.columns = cols
161
+ }
162
+ if (kind === 'cellFlash' && cols) action.columns = cols
163
+ return action
164
+ })
165
+ }
166
+
167
+ function save() {
168
+ if (!name.trim()) { name = 'Untitled alert' }
169
+ const built: AlertRule = {
170
+ id: ruleId,
171
+ name: name.trim(),
172
+ enabled: rule?.enabled ?? true,
173
+ severity,
174
+ scope,
175
+ predicate,
176
+ trigger: buildTrigger(),
177
+ actions: buildActions(),
178
+ ...(targetColumns.length ? { columns: targetColumns } : {}),
179
+ createdAt,
180
+ }
181
+ onSave?.(built)
182
+ open = false
183
+ onClose?.()
184
+ }
185
+ </script>
186
+
187
+ <SvModal bind:open size="lg" title={rule ? 'Edit alert rule' : 'New alert rule'} onClose={() => onClose?.()}>
188
+ <div class="ar-body">
189
+ <div class="ar-grid">
190
+ <SvField label="Name">
191
+ <SvTextInput value={name} onChange={(v) => (name = v)} placeholder="e.g. Price spike" />
192
+ </SvField>
193
+ <SvField label="Severity">
194
+ <SvDropDownList
195
+ options={[{ value: 'info', label: 'Info' }, { value: 'success', label: 'Success' }, { value: 'warning', label: 'Warning' }, { value: 'error', label: 'Error' }]}
196
+ value={severity}
197
+ onChange={(v) => (severity = v as AlertSeverity)} />
198
+ </SvField>
199
+ <SvField label="Scope">
200
+ <SvDropDownList
201
+ options={[{ value: 'row', label: 'Row' }, { value: 'cell', label: 'Cell' }, { value: 'aggregate', label: 'Aggregate' }]}
202
+ value={scope}
203
+ onChange={(v) => (scope = v as AlertScope)} />
204
+ </SvField>
205
+ <SvField label="Trigger">
206
+ <SvDropDownList options={TRIGGERS} value={triggerType} onChange={(v) => (triggerType = v as AlertTrigger['type'])} />
207
+ </SvField>
208
+ </div>
209
+
210
+ {#if triggerType === 'relativeChange'}
211
+ <fieldset class="ar-fieldset">
212
+ <legend>Value movement</legend>
213
+ <div class="ar-change">
214
+ <SvDropDownList size="sm" options={columns.map((c) => ({ value: c.id, label: c.name ?? c.id }))} value={changeColumn} onChange={(v) => (changeColumn = String(v))} ariaLabel="Column" />
215
+ <SvDropDownList size="sm" options={CHANGE_KINDS} value={changeKind} onChange={(v) => (changeKind = v as typeof changeKind)} ariaLabel="Change kind" />
216
+ {#if changeKind === 'delta' || changeKind === 'percentChange'}
217
+ <SvDropDownList size="sm" options={[{ value: '>', label: '>' }, { value: '<', label: '<' }, { value: '>=', label: '>=' }, { value: '<=', label: '<=' }]} value={changeOp} onChange={(v) => (changeOp = v as typeof changeOp)} ariaLabel="Operator" />
218
+ <SvTextInput size="sm" value={changeValue} onChange={(v) => (changeValue = v)} placeholder={changeKind === 'percentChange' ? '%' : 'amount'} ariaLabel="Amount" />
219
+ <label class="ar-check"><input type="checkbox" checked={changeAbs} onchange={(e) => (changeAbs = e.currentTarget.checked)} /> absolute</label>
220
+ {:else if changeKind === 'crossed'}
221
+ <SvDropDownList size="sm" options={[{ value: 'above', label: 'upward through' }, { value: 'below', label: 'downward through' }]} value={changeDirection} onChange={(v) => (changeDirection = v as typeof changeDirection)} ariaLabel="Direction" />
222
+ <SvTextInput size="sm" value={changeValue} onChange={(v) => (changeValue = v)} placeholder="threshold" ariaLabel="Threshold" />
223
+ {/if}
224
+ </div>
225
+ </fieldset>
226
+ {:else if triggerType === 'scheduled'}
227
+ <SvField label="Schedule">
228
+ <SvDropDownList options={CRON_PRESETS.map((p) => ({ value: p.cron, label: p.label }))} value={cron} onChange={(v) => (cron = String(v))} />
229
+ </SvField>
230
+ {/if}
231
+
232
+ <SvField label={triggerType === 'validation' ? 'Reject the edit when' : 'Condition'}>
233
+ <SvExpressionEditor {columns} bind:value={predicate} {rows} />
234
+ </SvField>
235
+
236
+ <fieldset class="ar-fieldset">
237
+ <legend>When it fires</legend>
238
+ <div class="ar-actions">
239
+ {#each ACTION_KINDS as a (a.kind)}
240
+ <label class="ar-check">
241
+ <input type="checkbox" checked={selected.has(a.kind)} onchange={(e) => toggle(a.kind, e.currentTarget.checked)} />
242
+ {a.label}
243
+ </label>
244
+ {/each}
245
+ </div>
246
+ <div class="ar-message">
247
+ <SvField label="Message (tokens: {'{value}'}, {'{column}'}, {'{field}'})">
248
+ <SvTextInput value={message} onChange={(v) => (message = v)} placeholder="e.g. {'{'}region{'}'} price hit {'{'}value{'}'}" />
249
+ </SvField>
250
+ </div>
251
+ {#if showStyle}
252
+ <div class="ar-style">
253
+ <SvField label="Background"><SvColorInput value={background} onChange={(v) => (background = v)} /></SvField>
254
+ <SvField label="Text"><SvColorInput value={color} onChange={(v) => (color = v)} /></SvField>
255
+ </div>
256
+ {/if}
257
+ {#if showTargets}
258
+ <SvField label="Target columns (blank = whole row)">
259
+ <SvTagsInput value={targetColumns} onChange={(v) => (targetColumns = v)} placeholder="column id…" />
260
+ </SvField>
261
+ {/if}
262
+ </fieldset>
263
+ </div>
264
+
265
+ {#snippet footer()}
266
+ <button type="button" class="ar-btn ar-ghost" onclick={() => { open = false; onClose?.() }}>Cancel</button>
267
+ <button type="button" class="ar-btn ar-primary" onclick={save}>{rule ? 'Save changes' : 'Create alert'}</button>
268
+ {/snippet}
269
+ </SvModal>
270
+
271
+ <style>
272
+ /* Theme-agnostic surfaces: currentColor + inherit (readable in any theme). */
273
+ .ar-body { display: flex; flex-direction: column; gap: 14px; max-height: 64vh; overflow: auto; padding: 2px; font-family: var(--sg-font, inherit); color: inherit; }
274
+ .ar-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
275
+ .ar-fieldset { border: 1px solid color-mix(in srgb, currentColor 14%, transparent); border-radius: 10px; padding: 12px 14px 14px; margin: 0; background: color-mix(in srgb, currentColor 3%, transparent); }
276
+ .ar-fieldset legend { font-size: 11px; font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase; opacity: 0.6; padding: 0 6px; }
277
+ .ar-change { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
278
+ .ar-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 14px; }
279
+ .ar-check {
280
+ display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: inherit;
281
+ padding: 7px 10px; border: 1px solid color-mix(in srgb, currentColor 15%, transparent); border-radius: 8px;
282
+ background: color-mix(in srgb, currentColor 3%, transparent);
283
+ cursor: pointer; transition: border-color 0.13s ease, background 0.13s ease;
284
+ }
285
+ .ar-check:hover { border-color: color-mix(in srgb, var(--sg-accent, #4f46e5) 45%, currentColor); background: color-mix(in srgb, var(--sg-accent, #4f46e5) 7%, transparent); }
286
+ .ar-check input { accent-color: var(--sg-accent, #4f46e5); width: 15px; height: 15px; }
287
+ .ar-message { margin-top: 10px; }
288
+ .ar-style { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 10px; }
289
+ .ar-btn { border-radius: 8px; padding: 8px 16px; font-size: 13px; font-weight: 500; cursor: pointer; border: 1px solid transparent; transition: background 0.14s ease, border-color 0.14s ease, filter 0.14s ease; }
290
+ .ar-ghost { background: color-mix(in srgb, currentColor 5%, transparent); border-color: color-mix(in srgb, currentColor 22%, transparent); color: inherit; }
291
+ .ar-ghost:hover { background: color-mix(in srgb, currentColor 12%, transparent); }
292
+ .ar-primary { background: var(--sg-accent, #4f46e5); color: #fff; font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,0.12); }
293
+ .ar-primary:hover { filter: brightness(1.08); }
294
+ </style>
@@ -0,0 +1,210 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvAlertsManager - the rule list. Enable/disable, edit, delete, create, and
4
+ * share (export/import JSON) alert rules. Editing opens SvAlertRuleEditor.
5
+ * Operates through an AlertRulesManager (persistence) and emits the updated
6
+ * list so the running engine can re-sync.
7
+ */
8
+ import { SvModal, SvSwitchButton, toast } from '@svgrid/grid'
9
+ import SvAlertRuleEditor from './SvAlertRuleEditor.svelte'
10
+ import { stringifyPredicate } from './expressions/parse'
11
+ import type { ExprColumn } from './expressions/expression-columns'
12
+ import type { AlertRulesManager } from './alerts/alert-storage'
13
+ import type { AlertRule, AlertSeverity } from './alerts/alert-types'
14
+
15
+ type Props = {
16
+ open?: boolean
17
+ manager: AlertRulesManager
18
+ columns: ReadonlyArray<ExprColumn>
19
+ rows?: ReadonlyArray<Record<string, unknown>>
20
+ onChange?: (rules: AlertRule[]) => void
21
+ onClose?: () => void
22
+ }
23
+
24
+ let { open = $bindable(false), manager, columns, rows = [], onChange, onClose }: Props = $props()
25
+
26
+ let rules = $state<AlertRule[]>(manager.list())
27
+ let editing = $state<AlertRule | null>(null)
28
+ let editorOpen = $state(false)
29
+ let fileInput = $state<HTMLInputElement | null>(null)
30
+
31
+ // Re-read the persisted list whenever the manager modal opens.
32
+ let wasOpen = $state(false)
33
+ $effect(() => {
34
+ if (open && !wasOpen) rules = manager.list()
35
+ wasOpen = open
36
+ })
37
+
38
+ const SEV_COLOR: Record<AlertSeverity, string> = {
39
+ info: '#3b82f6', success: '#16a34a', warning: '#d97706', error: '#dc2626',
40
+ }
41
+ const SEV_LABEL: Record<AlertSeverity, string> = {
42
+ info: 'Info', success: 'Success', warning: 'Warning', error: 'Critical',
43
+ }
44
+
45
+ function sync() {
46
+ rules = manager.list()
47
+ onChange?.(rules)
48
+ }
49
+
50
+ function newRule() {
51
+ editing = null
52
+ editorOpen = true
53
+ }
54
+ function editRule(rule: AlertRule) {
55
+ editing = rule
56
+ editorOpen = true
57
+ }
58
+ function onSaved(rule: AlertRule) {
59
+ manager.save(rule)
60
+ sync()
61
+ }
62
+ function toggle(rule: AlertRule) {
63
+ manager.toggle(rule.id)
64
+ sync()
65
+ }
66
+ function remove(rule: AlertRule) {
67
+ manager.remove(rule.id)
68
+ sync()
69
+ }
70
+
71
+ function exportRules() {
72
+ const json = manager.export()
73
+ const blob = new Blob([json], { type: 'application/json' })
74
+ const url = URL.createObjectURL(blob)
75
+ const a = document.createElement('a')
76
+ a.href = url
77
+ a.download = 'alert-rules.json'
78
+ a.click()
79
+ URL.revokeObjectURL(url)
80
+ toast.success('Exported alert rules')
81
+ }
82
+ function importRules(e: Event) {
83
+ const file = (e.currentTarget as HTMLInputElement).files?.[0]
84
+ if (!file) return
85
+ const reader = new FileReader()
86
+ reader.onload = () => {
87
+ try {
88
+ manager.import(String(reader.result))
89
+ sync()
90
+ toast.success('Imported alert rules')
91
+ } catch {
92
+ toast.error('Could not import - invalid file')
93
+ }
94
+ }
95
+ reader.readAsText(file)
96
+ }
97
+
98
+ function triggerText(rule: AlertRule): string {
99
+ switch (rule.trigger.type) {
100
+ case 'relativeChange': return 'on movement'
101
+ case 'validation': return 'on edit'
102
+ case 'scheduled': return 'scheduled'
103
+ default: return 'on match'
104
+ }
105
+ }
106
+ </script>
107
+
108
+ <SvModal bind:open size="lg" title="Alert rules" onClose={() => onClose?.()}>
109
+ <div class="am-body">
110
+ {#if rules.length === 0}
111
+ <div class="am-empty">
112
+ <svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
113
+ <path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
114
+ <path d="M13.7 21a2 2 0 0 1-3.4 0" />
115
+ </svg>
116
+ <p class="am-empty-title">No alert rules yet</p>
117
+ <p class="am-empty-sub">Create one to get a toast, a highlight, or a flash the moment your data crosses a line.</p>
118
+ <button type="button" class="am-btn am-primary" onclick={newRule}>+ New alert</button>
119
+ </div>
120
+ {:else}
121
+ <ul class="am-list">
122
+ {#each rules as rule (rule.id)}
123
+ <li class="am-item" class:am-off={!rule.enabled} style={`--sev: ${SEV_COLOR[rule.severity]}`}>
124
+ <span class="am-strip" aria-hidden="true"></span>
125
+ <SvSwitchButton checked={rule.enabled} onChange={() => toggle(rule)} ariaLabel={`Enable ${rule.name}`} />
126
+ <button type="button" class="am-main" onclick={() => editRule(rule)}>
127
+ <div class="am-title">
128
+ <span class="am-name">{rule.name}</span>
129
+ <span class="am-sev">{SEV_LABEL[rule.severity]}</span>
130
+ <span class="am-trigger">{triggerText(rule)}</span>
131
+ </div>
132
+ <code class="am-expr">{stringifyPredicate(rule.predicate, columns)}</code>
133
+ </button>
134
+ <div class="am-tools">
135
+ <button type="button" class="am-icon" onclick={() => editRule(rule)} aria-label={`Edit ${rule.name}`} title="Edit">
136
+ <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
137
+ </button>
138
+ <button type="button" class="am-icon am-danger" onclick={() => remove(rule)} aria-label={`Delete ${rule.name}`} title="Delete">
139
+ <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /></svg>
140
+ </button>
141
+ </div>
142
+ </li>
143
+ {/each}
144
+ </ul>
145
+ {/if}
146
+ </div>
147
+
148
+ {#snippet footer()}
149
+ <div class="am-footer">
150
+ <div class="am-share">
151
+ <button type="button" class="am-btn am-ghost" onclick={exportRules}>Export</button>
152
+ <button type="button" class="am-btn am-ghost" onclick={() => fileInput?.click()}>Import</button>
153
+ <input bind:this={fileInput} type="file" accept="application/json" hidden onchange={importRules} />
154
+ </div>
155
+ <button type="button" class="am-btn am-primary" onclick={newRule}>+ New alert</button>
156
+ </div>
157
+ {/snippet}
158
+ </SvModal>
159
+
160
+ <SvAlertRuleEditor bind:open={editorOpen} {columns} rule={editing} {rows} onSave={onSaved} />
161
+
162
+ <style>
163
+ /* Theme-agnostic surfaces: currentColor + inherit (readable in any theme). */
164
+ .am-body { min-width: 540px; max-width: 640px; max-height: 60vh; overflow: auto; font-family: var(--sg-font, inherit); color: inherit; }
165
+
166
+ .am-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 44px 24px; text-align: center; }
167
+ .am-empty svg { color: var(--sg-accent, #4f46e5); opacity: 0.75; margin-bottom: 4px; }
168
+ .am-empty-title { margin: 0; font-size: 15px; font-weight: 600; }
169
+ .am-empty-sub { margin: 0 0 8px; font-size: 13px; max-width: 34ch; line-height: 1.5; opacity: 0.65; }
170
+
171
+ .am-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
172
+ .am-item {
173
+ position: relative; display: grid; grid-template-columns: auto auto 1fr auto; gap: 12px; align-items: center;
174
+ padding: 11px 12px 11px 16px;
175
+ background: color-mix(in srgb, currentColor 3%, transparent);
176
+ border: 1px solid color-mix(in srgb, currentColor 14%, transparent); border-radius: 11px; overflow: hidden;
177
+ transition: border-color 0.14s ease, box-shadow 0.14s ease;
178
+ }
179
+ .am-item:hover { border-color: color-mix(in srgb, var(--sev) 55%, transparent); box-shadow: 0 4px 14px -8px color-mix(in srgb, var(--sev) 60%, transparent); }
180
+ .am-item.am-off { opacity: 0.55; }
181
+ .am-strip { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--sev); }
182
+
183
+ .am-main { flex: 1; min-width: 0; text-align: left; border: 0; background: transparent; padding: 0; cursor: pointer; font: inherit; color: inherit; }
184
+ .am-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
185
+ .am-name { font-weight: 600; font-size: 14px; }
186
+ .am-sev {
187
+ font-size: 10.5px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase;
188
+ color: var(--sev); background: color-mix(in srgb, var(--sev) 16%, transparent);
189
+ padding: 2px 7px; border-radius: 999px;
190
+ }
191
+ .am-trigger { font-size: 11px; opacity: 0.55; text-transform: uppercase; letter-spacing: 0.04em; }
192
+ .am-expr { display: block; font-size: 12px; opacity: 0.7; margin-top: 4px; word-break: break-word; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
193
+
194
+ .am-tools { display: flex; gap: 4px; }
195
+ .am-icon {
196
+ display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px;
197
+ border: 1px solid transparent; background: transparent; border-radius: 8px; cursor: pointer;
198
+ color: inherit; opacity: 0.6; transition: background 0.13s ease, opacity 0.13s ease, border-color 0.13s ease, color 0.13s ease;
199
+ }
200
+ .am-icon:hover { background: color-mix(in srgb, currentColor 10%, transparent); opacity: 1; border-color: color-mix(in srgb, currentColor 15%, transparent); }
201
+ .am-icon.am-danger:hover { color: var(--sg-danger, #dc2626); opacity: 1; background: color-mix(in srgb, var(--sg-danger, #dc2626) 12%, transparent); border-color: color-mix(in srgb, var(--sg-danger, #dc2626) 35%, transparent); }
202
+
203
+ .am-footer { display: flex; justify-content: space-between; align-items: center; width: 100%; }
204
+ .am-share { display: flex; gap: 6px; }
205
+ .am-btn { border-radius: 8px; padding: 7px 15px; font-size: 13px; font-weight: 500; cursor: pointer; border: 1px solid transparent; transition: background 0.14s ease, border-color 0.14s ease, filter 0.14s ease; }
206
+ .am-ghost { background: color-mix(in srgb, currentColor 5%, transparent); border-color: color-mix(in srgb, currentColor 22%, transparent); color: inherit; }
207
+ .am-ghost:hover { background: color-mix(in srgb, currentColor 12%, transparent); }
208
+ .am-primary { background: var(--sg-accent, #4f46e5); color: #fff; font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,0.12); }
209
+ .am-primary:hover { filter: brightness(1.08); }
210
+ </style>