@reqquest/ui 1.17.0 → 1.19.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 (102) hide show
  1. package/app/env.template +5 -0
  2. package/app/runtime/apply-env.sh +10 -0
  3. package/app/runtime/nginx.conf +37 -0
  4. package/app/src/app.css +21 -0
  5. package/app/src/app.d.ts +13 -0
  6. package/app/src/app.html +12 -0
  7. package/app/src/internal/api.ts +1707 -0
  8. package/app/src/internal/appreq-utils.ts +33 -0
  9. package/app/src/internal/components/AnnouncementBanner.svelte +47 -0
  10. package/app/src/internal/components/AppRequestCard.svelte +173 -0
  11. package/app/src/internal/components/ApplicantOptOutModal.svelte +78 -0
  12. package/app/src/internal/components/ApplicantProgramList.svelte +276 -0
  13. package/app/src/internal/components/ApplicantProgramListTooltip.svelte +44 -0
  14. package/app/src/internal/components/ApplicantPromptPage.svelte +136 -0
  15. package/app/src/internal/components/ApplicantPromptSkeleton.svelte +12 -0
  16. package/app/src/internal/components/ApplicationDetailsView.svelte +369 -0
  17. package/app/src/internal/components/ButtonLoadingIcon.svelte +28 -0
  18. package/{dist → app/src/internal}/components/DelayedSkeleton.svelte +2 -2
  19. package/app/src/internal/components/FieldNestedMultiselect.svelte +270 -0
  20. package/app/src/internal/components/IntroPanel.svelte +41 -0
  21. package/app/src/internal/components/MissingDefinitionNotification.svelte +20 -0
  22. package/app/src/internal/components/PeriodPanel.svelte +125 -0
  23. package/app/src/internal/components/ProgramStatusCell.svelte +134 -0
  24. package/app/src/internal/components/RenderDisplayComponent.svelte +42 -0
  25. package/app/src/internal/components/ReviewerActions.svelte +171 -0
  26. package/app/src/internal/components/ReviewerQuestions.svelte +508 -0
  27. package/app/src/internal/components/StatusMessageList.svelte +54 -0
  28. package/app/src/internal/components/SystemDeterminationNotice.svelte +37 -0
  29. package/app/src/internal/components/TabLinks.svelte +265 -0
  30. package/app/src/internal/components/WarningIconYellow.svelte +22 -0
  31. package/app/src/internal/components/index.ts +19 -0
  32. package/app/src/internal/csv.ts +30 -0
  33. package/app/src/internal/index.ts +8 -0
  34. package/app/src/internal/prompt-save-queue.ts +91 -0
  35. package/app/src/internal/prompt-utils.ts +40 -0
  36. package/app/src/internal/status-utils.ts +503 -0
  37. package/app/src/internal/stores/IStateStore.ts +5 -0
  38. package/app/src/internal/util.ts +27 -0
  39. package/app/src/routes/+error.svelte +4 -0
  40. package/app/src/routes/+layout.svelte +134 -0
  41. package/app/src/routes/+layout.ts +18 -0
  42. package/app/src/routes/+page.ts +14 -0
  43. package/app/src/routes/announcement/+page.svelte +164 -0
  44. package/app/src/routes/announcement/+page.ts +16 -0
  45. package/app/src/routes/dashboards/applicant/+page.svelte +494 -0
  46. package/app/src/routes/dashboards/applicant/+page.ts +95 -0
  47. package/app/src/routes/dashboards/reviewer/+page.svelte +143 -0
  48. package/app/src/routes/dashboards/reviewer/+page.ts +43 -0
  49. package/app/src/routes/periods/+page.svelte +158 -0
  50. package/app/src/routes/periods/+page.ts +13 -0
  51. package/app/src/routes/periods/[id]/configure/+page.svelte +150 -0
  52. package/app/src/routes/periods/[id]/configure/+page.ts +10 -0
  53. package/app/src/routes/requests/+page.svelte +246 -0
  54. package/app/src/routes/requests/+page.ts +24 -0
  55. package/app/src/routes/requests/[id]/+layout.ts +11 -0
  56. package/app/src/routes/requests/[id]/accept/+layout.svelte +80 -0
  57. package/app/src/routes/requests/[id]/accept/+layout.ts +12 -0
  58. package/app/src/routes/requests/[id]/accept/+page.ts +13 -0
  59. package/app/src/routes/requests/[id]/accept/[promptId]/+page.svelte +7 -0
  60. package/app/src/routes/requests/[id]/accept/[promptId]/+page.ts +10 -0
  61. package/app/src/routes/requests/[id]/accept/review/+page.svelte +87 -0
  62. package/app/src/routes/requests/[id]/apply/+layout.svelte +181 -0
  63. package/app/src/routes/requests/[id]/apply/+layout.ts +12 -0
  64. package/app/src/routes/requests/[id]/apply/+page.ts +14 -0
  65. package/app/src/routes/requests/[id]/apply/ProgressNavContainer.svelte +42 -0
  66. package/app/src/routes/requests/[id]/apply/[promptId]/+page.svelte +23 -0
  67. package/app/src/routes/requests/[id]/apply/[promptId]/+page.ts +11 -0
  68. package/app/src/routes/requests/[id]/apply/programs/+page.svelte +71 -0
  69. package/app/src/routes/requests/[id]/apply/review/+page.svelte +102 -0
  70. package/app/src/routes/requests/[id]/approve/+layout.svelte +114 -0
  71. package/app/src/routes/requests/[id]/approve/+page.ts +14 -0
  72. package/app/src/routes/requests/[id]/approve/ApproveLayout.svelte +164 -0
  73. package/app/src/routes/requests/[id]/approve/[programKey]/+page.svelte +396 -0
  74. package/app/src/routes/requests/[id]/approve/[programKey]/+page.ts +15 -0
  75. package/app/src/routes/requests/[id]/approve/activity/+page.svelte +66 -0
  76. package/app/src/routes/requests/[id]/approve/activity/+page.ts +12 -0
  77. package/app/src/routes/requests/[id]/approve/export/+page.ts +12 -0
  78. package/app/src/routes/requests/[id]/approve/export/+page@[id].svelte +41 -0
  79. package/app/src/routes/requests/[id]/export/+page.svelte +32 -0
  80. package/app/src/routes/requests/[id]/export/+page.ts +12 -0
  81. package/app/src/routes/roles/+layout.svelte +16 -0
  82. package/app/src/routes/roles/+layout.ts +14 -0
  83. package/app/src/routes/roles/+page.svelte +225 -0
  84. package/app/src/routes/roles/+page.ts +13 -0
  85. package/app/src/routes/roles/[id]/+layout.ts +10 -0
  86. package/app/src/routes/roles/[id]/+page.svelte +335 -0
  87. package/app/src/routes/roles/[id]/+page.ts +10 -0
  88. package/app/src/routes/roles/[id]/ControlWithTooltip.svelte +15 -0
  89. package/app/src/routes/roles/[id]/TagsWithTooltip.svelte +42 -0
  90. package/app/src/routes/roles/users/+layout.ts +13 -0
  91. package/app/src/routes/roles/users/+page.svelte +46 -0
  92. package/app/src/routes/roles/users/+page.ts +90 -0
  93. package/app/static/favicon.png +0 -0
  94. package/bin/reqquest-ui.js +117 -0
  95. package/cli/config.js +105 -0
  96. package/cli/sync.js +296 -0
  97. package/cli/util.js +29 -0
  98. package/dist/components.md +5 -4
  99. package/dist/registry.d.ts +17 -0
  100. package/dist/registry.js +3 -0
  101. package/package.json +29 -15
  102. package/dist/components/DelayedSkeleton.svelte.d.ts +0 -21
