@scalio-cloud/ui 0.19.0 → 0.21.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.21.0
4
+ Testmodus (scalio-docs ADR 0053, package scalio-selftest).
5
+ - `SelfTestPanel` for the page «Tests»: last runs with verdict, scope, source, version and counts; details per
6
+ section; «Neu fehlgeschlagen» and «Wieder in Ordnung» since the run before; «Jetzt ausführen» and «Abbrechen» with
7
+ live progress; schedule (scope and hour of the nightly run). `selftestApi(api)` maps the routes `/e2e/*`.
8
+ - `SelfTestBanner`: hint «Testmodus (SAFE)» while the test mode is on.
9
+ - CSS in `nav.css` (`.scalio-selftest*`).
10
+ - **Visible change:** only in apps that show the new page. No token changes.
11
+
12
+ ## 0.20.0
13
+ Scalio Ideen (ADR 0050): feature requests from the apps, votes and comments, kept centrally in Compass.
14
+ - `supportApi(api, { ideas: true })` adds the routes of scalio-connect 0.8.0 (`ideas_router`). The support drawer then
15
+ gets a third tab «Ideen»: ideas of this app (most votes, newest, done), vote and take back, «Meine», idea page with
16
+ the answer from Scalio and comments, and «Idee vorschlagen» with similar ideas shown while typing.
17
+ - With ideas on, the ticket category «Wunsch» leaves the ticket form. A self-hosted installation without a connection
18
+ shows a link to the public page (`<portal>/ideen`), also in the link-only support view.
19
+ - New export `IdeasPanel`; CSS in `nav.css` (`.scalio-idea-*`, `.scalio-ideas*`).
20
+ - **Visible change:** only in apps that pass `{ ideas: true }`. No token changes.
21
+
3
22
  ## 0.19.0
4
23
  Settings card "Scalio Beacon" for the apps (Beacon phase 3, scalio-docs `concepts/beacon-suite.md`).
