@reqquest/ui 1.17.0 → 1.18.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 (100) 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 +444 -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 +0 -4
  99. package/package.json +29 -15
  100. package/dist/components/DelayedSkeleton.svelte.d.ts +0 -21
@@ -0,0 +1,164 @@
1
+ <script lang="ts">
2
+ import { api, getApplicationStatusInfo } from '$internal'
3
+ import type { LayoutData } from '../$types.js'
4
+ import { uiRegistry } from '$local'
5
+ import { enumApplicationStatus, enumRequirementStatus, InfoCard } from '@reqquest/ui'
6
+ import { Loading, Modal } from 'carbon-components-svelte'
7
+ import { Information } from 'carbon-icons-svelte'
8
+ import StatusMessageList from '$internal/components/StatusMessageList.svelte'
9
+ import { getContext } from 'svelte'
10
+ import { page } from '$app/stores'
11
+ import { UISHELL_STICKY_CONTEXT, type UIShellStickyStore } from '@txstate-mws/carbon-svelte'
12
+
13
+ export let basicRequestData: LayoutData['basicRequestData']
14
+ let open = false
15
+
16
+ const stickyShellHeader = getContext<UIShellStickyStore>(UISHELL_STICKY_CONTEXT)
17
+
18
+ // the review page only loads the displayed program, so the dialog fetches every program's outcome when it opens
19
+ type IneligibilityInfo = Awaited<ReturnType<typeof api.getIneligibilityInfo>>
20
+ let ineligibilityInfo: IneligibilityInfo | undefined
21
+ let loadingInfo = false
22
+ async function openIneligibilityInfo () {
23
+ open = true
24
+ loadingInfo = true
25
+ try {
26
+ ineligibilityInfo = await api.getIneligibilityInfo($page.params.id!)
27
+ } finally {
28
+ loadingInfo = false
29
+ }
30
+ }
31
+
32
+ $: optedOutPrograms = ineligibilityInfo?.applications
33
+ .filter(curr => curr.requirements.flatMap(r => r.prompts).find(r => r.optOut))
34
+ .reduce((acc, c) => {
35
+ const optOutRequirement = c.requirements.find(r => r.prompts.some(p => p.optOut))
36
+ return {
37
+ ...acc,
38
+ [c.id]: optOutRequirement?.status === enumRequirementStatus.DISQUALIFYING
39
+ }
40
+ }, {} as Record<string, boolean>) ?? {}
41
+ $: ineligiblePrograms = ineligibilityInfo?.applications.filter(app => app.status === enumApplicationStatus.INELIGIBLE) ?? []
42
+
43
+ </script>
44
+
45
+ <div class="review-container">
46
+ <div class="review-sidebar flow" style="--sticky-top: {$stickyShellHeader.height}px">
47
+ <InfoCard
48
+ noPrimaryAction
49
+ title={basicRequestData.applicant.fullname}
50
+ actions={[
51
+ { label: 'Ineligibility Information', icon: Information, onClick: openIneligibilityInfo }
52
+ ]}>
53
+ <dl class="identifier">
54
+ <dt>{uiRegistry.getWord('login')}</dt>
55
+ <dd>{basicRequestData.applicant.login}</dd>
56
+ {#each basicRequestData.applicant.otherIdentifiers as identifier (identifier.id)}
57
+ <dt>{identifier.label}:</dt>
58
+ <dd>{identifier.id}</dd>
59
+ {/each}
60
+ </dl>
61
+ {#if uiRegistry.config.slots?.reviewerSidebarCard}
62
+ <svelte:component this={uiRegistry.config.slots.reviewerSidebarCard} appRequest={basicRequestData} applicant={basicRequestData.applicant} {api} />
63
+ {/if}
64
+ </InfoCard>
65
+
66
+ <slot name="sidebar" />
67
+ {#if uiRegistry.config.slots?.reviewerSidebar}
68
+ <svelte:component this={uiRegistry.config.slots.reviewerSidebar} appRequest={basicRequestData} applicant={basicRequestData.applicant} {api} />
69
+ {/if}
70
+ </div>
71
+ <div class="review-content">
72
+ <slot />
73
+ </div>
74
+ </div>
75
+
76
+ <Modal passiveModal bind:open modalHeading="Ineligible programs">
77
+ <div class='flex flex-col gap-4 text-base'>
78
+ {#if loadingInfo}
79
+ <Loading withOverlay={false} small />
80
+ {:else if ineligiblePrograms.length && ineligibilityInfo}
81
+ {#each ineligiblePrograms as application}
82
+ {@const warningReqs = application.requirements.filter(r => r.status === 'WARNING' && r.statusReason)}
83
+ {@const message = `${getApplicationStatusInfo(application.status, ineligibilityInfo.phase, ineligibilityInfo.closedAt, application.rescindedStatus)[0].label}${application.statusReason ?? optedOutPrograms[application.id] ? ':' : ''} ${optedOutPrograms[application.id] ? 'Opted out' : application.statusReason}`}
84
+ <span class='py-2'>{application.title}</span>
85
+ <StatusMessageList
86
+ icon
87
+ items={[{ id: '', message }]}
88
+ variant="error"
89
+ accordionTitle="Multiple warnings" />
90
+ <StatusMessageList
91
+ icon
92
+ items={warningReqs.map(r => ({ id: r.id, message: r.statusReason! }))}
93
+ variant="warning"
94
+ accordionTitle="Multiple warnings" />
95
+ {/each}
96
+ {:else}
97
+ <p class="text-center">No ineligible programs</p>
98
+ {/if}
99
+ </div>
100
+ </Modal>
101
+
102
+
103
+ <style>
104
+ .review-container {
105
+ display: flex;
106
+ flex-direction: row-reverse;
107
+ gap: 15px;
108
+ padding: 15px;
109
+ align-items: flex-start;
110
+ }
111
+
112
+ .review-sidebar {
113
+ container-type: inline-size;
114
+ width: min(20rem, 33%);
115
+ position: sticky;
116
+ top: calc(var(--sticky-top, 0px) + 15px);
117
+ /* Cards taller than the viewport scroll within rather than being cut off. */
118
+ max-height: calc(100dvh - var(--sticky-top, 0px) - 30px);
119
+ overflow-y: auto;
120
+ }
121
+
122
+ .review-content {
123
+ flex-grow: 1;
124
+ container-type: inline-size;
125
+ }
126
+
127
+ @media (max-width: 800px) {
128
+ .review-container {
129
+ flex-direction: column-reverse;
130
+ align-items: stretch;
131
+ }
132
+
133
+ .review-sidebar {
134
+ width: 100%;
135
+ position: static;
136
+ max-height: none;
137
+ overflow-y: visible;
138
+ }
139
+ }
140
+
141
+ .identifier {
142
+ width: 100%;
143
+ display: grid;
144
+ grid-template-columns: auto minmax(0, 1fr);
145
+ gap: 8px;
146
+ container-type: inline-size;
147
+ }
148
+ .identifier dt {
149
+ font-weight: bold;
150
+ }
151
+ .identifier dd, .identifier dt {
152
+ margin: 0;
153
+ padding: 0;
154
+ }
155
+ @container (width < 18rem) {
156
+ .identifier {
157
+ grid-template-columns: 1fr;
158
+ }
159
+ }
160
+
161
+ .ineligible-warning {
162
+ padding: .625rem 0;
163
+ }
164
+ </style>
@@ -0,0 +1,396 @@
1
+ <script lang="ts">
2
+ import { BadgeNumber, FieldTextArea, PanelDialog, PanelFormDialog } from '@txstate-mws/carbon-svelte'
3
+ import { toasts } from '@txstate-mws/svelte-components'
4
+ import { ReviewerQuestions, AppRequestActions, SystemDeterminationNotice } from '$internal/components'
5
+ import { Button, InlineNotification, Select, SelectItem } from 'carbon-components-svelte'
6
+ import Edit from 'carbon-icons-svelte/lib/Edit.svelte'
7
+ import Pen from 'carbon-icons-svelte/lib/Pen.svelte'
8
+ import TrashCan from 'carbon-icons-svelte/lib/TrashCan.svelte'
9
+ import View from 'carbon-icons-svelte/lib/View.svelte'
10
+ import { invalidateAll } from '$app/navigation'
11
+ import { api, getApplicationStatusInfo, hasDisplayablePrompts } from '$internal'
12
+ import { CommentCard, enumPromptVisibility, enumRequirementStatus, enumRequirementType, InfoCard, PromptIndicators, type StatusReason } from '@reqquest/ui'
13
+ import type { PageData } from './$types'
14
+ import { uiRegistry } from '$local'
15
+ import ApproveLayout from '../ApproveLayout.svelte'
16
+
17
+
18
+ /**
19
+ * This page is the primary reviewer screen, it will show all the prompt
20
+ * data so far collected and allow the reviewer to navigate to (modal popup?)
21
+ * any available prompts to fill in more data.
22
+ */
23
+ export let data: PageData
24
+ $: ({ basicRequestData, appRequest, programKey, requestId } = data)
25
+ $: application = appRequest.applications.find(a => a.programKey === programKey)!
26
+ // mirrors the API's "no trailing work" rule
27
+ $: nothingToReview = !application.requirements.some(r => r.workflowStage || r.type === enumRequirementType.PREAPPROVAL || r.type === enumRequirementType.APPROVAL || r.type === enumRequirementType.ACCEPTANCE)
28
+ const NOTE_MAX_LENGTH = 350
29
+ $: notes = appRequest?.notes ?? []
30
+ $: latestNote = notes[0]
31
+
32
+ let showNotesDialog = false
33
+ let showAddNoteDialog = false
34
+ let showUpdateNoteDialog = false
35
+ let noteBeingEdited: { id: string, content: string, persistent: boolean } | undefined = undefined
36
+
37
+ async function onAddNoteValidate (data: { content: string, persistent?: boolean }) {
38
+ const response = await api.addNote(appRequest.id, data.content, data.persistent, true)
39
+ return response.messages
40
+ }
41
+
42
+ async function onAddNoteSubmit (data: { content: string, persistent?: boolean }) {
43
+ const response = await api.addNote(appRequest.id, data.content, data.persistent, false)
44
+ return { ...response, data }
45
+ }
46
+
47
+ async function onAddNoteSaved () {
48
+ showAddNoteDialog = false
49
+ await invalidateAll()
50
+ }
51
+
52
+ // NOTE: addNote and updateNote should always share validation rules.
53
+ // As long as we do not set persistent flag, then we can reuse addNote
54
+ // to validate.
55
+ async function onUpdateNoteValidate (data: { content: string }) {
56
+ const response = await api.addNote(appRequest.id, data.content, undefined, true)
57
+ return response.messages
58
+ }
59
+
60
+ async function onUpdateNoteSubmit (data: { content: string, persistent: boolean }) {
61
+ if (!noteBeingEdited) return { success: false, messages: [], data }
62
+ if (data.persistent !== noteBeingEdited.persistent) {
63
+ const response = await api.togglePersistence(noteBeingEdited.id)
64
+ if (!response.success) return { ...response, data }
65
+ }
66
+ if (data.content !== noteBeingEdited.content) {
67
+ const response = await api.updateNote(noteBeingEdited.id, data.content)
68
+ return { ...response, data }
69
+ }
70
+ return { success: true, messages: [], data }
71
+ }
72
+
73
+ async function onUpdateNoteSaved () {
74
+ showUpdateNoteDialog = false
75
+ noteBeingEdited = undefined
76
+ await invalidateAll()
77
+ }
78
+
79
+ function editNote (note: { id: string, content: string, persistent?: boolean | null }) {
80
+ noteBeingEdited = {
81
+ id: note.id,
82
+ content: note.content,
83
+ persistent: note.persistent ?? false
84
+ }
85
+ showNotesDialog = false
86
+ showUpdateNoteDialog = true
87
+ }
88
+
89
+ async function deleteNote (noteId: string) {
90
+ const success = await api.deleteNote(noteId)
91
+ if (success) {
92
+ toasts.add({ type: 'success', message: 'Note deleted.' })
93
+ await invalidateAll()
94
+ }
95
+ }
96
+
97
+ type ApplicationRequirement = (typeof appRequest)['applications'][number]['requirements'][number]
98
+ type Section = { key: string, title: string, requirements: ApplicationRequirement[] }
99
+
100
+ // the screen is split into panels by requirement type, in lifecycle order, unless the program's
101
+ // reviewSections (on its UIConfig entry) says otherwise. A custom panel takes the requirements it names, in the order it names
102
+ // them; the default panels keep whatever is left, in requirementKeys order. Panels the layout does not
103
+ // mention trail it in the default order, so a layout may be partial.
104
+ let sections: Section[]
105
+ // the stage panel the "Send to" button lands on once the application is ready for workflow - the last
106
+ // stage in lifecycle order, whatever order the layout displays the panels in
107
+ let lastStageKey: string | undefined
108
+ // it's not enough to show an indicator for the prompt's requirement status; since we only show the prompt
109
+ // once, we need to show the highest indicator for any requirement that uses this prompt. So if two requirements
110
+ // share a prompt and one requirement is disqualifying while the other is just a warning, we need to show the
111
+ // disqualifying indicator.
112
+ let promptIndicator: Record<string, { indicator: typeof PromptIndicators[keyof typeof PromptIndicators], reason: string | undefined } | undefined>
113
+ // every requirement status reason aimed at each prompt key (all statuses, blamed or fallback), for the
114
+ // inline notifications under the reviewer's edit forms and the `statusReasons` prop of every prompt component
115
+ let promptStatusReasons: Record<string, StatusReason[]>
116
+ $: {
117
+ promptIndicator = {}
118
+ promptStatusReasons = {}
119
+ const general: Section = { key: 'general', title: 'General Questions', requirements: [] }
120
+ const program: Section = { key: 'program', title: application.title, requirements: [] }
121
+ const reviewer: Section = { key: 'reviewer', title: 'Reviewer Questions', requirements: [] }
122
+ const acceptance: Section = { key: 'acceptance', title: 'Acceptance', requirements: [] }
123
+ const defaults: Record<string, Section> = { GENERAL: general, PROGRAM: program, REVIEWER: reviewer, ACCEPTANCE: acceptance }
124
+ const blockingStages: Section[] = []
125
+ const nonBlockingStages: Section[] = []
126
+ const stages: Record<string, Section> = {}
127
+ const layout = uiRegistry.getProgram(programKey)?.reviewSections ?? []
128
+ const custom = layout.map((entry, i) => 'requirementKeys' in entry
129
+ ? { key: `custom:${i}`, title: entry.title, requirementKeys: entry.requirementKeys, requirements: [] as ApplicationRequirement[] }
130
+ : undefined)
131
+ const claimedKeys = new Set<string>(custom.flatMap(c => c?.requirementKeys ?? []))
132
+ const claimed = new Map<string, ApplicationRequirement>()
133
+ for (const req of application.requirements) {
134
+ // automation indicator is about the prompt itself not requirement's status, so only applies to the requirements own prompts
135
+ for (const prompt of req.prompts) {
136
+ if (prompt.visibility === enumPromptVisibility.UNREACHABLE) continue
137
+ if (uiRegistry.getPrompt(prompt.key)?.automation && (promptIndicator[prompt.key]?.indicator ?? 0) < PromptIndicators.AUTOMATION) {
138
+ promptIndicator[prompt.key] = { indicator: PromptIndicators.AUTOMATION, reason: 'This answer will be filled in by an automation.' }
139
+ }
140
+ }
141
+ const faulted = req.blame?.length
142
+ ? req.blame
143
+ : req.prompts.filter(p => p.visibility !== enumPromptVisibility.UNREACHABLE).map(p => p.key)
144
+ for (const key of faulted) {
145
+ (promptStatusReasons[key] ??= []).push({ status: req.status, statusReason: req.statusReason, programName: application.title, blamed: !!req.blame?.length })
146
+ if (req.status === enumRequirementStatus.DISQUALIFYING && (promptIndicator[key]?.indicator ?? 0) < PromptIndicators.DISQUALIFYING) {
147
+ promptIndicator[key] = { indicator: PromptIndicators.DISQUALIFYING, reason: req.statusReason ?? undefined }
148
+ } else if (req.status === enumRequirementStatus.WARNING && (promptIndicator[key]?.indicator ?? 0) < PromptIndicators.WARNING) {
149
+ promptIndicator[key] = { indicator: PromptIndicators.WARNING, reason: req.statusReason ?? undefined }
150
+ } else if (req.status === enumRequirementStatus.PENDING && req.statusReason && (promptIndicator[key]?.indicator ?? 0) < PromptIndicators.PENDING) {
151
+ promptIndicator[key] = { indicator: PromptIndicators.PENDING, reason: req.statusReason }
152
+ }
153
+ }
154
+ if (req.workflowStage) {
155
+ if (!stages[req.workflowStage.key]) {
156
+ stages[req.workflowStage.key] = { key: `stage:${req.workflowStage.key}`, title: req.workflowStage.title, requirements: [] }
157
+ ;(req.workflowStage.blocking ? blockingStages : nonBlockingStages).push(stages[req.workflowStage.key])
158
+ }
159
+ stages[req.workflowStage.key].requirements.push(req)
160
+ } else if (claimedKeys.has(req.key)) claimed.set(req.key, req)
161
+ else if (req.type === enumRequirementType.ACCEPTANCE) acceptance.requirements.push(req)
162
+ else if (req.type === enumRequirementType.APPROVAL || req.type === enumRequirementType.PREAPPROVAL) reviewer.requirements.push(req)
163
+ else if (req.type === enumRequirementType.QUALIFICATION || req.type === enumRequirementType.POSTQUAL) program.requirements.push(req)
164
+ else general.requirements.push(req) // PREQUAL
165
+ }
166
+ // a requirement the layout names but the period disabled has no record here, so it is simply skipped
167
+ for (const c of custom) {
168
+ if (c) c.requirements = c.requirementKeys.map(k => claimed.get(k)).filter((r): r is ApplicationRequirement => r != null)
169
+ }
170
+ const ordered: Section[] = []
171
+ for (const [i, entry] of layout.entries()) {
172
+ const section = 'requirementKeys' in entry
173
+ ? custom[i]
174
+ : 'workflowStage' in entry
175
+ ? stages[entry.workflowStage]
176
+ : defaults[entry.section]
177
+ if (section) ordered.push(section)
178
+ }
179
+ for (const section of [general, program, reviewer, acceptance, ...blockingStages, ...nonBlockingStages]) {
180
+ if (!ordered.includes(section)) ordered.push(section)
181
+ }
182
+ // a panel with nothing to show is dropped rather than rendered as an empty header. Stage panels are the
183
+ // exception: they carry the advance/return controls and a deliberate "nothing to answer, you may advance" state
184
+ sections = ordered.filter(s => (!!s.requirements[0]?.workflowStage) || s.requirements.some(hasDisplayablePrompts))
185
+ lastStageKey = [...blockingStages, ...nonBlockingStages].pop()?.key
186
+ }
187
+ $: applicationStatusTags = getApplicationStatusInfo(application.status, appRequest.phase, appRequest.closedAt, application.rescindedStatus).map(info => ({ label: info.label, type: info.color }))
188
+ $: loading = false
189
+ let showLoading = false
190
+ let loadingTimer: NodeJS.Timeout | undefined
191
+ $: {
192
+ if (loading) {
193
+ if (!loadingTimer) {
194
+ loadingTimer = setTimeout(() => {
195
+ showLoading = true
196
+ }, 500)
197
+ }
198
+ } else {
199
+ clearTimeout(loadingTimer)
200
+ loadingTimer = undefined
201
+ showLoading = false
202
+ }
203
+ }
204
+ // open rescind and restore panelformdialog
205
+ let reasonAction: 'rescind' | 'restore' | undefined = undefined
206
+ $: reasonActionInfo = reasonAction === 'restore'
207
+ ? { title: `Restore ${application.navTitle} benefit`, instructions: 'You will need to provide a reason why this benefit should be restored. The benefit will be restored to the state it was in before it was rescinded.', submitText: 'Restore benefit', successMessage: 'Benefit restored.' }
208
+ : { title: `Rescind ${application.navTitle} benefit`, instructions: 'You will need to provide a reason why this benefit should be rescinded. The applicant will no longer be able to use this benefit after rescinding.', submitText: 'Rescind benefit', successMessage: 'Benefit rescinded.' }
209
+
210
+ async function onReasonValidate (data: { reason: string }) {
211
+ const response = reasonAction === 'restore'
212
+ ? await api.restore(application.id, data.reason, true)
213
+ : await api.rescind(application.id, data.reason, true)
214
+ return response.messages
215
+ }
216
+
217
+ async function onReasonSubmit (data: { reason: string }) {
218
+ const response = reasonAction === 'restore'
219
+ ? await api.restore(application.id, data.reason, false)
220
+ : await api.rescind(application.id, data.reason, false)
221
+ return { ...response, data }
222
+ }
223
+
224
+ async function onReasonSaved () {
225
+ const message = reasonActionInfo.successMessage
226
+ onReasonCancel()
227
+ await invalidateAll()
228
+ toasts.add({ type: 'success', message })
229
+ }
230
+
231
+ function onReasonCancel () {
232
+ reasonAction = undefined
233
+ }
234
+ </script>
235
+
236
+ {#if nothingToReview}
237
+ <SystemDeterminationNotice programTitle={application.title} />
238
+ {/if}
239
+ <ApproveLayout {basicRequestData}>
240
+ <svelte:fragment slot="sidebar">
241
+ <InfoCard title={application.title} tags={applicationStatusTags} tagsInBody />
242
+ <AppRequestActions {application} {basicRequestData} {requestId} />
243
+ <!-- <InfoCard title={application.title} tags={applicationStatusTags} tagsInBody> -->
244
+ <!--
245
+ <dl class="card">
246
+ <dt>Status</dt>
247
+ <dd><TagSet tags={applicationStatusTags} /></dd>
248
+ </dl>
249
+ -->
250
+ <!-- </InfoCard> -->
251
+ <InfoCard
252
+ title="Application Notes"
253
+ actions={[
254
+ ...(appRequest.actions.createNote ? [{ label: 'Add Note', icon: Pen, onClick: () => { showAddNoteDialog = true } }] : []),
255
+ ...(notes.length > 0 ? [{ label: 'See All Notes', icon: View, onClick: () => { showNotesDialog = true } }] : [])
256
+ ]}
257
+ >
258
+ <BadgeNumber slot="header-right" value={notes.length} style="--badge-bg: var(--cds-ui-04)" />
259
+ <div class="active-note">
260
+ {#if latestNote}
261
+ <CommentCard
262
+ authorName={latestNote.author.fullname}
263
+ authorLogin={latestNote.author.login}
264
+ content={latestNote.content}
265
+ createdAt={latestNote.createdAt}
266
+ noborder
267
+ />
268
+ {:else}
269
+ <InlineNotification kind="info" lowContrast hideCloseButton title="No application notes." subtitle="Add a note to see it here."></InlineNotification>
270
+ {/if}
271
+ </div>
272
+ </InfoCard>
273
+
274
+ {#if application.actions.rescindApplication || application.actions.restoreApplication}
275
+ <InfoCard title="Rescind / Restore Benefit">
276
+ <div class="flow">
277
+ {#if application.actions.rescindApplication}
278
+ <Button size="small" class="[ w-full ]" kind="danger" on:click={() => { reasonAction = 'rescind' }}>Rescind benefit</Button>
279
+ {/if}
280
+ {#if application.actions.restoreApplication}
281
+ <Button size="small" class="[ w-full ]" kind="danger-ghost" on:click={() => { reasonAction = 'restore' }}>Restore benefit</Button>
282
+ {/if}
283
+ </div>
284
+ </InfoCard>
285
+ {/if}
286
+ </svelte:fragment>
287
+ <ReviewerQuestions {sections} {lastStageKey} {appRequest} {application} {promptIndicator} {promptStatusReasons} {basicRequestData} bind:loading/>
288
+ </ApproveLayout>
289
+
290
+
291
+ {#if reasonAction}
292
+ <PanelFormDialog
293
+ title={reasonActionInfo.title}
294
+ open
295
+ on:cancel={onReasonCancel}
296
+ on:saved={onReasonSaved}
297
+ validate={onReasonValidate}
298
+ submit={onReasonSubmit}
299
+ submitText={reasonActionInfo.submitText}
300
+ centered
301
+ >
302
+ <div>{reasonActionInfo.instructions}</div>
303
+ <FieldTextArea path="reason" labelText={`Reason to ${reasonAction} this benefit.`} required notNull rows={4} />
304
+ <div class="bx--form__helper-text">This reason will be recorded in the request activity and included in the email sent to the applicant.</div>
305
+ </PanelFormDialog>
306
+ {/if}
307
+
308
+ {#if showUpdateNoteDialog && noteBeingEdited}
309
+ <PanelFormDialog
310
+ title="Update Note"
311
+ bind:open={showUpdateNoteDialog}
312
+ on:cancel={() => { showUpdateNoteDialog = false; noteBeingEdited = undefined }}
313
+ on:saved={onUpdateNoteSaved}
314
+ validate={onUpdateNoteValidate}
315
+ submit={onUpdateNoteSubmit}
316
+ submitText="Save"
317
+ disableSaveUntilChanged={true}
318
+ centered
319
+ preload={{
320
+ content: noteBeingEdited.content,
321
+ persistent: noteBeingEdited.persistent
322
+ }}
323
+ >
324
+ <FieldTextArea path="content" labelText="Note" required notNull rows={6} maxCount={NOTE_MAX_LENGTH} />
325
+ <!--
326
+ {#if appRequest.actions.createPersistentNote}
327
+ <FieldCheckbox path="persistent" labelText="Persistent (show on the applicant's profile)" />
328
+ {/if}
329
+ -->
330
+ </PanelFormDialog>
331
+ {/if}
332
+
333
+ <PanelFormDialog
334
+ title="New application note"
335
+ bind:open={showAddNoteDialog}
336
+ on:cancel={() => { showAddNoteDialog = false }}
337
+ on:saved={onAddNoteSaved}
338
+ validate={onAddNoteValidate}
339
+ submit={onAddNoteSubmit}
340
+ submitText="Save note"
341
+ centered
342
+ >
343
+ <div>Write a note for this application.</div>
344
+ <FieldTextArea path="content" labelText="Note" required notNull rows={6} maxCount={NOTE_MAX_LENGTH} placeholder="Enter your note here." />
345
+ <div class="bx--form__helper-text">This note will only be visible to other reviewers who access this application.</div>
346
+ <!-- {#if appRequest.actions.createPersistentNote}
347
+ <FieldCheckbox path="persistent" labelText="Persistent (show on the applicant's profile)" />
348
+ {/if}
349
+ -->
350
+ </PanelFormDialog>
351
+
352
+ <PanelDialog
353
+ title="Application Notes"
354
+ bind:open={showNotesDialog}
355
+ on:cancel={() => { showNotesDialog = false }}
356
+ cancelText="Close"
357
+ on:submit={() => { showNotesDialog = false; showAddNoteDialog = true }}
358
+ submitText="Add a note"
359
+ centered
360
+ size="large"
361
+ >
362
+ <div class="notes-list flow">
363
+ {#each notes as note (note.id)}
364
+ <CommentCard
365
+ content={note.content}
366
+ authorName={note.author.fullname}
367
+ authorLogin={note.author.login}
368
+ createdAt={note.createdAt}
369
+ actions={[
370
+ { label: 'Edit', icon: Edit, onClick: () => editNote(note) },
371
+ { label: 'Delete', icon: TrashCan, onClick: () => deleteNote(note.id) }
372
+ ]}
373
+ />
374
+ {:else}
375
+ <p>No application notes. Add note to see it here.</p>
376
+ {/each}
377
+ </div>
378
+ </PanelDialog>
379
+
380
+ <style>
381
+ .active-note {
382
+ padding: 4px 8px;
383
+ }
384
+
385
+ :global(.content):has(.notes-list) {
386
+ max-height: calc(90vh - 1.5rem - 64px);
387
+ background-color: var(--cds-ui-01);
388
+ }
389
+
390
+ .app-actions {
391
+ width: fit-content;
392
+ }
393
+ .app-actions :global(.bx--select) {
394
+ width: auto;
395
+ }
396
+ </style>
@@ -0,0 +1,15 @@
1
+ import { api } from '$internal'
2
+ import { error } from '@sveltejs/kit'
3
+ import { uiRegistry } from '$local'
4
+ import type { PageLoad } from './$types'
5
+ import { getInlineReviewerEditPrompts, coalesceAppRequestPrompts } from '$internal'
6
+
7
+ export const load: PageLoad = async ({ params, depends }) => {
8
+ const appRequest = await api.getReviewData(params.id)
9
+ if (!appRequest) throw error(404, `${uiRegistry.getWord('appRequest')} not found`)
10
+ const inlinePrompts = getInlineReviewerEditPrompts(appRequest)
11
+ const inlinePromptsWithData = await api.getPromptDataLegion(params.id, (inlinePrompts ?? []).map(prompt => prompt.id))
12
+ const coalescedAppRequest = coalesceAppRequestPrompts(appRequest, inlinePromptsWithData)
13
+ depends('request:approve')
14
+ return { appRequest: coalescedAppRequest, programKey: params.programKey, requestId: params.id }
15
+ }
@@ -0,0 +1,66 @@
1
+ <script lang="ts">
2
+ import { ColumnList, FieldDate, FieldMultiselect, FilterUI, Pagination } from '@txstate-mws/carbon-svelte'
3
+ import { htmlEncode, sortby } from 'txstate-utils'
4
+ import { longDateTime, downloadCsv } from '$internal'
5
+ import { uiRegistry } from '$local'
6
+ import type { PageData } from './$types'
7
+ import ApproveLayout from '../ApproveLayout.svelte'
8
+ import DocExport from 'carbon-icons-svelte/lib/DocumentExport.svelte'
9
+
10
+ export let data: PageData
11
+ $: ({ activity, actions, people, basicRequestData } = data)
12
+ $: activityActions = actions.map(value => ({ value }))
13
+ $: activityPeople = sortby(people.map(p => ({ value: p.login, label: `${p.fullname} (${p.login})` })), 'label')
14
+
15
+ function formatCSVData (d: any[]) {
16
+ return d.map(row => ({
17
+ User: `${row.user.fullname} (${row.user.login})` + (row.impersonatedBy ? `<div class="impersonator">${htmlEncode(row.impersonatedBy.fullname)} (${row.impersonatedBy.login})</div>` : ''),
18
+ 'Created At': longDateTime(row.createdAt),
19
+ Action: row.action,
20
+ Description: row.description
21
+ }))
22
+ }
23
+ </script>
24
+
25
+ <ApproveLayout {basicRequestData}>
26
+ <div>
27
+ <FilterUI>
28
+ <svelte:fragment slot="quickfilters">
29
+ <FieldMultiselect path="actions" label="Activity Type" placeholder="Filter by activity type" items={activityActions} />
30
+ <FieldMultiselect path="users" label="People" placeholder="Filter by person" items={activityPeople} />
31
+ </svelte:fragment>
32
+ <FieldDate path="happenedAfter" labelText="From" placeholder="Activities After" />
33
+ <FieldDate path="happenedBefore" labelText="To" placeholder="Activities Before" />
34
+ </FilterUI>
35
+
36
+ <ColumnList
37
+ searchable
38
+ title='Activity Log'
39
+ rows={activity}
40
+ columns={[
41
+ { id: 'user', label: 'Person', render: row => htmlEncode(`${row.user.fullname} (${row.user.login})`) + (row.impersonatedBy ? `<div class="impersonator">${htmlEncode(row.impersonatedBy.fullname)} (${row.impersonatedBy.login})</div>` : '') },
42
+ { id: 'createdAt', label: 'Date', render: row => longDateTime(row.createdAt) },
43
+ { id: 'action', label: 'Activity', get: 'action' },
44
+ { id: 'description', label: 'Detail', get: 'description' }
45
+ ]}
46
+ listActions={[
47
+ { label: 'Download', icon: DocExport, onClick: () => { downloadCsv(formatCSVData(data.activity), 'app_requests_activity_log') } }
48
+ ]}
49
+ noItemsTitle="No activity."
50
+ noItemsSubtitle="No activity has been recorded for this {uiRegistry.getWord('appRequest').toLocaleLowerCase()}." />
51
+ </div>
52
+ <Pagination
53
+ page={data.pageInfo?.currentPage}
54
+ totalItems={data.pageInfo?.totalItems}
55
+ pageSize={25}
56
+ chooseSize
57
+ />
58
+
59
+ </ApproveLayout>
60
+
61
+ <style>
62
+ div :global(.impersonator) {
63
+ font-size: 0.8em;
64
+ color: var(--cds-text-02);
65
+ }
66
+ </style>
@@ -0,0 +1,12 @@
1
+ import { api } from '$internal'
2
+ import { error } from '@sveltejs/kit'
3
+ import type { PageLoad } from './$types'
4
+ import { extractMergedFilters, extractPaginationParams } from '@txstate-mws/carbon-svelte'
5
+
6
+ export const load: PageLoad = async ({ params, url, depends }) => {
7
+ const filters = extractMergedFilters(url)
8
+ const { page, pagesize } = extractPaginationParams(url)
9
+ const { activity, actions, people, pageInfo } = await api.getRequestActivity(params.id, filters, { page, perPage: pagesize })
10
+ depends('request:approve:activity')
11
+ return { activity, actions, people, pageInfo }
12
+ }
@@ -0,0 +1,12 @@
1
+ import { api } from '$internal'
2
+ import type { PageLoad } from './$types'
3
+
4
+ export const load: PageLoad = async ({ params, depends }) => {
5
+ const details = await api.getAppRequestForExport(params.id)
6
+
7
+ depends('request:reviewer-export')
8
+ return {
9
+ ...details,
10
+ appData: details.appRequest.data
11
+ }
12
+ }