@@ -0,0 +1,171 @@
1
+ <script lang="ts">
2
+ import { toasts } from '@txstate-mws/svelte-components'
3
+ import { Loading } from "carbon-components-svelte"
4
+ import { Button, Select, SelectItem } from 'carbon-components-svelte'
5
+ import { InfoCard, translateMutations } from '@reqquest/ui'
6
+ import { phaseChangeMutations, type PhaseChangeMutations } from '@reqquest/ui'
7
+ import { invalidateAll } from '$app/navigation'
8
+ import { api, type BasicRequestData } from '$internal/api'
9
+ import type { PageData } from '../../routes/requests/[id]/approve/[programKey]/$types'
10
+ import { uiRegistry } from '$local'
11
+ import DocumentExport from 'carbon-icons-svelte/lib/DocumentExport.svelte'
12
+
13
+
14
+ export let application: PageData['appRequest']['applications'][0]
15
+ export let basicRequestData: BasicRequestData
16
+ export let requestId: string
17
+
18
+ $: loading = false
19
+
20
+
21
+
22
+ let appRequestAction: '' | PhaseChangeMutations | 'reopen' | 'close' = ''
23
+ async function onAppRequestAction () {
24
+ loading = true
25
+ if ((phaseChangeMutations as readonly string[]).includes(appRequestAction)) {
26
+ await appRequestPhaseChange(appRequestAction as PhaseChangeMutations)
27
+ } else if (appRequestAction === 'close') {
28
+ await closeRequest()
29
+ } else if (appRequestAction === 'reopen') {
30
+ await reopenRequest()
31
+ }
32
+ appRequestAction = ''
33
+ loading = false
34
+ }
35
+
36
+ async function appRequestPhaseChange (action: PhaseChangeMutations) {
37
+ const response = await api.appRequestPhaseChange(requestId, action)
38
+ await invalidateAll()
39
+ loading = false
40
+ if (!response.success) {
41
+ toasts.add({
42
+ type: 'error',
43
+ title: 'Action Failed',
44
+ message: response.messages.map(m => m.message).join('\n') || 'An unknown error occurred.'
45
+ })
46
+ } else {
47
+ toasts.add({
48
+ type: 'success',
49
+ message: `Successfully ${translateMutations[action]}.`
50
+ })
51
+ }
52
+ await invalidateAll()
53
+ }
54
+
55
+ async function closeRequest () {
56
+ const response = await api.closeAppRequest(requestId)
57
+ await invalidateAll()
58
+ if (!response.success) {
59
+ toasts.add({
60
+ type: 'error',
61
+ title: `Could not close ${uiRegistry.getWord('appRequest').toLowerCase()}`,
62
+ message: response.messages.map(m => m.message).join('\n') || 'An unknown error occurred.'
63
+ })
64
+ } else {
65
+ toasts.add({
66
+ type: 'success',
67
+ message: `${uiRegistry.getWord('appRequest')} closed.`
68
+ })
69
+ }
70
+
71
+ }
72
+
73
+ async function reopenRequest () {
74
+ const response = await api.reopenAppRequest(requestId)
75
+ await invalidateAll()
76
+ if (!response.success) {
77
+ toasts.add({
78
+ type: 'error',
79
+ title: `Could not reopen ${uiRegistry.getWord('appRequest').toLowerCase()}`,
80
+ message: response.messages.map(m => m.message).join('\n') || 'An unknown error occurred.'
81
+ })
82
+ } else {
83
+ toasts.add({
84
+ type: 'success',
85
+ message: `${uiRegistry.getWord('appRequest')} reopened.`
86
+ })
87
+ }
88
+ }
89
+
90
+ let appAction: '' | 'advanceWorkflow' | 'reverseWorkflow' = ''
91
+ async function onAppAction () {
92
+ if (appAction === 'advanceWorkflow') {
93
+ await advanceWorkflow()
94
+ } else if (appAction === 'reverseWorkflow') {
95
+ await reverseWorkflow()
96
+ }
97
+ }
98
+
99
+ async function advanceWorkflow () {
100
+ loading = true
101
+ const response = await api.advanceWorkflow(application.id)
102
+ await invalidateAll()
103
+ loading = false
104
+ if (!response.success) {
105
+ toasts.add({
106
+ type: 'error',
107
+ title: 'Could not advance application',
108
+ message: response.messages.map(m => m.message).join('\n') || 'An unknown error occurred.'
109
+ })
110
+ } else {
111
+ toasts.add({
112
+ type: 'success',
113
+ message: 'Application advanced.'
114
+ })
115
+ }
116
+ }
117
+
118
+ async function reverseWorkflow () {
119
+ loading = true
120
+ const response = await api.reverseWorkflow(application.id)
121
+ await invalidateAll()
122
+ loading = false
123
+ if (!response.success) {
124
+ toasts.add({
125
+ type: 'error',
126
+ title: 'Could not reverse application workflow',
127
+ message: response.messages.map(m => m.message).join('\n') || 'An unknown error occurred.'
128
+ })
129
+ } else {
130
+ toasts.add({
131
+ type: 'success',
132
+ message: 'Application workflow reversed.'
133
+ })
134
+ }
135
+ }
136
+ </script>
137
+
138
+ {#if loading}
139
+ <Loading />
140
+ {/if}
141
+
142
+ <InfoCard title='Reviewer Actions'>
143
+ <div slot='header-right'>
144
+ <Button aria-label='export' href={`/requests/${requestId}/approve/export`} kind="secondary" size="small" icon={DocumentExport} class="ml-[32px]" />
145
+ </div>
146
+ <div class="flow">
147
+ <!-- <Select bind:selected={appAction} labelText="Next step" size="sm">
148
+ <SelectItem value="" text="Choose one" />
149
+ <SelectItem disabled={!application.actions.advanceWorkflow} value="advanceWorkflow" text={'Send to ' + (application.nextWorkflowStage?.title ?? (!application.workflowStage?.blocking ? 'Complete' : 'Review Complete'))} />
150
+ <SelectItem disabled={!application.actions.reverseWorkflow} value="reverseWorkflow" text={'Return to ' + (application.previousWorkflowStage?.title ?? 'Review')} />
151
+ </Select>
152
+ <Button disabled={!appAction} on:click={onAppAction} size="small" class="[ w-full ]">Update program</Button> -->
153
+
154
+ {#if basicRequestData}
155
+ <Select bind:selected={appRequestAction} labelText={`${uiRegistry.getWord('appRequest')} action`} size="sm">
156
+ <SelectItem value="" text="Choose one" />
157
+ <SelectItem disabled={!basicRequestData.actions.completeReview} value="completeReview" text="Complete Review" />
158
+ <SelectItem disabled={!basicRequestData.actions.completeRequest} value="completeRequest" text="Complete Request" />
159
+ <SelectItem disabled={!basicRequestData.actions.reopen} value="reopen" text="Reopen Request" />
160
+ <SelectItem disabled={!basicRequestData.actions.close} value="close" text="Close Request" />
161
+ <SelectItem disabled={!basicRequestData.actions.returnToApplicant} value="returnToApplicant" text="Return To Applicant" />
162
+ <SelectItem disabled={!basicRequestData.actions.returnToOffer} value="returnToOffer" text="Return To Offer" />
163
+ <SelectItem disabled={!basicRequestData.actions.returnToReview} value="returnToReview" text="Return To Review" />
164
+ <SelectItem disabled={!basicRequestData.actions.returnToNonBlocking} value="returnToNonBlocking" text="Return To Final Workflow Tasks" />
165
+ <SelectItem disabled={!basicRequestData.actions.submit} value="submitAppRequest" text="Submit On Behalf Of Applicant" />
166
+ <SelectItem disabled={!basicRequestData.actions.acceptOffer} value="acceptOffer" text="Accept Offer On Behalf Of Applicant" />
167
+ </Select>
168
+ <Button disabled={!appRequestAction} on:click={onAppRequestAction} size="small" class="[ w-full ]">Update application</Button>
169
+ {/if}
170
+ </div>
171
+ </InfoCard>
@@ -0,0 +1,508 @@
1
+ <script lang="ts">
2
+ import { Form } from '@txstate-mws/svelte-forms'
3
+ import MachineLearning from 'carbon-icons-svelte/lib/MachineLearning.svelte'
4
+ import WarningFilled from 'carbon-icons-svelte/lib/WarningFilled.svelte'
5
+ import Edit from 'carbon-icons-svelte/lib/Edit.svelte'
6
+ import SidePanelOpenFilled from 'carbon-icons-svelte/lib/SidePanelOpenFilled.svelte'
7
+ import { enumRequirementStatus, enumRequirementType, PromptIndicators, statusReasonsToFeedback, translateMutations, type PhaseChangeMutations, type StatusReason } from '@reqquest/ui'
8
+ import { isInlineReviewerEditPrompt, isDisplayablePrompt, hasDisplayablePrompts, RenderDisplayComponent, applicantRequirementTypes, reviewerRequirementTypes, api, PromptSaveQueue, type BasicRequestData } from '$internal'
9
+ import { FormInlineNotification, Panel, PanelFormDialog, TagSet } from '@txstate-mws/carbon-svelte'
10
+ import { Tooltip } from 'carbon-components-svelte'
11
+ import { uiRegistry } from '$local';
12
+ import { Button } from 'carbon-components-svelte'
13
+ import type { PageData } from '../../routes/requests/[id]/approve/[programKey]/$types'
14
+ import { invalidate, invalidateAll } from '$app/navigation'
15
+ import Review from "carbon-icons-svelte/lib/Review.svelte";
16
+ import DelayedSkeleton from '$internal/components/DelayedSkeleton.svelte';
17
+ import WarningIconYellow from './WarningIconYellow.svelte';
18
+ import MissingDefinitionNotification from './MissingDefinitionNotification.svelte';
19
+ import { toasts } from '@txstate-mws/svelte-components';
20
+ import { randomid } from 'txstate-utils'
21
+
22
+ type ApplicationRequirement = (typeof appRequest)['applications'][number]['requirements'][number]
23
+
24
+ export let sections: { key: string, title: string, requirements: ApplicationRequirement[] }[]
25
+ /** the stage panel that gets the "Send to" button once the application is ready for workflow */
26
+ export let lastStageKey: string | undefined = undefined
27
+ export let promptIndicator: Record<string, any>
28
+ export let promptStatusReasons: Record<string, StatusReason[]> = {}
29
+ export let loading = false
30
+ export let appRequest: PageData['appRequest']
31
+ export let application: PageData['appRequest']['applications'][0]
32
+ export let basicRequestData: BasicRequestData
33
+
34
+ type PromptExtraData = Awaited<ReturnType<typeof api.getPromptData>>
35
+ type Prompt = PageData['appRequest']['applications'][0]['requirements'][0]['prompts'][0]
36
+ type PromptWithExtra = Prompt & PromptExtraData & { allowSaveWithoutChanges?: boolean }
37
+ let editingPromptWithData: PromptWithExtra | undefined = undefined
38
+ let promptBeingEdited: Prompt | undefined = undefined
39
+ let editingViaReviewDialog = false
40
+ let showPromptDialog = false
41
+ let fetchingEditPrompt = false
42
+
43
+ // every save on this screen writes the same appRequest row, so they all run in sequence through one
44
+ // queue that threads the row's dataVersion from each save into the next
45
+ let saveQueue = new PromptSaveQueue()
46
+ let saveQueueAppRequestId: string | undefined
47
+ $: {
48
+ if (saveQueueAppRequestId !== appRequest.id) {
49
+ saveQueueAppRequestId = appRequest.id
50
+ saveQueue = new PromptSaveQueue()
51
+ }
52
+ saveQueue.adopt(appRequest.dataVersion)
53
+ }
54
+
55
+ function editPrompt (prompt: Prompt, allowSaveWithoutChanges: boolean = false, viaReviewDialog: boolean = false) {
56
+ return async () => {
57
+ if (fetchingEditPrompt) return
58
+ promptBeingEdited = prompt
59
+ editingViaReviewDialog = viaReviewDialog
60
+ fetchingEditPrompt = true
61
+ showPromptDialog = true
62
+ try {
63
+ // empty the save queue before fetching, so the version we pin to the modal includes everything
64
+ // this session has typed inline.
65
+ await saveQueue.drain()
66
+ const extra = await api.getPromptData(appRequest.id, prompt.id)
67
+ editingPromptWithData = { ...prompt, ...extra, allowSaveWithoutChanges }
68
+ } finally {
69
+ fetchingEditPrompt = false
70
+ }
71
+ }
72
+ }
73
+ // Diff issue, but needed because a bare document.querySelector('.tcbs-dialog') grabs the first
74
+ // dialog in document order, and the page keeps note form and the notes list permanently mounted.
75
+ // so need to be specific
76
+ const editDialogFormId = randomid()
77
+ function editDialogElement () {
78
+ return document.getElementById(editDialogFormId)?.closest('.tcbs-dialog') ?? undefined
79
+ }
80
+ function hideEditModalPrompt () { // keep the dialog in the DOM so on:saved fires, but remove from view
81
+ if (!showPromptDialog) return
82
+ editDialogElement()?.classList.add('invisible')
83
+ }
84
+ function showEditModalPrompt () {
85
+ editDialogElement()?.classList.remove('invisible')
86
+ }
87
+
88
+ function closePromptDialog () {
89
+ fetchingEditPrompt = false
90
+ showPromptDialog = false
91
+ editingPromptWithData = undefined
92
+ promptBeingEdited = undefined
93
+ editingViaReviewDialog = false
94
+ }
95
+ function onPromptSubmit (prompt: any, modal = false) {
96
+ return async (data: any) => {
97
+ if (modal) { loading = true; hideEditModalPrompt() }
98
+ try {
99
+ // the modal is editing the data it fetched when it opened, so it saves against that version;
100
+ // inline forms carry whatever version the queue is holding when their turn comes up
101
+ const pinnedVersion = modal ? prompt.dataVersion : undefined
102
+ const response = await saveQueue.save(async dataVersion => {
103
+ return (!prompt.allowSaveWithoutChanges)
104
+ ? await api.updatePrompt(prompt.id, data, false, dataVersion)
105
+ : await api.updatePrompt(prompt.id, data, false, dataVersion, true) // triggers from review corrections edit selection, allow saving without changes to handle invalidate prompts that require no changes
106
+ }, pinnedVersion)
107
+ if (modal && !response.success) { loading = false; showEditModalPrompt() }
108
+ // prev resp.data was replacing the store's data with a wrongly-shaped object and
109
+ // re-baselines beforeUserChanges to it. Reduce to this prompt's slice like ApplicantPromptPage.onSubmit
110
+ return { ...response, data: response.data?.[prompt.key] }
111
+ } catch (e) {
112
+ if (modal) { loading = false; showEditModalPrompt() }
113
+ throw e
114
+ }
115
+ }
116
+ }
117
+
118
+ function onPromptValidate (prompt: any) {
119
+ return async (data: any) => {
120
+ const response = await api.updatePrompt(prompt.id, data, true, prompt.dataVersion)
121
+ return response.messages
122
+ }
123
+ }
124
+
125
+ async function refreshReviewData () {
126
+ // Scoped instead of invalidateAll()
127
+ await Promise.all([invalidate('request:approve'), invalidate('request:id')])
128
+ }
129
+
130
+ async function appRequestPhaseChange (action: PhaseChangeMutations) {
131
+ const response = await api.appRequestPhaseChange(appRequest.id, action)
132
+ await invalidateAll()
133
+ loading = false
134
+ if (!response.success) {
135
+ toasts.add({
136
+ type: 'error',
137
+ title: 'Action Failed',
138
+ message: response.messages.map(m => m.message).join('\n') || 'An unknown error occurred.'
139
+ })
140
+ } else {
141
+ toasts.add({
142
+ type: 'success',
143
+ message: `Successfully ${translateMutations[action]}.`
144
+ })
145
+ }
146
+ await invalidateAll()
147
+ }
148
+
149
+ async function advanceWorkflow () {
150
+ loading = true
151
+ const response = await api.advanceWorkflow(application.id)
152
+ await invalidateAll()
153
+ if (basicRequestData?.actions.completeReview) return await appRequestPhaseChange('completeReview')
154
+ if (basicRequestData?.actions.completeRequest) return await appRequestPhaseChange('completeRequest')
155
+ loading = false
156
+ if (!response.success) {
157
+ toasts.add({
158
+ type: 'error',
159
+ title: 'Could not advance application',
160
+ message: response.messages.map(m => m.message).join('\n') || 'An unknown error occurred.'
161
+ })
162
+ } else {
163
+ toasts.add({
164
+ type: 'success',
165
+ message: 'Application advanced.'
166
+ })
167
+ }
168
+ }
169
+
170
+ async function reverseWorkflow () {
171
+ loading = true
172
+ const response = await api.reverseWorkflow(application.id)
173
+ await invalidateAll()
174
+ loading = false
175
+ if (!response.success) {
176
+ toasts.add({
177
+ type: 'error',
178
+ title: 'Could not reverse application workflow',
179
+ message: response.messages.map(m => m.message).join('\n') || 'An unknown error occurred.'
180
+ })
181
+ } else {
182
+ toasts.add({
183
+ type: 'success',
184
+ message: 'Application workflow reversed.'
185
+ })
186
+ }
187
+ }
188
+
189
+ // answered/total reviewer questions in a section
190
+ function reviewerQuestionTally (section: { requirements: ApplicationRequirement[] }) {
191
+ const questions = new Map<string, ApplicationRequirement['prompts'][number]>()
192
+ for (const requirement of section.requirements) {
193
+ if (!reviewerRequirementTypes.has(requirement.type)) continue
194
+ for (const prompt of requirement.prompts.filter(isDisplayablePrompt)) {
195
+ if (!uiRegistry.getPrompt(prompt.key)?.automation) questions.set(prompt.id, prompt)
196
+ }
197
+ }
198
+ const answered = [...questions.values()].filter(p => p.answered && !p.invalidated).length
199
+ return { answered, total: questions.size }
200
+ }
201
+ $: reviewerQuestionTallies = Object.fromEntries(sections.map(section => [section.key, reviewerQuestionTally(section)]))
202
+
203
+ $: readyForWorkflow = !application.nextWorkflowStage && application.phase === 'READY_FOR_WORKFLOW' ? lastStageKey : undefined
204
+ // otherwise the button sits on the last non-stage panel on the page, wherever the layout put it
205
+ $: latestWorkflow = readyForWorkflow ?? sections.filter(section => section.requirements.every(r => !r.workflowStage?.key)).pop()?.key
206
+ // the one panel "Edit answers" sits on: the stage being reversed onto, or the last review panel when reversing back into review.
207
+ // Requirement statuses don't matter here - a disqualified review must be reversible, and the API decides via actions.reverseWorkflow
208
+ $: reverseTarget = application.previousWorkflowStage
209
+ ? sections.find(section => section.requirements[0]?.workflowStage?.key === application.previousWorkflowStage?.key)?.key
210
+ : sections.filter(section => section.requirements.every(r => !r.workflowStage?.key)).pop()?.key
211
+
212
+ /** inline autoSave forms: refresh statuses/indicators only, never touch the live form */
213
+ async function onPromptAutoSaved () {
214
+ await refreshReviewData()
215
+ }
216
+
217
+ /** modal edit form: close first, so the dialog is not left hidden for the whole refresh */
218
+ async function onPromptSaved () {
219
+ loading = false
220
+ closePromptDialog()
221
+ await refreshReviewData()
222
+ }
223
+
224
+ </script>
225
+ {#each sections as section (section.key)}
226
+ <Panel title={section.title} expandable expanded>
227
+ <svelte:fragment slot="headerRight">
228
+ {@const tally = reviewerQuestionTallies[section.key]}
229
+ {#if tally?.total}
230
+ <!-- visible pill is hidden from screen readers in favor of the spelled-out text, and the status role announces changes politely on autosave -->
231
+ <span role="status" class="reviewer-question-tally">
232
+ <span aria-hidden="true">
233
+ <TagSet small tags={[{ label: `${tally.answered}/${tally.total} answered`, type: tally.answered === tally.total ? 'green' : 'yellow' }]} />
234
+ </span>
235
+ <span class="bx--visually-hidden">{tally.answered} of {tally.total} questions answered</span>
236
+ </span>
237
+ {/if}
238
+ </svelte:fragment>
239
+ {#if section.requirements.some(hasDisplayablePrompts)}
240
+ <dl class="prompts">
241
+ {#each section.requirements as requirement (requirement.id)}
242
+ {#each requirement.prompts.filter(isDisplayablePrompt) as prompt (prompt.id)}
243
+ {@const def = uiRegistry.getPrompt(prompt.key)}
244
+ {@const isReviewerQuestion = reviewerRequirementTypes.has(requirement.type) && !def?.automation}
245
+ {@const isAutomation = !!def?.automation}
246
+ {@const editMode = isInlineReviewerEditPrompt(def, requirement.type, prompt)}
247
+ {@const opensReviewDialog = isReviewerQuestion && def?.formMode === 'full'}
248
+ {@const small = editMode && def?.formMode !== 'full' ? def?.formMode !== 'large' : def?.displayMode !== 'large'}
249
+ {@const large = editMode && def?.formMode !== 'full' ? def?.formMode === 'large' : def?.displayMode === 'large'}
250
+ {@const dtid = `dt-title-${prompt.id}`}
251
+ {@const currentWorkflow = application.workflowStage ? application.workflowStage?.key === section.requirements[0]?.workflowStage?.key : undefined}
252
+ {@const disabled = ((!editMode && def?.formMode !== 'full') && (currentWorkflow || requirement.status !== enumRequirementStatus.MET))}
253
+ {#snippet editButtons()}
254
+ {#if prompt.actions.update}
255
+ {#if prompt.invalidated && !applicantRequirementTypes.has(requirement.type)}
256
+ <Button kind="primary" size="field" class="prompt-edit mr-2" icon={Review} iconDescription="Review corrections" id="edit-btn-{prompt.id}" aria-labelledby="edit-btn-{prompt.id} {dtid}" on:click={editPrompt(prompt, true)} />
257
+ {:else if opensReviewDialog}
258
+ <Button kind="secondary" size="field" icon={SidePanelOpenFilled} class="prompt-dialog-open" id="edit-btn-{prompt.id}" aria-labelledby="edit-btn-{prompt.id} {dtid}" on:click={editPrompt(prompt, false, true)}>{uiRegistry.reviewerDialogButtonText(prompt.key)}</Button>
259
+ {:else}
260
+ <Button kind="ghost" size="field" icon={Edit} iconDescription="Edit" class="prompt-edit" id="edit-btn-{prompt.id}" aria-labelledby="edit-btn-{prompt.id} {dtid}" on:click={editPrompt(prompt)} />
261
+ {/if}
262
+ {/if}
263
+ {/snippet}
264
+ <dt class:small class:large class:isReviewerQuestion class:disabled={disabled} class:bg-tagyellow-200={isAutomation}>
265
+ {#if promptIndicator[prompt.key]?.indicator && (promptIndicator[prompt.key].indicator !== PromptIndicators.PENDING || editMode)}
266
+ <div class="indicator-tooltip">
267
+ <Tooltip align="start" direction="bottom">
268
+ <svelte:fragment slot="icon">
269
+ {#if promptIndicator[prompt.key]?.indicator === PromptIndicators.AUTOMATION}
270
+ <MachineLearning size={20} />
271
+ {:else if promptIndicator[prompt.key]?.indicator === PromptIndicators.WARNING}
272
+ <WarningIconYellow />
273
+ {:else if promptIndicator[prompt.key]?.indicator === PromptIndicators.DISQUALIFYING}
274
+ <WarningFilled size={20} class="disqualifying-icon" style="fill: var(--cds-support-01, #da1e28)" />
275
+ {:else if promptIndicator[prompt.key]?.indicator === PromptIndicators.PENDING}
276
+ <svg class="pending-dot" xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
277
+ <circle cx="4" cy="4" r="4" />
278
+ </svg>
279
+ {/if}
280
+ </svelte:fragment>
281
+ {promptIndicator[prompt.key]?.reason}
282
+ </Tooltip>
283
+ </div>
284
+ {/if}
285
+ <div id={dtid}>
286
+ {prompt.title}
287
+ </div>
288
+ {#if large && !editMode}
289
+ {@render editButtons()}
290
+ {/if}
291
+ </dt>
292
+ <dd class="flow" class:small class:large class:isReviewerQuestion class:disabled={disabled} class:bg-tagyellow-200={isAutomation} role={editMode ? 'group' : undefined} aria-labelledby={dtid}>
293
+ {#if editMode}
294
+ <Form bind:store={saveQueue.stores[prompt.id]} preload={prompt.preloadData} submit={onPromptSubmit(prompt)} validate={onPromptValidate(prompt)} autoSave on:autosaved={onPromptAutoSaved} let:data let:messages>
295
+ <svelte:component this={def?.formComponent} {data} appRequestData={appRequest.data} prestageData={{latest: prompt.prestageData, current: appRequest.data[prompt.key]?.__prestage}} fetched={prompt.fetchedData} configData={prompt.configurationData} gatheredConfigData={prompt.gatheredConfigData} invalidated={prompt.invalidated} invalidatedReason={prompt.invalidatedReason} statusReasons={promptStatusReasons[prompt.key] ?? []} />
296
+ {#each statusReasonsToFeedback(promptStatusReasons[prompt.key], { blamedOnly: true, statuses: uiRegistry.statusReasonNotificationStatuses(prompt.key, 'reviewer') }) as message}
297
+ <FormInlineNotification {message} />
298
+ {/each}
299
+ {#each messages as message (message.message, message.type)}
300
+ <FormInlineNotification {message} />
301
+ {/each}
302
+ </Form>
303
+ {:else}
304
+ <div class="pr-4">
305
+ <RenderDisplayComponent
306
+ {def}
307
+ appRequestId={appRequest.id}
308
+ appData={appRequest.data}
309
+ prompt={prompt}
310
+ prestageData={{latest: prompt.prestageData, current: appRequest.data[prompt.key]?.__prestage}}
311
+ configData={prompt.configurationData}
312
+ gatheredConfigData={prompt.gatheredConfigData} showMoot
313
+ statusReasons={promptStatusReasons[prompt.key] ?? []}
314
+ showInlineReviewNotification={prompt.actions.update && prompt.invalidated && !applicantRequirementTypes.has(requirement.type)} />
315
+ </div>
316
+ {#if !large}
317
+ {@render editButtons()}
318
+ {/if}
319
+ {/if}
320
+ </dd>
321
+ {/each}
322
+ {/each}
323
+ </dl>
324
+ {:else if section.requirements[0]?.workflowStage && section.requirements[0]?.workflowStage.key === application.workflowStage?.key}
325
+ No questions need to be answered in this section. You may advance to the next step.
326
+ {:else}
327
+ No questions in this section.
328
+ {/if}
329
+ {#if !section.requirements.every(r => r.type === enumRequirementType.PREQUAL) && (application.actions?.advanceWorkflow || application.actions?.reverseWorkflow)}
330
+ <div class="flex justify-end mt-8">
331
+ {#if application.actions?.advanceWorkflow && (application.workflowStage?.key ? application.workflowStage?.key === section.requirements[0]?.workflowStage?.key : section.key === latestWorkflow)}
332
+ <Button size="small" on:click={advanceWorkflow}>{'Send to ' + (application.nextWorkflowStage?.title ?? (!application.workflowStage?.blocking ? 'Complete' : 'Review Complete'))}</Button>
333
+ {:else if application.actions?.reverseWorkflow && section.key === reverseTarget}
334
+ <Button kind='secondary' size="small" on:click={reverseWorkflow}>Edit answers</Button>
335
+ {/if}
336
+ </div>
337
+ {/if}
338
+ </Panel>
339
+ {/each}
340
+
341
+ {#if showPromptDialog && promptBeingEdited}
342
+ {@const formMode = uiRegistry.getPrompt(promptBeingEdited.key)?.formMode === 'full' ? 'large' : undefined}
343
+ <PanelFormDialog
344
+ id={editDialogFormId}
345
+ title={editingPromptWithData?.invalidated ? `Review correction "${editingPromptWithData?.title}"` : editingViaReviewDialog ? uiRegistry.reviewerDialogButtonText(promptBeingEdited.key) : 'Edit Prompt'}
346
+ bind:open={showPromptDialog}
347
+ on:cancel={closePromptDialog}
348
+ submit={onPromptSubmit(editingPromptWithData, true)}
349
+ validate={onPromptValidate(editingPromptWithData)}
350
+ on:saved={onPromptSaved}
351
+ disableSaveUntilChanged={!editingPromptWithData?.allowSaveWithoutChanges} // allow saving without changes if prompt was previously invalidated ...accomodates reviewer saying no changes required on correction check
352
+ centered={!formMode}
353
+ size={formMode}
354
+ preload={editingPromptWithData?.preloadData}
355
+ let:data
356
+ >
357
+ {#if editingPromptWithData}
358
+ {@const def = uiRegistry.getPrompt(editingPromptWithData.key)}
359
+ <div class='font-medium text-center mt-2'>
360
+ <p class="text-xl font-medium ">{editingPromptWithData.title}</p>
361
+ </div>
362
+ {#if def?.formComponent == null}
363
+ <MissingDefinitionNotification kind="prompt" definitionKey={editingPromptWithData.key} />
364
+ {:else}
365
+ <svelte:component
366
+ this={def.formComponent}
367
+ appRequestId={appRequest.id}
368
+ {data}
369
+ appRequestData={editingPromptWithData.data}
370
+ prestageData={{
371
+ latest: editingPromptWithData.prestageData,
372
+ current: appRequest.data[editingPromptWithData.key]?.__prestage
373
+ }}
374
+ fetched={editingPromptWithData.fetchedData}
375
+ configData={editingPromptWithData.configurationData}
376
+ gatheredConfigData={editingPromptWithData.gatheredConfigData}
377
+ invalidated={editingPromptWithData.invalidated}
378
+ invalidatedReason={editingPromptWithData.invalidatedReason}
379
+ statusReasons={promptStatusReasons[editingPromptWithData.key] ?? []}
380
+ />
381
+ {#each statusReasonsToFeedback(promptStatusReasons[editingPromptWithData.key], { blamedOnly: true, statuses: uiRegistry.statusReasonNotificationStatuses(editingPromptWithData.key, 'reviewer') }) as message}
382
+ <FormInlineNotification {message} />
383
+ {/each}
384
+ {/if}
385
+ {:else if fetchingEditPrompt}
386
+ {@const loader = uiRegistry.getPrompt(promptBeingEdited.key)?.loader}
387
+ <div class='font-medium text-center mt-2'>
388
+ <p class="text-xl font-medium ">{promptBeingEdited.title}</p>
389
+ </div>
390
+ {#if loader}
391
+ <DelayedSkeleton {loader} />
392
+ {/if}
393
+ {/if}
394
+ </PanelFormDialog>
395
+ {/if}
396
+
397
+ <style>
398
+ dl.prompts {
399
+ display: grid;
400
+ grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
401
+ align-items: stretch;
402
+ gap: 0;
403
+ margin: -16px;
404
+ }
405
+ .prompts dt, .prompts dd {
406
+ position: relative;
407
+ border-bottom: 1px solid var(--cds-border-subtle);
408
+ padding: 1rem 15px;
409
+ }
410
+ .prompts dt.small {
411
+ padding-right: 15px;
412
+ }
413
+ .prompts dt.large, .prompts dd.large {
414
+ grid-column: span 2;
415
+ }
416
+ /* When a display component's root is a PromptDisplayGrid — directly, or through the
417
+ <form> of an inline edit (formMode 'large') — hand it our column tracks via subgrid
418
+ so its challenge/response rows align with the real prompt rows. */
419
+ .prompts dd.large:has(> :global(.prompt-display-grid)),
420
+ .prompts dd.large:has(> :global(form > .prompt-display-grid)) {
421
+ display: grid;
422
+ grid-template-columns: subgrid;
423
+ align-content: start;
424
+ padding: 0;
425
+ border-bottom: none;
426
+ --prompt-display-subgrid: subgrid;
427
+ --prompt-display-row-padding: 1rem 15px;
428
+ --prompt-display-challenge-padding-left: 32px;
429
+ }
430
+ .prompts dd.large:has(> :global(.prompt-display-grid)) > :global(*),
431
+ .prompts dd.large:has(> :global(form > .prompt-display-grid)) > :global(*) {
432
+ grid-column: 1 / -1;
433
+ }
434
+ .prompts dd.large > :global(form:has(> .prompt-display-grid)) {
435
+ display: grid;
436
+ grid-template-columns: subgrid;
437
+ }
438
+ .prompts dd.large > :global(form:has(> .prompt-display-grid) > *) {
439
+ grid-column: 1 / -1;
440
+ }
441
+ .prompts dt.isReviewerQuestion, .prompts dd.isReviewerQuestion {
442
+ background-color: var(--background-cool-light);
443
+ }
444
+ .prompts dt.isReviewerQuestion.disabled, .prompts dd.isReviewerQuestion.disabled {
445
+ background-color: #f2f2f2;
446
+ color: #8C8C8C;
447
+ }
448
+ .prompts dt {
449
+ display: flex;
450
+ align-items: center;
451
+ gap: 4px;
452
+ padding-left: 32px;
453
+ }
454
+ .prompts dt :global(.bx--btn.prompt-edit),
455
+ .prompts dd :global(.bx--btn.prompt-edit) {
456
+ position: absolute;
457
+ top: 0.35rem;
458
+ right: 0;
459
+ margin: 0;
460
+ }
461
+ .prompts dt .dt-title {
462
+ display: flex;
463
+ align-items: center;
464
+ gap: 4px;
465
+ min-width: 0;
466
+ }
467
+ /* a large prompt's open-in-dialog button sits at the start of the answer column, so the dt borrows
468
+ the prompt list's column tracks */
469
+ .prompts dt.large:has(> :global(.prompt-dialog-open)) {
470
+ display: grid;
471
+ grid-template-columns: subgrid;
472
+ padding: 0;
473
+ }
474
+ .prompts dt.large:has(> :global(.prompt-dialog-open)) > .dt-title {
475
+ padding: 1rem 15px 1rem 32px;
476
+ }
477
+ .prompts dt.large > :global(.bx--btn.prompt-dialog-open) {
478
+ grid-column: 2;
479
+ justify-self: start;
480
+ align-self: center;
481
+ margin: 0.5rem 15px;
482
+ }
483
+ .prompts dt .indicator-tooltip {
484
+ display: inline-block;
485
+ margin-left: -24px;
486
+ }
487
+ .prompts dt .indicator-tooltip :global(> div) {
488
+ line-height: 0;
489
+ }
490
+ .prompts dt .indicator-tooltip :global(> div .bx--tooltip--shown) {
491
+ line-height: 1.2;
492
+ }
493
+ .prompts dt :global(.warning-icon) {
494
+ fill: var(--cds-inverse-support-03, #f1c21b);
495
+ }
496
+ :global(.disqualifying-icon) {
497
+ fill: var(--cds-support-01, #da1e28);
498
+ }
499
+ .pending-dot {
500
+ width: 8px;
501
+ height: 8px;
502
+ aspect-ratio: 1/1;
503
+ fill: var(--cds-support-01, #da1e28);
504
+ }
505
+ :global(.panel:has(.isReviewerQuestion.disabled) .panel-header) {
506
+ background-color: #8C8C8C !important;
507
+ }
508
+ </style>