5
24
  - `BeaconConnection` with `peersApi(api)`: pair the installation with a Beacon instance (address + one-time code from
package/README.md CHANGED
@@ -116,6 +116,9 @@ With `client` the drawer has the tabs "Neue Anfrage" (category, subject, descrip
116
116
  0022), the backend routes come from scalio-connect `scalio_connect.support.web`. `supportApi(api)` takes the app's
117
117
  axios instance. The settings card `<SupportConnection client={supportApi(api)} lang={lang} />` connects the
118
118
  installation with a one-time code (admins). Without `client` the drawer keeps the old single form with `onSubmit`.
119
+ `supportApi(api, { ideas: true })` adds the tab "Ideen" (Scalio Ideen, ADR 0050; backend router
120
+ `scalio_connect.support.ideas_web.ideas_router`, scalio-connect 0.8.0): ideas of the app with votes and comments and
121
+ "Idee vorschlagen" with similar ideas; the ticket category "Wunsch" then moves there.
119
122
 
120
123
  The settings card `<BeaconConnection client={peersApi(api)} lang={lang} />` pairs the app with Scalio Beacon
121
124
  (address and one-time code from Beacon, Einstellungen -> Verbundene Apps); findings then go to Beacon. Backend:
package/nav.css CHANGED
@@ -192,6 +192,24 @@
192
192
  .scalio-support-msg { margin: 0; padding: 0.75rem; border: 1px solid hsl(var(--border)); border-radius: 2px; font-size: 0.875rem; white-space: pre-wrap; overflow-wrap: anywhere; }
193
193
  .scalio-support-msg.is-support { border-color: hsl(var(--primary) / 0.3); background: hsl(var(--primary) / 0.05); }
194
194
  .scalio-support-stars { display: flex; gap: 0.25rem; }
195
+ /* Scalio Ideen tab in the support drawer (ADR 0050) */
196
+ .scalio-ideas { display: flex; flex-direction: column; gap: 0.75rem; }
197
+ .scalio-ideas-bar { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.5rem; }
198
+ .scalio-ideas-bar .scalio-support-tab { flex: 0 0 auto; }
199
+ .scalio-ideas-sort { width: auto; margin-left: auto; padding-block: 0.25rem; font-size: 0.75rem; }
200
+ .scalio-idea-row { display: grid; grid-template-columns: 3rem 1fr; gap: 0.75rem; align-items: start; padding: 0.75rem; border: 1px solid hsl(var(--border)); border-radius: 2px; font-size: 0.875rem; cursor: pointer; }
201
+ .scalio-idea-row:hover { background: hsl(var(--muted)); }
202
+ .scalio-idea-row:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; }
203
+ .scalio-idea-row.is-static { padding: 0; border: 0; cursor: default; }
204
+ .scalio-idea-row.is-static:hover { background: none; }
205
+ .scalio-idea-body { min-width: 0; display: flex; flex-direction: column; gap: 0.25rem; overflow-wrap: anywhere; }
206
+ .scalio-idea-vote { display: flex; flex-direction: column; align-items: center; padding: 0.25rem 0; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--background)); color: inherit; font: inherit; cursor: pointer; }
207
+ .scalio-idea-vote:hover:not(:disabled) { border-color: hsl(var(--primary)); }
208
+ .scalio-idea-vote:disabled { opacity: 0.6; cursor: default; }
209
+ .scalio-idea-vote.is-voted { border-color: hsl(var(--primary)); background: hsl(var(--primary) / 0.08); color: hsl(var(--primary)); }
210
+ .scalio-idea-vote-n { font-family: 'JetBrains Mono', monospace; font-size: 1rem; font-weight: 600; line-height: 1.2; font-variant-numeric: tabular-nums; }
211
+ .scalio-idea-vote-l { font-size: 0.625rem; color: hsl(var(--muted-foreground)); }
212
+ .scalio-ideas-page { align-self: flex-start; text-decoration: underline; }
195
213
  .scalio-support-stars button { border: 0; background: none; font-size: 1.25rem; line-height: 1; color: #f59e0b; cursor: pointer; }
196
214
  .scalio-support-conn { font-size: 0.875rem; }
197
215
  .scalio-support-conn-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
@@ -461,3 +479,43 @@
461
479
  .dark .scalio-license-addon.tone-warn { border-color: rgb(251 191 36 / 0.4); background: rgb(251 191 36 / 0.08); }
462
480
  .scalio-license-addon-error { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
463
481
  .scalio-license-link { padding: 0; border: 0; background: none; color: inherit; font: inherit; text-decoration: underline; cursor: pointer; }
482
+
483
+ /* Self-check page (SelfTestPanel, ADR 0053) */
484
+ .scalio-selftest { border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--card)); color: hsl(var(--card-foreground)); font-size: 0.875rem; }
485
+ .scalio-selftest-part { padding: 1.25rem; }
486
+ .scalio-selftest-part + .scalio-selftest-part { border-top: 1px solid hsl(var(--border)); }
487
+ .scalio-selftest-head { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 1rem; }
488
+ .scalio-selftest-title { display: flex; align-items: center; gap: 0.5rem; font-family: 'Chivo', sans-serif; font-weight: 700; }
489
+ .scalio-selftest-title .scalio-icon { color: hsl(var(--primary)); }
490
+ .scalio-selftest-hint { margin: 0.25rem 0 0; color: hsl(var(--muted-foreground)); }
491
+ .scalio-selftest-actions { display: flex; gap: 0.5rem; }
492
+ .scalio-selftest-sub { display: flex; align-items: center; gap: 0.375rem; margin-bottom: 0.75rem; font-weight: 600; }
493
+ .scalio-selftest-live { margin-top: 1rem; display: flex; flex-direction: column; gap: 0.5rem; }
494
+ .scalio-selftest-live-head { display: flex; justify-content: space-between; color: hsl(var(--muted-foreground)); font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; }
495
+ .scalio-selftest-form { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.75rem; }
496
+ .scalio-selftest-form label { display: flex; flex-direction: column; gap: 0.25rem; min-width: 10rem; }
497
+ .scalio-selftest-form label span { color: hsl(var(--muted-foreground)); font-size: 0.75rem; }
498
+ .scalio-selftest-run { border-top: 1px solid hsl(var(--border)); padding: 0.625rem 0; }
499
+ .scalio-selftest-run:first-of-type { border-top: 0; }
500
+ .scalio-selftest-run-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 0.75rem; }
501
+ .scalio-selftest-when, .scalio-selftest-version { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; }
502
+ .scalio-selftest-source, .scalio-selftest-version { color: hsl(var(--muted-foreground)); }
503
+ .scalio-selftest-counts { display: inline-flex; gap: 0.5rem; font-family: 'JetBrains Mono', monospace; font-size: 0.6875rem; }
504
+ .scalio-selftest-run-head .scalio-btn { margin-left: auto; }
505
+ .scalio-selftest-run-body { display: flex; flex-direction: column; gap: 0.75rem; margin-top: 0.75rem; }
506
+ .scalio-selftest-run-body ul { margin: 0.25rem 0 0; padding-left: 1rem; }
507
+ .scalio-selftest-sections { display: flex; flex-direction: column; gap: 0.75rem; }
508
+ .scalio-selftest-section-title { font-weight: 600; margin-bottom: 0.25rem; }
509
+ .scalio-selftest-section ul { list-style: none; margin: 0; padding: 0; }
510
+ .scalio-selftest-section li { display: grid; grid-template-columns: 3rem minmax(0, 1fr); gap: 0 0.5rem; padding: 0.1875rem 0; }
511
+ .scalio-selftest-status { font-family: 'JetBrains Mono', monospace; font-size: 0.6875rem; font-weight: 600; }
512
+ .scalio-selftest-detail { grid-column: 2; color: hsl(var(--muted-foreground)); font-size: 0.75rem; overflow-wrap: anywhere; }
513
+ .scalio-selftest .is-ok, .scalio-selftest li.is-ok .scalio-selftest-status { color: #15803d; }
514
+ .scalio-selftest .is-warn, .scalio-selftest li.is-warn .scalio-selftest-status { color: #b45309; }
515
+ .scalio-selftest .is-error, .scalio-selftest li.is-error .scalio-selftest-status { color: hsl(var(--destructive)); }
516
+ .scalio-selftest .is-neutral { color: hsl(var(--muted-foreground)); }
517
+ .dark .scalio-selftest .is-ok, .dark .scalio-selftest li.is-ok .scalio-selftest-status { color: #4ade80; }
518
+ .dark .scalio-selftest .is-warn, .dark .scalio-selftest li.is-warn .scalio-selftest-status { color: #fbbf24; }
519
+ .scalio-selftest .scalio-notice.is-ok, .scalio-selftest .scalio-notice.is-error { color: inherit; }
520
+ .scalio-selftest-banner { display: flex; align-items: center; gap: 0.5rem; }
521
+ @media (max-width: 640px) { .scalio-selftest-run-head .scalio-btn { margin-left: 0; } }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalio-cloud/ui",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Scalio Control Room design tokens, Tailwind preset, self-hosted fonts, the shared app header, sign-in layout, sidebar and page blocks",
5
5
  "license": "SEE LICENSE IN README.md",
6
6
  "private": false,
package/react/index.js CHANGED
@@ -378,6 +378,6 @@ module.exports = {
378
378
  TEST_IDS,
379
379
  initials,
380
380
  };
381
- Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./beacon"), require("./security"), require("./license"), require("./palette"), require("./unify"), require("./profile"), require("./actions"));
381
+ Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"), require("./beacon"), require("./security"), require("./license"), require("./palette"), require("./unify"), require("./profile"), require("./actions"), require("./selftest"));
382
382
  // After the exports above: auth.js uses LanguageToggle and ThemeToggle from this module.
383
383
  Object.assign(module.exports, require("./auth"));
package/react/labels.js CHANGED
@@ -140,6 +140,32 @@ const LABELS = {
140
140
  supportRemove: "Trennen",
141
141
  supportRemoveConfirm: "Verbindung zum Support trennen? Neue Tickets warten dann, bis wieder verbunden ist.",
142
142
  supportRemoved: "Verbindung getrennt.",
143
+ ideasTab: "Ideen",
144
+ ideasAll: "Alle",
145
+ ideasMine: "Meine",
146
+ ideasSuggest: "Idee vorschlagen",
147
+ ideasSort: "Sortierung",
148
+ ideasSortTop: "Meiste Stimmen",
149
+ ideasSortNew: "Neueste",
150
+ ideasSortDone: "Erledigt",
151
+ ideasVote: "Abstimmen",
152
+ ideasVoted: "Abgestimmt",
153
+ ideasVotes: "Stimmen",
154
+ ideasComments: "Kommentare",
155
+ ideasComment: "Kommentar schreiben",
156
+ ideasAnswer: "Antwort von Scalio",
157
+ ideasNone: "Noch keine Ideen für dieses Produkt.",
158
+ ideasTitle: "Titel",
159
+ ideasTitleHelp: "Ein Satz, z.B. «An offene Zugriffsprüfungen erinnern»",
160
+ ideasDescriptionHelp: "Was möchten Sie erreichen? Wer würde es nutzen?",
161
+ ideasSimilar: "Ähnliche Ideen gibt es schon. Vielleicht lieber dort abstimmen:",
162
+ ideasSubmit: "Idee einreichen",
163
+ ideasSent: "Danke. Ihre Idee {n} erscheint nach unserer Prüfung für alle.",
164
+ ideasWaiting: "Wartet auf Prüfung: vorerst sehen nur Sie diese Idee.",
165
+ ideasWaitingShort: "wartet auf Prüfung",
166
+ ideasLinkOnly: "Diese Installation ist nicht mit Scalio verbunden. Ideen können Sie auf der öffentlichen Seite einreichen und abstimmen.",
167
+ ideasOpenPage: "Zur Seite Scalio Ideen",
168
+ ideasStatus: { new: "neu", open: "offen", reviewed: "geprüft", planned: "geplant", in_progress: "in Arbeit", done: "erledigt", declined: "abgelehnt", merged: "zusammengeführt" },
143
169
  // Scalio Beacon (connected apps, Beacon phase 3)
144
170
  beaconTitle: "Scalio Beacon",
145
171
  beaconHelp: "Befunde dieser Installation gehen an Scalio Beacon und werden dort Tickets. Adresse und Kopplungscode erhalten Sie in Beacon unter Einstellungen -> Verbundene Apps.",
@@ -397,6 +423,32 @@ const LABELS = {
397
423
  supportRemove: "Disconnect",
398
424
  supportRemoveConfirm: "Disconnect from support? New tickets then wait until the instance is connected again.",
399
425
  supportRemoved: "Disconnected.",
426
+ ideasTab: "Ideas",
427
+ ideasAll: "All",
428
+ ideasMine: "Mine",
429
+ ideasSuggest: "Suggest an idea",
430
+ ideasSort: "Sort",
431
+ ideasSortTop: "Most votes",
432
+ ideasSortNew: "Newest",
433
+ ideasSortDone: "Done",
434
+ ideasVote: "Vote",
435
+ ideasVoted: "Voted",
436
+ ideasVotes: "votes",
437
+ ideasComments: "comments",
438
+ ideasComment: "Write a comment",
439
+ ideasAnswer: "Answer from Scalio",
440
+ ideasNone: "No ideas for this product yet.",
441
+ ideasTitle: "Title",
442
+ ideasTitleHelp: "One sentence, e.g. “Remind owners about open access reviews”",
443
+ ideasDescriptionHelp: "What do you want to achieve? Who would use it?",
444
+ ideasSimilar: "Similar ideas exist already. Maybe vote there instead:",
445
+ ideasSubmit: "Submit idea",
446
+ ideasSent: "Thank you. Your idea {n} becomes visible to everyone after our review.",
447
+ ideasWaiting: "Waiting for review: only you see this idea for now.",
448
+ ideasWaitingShort: "waiting for review",
449
+ ideasLinkOnly: "This installation is not connected to Scalio. You can suggest and vote for ideas on the public page.",
450
+ ideasOpenPage: "Open Scalio Ideas",
451
+ ideasStatus: { new: "new", open: "open", reviewed: "reviewed", planned: "planned", in_progress: "in progress", done: "done", declined: "declined", merged: "merged" },
400
452
  // Scalio Beacon (connected apps, Beacon phase 3)
401
453
  beaconTitle: "Scalio Beacon",
402
454
  beaconHelp: "Findings of this installation go to Scalio Beacon and become tickets there. You get the address and the pairing code in Beacon under Settings -> Connected apps.",
@@ -0,0 +1,277 @@
1
+ "use strict";
2
+ // Page «Tests» of the test mode (scalio-docs ADR 0053, package scalio-selftest): last runs, live progress,
3
+ // run now / cancel, nightly schedule, results per section, what broke and what recovered since the run before.
4
+ // selftestApi(api) maps the routes of scalio-selftest (/e2e/*). SelfTestBanner shows the hint on every page.
5
+ const React = require("react");
6
+ const h = React.createElement;
7
+ const Icons = require("./icons");
8
+
9
+ const LABELS = {
10
+ de: {
11
+ title: "Selbsttest",
12
+ hint: "Diese Instanz prüft sich selbst von aussen: Schnittstellen und Browser, mit eigenen Testkonten.",
13
+ off: "Der Testmodus ist auf dieser Instanz aus (TEST_MODE und SCALIO_STAGE dev oder test). Gezeigt werden nur frühere Läufe.",
14
+ banner: "Testmodus ({suite}): Diese Instanz prüft sich automatisch selbst.",
15
+ runNow: "Jetzt ausführen",
16
+ queued: "Lauf angefordert. Er startet innerhalb einer Minute.",
17
+ cancel: "Abbrechen",
18
+ cancelling: "Abbruch angefordert.",
19
+ running: "Läuft",
20
+ waiting: "Wartet auf den Start",
21
+ checks: "Prüfungen",
22
+ never: "Noch keine Läufe.",
23
+ schedule: "Zeitplan",
24
+ suite: "Umfang",
25
+ hour: "Nachtlauf um",
26
+ noNight: "kein Nachtlauf",
27
+ save: "Speichern",
28
+ saved: "Zeitplan gespeichert.",
29
+ safeHint: "SAFE prüft nur mit eigenen Daten und simulierten Microsoft-Schichten. FULL schreibt auch in den Test-Tenant.",
30
+ runs: "Läufe",
31
+ newFailures: "Neu fehlgeschlagen",
32
+ recovered: "Wieder in Ordnung",
33
+ was: "vorher {s}",
34
+ sources: { manual: "von Hand", scheduled: "Nachtlauf", deploy: "nach Deploy" },
35
+ verdicts: { PASS: "Bestanden", WARN: "Warnung", FAIL: "Fehler", SKIP: "Übersprungen", CANCELLED: "Abgebrochen" },
36
+ show: "Details",
37
+ hide: "Ausblenden",
38
+ version: "Version",
39
+ },
40
+ en: {
41
+ title: "Self-check",
42
+ hint: "This instance checks itself from the outside: API and browser, with its own test accounts.",
43
+ off: "The test mode is off on this instance (TEST_MODE and SCALIO_STAGE dev or test). Only earlier runs are shown.",
44
+ banner: "Test mode ({suite}): this instance checks itself automatically.",
45
+ runNow: "Run now",
46
+ queued: "Run requested. It starts within a minute.",
47
+ cancel: "Cancel",
48
+ cancelling: "Cancel requested.",
49
+ running: "Running",
50
+ waiting: "Waiting to start",
51
+ checks: "checks",
52
+ never: "No runs yet.",
53
+ schedule: "Schedule",
54
+ suite: "Scope",
55
+ hour: "Nightly run at",
56
+ noNight: "no nightly run",
57
+ save: "Save",
58
+ saved: "Schedule saved.",
59
+ safeHint: "SAFE checks with own data and simulated Microsoft layers only. FULL also writes to the test tenant.",
60
+ runs: "Runs",
61
+ newFailures: "Newly failing",
62
+ recovered: "Recovered",
63
+ was: "was {s}",
64
+ sources: { manual: "by hand", scheduled: "nightly", deploy: "after deploy" },
65
+ verdicts: { PASS: "Passed", WARN: "Warning", FAIL: "Failed", SKIP: "Skipped", CANCELLED: "Cancelled" },
66
+ show: "Details",
67
+ hide: "Hide",
68
+ version: "Version",
69
+ },
70
+ };
71
+
72
+ function selftestLabels(lang, overrides) {
73
+ const base = LABELS[lang === "en" ? "en" : "de"];
74
+ return Object.assign({}, base, overrides || {});
75
+ }
76
+
77
+ const TONE = { PASS: "ok", WARN: "warn", FAIL: "error", SKIP: "neutral", CANCELLED: "neutral" };
78
+
79
+ function fill(text, values) {
80
+ return String(text || "").replace(/\{(\w+)\}/g, (m, k) => (values && values[k] !== undefined ? values[k] : m));
81
+ }
82
+
83
+ function errorText(err) {
84
+ const d = err && err.response && err.response.data && err.response.data.detail;
85
+ return typeof d === "string" ? d : (err && err.message) || String(err);
86
+ }
87
+
88
+ // api: axios instance with the app's base URL (/api).
89
+ function selftestApi(api) {
90
+ const data = (p) => p.then((r) => r.data);
91
+ return {
92
+ health: () => data(api.get("/e2e/health")),
93
+ runs: () => data(api.get("/e2e/runs")),
94
+ progress: () => data(api.get("/e2e/progress")),
95
+ setConfig: (cfg) => data(api.post("/e2e/config", cfg)),
96
+ run: () => data(api.post("/e2e/run")),
97
+ cancel: () => data(api.post("/e2e/cancel")),
98
+ };
99
+ }
100
+
101
+ function fmtTime(iso, lang) {
102
+ if (!iso) return "";
103
+ try {
104
+ return new Date(iso).toLocaleString(lang === "en" ? "en-GB" : "de-CH", { dateStyle: "short", timeStyle: "short" });
105
+ } catch (e) {
106
+ return iso;
107
+ }
108
+ }
109
+
110
+ function Verdict({ verdict, L }) {
111
+ return h("span", { className: `scalio-badge is-${TONE[verdict] || "neutral"}`, "data-testid": "selftest-verdict" },
112
+ (L.verdicts && L.verdicts[verdict]) || verdict || "-");
113
+ }
114
+
115
+ function Counts({ counts }) {
116
+ const c = counts || {};
117
+ return h("span", { className: "scalio-selftest-counts" },
118
+ ["PASS", "FAIL", "WARN", "SKIP"].map((k) => h("span", { key: k, className: `is-${TONE[k]}` }, `${k} ${c[k] || 0}`)));
119
+ }
120
+
121
+ function groupBySection(results) {
122
+ const out = [];
123
+ const index = {};
124
+ (results || []).forEach((r) => {
125
+ if (!(r.section in index)) {
126
+ index[r.section] = out.length;
127
+ out.push({ section: r.section, items: [] });
128
+ }
129
+ out[index[r.section]].items.push(r);
130
+ });
131
+ return out;
132
+ }
133
+
134
+ function ResultList({ results }) {
135
+ return h("div", { className: "scalio-selftest-sections" },
136
+ groupBySection(results).map((g) =>
137
+ h("div", { key: g.section, className: "scalio-selftest-section" },
138
+ h("div", { className: "scalio-selftest-section-title" }, g.section),
139
+ h("ul", null, g.items.map((r, i) =>
140
+ h("li", { key: r.name + i, className: `is-${TONE[r.status] || "neutral"}`, "data-testid": "selftest-result" },
141
+ h("span", { className: "scalio-selftest-status" }, r.status),
142
+ h("span", { className: "scalio-selftest-name" }, r.name),
143
+ r.detail ? h("span", { className: "scalio-selftest-detail" }, r.detail) : null))))));
144
+ }
145
+
146
+ function RunRow({ run, L, lang }) {
147
+ const [open, setOpen] = React.useState(false);
148
+ const reg = run.regression || {};
149
+ return h("div", { className: "scalio-selftest-run", "data-testid": "selftest-run" },
150
+ h("div", { className: "scalio-selftest-run-head" },
151
+ h(Verdict, { verdict: run.verdict, L }),
152
+ h("span", { className: "scalio-selftest-when" }, fmtTime(run.ranAt, lang)),
153
+ h("span", { className: "scalio-badge is-neutral" }, run.suite),
154
+ h("span", { className: "scalio-selftest-source" }, (L.sources && L.sources[run.source]) || run.source),
155
+ run.version ? h("span", { className: "scalio-selftest-version" }, `${run.version}${run.commit ? ` · ${String(run.commit).slice(0, 7)}` : ""}`) : null,
156
+ h(Counts, { counts: run.counts }),
157
+ run.results ? h("button", { type: "button", className: "scalio-btn is-ghost", onClick: () => setOpen(!open), "data-testid": "selftest-run-toggle" },
158
+ open ? L.hide : L.show) : null),
159
+ open ? h("div", { className: "scalio-selftest-run-body" },
160
+ (reg.newFailures || []).length ? h("div", { className: "scalio-notice is-error", "data-testid": "selftest-new-failures" },
161
+ h("strong", null, L.newFailures), h("ul", null, reg.newFailures.map((r, i) =>
162
+ h("li", { key: i }, `${r.section} / ${r.name}`, r.was ? ` (${fill(L.was, { s: r.was })})` : "")))) : null,
163
+ (reg.recovered || []).length ? h("div", { className: "scalio-notice is-ok", "data-testid": "selftest-recovered" },
164
+ h("strong", null, L.recovered), h("ul", null, reg.recovered.map((r, i) => h("li", { key: i }, `${r.section} / ${r.name}`)))) : null,
165
+ h(ResultList, { results: run.results })) : null);
166
+ }
167
+
168
+ // Props: client (selftestApi or same shape), lang, labels, notify(text, tone), pollMs (default 3000), testId.
169
+ function SelfTestPanel(props) {
170
+ const { client, lang, notify } = props;
171
+ const L = selftestLabels(lang, props.labels);
172
+ const pollMs = props.pollMs === undefined ? 3000 : props.pollMs;
173
+ const [health, setHealth] = React.useState(null);
174
+ const [runs, setRuns] = React.useState([]);
175
+ const [progress, setProgress] = React.useState(null);
176
+ const [error, setError] = React.useState("");
177
+ const [cfg, setCfg] = React.useState({ suite: "", runHour: -1 });
178
+ const [busy, setBusy] = React.useState(false);
179
+ const say = (text, tone) => (notify ? notify(text, tone || "success") : null);
180
+
181
+ const load = React.useCallback(async () => {
182
+ try {
183
+ const [hh, rr, pp] = await Promise.all([client.health(), client.runs(), client.progress()]);
184
+ setHealth(hh);
185
+ setRuns(rr.runs || []);
186
+ setProgress(pp);
187
+ setCfg({ suite: hh.suite || "", runHour: hh.runHour === null || hh.runHour === undefined ? -1 : hh.runHour });
188
+ setError("");
189
+ } catch (err) {
190
+ setError(errorText(err));
191
+ }
192
+ }, [client]);
193
+
194
+ React.useEffect(() => { load(); }, [load]);
195
+ const active = !!(progress && (progress.running || progress.queued));
196
+ React.useEffect(() => {
197
+ if (!active || !pollMs) return undefined;
198
+ const t = setInterval(async () => {
199
+ try {
200
+ const p = await client.progress();
201
+ setProgress(p);
202
+ if (!p.running && !p.queued) load();
203
+ } catch (e) { /* keep the last state */ }
204
+ }, pollMs);
205
+ return () => clearInterval(t);
206
+ }, [active, pollMs, client, load]);
207
+
208
+ if (!health) return error ? h("div", { className: "scalio-notice is-error", role: "alert" }, error) : null;
209
+ const enabled = !!health.enabled;
210
+
211
+ const act = async (fn, ok) => {
212
+ setBusy(true);
213
+ try {
214
+ await fn();
215
+ if (ok) say(ok);
216
+ await load();
217
+ } catch (err) {
218
+ say(errorText(err), "error");
219
+ } finally {
220
+ setBusy(false);
221
+ }
222
+ };
223
+
224
+ const done = progress ? (progress.results || []).length : 0;
225
+ const total = Math.max(done, (progress && progress.expectedTotal) || 0);
226
+ const pct = total ? Math.round((done / total) * 100) : 0;
227
+ const hours = [-1].concat(Array.from({ length: 24 }, (_, i) => i));
228
+
229
+ return h("section", { className: "scalio-selftest", "data-testid": props.testId || "selftest-panel" },
230
+ h("div", { className: "scalio-selftest-part" },
231
+ h("div", { className: "scalio-selftest-head" },
232
+ h("div", null,
233
+ h("div", { className: "scalio-selftest-title" }, h(Icons.Activity, { className: "scalio-icon" }), L.title,
234
+ health.suite ? h("span", { className: "scalio-badge is-running", "data-testid": "selftest-suite" }, health.suite) : null),
235
+ h("p", { className: "scalio-selftest-hint" }, enabled ? L.hint : L.off)),
236
+ enabled ? h("div", { className: "scalio-selftest-actions" },
237
+ active
238
+ ? h("button", { type: "button", className: "scalio-btn is-danger", disabled: busy, "data-testid": "selftest-cancel",
239
+ onClick: () => act(() => client.cancel(), L.cancelling) }, h(Icons.X, { className: "scalio-icon" }), L.cancel)
240
+ : h("button", { type: "button", className: "scalio-btn is-primary", disabled: busy, "data-testid": "selftest-run-now",
241
+ onClick: () => act(() => client.run(), L.queued) }, h(Icons.Activity, { className: "scalio-icon" }), L.runNow)) : null),
242
+ active ? h("div", { className: "scalio-selftest-live", "data-testid": "selftest-live" },
243
+ h("div", { className: "scalio-selftest-live-head" },
244
+ h("span", null, progress.running ? L.running : L.waiting),
245
+ h("span", null, `${done}${total ? ` / ${total}` : ""} ${L.checks}`)),
246
+ h("div", { className: "scalio-license-bar" }, h("div", { style: { width: `${pct}%` } })),
247
+ progress.running ? h(ResultList, { results: (progress.results || []).slice(-8) }) : null) : null),
248
+ enabled ? h("div", { className: "scalio-selftest-part" },
249
+ h("div", { className: "scalio-selftest-sub" }, h(Icons.Clock, { className: "scalio-icon", width: 14, height: 14 }), L.schedule),
250
+ h("div", { className: "scalio-selftest-form" },
251
+ h("label", null, h("span", null, L.suite),
252
+ h("select", { className: "scalio-input", value: cfg.suite, "data-testid": "selftest-suite-select",
253
+ onChange: (e) => setCfg(Object.assign({}, cfg, { suite: e.target.value })) },
254
+ (health.suites || []).map((s) => h("option", { key: s, value: s }, s)))),
255
+ h("label", null, h("span", null, L.hour),
256
+ h("select", { className: "scalio-input", value: String(cfg.runHour), "data-testid": "selftest-hour-select",
257
+ onChange: (e) => setCfg(Object.assign({}, cfg, { runHour: Number(e.target.value) })) },
258
+ hours.map((x) => h("option", { key: x, value: String(x) }, x === -1 ? L.noNight : `${String(x).padStart(2, "0")}:00`)))),
259
+ h("button", { type: "button", className: "scalio-btn", disabled: busy, "data-testid": "selftest-save",
260
+ onClick: () => act(() => client.setConfig({ suite: cfg.suite, runHour: cfg.runHour }), L.saved) }, L.save)),
261
+ h("p", { className: "scalio-selftest-hint" }, L.safeHint)) : null,
262
+ h("div", { className: "scalio-selftest-part" },
263
+ h("div", { className: "scalio-selftest-sub" }, L.runs),
264
+ runs.length ? runs.map((r) => h(RunRow, { key: r.id || r.ranAt, run: r, L, lang }))
265
+ : h("p", { className: "scalio-selftest-hint", "data-testid": "selftest-empty" }, L.never)));
266
+ }
267
+
268
+ // Hint above the page content while the test mode is on. Props: health ({enabled, suite}) or suite, lang, labels.
269
+ function SelfTestBanner(props) {
270
+ const suite = props.suite || (props.health && props.health.enabled && props.health.suite);
271
+ if (!suite) return null;
272
+ const L = selftestLabels(props.lang, props.labels);
273
+ return h("div", { className: "scalio-notice is-warn scalio-selftest-banner", "data-testid": "selftest-banner", role: "status" },
274
+ h(Icons.Activity, { className: "scalio-icon" }), h("span", null, fill(L.banner, { suite })));
275
+ }
276
+
277
+ module.exports = { SelfTestPanel, SelfTestBanner, selftestApi, selftestLabels, SELFTEST_LABELS: LABELS };
package/react/support.js CHANGED
@@ -4,6 +4,9 @@
4
4
  // them for an axios-like client. SupportDesk is the body of SupportDrawer when it gets a `client`:
5
5
  // "Neue Anfrage" (category, subject, description, diagnostics) and "Meine Tickets" (list, detail, reply, rating).
6
6
  // SupportConnection is the settings card "Support-Verbindung" (portal address + one-time code).
7
+ // Scalio Ideen (ADR 0050): supportApi(api, { ideas: true }) adds the tab "Ideen" (routes of
8
+ // scalio_connect.support.ideas_web): ideas of this app with votes and comments, and "Idee vorschlagen" with similar
9
+ // ideas shown while typing. The category "Wunsch" then leaves the ticket form.
7
10
  const React = require("react");
8
11
  const h = React.createElement;
9
12
  const { labelsFor } = require("./labels");
@@ -16,10 +19,23 @@ function cx() {
16
19
  }
17
20
 
18
21
  // api: axios instance with the app's base URL (/api). Every method resolves to the response body.
19
- function supportApi(api) {
22
+ function supportApi(api, options) {
20
23
  const data = (p) => p.then((r) => r.data);
21
24
  const enc = encodeURIComponent;
25
+ const ideas = options && options.ideas
26
+ ? {
27
+ status: () => data(api.get("/ideas/status")),
28
+ list: (params) => data(api.get("/ideas", { params: params || {} })),
29
+ similar: (text) => data(api.get("/ideas/similar", { params: { text } })),
30
+ mine: () => data(api.get("/ideas/mine")),
31
+ get: (ref) => data(api.get(`/ideas/${enc(ref)}`)),
32
+ submit: (idea) => data(api.post("/ideas", idea)),
33
+ vote: (ref, on) => data(api.post(`/ideas/${enc(ref)}/vote`, { on })),
34
+ comment: (ref, body) => data(api.post(`/ideas/${enc(ref)}/comments`, { body })),
35
+ }
36
+ : null;
22
37
  return {
38
+ ideas,
23
39
  create: (ticket) => data(api.post("/support/tickets", ticket)),
24
40
  list: () => data(api.get("/support/tickets")),
25
41
  get: (id) => data(api.get(`/support/tickets/${enc(id)}`)),
@@ -53,6 +69,7 @@ function statusText(L, s) {
53
69
  }
54
70
 
55
71
  function NewTicket({ client, page, L, onSent }) {
72
+ const categories = client.ideas ? CATEGORIES.filter((c) => c !== "wish") : CATEGORIES;
56
73
  const [category, setCategory] = React.useState("question");
57
74
  const [subject, setSubject] = React.useState("");
58
75
  const [description, setDescription] = React.useState("");
@@ -83,7 +100,7 @@ function NewTicket({ client, page, L, onSent }) {
83
100
  h(
84
101
  "select",
85
102
  { id: "support-category", className: "scalio-input", value: category, onChange: (e) => setCategory(e.target.value), "data-testid": "ticket-category-select" },
86
- CATEGORIES.map((c) => h("option", { key: c, value: c }, L.supportCategories[c]))
103
+ categories.map((c) => h("option", { key: c, value: c }, L.supportCategories[c]))
87
104
  ),
88
105
  h("label", { className: "scalio-field-label", htmlFor: "support-subject" }, L.subject),
89
106
  h("input", {
@@ -226,14 +243,194 @@ function TicketDetail({ client, id, L, lang, onBack }) {
226
243
  }
227
244
 
228
245
  // Self-hosted without a connection (ADR 0022, T11): no form, only the way to the portal's support page.
229
- function PortalLink({ status, L }) {
246
+ function PortalLink({ status, L, ideasLink }) {
230
247
  return h(
231
248
  "div",
232
249
  { className: "scalio-support-form", "data-testid": "support-link-only" },
233
250
  h("p", { className: "scalio-support-meta" }, L.supportLinkOnly),
234
251
  h("a", { className: "scalio-btn-primary", href: status.portal_page, target: "_blank", rel: "noopener noreferrer",
235
252
  "data-testid": "support-portal-link" }, L.supportOpenPortal),
236
- h("p", { className: "scalio-support-meta" }, L.supportLinkOnlyAdmin)
253
+ h("p", { className: "scalio-support-meta" }, L.supportLinkOnlyAdmin),
254
+ ideasLink ? h("a", { className: "scalio-support-meta scalio-ideas-page", href: ideasLink, target: "_blank", rel: "noopener noreferrer",
255
+ "data-testid": "support-ideas-link" }, L.ideasOpenPage) : null
256
+ );
257
+ }
258
+
259
+
260
+ // ---------------- Scalio Ideen ----------------
261
+ function ideaStatus(L, s) {
262
+ return (L.ideasStatus && L.ideasStatus[s]) || s;
263
+ }
264
+
265
+ function VoteBtn({ idea, L, onVote }) {
266
+ const closed = ["done", "declined", "merged"].includes(idea.status);
267
+ return h(
268
+ "button",
269
+ {
270
+ type: "button", className: cx("scalio-idea-vote", idea.voted && "is-voted"), disabled: closed && !idea.voted,
271
+ "aria-pressed": !!idea.voted, title: idea.voted ? L.ideasVoted : L.ideasVote, "data-testid": `idea-vote-${idea.number}`,
272
+ onClick: (e) => { e.stopPropagation(); onVote(idea); },
273
+ },
274
+ h("span", { className: "scalio-idea-vote-n" }, String(idea.votes || 0)),
275
+ h("span", { className: "scalio-idea-vote-l" }, L.ideasVotes)
276
+ );
277
+ }
278
+
279
+ function IdeaForm({ ideas, L, onDone }) {
280
+ const [title, setTitle] = React.useState("");
281
+ const [description, setDescription] = React.useState("");
282
+ const [similar, setSimilar] = React.useState([]);
283
+ const [busy, setBusy] = React.useState(false);
284
+ const [error, setError] = React.useState("");
285
+ React.useEffect(() => {
286
+ if (title.trim().length < 4) { setSimilar([]); return undefined; }
287
+ let alive = true;
288
+ const t = setTimeout(() => {
289
+ ideas.similar(`${title} ${description}`).then((d) => alive && setSimilar((d && d.ideas) || [])).catch(() => alive && setSimilar([]));
290
+ }, 300);
291
+ return () => { alive = false; clearTimeout(t); };
292
+ }, [ideas, title, description]);
293
+ const submit = async (e) => {
294
+ e.preventDefault();
295
+ setBusy(true);
296
+ setError("");
297
+ try {
298
+ const res = await ideas.submit({ title: title.trim(), description: description.trim() });
299
+ setTitle("");
300
+ setDescription("");
301
+ onDone(L.ideasSent.replace("{n}", (res && res.number) || ""));
302
+ } catch (err) {
303
+ setError(errorText(err));
304
+ } finally {
305
+ setBusy(false);
306
+ }
307
+ };
308
+ return h(
309
+ "form",
310
+ { className: "scalio-support-form", onSubmit: submit, "data-testid": "idea-form" },
311
+ h("label", { className: "scalio-field-label", htmlFor: "idea-title" }, L.ideasTitle),
312
+ h("input", { id: "idea-title", className: "scalio-input", value: title, required: true, minLength: 5, maxLength: 140,
313
+ placeholder: L.ideasTitleHelp, onChange: (e) => setTitle(e.target.value), "data-testid": "idea-title-input" }),
314
+ similar.length
315
+ ? h(
316
+ "div",
317
+ { className: "scalio-notice is-warn", "data-testid": "idea-similar" },
318
+ h("div", null, L.ideasSimilar),
319
+ similar.map((s) => h("div", { key: s.id, className: "scalio-support-meta" }, `${s.number} · ${s.title} (${s.votes} ${L.ideasVotes})`))
320
+ )
321
+ : null,
322
+ h("label", { className: "scalio-field-label", htmlFor: "idea-description" }, L.description),
323
+ h("textarea", { id: "idea-description", className: "scalio-input scalio-textarea", rows: 4, value: description, maxLength: 10000,
324
+ placeholder: L.ideasDescriptionHelp, onChange: (e) => setDescription(e.target.value), "data-testid": "idea-description-input" }),
325
+ error ? h("div", { className: "scalio-notice is-error", role: "alert", "data-testid": "idea-error" }, error) : null,
326
+ h("button", { type: "submit", className: "scalio-btn-primary", disabled: busy, "data-testid": "idea-submit-btn" }, busy ? L.sending : L.ideasSubmit)
327
+ );
328
+ }
329
+
330
+ function IdeaDetail({ ideas, ref_, L, lang, onBack }) {
331
+ const [idea, setIdea] = React.useState(null);
332
+ const [text, setText] = React.useState("");
333
+ const [error, setError] = React.useState("");
334
+ const load = React.useCallback(() => ideas.get(ref_).then(setIdea).catch((err) => { setError(errorText(err)); setIdea(false); }), [ideas, ref_]);
335
+ React.useEffect(() => { load(); }, [load]);
336
+ const back = h("button", { type: "button", className: "scalio-support-back", onClick: onBack, "data-testid": "idea-back" }, `‹ ${L.ideasTab}`);
337
+ if (idea === null) return h("div", null, back, h("div", { className: "scalio-support-meta" }, L.loading));
338
+ if (idea === false) return h("div", null, back, h("div", { className: "scalio-notice is-error", role: "alert" }, error));
339
+ const vote = async () => {
340
+ try { await ideas.vote(idea.number, !idea.voted); await load(); } catch (err) { setError(errorText(err)); }
341
+ };
342
+ const send = async () => {
343
+ try { await ideas.comment(idea.number, text.trim()); setText(""); await load(); } catch (err) { setError(errorText(err)); }
344
+ };
345
+ return h(
346
+ "div",
347
+ { className: "scalio-support-detail", "data-testid": "idea-detail" },
348
+ back,
349
+ h("div", { className: "scalio-idea-row is-static" },
350
+ h(VoteBtn, { idea, L, onVote: vote }),
351
+ h("div", { className: "scalio-idea-body" },
352
+ h("h4", { className: "scalio-support-item-title" }, idea.title),
353
+ h("div", { className: "scalio-support-meta" }, `${idea.number} · ${ideaStatus(L, idea.status)}${idea.author ? ` · ${idea.author}` : ""}`),
354
+ idea.public === false ? h("div", { className: "scalio-support-meta" }, L.ideasWaiting) : null)),
355
+ idea.description ? h("p", { className: "scalio-support-msg" }, idea.description) : null,
356
+ idea.answer && idea.answer.body
357
+ ? h("div", { className: "scalio-support-msg is-support" }, h("div", { className: "scalio-support-meta" }, L.ideasAnswer), h("div", null, idea.answer.body))
358
+ : null,
359
+ (idea.comments || []).map((c) =>
360
+ h("div", { key: c.id, className: cx("scalio-support-msg", c.official && "is-support") },
361
+ h("div", { className: "scalio-support-meta" }, `${c.author} · ${fmtDate(c.created_at, lang)}`),
362
+ h("div", null, c.body))
363
+ ),
364
+ error ? h("div", { className: "scalio-notice is-error", role: "alert" }, error) : null,
365
+ idea.status !== "merged"
366
+ ? h("div", { className: "scalio-support-form" },
367
+ h("textarea", { className: "scalio-input scalio-textarea", rows: 3, value: text, placeholder: L.ideasComment,
368
+ "aria-label": L.ideasComment, onChange: (e) => setText(e.target.value), "data-testid": "idea-comment-input" }),
369
+ h("button", { type: "button", className: "scalio-btn-primary", disabled: !text.trim(), onClick: send, "data-testid": "idea-comment-btn" }, L.send))
370
+ : null
371
+ );
372
+ }
373
+
374
+ function IdeasPanel({ ideas, L, lang }) {
375
+ const [status, setStatus] = React.useState(null);
376
+ const [view, setView] = React.useState("list");
377
+ const [sort, setSort] = React.useState("top");
378
+ const [rows, setRows] = React.useState(null);
379
+ const [open, setOpen] = React.useState(null);
380
+ const [note, setNote] = React.useState("");
381
+ const [error, setError] = React.useState("");
382
+ React.useEffect(() => {
383
+ let alive = true;
384
+ ideas.status().then((d) => alive && setStatus(d || {})).catch(() => alive && setStatus({}));
385
+ return () => { alive = false; };
386
+ }, [ideas]);
387
+ const load = React.useCallback(() => {
388
+ setRows(null);
389
+ const p = view === "mine" ? ideas.mine() : ideas.list({ sort });
390
+ return p.then((d) => setRows((d && d.ideas) || [])).catch((err) => { setError(errorText(err)); setRows([]); });
391
+ }, [ideas, sort, view]);
392
+ React.useEffect(() => { if (status && status.connected && view !== "new") load(); }, [status, view, load]);
393
+ if (status === null) return h("div", { className: "scalio-support-meta" }, L.loading);
394
+ if (!status.connected) {
395
+ return h("div", { className: "scalio-support-form", "data-testid": "ideas-link-only" },
396
+ h("p", { className: "scalio-support-meta" }, L.ideasLinkOnly),
397
+ status.page ? h("a", { className: "scalio-btn-primary", href: status.page, target: "_blank", rel: "noopener noreferrer", "data-testid": "ideas-page-link" }, L.ideasOpenPage) : null);
398
+ }
399
+ if (open) return h(IdeaDetail, { ideas, ref_: open, L, lang, onBack: () => { setOpen(null); load(); } });
400
+ const vote = async (idea) => {
401
+ try {
402
+ const res = await ideas.vote(idea.number, !idea.voted);
403
+ setRows((list) => (list || []).map((r) => (r.id === idea.id ? { ...r, votes: res.votes, voted: res.voted } : r)));
404
+ } catch (err) { setError(errorText(err)); }
405
+ };
406
+ const pill = (key, label) => h("button", { type: "button", className: cx("scalio-support-tab", view === key && "is-active"),
407
+ onClick: () => { setView(key); setNote(""); setError(""); }, "data-testid": `ideas-view-${key}` }, label);
408
+ return h(
409
+ "div",
410
+ { className: "scalio-ideas", "data-testid": "ideas-panel" },
411
+ h("div", { className: "scalio-ideas-bar" },
412
+ pill("list", L.ideasAll), pill("mine", L.ideasMine), pill("new", L.ideasSuggest),
413
+ view === "list"
414
+ ? h("select", { className: "scalio-input scalio-ideas-sort", value: sort, "aria-label": L.ideasSort,
415
+ onChange: (e) => setSort(e.target.value), "data-testid": "ideas-sort" },
416
+ h("option", { value: "top" }, L.ideasSortTop), h("option", { value: "new" }, L.ideasSortNew), h("option", { value: "done" }, L.ideasSortDone))
417
+ : null),
418
+ note ? h("div", { className: "scalio-notice is-ok", "data-testid": "ideas-note" }, note) : null,
419
+ error ? h("div", { className: "scalio-notice is-error", role: "alert" }, error) : null,
420
+ view === "new"
421
+ ? h(IdeaForm, { ideas, L, onDone: (text) => { setNote(text); setView("mine"); } })
422
+ : rows === null
423
+ ? h("div", { className: "scalio-support-meta" }, L.loading)
424
+ : h("div", { className: "scalio-support-list" },
425
+ rows.length ? null : h("div", { className: "scalio-support-empty" }, L.ideasNone),
426
+ rows.map((i) => h("div", { key: i.id, role: "button", tabIndex: 0, className: "scalio-idea-row", "data-testid": `idea-row-${i.number}`,
427
+ onClick: () => setOpen(i.number), onKeyDown: (e) => { if (e.key === "Enter") setOpen(i.number); } },
428
+ h(VoteBtn, { idea: i, L, onVote: vote }),
429
+ h("div", { className: "scalio-idea-body" },
430
+ h("div", { className: "scalio-support-item-title" }, i.title),
431
+ h("div", { className: "scalio-support-meta" },
432
+ `${ideaStatus(L, i.status)} · ${i.number}${i.comment_count ? ` · ${i.comment_count} ${L.ideasComments}` : ""}${i.public === false ? ` · ${L.ideasWaitingShort}` : ""}`))))),
433
+ status.page ? h("a", { className: "scalio-support-meta scalio-ideas-page", href: status.page, target: "_blank", rel: "noopener noreferrer" }, L.ideasOpenPage) : null
237
434
  );
238
435
  }
239
436
 
@@ -255,15 +452,19 @@ function SupportDesk({ client, page, lang, labels }) {
255
452
  onClick: () => { setTab(key); setTicket(null); setNote(""); }, "data-testid": `support-tab-${key}`,
256
453
  }, label);
257
454
  if (status === null) return h("div", { className: "scalio-support-meta" }, L.loading);
258
- if (status.link_only && status.portal_page) return h(PortalLink, { status, L });
455
+ if (status.link_only && status.portal_page) {
456
+ return h(PortalLink, { status, L, ideasLink: client.ideas && status.portal ? `${status.portal}/ideen` : "" });
457
+ }
259
458
  return h(
260
459
  React.Fragment,
261
460
  null,
262
- h("div", { className: "scalio-support-tabs", role: "tablist" }, tabBtn("new", L.supportNew), tabBtn("mine", L.supportMine)),
461
+ h("div", { className: "scalio-support-tabs", role: "tablist" }, tabBtn("new", L.supportNew), tabBtn("mine", L.supportMine),
462
+ client.ideas ? tabBtn("ideas", L.ideasTab) : null),
263
463
  note ? h("div", { className: "scalio-notice is-ok", "data-testid": "support-note" }, note) : null,
264
464
  tab === "new" ? h(NewTicket, { client, page, L, onSent: (text) => { setNote(text); setTab("mine"); } }) : null,
265
465
  tab === "mine" && !ticket ? h(MyTickets, { client, L, lang, onOpen: (id) => { setNote(""); setTicket(id); } }) : null,
266
- tab === "mine" && ticket ? h(TicketDetail, { client, id: ticket, L, lang, onBack: () => setTicket(null) }) : null
466
+ tab === "mine" && ticket ? h(TicketDetail, { client, id: ticket, L, lang, onBack: () => setTicket(null) }) : null,
467
+ tab === "ideas" && client.ideas ? h(IdeasPanel, { ideas: client.ideas, L, lang }) : null
267
468
  );
268
469
  }
269
470
 
@@ -369,4 +570,4 @@ function SupportConnection({ client, lang, labels, onChange }) {
369
570
  );
370
571
  }
371
572
 
372
- module.exports = { supportApi, SupportDesk, SupportConnection, SUPPORT_CATEGORIES: CATEGORIES };
573
+ module.exports = { supportApi, SupportDesk, SupportConnection, IdeasPanel, SUPPORT_CATEGORIES: CATEGORIES };