@reqquest/ui 1.16.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 (105) 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/dist/registry.js +1 -1
  100. package/dist/typed-client/schema.d.ts +25 -3
  101. package/dist/typed-client/schema.graphql +18 -2
  102. package/dist/typed-client/types.d.ts +9 -1
  103. package/dist/typed-client/types.js +29 -5
  104. package/package.json +29 -15
  105. package/dist/components/DelayedSkeleton.svelte.d.ts +0 -21
@@ -0,0 +1,33 @@
1
+ import { enumIneligiblePhases, enumRequirementStatus, type IneligiblePhases } from '@reqquest/ui'
2
+
3
+ /**
4
+ * True when this application is ineligible because of an applicant requirement (PREQUAL, QUALIFICATION
5
+ * or POSTQUAL). This looks at the type of the failing requirement, not at when it failed - a reviewer
6
+ * changing an applicant answer after submission also makes this true. For "was it ineligible before
7
+ * submission" use the API's `hiddenIneligiblePreSubmit` instead.
8
+ *
9
+ * These are the applications that land in the "Ineligible benefits" panel on
10
+ * the applicant screens, and the ones whose program `eligibilityDescription` should be shown.
11
+ * Anywhere that cares about membership in that panel should go through this function so the
12
+ * panel and the extra messaging can't drift out of sync.
13
+ */
14
+ export function isIneligibleByApplicantRequirement (application: { ineligiblePhase?: IneligiblePhases | null }) {
15
+ return application.ineligiblePhase === enumIneligiblePhases.PREQUAL || application.ineligiblePhase === enumIneligiblePhases.QUALIFICATION
16
+ }
17
+
18
+
19
+ // Return the appRequests with any application that is ineligible because of an applicant requirement stripped out of each one. See `isIneligibleByApplicantRequirement`.
20
+ export function excludeApplicantRequirementIneligibleApps<T extends { applications: { ineligiblePhase?: IneligiblePhases | null }[] }> (appRequests: T[]): T[] {
21
+ return appRequests.map(ar => ({
22
+ ...ar,
23
+ applications: ar.applications.filter(app => !isIneligibleByApplicantRequirement(app))
24
+ }))
25
+ }
26
+
27
+ export function isHiddenFromApplicant (application: { hiddenIneligiblePreSubmit?: boolean | null, requirements?: { status: string, prompts: { optOut?: boolean | null }[] }[] }) {
28
+ if (!application.hiddenIneligiblePreSubmit) return false
29
+ const optedOut = application.requirements?.some(r => r.status === enumRequirementStatus.DISQUALIFYING && r.prompts.some(p => p.optOut))
30
+ return !optedOut
31
+ }
32
+
33
+ export const DEFAULT_APPLICANT_NO_PROGRAMS_MESSAGE = 'Based on your responses, you don\'t currently qualify for any available benefits. If you believe this is incorrect, review your answers.'
@@ -0,0 +1,47 @@
1
+ <script lang="ts">
2
+ import { InlineNotification, NotificationActionButton } from 'carbon-components-svelte'
3
+
4
+ /** Short headline shown in bold. */
5
+ export let subject: string
6
+ /** Body text shown under the subject. */
7
+ export let body: string
8
+ /** Optional URL rendered as a ghost action button after the text. */
9
+ export let link: string | null | undefined = undefined
10
+ /** Visible text for the link. Required by the API whenever a link is given. */
11
+ export let linkText: string | null | undefined = undefined
12
+ </script>
13
+
14
+ <!--
15
+ Deliberately NOT carbon's default role="alert". The banner is ordinary page content that is
16
+ already there when the applicant arrives, so it must be a labelled landmark that screen readers
17
+ meet once in reading order (and can jump to), not a live region that announces itself and its
18
+ link on insertion. NVDA was reading the link three times under role="alert". The prop type only
19
+ admits alert|log|status, but the component forwards the string verbatim to the root div, hence
20
+ the cast. The fixed label keeps the subject from being spoken twice (landmark name + title).
21
+ -->
22
+ <InlineNotification
23
+ role={'region' as any}
24
+ aria-label="Announcement"
25
+ kind="warning"
26
+ title={subject}
27
+ subtitle={body}
28
+ lowContrast
29
+ hideCloseButton
30
+ class="time-sensitive-banner"
31
+ >
32
+ <svelte:fragment slot="actions">
33
+ {#if link}
34
+ <NotificationActionButton href={link}>{linkText}</NotificationActionButton>
35
+ {/if}
36
+ </svelte:fragment>
37
+ </InlineNotification>
38
+
39
+ <style>
40
+ :global(div.time-sensitive-banner.bx--inline-notification) {
41
+ min-width: unset;
42
+ max-width: fit-content;
43
+ flex-wrap: wrap;
44
+ width: auto;
45
+ align-items: center;
46
+ }
47
+ </style>
@@ -0,0 +1,173 @@
1
+ <script lang="ts">
2
+ import type { ActionItem } from '@txstate-mws/carbon-svelte'
3
+ import { Card, TagSet } from '@txstate-mws/carbon-svelte'
4
+ import { Button } from 'carbon-components-svelte'
5
+ import { goto } from '$app/navigation'
6
+ import { resolve } from '$app/paths'
7
+ import { enumPromptVisibility } from '@reqquest/ui'
8
+ import { getApplicationStatusInfo, getAppRequestStatusInfo, getNavigationButton } from '../status-utils.js'
9
+ import { longNumericTime } from '../util.js'
10
+ import { uiRegistry } from '$local'
11
+ import type { PageData } from '../../routes/dashboards/applicant/$types'
12
+ import StatusMessageList from './StatusMessageList.svelte'
13
+ import WarningIconYellow from './WarningIconYellow.svelte'
14
+
15
+ // Type for the partial AppRequest data passed from dashboard
16
+ type DashboardAppRequest = PageData['appRequests'][number]
17
+
18
+ export let request: DashboardAppRequest
19
+ export let actions: ActionItem[] = []
20
+ export let showAcceptanceButtons = true
21
+ export let onAcceptanceNavigate: ((requestId: string) => void) | undefined = undefined
22
+
23
+ $: statusInfo = getAppRequestStatusInfo(request.status, request.phase, request.closedAt)
24
+ $: firstInvalidatedPrompt = request.applications
25
+ .flatMap(app => app.requirements)
26
+ .flatMap(req => req.prompts)
27
+ .find(p => p.visibility === enumPromptVisibility.AVAILABLE && !p.moot && p.invalidated && p.invalidatedReason)
28
+ $: navButton = firstInvalidatedPrompt
29
+ ? { label: 'Make corrections', href: `/requests/${request.id}/apply/${firstInvalidatedPrompt.id}` }
30
+ : getNavigationButton(request.status, request.id)
31
+
32
+ async function handleAcceptanceClick () {
33
+ if (onAcceptanceNavigate) {
34
+ onAcceptanceNavigate(request.id)
35
+ } else {
36
+ await goto(resolve(`/requests/${request.id}/apply`))
37
+ }
38
+ }
39
+ </script>
40
+
41
+ <Card
42
+ title={request.period.name}
43
+ subhead={`Started: ${longNumericTime(request.createdAt)}`}
44
+ tags={statusInfo.tags}
45
+ tagsInBody
46
+ {actions}
47
+ forceOverflow={true}
48
+ navigations={navButton ? [navButton] : []}>
49
+
50
+ <!-- Status description -->
51
+ {#if statusInfo.description}
52
+ <p class="pl-1.5 mb-4 text-sm">{statusInfo.description}</p>
53
+ {/if}
54
+
55
+ <!-- Benefits section -->
56
+ <div class="rounded">
57
+ <h4 class="m-0 mb-2 text-lg benefits-title">Potential benefits</h4>
58
+ <p class="m-0 mb-3 benefits-subtitle text-sm">Benefit results will be finalized once the {uiRegistry.getWord('appRequest').toLowerCase()} submission and review is completed.</p>
59
+
60
+ {#if request.applications.length > 0}
61
+ {#each request.applications as application (application.id)}
62
+ {@const invalidatedPrompts = application.requirements
63
+ .flatMap(req => req.prompts)
64
+ .filter(p => p.visibility === enumPromptVisibility.AVAILABLE && !p.moot && p.invalidated && p.invalidatedReason)
65
+ }
66
+ {@const warningReqs = application.requirements.filter(r => r.status === 'WARNING' && r.statusReason)}
67
+ {@const appStatusTags = invalidatedPrompts.length > 0
68
+ ? [{ label: 'Needs corrections', type: 'magenta' as const }]
69
+ : getApplicationStatusInfo(application.status, request.phase, request.closedAt, application.rescindedStatus).map(info => ({ label: info.label, type: info.color }))}
70
+ <div class="program-status py-2 px-4 mb-4">
71
+ <div class="flex items-center">
72
+ <span class="font-medium">{application.title}</span>
73
+ <div class="tagwrap">
74
+ <TagSet tags={appStatusTags} />
75
+ </div>
76
+ {#if (application.status === 'PENDING' || application.status === 'ELIGIBLE') && warningReqs.length > 0}
77
+ <WarningIconYellow size={20} />
78
+ {/if}
79
+
80
+ <!-- Acceptance buttons -->
81
+ {#if showAcceptanceButtons && (request.status === 'ACCEPTANCE' || request.status === 'READY_TO_ACCEPT') && application.status === 'ELIGIBLE'}
82
+ <Button kind="primary" size="small" class="ml-auto"
83
+ on:click={handleAcceptanceClick}>
84
+ {request.status === 'READY_TO_ACCEPT' ? 'Accept Offer' : 'Review Offer'}
85
+ </Button>
86
+ {/if}
87
+ </div>
88
+
89
+ <!-- Status reason -->
90
+ {#if application.statusReason && application.status !== 'INELIGIBLE' && invalidatedPrompts.length === 0}
91
+ <p class="status-reason mt-2 mb-0 text-sm">{application.statusReason}</p>
92
+ {/if}
93
+
94
+ <!-- Warnings for PENDING/ELIGIBLE applications -->
95
+ {#if (application.status === 'PENDING' || application.status === 'ELIGIBLE') && warningReqs.length > 0}
96
+ <StatusMessageList
97
+ items={warningReqs.map(r => ({ id: r.id, message: r.statusReason! }))}
98
+ variant="warning"
99
+ accordionTitle="Multiple warnings" />
100
+ {/if}
101
+
102
+ <!-- Failed requirements for INELIGIBLE applications -->
103
+ {#if application.status === 'INELIGIBLE' && application.requirements}
104
+ {@const failedRequirements = application.requirements.filter(req => req.status === 'DISQUALIFYING' && req.statusReason)}
105
+ <StatusMessageList
106
+ items={failedRequirements.map(r => ({ id: r.id, message: r.statusReason! }))}
107
+ accordionTitle="Multiple eligibility issues" />
108
+ {/if}
109
+
110
+ <!-- Corrections needed for non-INELIGIBLE applications -->
111
+ {#if invalidatedPrompts.length > 0}
112
+ <StatusMessageList
113
+ items={invalidatedPrompts.map(p => ({ id: p.id, message: p.invalidatedReason! }))}
114
+ accordionTitle="Multiple corrections needed" />
115
+ {/if}
116
+ </div>
117
+ {/each}
118
+ {:else}
119
+ <p>No benefits associated with this {uiRegistry.getWord('appRequest').toLowerCase()}.</p>
120
+ {/if}
121
+ </div>
122
+
123
+ <!-- Footer info grid -->
124
+ <div class="mt-4 grid grid-cols-3 footer-grid">
125
+ <div class="text-center py-4 px-2 footer-section">
126
+ <span class="block text-sm font-semibold footer-label mb-1">Request number:</span>
127
+ <span class="block text-sm footer-value">{request.id.slice(0, 6).toUpperCase()}</span>
128
+ </div>
129
+ <div class="text-center py-4 px-2 footer-section">
130
+ <span class="block text-sm font-semibold footer-label mb-1">Last updated:</span>
131
+ <span class="block text-sm footer-value">{longNumericTime(request.updatedAt)}</span>
132
+ </div>
133
+ <div class="text-center py-4 px-2 footer-section">
134
+ <span class="block text-sm font-semibold footer-label mb-1">Waiting on:</span>
135
+ <span class="block text-sm footer-value">{statusInfo.waitingOn}</span>
136
+ </div>
137
+ </div>
138
+ </Card>
139
+
140
+ <style>
141
+ /* Global styles for external component overrides */
142
+ :global(.tagwrap .tag-set > *) {
143
+ max-width: 100% !important;
144
+ }
145
+
146
+ .benefits-subtitle {
147
+ color: var(--cds-text-02);
148
+ }
149
+
150
+ .program-status {
151
+ background-color: var(--cds-ui-01);
152
+ }
153
+
154
+ .footer-grid {
155
+ background-color: var(--cds-ui-01);
156
+ }
157
+
158
+ .footer-section {
159
+ border-right: 1px solid var(--cds-ui-05);
160
+
161
+ }
162
+ .footer-section:last-child {
163
+ border-right: none;
164
+ }
165
+
166
+ .footer-label {
167
+ color: var(--cds-text-01);
168
+ }
169
+
170
+ .footer-value {
171
+ color: var(--cds-text-01);
172
+ }
173
+ </style>
@@ -0,0 +1,78 @@
1
+ <script lang="ts">
2
+ import { Form, PanelFormDialog } from '@txstate-mws/carbon-svelte'
3
+ import type { FormStore } from '@txstate-mws/svelte-forms'
4
+ import { afterNavigate, invalidate, invalidateAll } from '$app/navigation'
5
+ import { uiRegistry } from '$local'
6
+ import { api, type PromptForEditing } from '../api.js'
7
+ import { Loading } from "carbon-components-svelte";
8
+ import MissingDefinitionNotification from './MissingDefinitionNotification.svelte'
9
+ import type { AppRequestForDetails, OptOutApplication } from '@reqquest/ui'
10
+
11
+ export let open = false
12
+ export let optIn = false
13
+ export let prompt: PromptForEditing
14
+ export let appRequest: AppRequestForDetails
15
+ export let optOutSelected: OptOutApplication | undefined
16
+
17
+ $: def = uiRegistry.getPrompt(prompt.key)
18
+ let loading = false
19
+
20
+
21
+ let store: FormStore | undefined
22
+
23
+ async function submit (data: any) {
24
+ loading = true
25
+ try {
26
+ const { success, messages, data: newData } = await api.updatePrompt(prompt.id, data, false)
27
+ if (!success) loading = false
28
+ return { success, messages, data: newData?.[prompt.key] }
29
+ } catch (e) {
30
+ loading = false
31
+ throw e
32
+ }
33
+ }
34
+
35
+ async function saved () {
36
+ open = false
37
+ loading = false
38
+ invalidateAll()
39
+ }
40
+
41
+ async function onValidate (data: any) {
42
+ const { messages } = await api.updatePrompt(prompt.id, data, true)
43
+ return messages
44
+ }
45
+
46
+ let lastPromptId: string | undefined
47
+ $: if (prompt.id !== lastPromptId) {
48
+ lastPromptId = prompt.id
49
+ store = undefined
50
+ }
51
+ </script>
52
+
53
+ {#if loading}
54
+ <Loading />
55
+ {/if}
56
+
57
+ <PanelFormDialog
58
+ let:data
59
+ bind:store
60
+ centered
61
+ open={open}
62
+ on:cancel={() => { open = false }}
63
+ validate={onValidate}
64
+ on:saved={saved}
65
+ disableSaveUntilChanged={true}
66
+ {submit}
67
+ title={`${optIn ? 'Opt in to' : 'Opt out of'} ${optOutSelected?.title}?`}
68
+ submitText={optIn ? 'Opt in' : 'Opt out'}
69
+ cancelText="Cancel"
70
+ preload={prompt.preloadData}
71
+ preloadAsDraft={!prompt.hasSavedData}
72
+ >
73
+ {#if def?.formComponent == null}
74
+ <MissingDefinitionNotification kind="prompt" definitionKey={prompt.key} />
75
+ {:else}
76
+ <svelte:component this={def.formComponent} {data} appRequestId={appRequest.id} 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={optOutSelected?.prompt.statusReasons ?? []} />
77
+ {/if}
78
+ </PanelFormDialog>
@@ -0,0 +1,276 @@
1
+ <script lang="ts">
2
+ import { TagSet } from '@txstate-mws/carbon-svelte'
3
+ import { Button } from 'carbon-components-svelte'
4
+ import { Close, InProgress, CheckmarkFilled, Information, SubtractAlt } from 'carbon-icons-svelte'
5
+ import { isNotBlank } from 'txstate-utils'
6
+ import { type ApplicationForDetails, type AppRequestForDetails, enumApplicationStatus, enumIneligiblePhases, enumPromptVisibility, enumRequirementStatus, enumRequirementType, type OptOutApplication } from '@reqquest/ui'
7
+ import { getApplicationStatusTags } from '../status-utils.js'
8
+ import { isIneligibleByApplicantRequirement } from '../appreq-utils.js'
9
+ import ApplicantProgramListTooltip from './ApplicantProgramListTooltip.svelte'
10
+ import WarningIconYellow from './WarningIconYellow.svelte'
11
+ import { api, type PromptForEditing } from '$internal/api.js'
12
+ import ApplicantOptOutModal from './ApplicantOptOutModal.svelte'
13
+
14
+ export let appRequest: AppRequestForDetails
15
+ export let applications: ApplicationForDetails[]
16
+ export let viewMode = false
17
+ export let showTooltipsAsText = false
18
+ export let promptsById: Record<string, any> = {}
19
+
20
+ let open = false
21
+ let optIn = false
22
+ let optOutPrompt: PromptForEditing | undefined
23
+ let optOutSelected: OptOutApplication | undefined
24
+
25
+ async function openOptOutModal (programId: string, openOptIn = false) {
26
+ optOutSelected = optOutPrograms[programId]
27
+ await loadOptOutPrompt()
28
+ optIn = openOptIn
29
+ open = true
30
+ }
31
+
32
+ $: promptsByApplicationId = applications.reduce<Record<string, typeof applications[0]['requirements'][0]['prompts'] | undefined>>((acc, curr) => ({
33
+ ...acc,
34
+ [curr.id]: curr.requirements
35
+ .filter(r => r.type === enumRequirementType.QUALIFICATION)
36
+ .flatMap(r => r.prompts)
37
+ }), {})
38
+
39
+ $: programButtonStatus = applications.reduce((acc, curr) => {
40
+ let status: string
41
+ if (curr.completionStatus === enumApplicationStatus.PENDING) {
42
+ const unanswered = curr.requirements
43
+ .filter(r => r.type === enumRequirementType.QUALIFICATION)
44
+ .flatMap(r => r.prompts)
45
+ .filter(p => (!p.answered || p.invalidated) && !p.optOut)
46
+ if (!curr.requirements.some(r => r.prompts.some(p => p.answered && !p.invalidated && !p.optOut))) status = 'start'
47
+ else if (unanswered.length === 0) status = 'complete'
48
+ else if (unanswered.some(p => p.visibility === enumPromptVisibility.AVAILABLE)) status = 'continue'
49
+ // every remaining unanswered prompt is owned by an earlier program, so continuing here shows nothing new
50
+ else status = 'revisitPending'
51
+ } else if (curr.completionStatus === enumApplicationStatus.INELIGIBLE) {
52
+ status = curr.ineligiblePhase === enumIneligiblePhases.PREQUAL ? 'ineligible' : 'revisit'
53
+ } else status = 'complete'
54
+ return { ...acc, [curr.id]: status }
55
+ }, {} as Record<string, string>)
56
+
57
+ const programButtonLabels: Record<string, string> = {
58
+ start: 'Start',
59
+ continue: 'Continue',
60
+ complete: 'Revisit',
61
+ revisit: 'Revisit',
62
+ revisitPending: 'Revisit'
63
+ }
64
+
65
+ $: programFirstPromptId = applications.reduce((acc, curr) => ({
66
+ ...acc,
67
+ [curr.id]: (promptsByApplicationId[curr.id]?.find(p => (!p.answered || p.invalidated) && !p.optOut) ?? (promptsByApplicationId[curr.id]?.[0]?.optOut ? promptsByApplicationId[curr.id]?.[1] : promptsByApplicationId[curr.id]?.[0]))?.id
68
+ }), {} as Record<string, string | undefined>)
69
+
70
+ $: optOutPrograms = applications.reduce((acc, curr) => {
71
+ const optOut = curr.requirements.flatMap(r => r.prompts).find(({ optOut: optOutPrompt }) => optOutPrompt)
72
+
73
+ if (!optOut) return acc
74
+
75
+ return {
76
+ ...acc,
77
+ [curr.id]: {
78
+ ...curr,
79
+ prompt: optOut
80
+ }
81
+ }
82
+ }, {} as Record<string, OptOutApplication | undefined>)
83
+
84
+ $: optedOutPrograms = applications
85
+ .filter(curr => curr.requirements.flatMap(r => r.prompts).find(r => r.optOut))
86
+ .reduce((acc, c) => {
87
+ const optOutRequirement = c.requirements.find(r => r.prompts.some(p => p.optOut))
88
+ return {
89
+ ...acc,
90
+ [c.id]: optOutRequirement?.status === enumRequirementStatus.DISQUALIFYING
91
+ }
92
+ }, {} as Record<string, boolean>)
93
+
94
+ async function loadOptOutPrompt () {
95
+ const promptId = optOutSelected?.prompt?.id
96
+ if (!promptId) return
97
+ const { prompt } = await api.getApplicantPrompt(appRequest.id, promptId)
98
+ optOutPrompt = prompt
99
+ }
100
+
101
+ </script>
102
+
103
+ <section class="programs-container">
104
+ <header>
105
+ <div class="program column">Program</div>
106
+ <div class="status column">Status</div>
107
+ </header>
108
+ {#each applications as application (application.id)}
109
+ {@const programStatus = programButtonStatus[application.id]}
110
+ {@const programFirstPrompt = programFirstPromptId[application.id]}
111
+ {@const programDescriptions = isIneligibleByApplicantRequirement(application) && !optedOutPrograms[application.id] ? [application.applicantDescription, application.eligibilityDescription].filter(isNotBlank) : []}
112
+ <div class="program column [ flex-col ]" style='align-items: start;'>
113
+ <span>{application.title}</span>
114
+ {#if !viewMode}
115
+ {#if optedOutPrograms[application.id]}
116
+ <Button on:click={() => openOptOutModal(application.id, true)} kind='ghost' style='padding: 0; min-height: 0;' class='[ p-0 justify-start ]'>Opt In</Button>
117
+ {:else if optOutPrograms[application.id] && application.completionStatus !== enumApplicationStatus.INELIGIBLE}
118
+ <Button on:click={() => openOptOutModal(application.id)} kind='ghost' style='padding: 0; min-height: 0;' class='[ p-0 justify-start ]'>Opt out</Button>
119
+ {/if}
120
+ {/if}
121
+ </div>
122
+ <div class="status column" class:no-tooltip={!application.statusReason?.length && !programDescriptions.length}>
123
+ {#if !viewMode}
124
+ <div class="icon-and-tooltip" class:wide-icon={application.completionStatus === enumApplicationStatus.INELIGIBLE}>
125
+ {#if optedOutPrograms[application.id]}
126
+ <SubtractAlt size={24} fill='#dd3b46'/>
127
+ {:else if application.completionStatus === enumApplicationStatus.INELIGIBLE}
128
+ <Close size={32} class="status-icon-ineligible" />
129
+ {:else if ['start', 'continue', 'revisitPending'].includes(programStatus)}
130
+ <InProgress size={24} class="status-icon-pending" />
131
+ {:else if application.hasWarning}
132
+ <WarningIconYellow size={24} class="status-icon-warning" />
133
+ {:else}
134
+ <CheckmarkFilled size={24} class="status-icon-complete" />
135
+ {/if}
136
+ <ApplicantProgramListTooltip {optedOutPrograms} {application} />
137
+ </div>
138
+ {#if optedOutPrograms[application.id]}
139
+ <p>Opted out</p>
140
+ {:else if programFirstPrompt && programStatus !== 'ineligible'}
141
+ <Button size="small" kind={programStatus === 'complete' ? 'ghost' : ['revisit', 'revisitPending'].includes(programStatus) ? 'secondary' : 'primary'} href={programFirstPrompt}>{programButtonLabels[programStatus]}</Button>
142
+ {/if}
143
+ {:else}
144
+ {#if ['start', 'continue', 'complete', 'revisitPending'].includes(programStatus) && !optedOutPrograms[application.id]}
145
+ <TagSet tags={getApplicationStatusTags(application.status, appRequest.phase, appRequest.closedAt, application.rescindedStatus)} />
146
+ {:else if optedOutPrograms[application.id]}
147
+ <SubtractAlt size={24} fill='#dd3b46'/><p>Opted out</p>
148
+ {:else}
149
+ <Close size={32} class="status-icon-ineligible" />
150
+ {/if}
151
+ <ApplicantProgramListTooltip {optedOutPrograms} {application} />
152
+ {/if}
153
+ </div>
154
+ {#if application.warningReasons.length || application.ineligibleReasons.length || programDescriptions.length}
155
+ <div class="tooltip-text-row" class:visible={showTooltipsAsText}>
156
+ {#each application.ineligibleReasons as reason (reason)}
157
+ <div class="tooltip-text-item">
158
+ <Information size={16} /> {reason}
159
+ </div>
160
+ {/each}
161
+ {#each programDescriptions as description (description)}
162
+ <div class="tooltip-text-item">
163
+ <Information size={16} /> {description}
164
+ </div>
165
+ {/each}
166
+ {#each application.warningReasons as reason (reason)}
167
+ <div class="tooltip-text-item">
168
+ <Information size={16} /> {reason}
169
+ </div>
170
+ {/each}
171
+ </div>
172
+ {/if}
173
+ {/each}
174
+ </section>
175
+
176
+
177
+ {#if optOutPrompt?.key}
178
+ <ApplicantOptOutModal bind:open bind:optIn bind:prompt={optOutPrompt} {optOutSelected} {appRequest} />
179
+ {/if}
180
+
181
+ <style>
182
+ .programs-container {
183
+ display: grid;
184
+ grid-template-columns: 2fr 1fr;
185
+ margin-bottom: 1.5rem;
186
+ background-color: #F2F2F2;
187
+ }
188
+
189
+ .programs-container header {
190
+ display: contents;
191
+ font-weight: bold;
192
+ }
193
+
194
+ .programs-container header .column {
195
+ padding: 0.75rem 10px;
196
+ }
197
+
198
+ .programs-container .column {
199
+ border-bottom: 1px solid var(--cds-ui-03, #e0e0e0);
200
+ padding: .5rem 10px;
201
+ }
202
+
203
+ .programs-container :global(.status-column) {
204
+ flex-grow: 0;
205
+ }
206
+
207
+ :not(.header) .program.column {
208
+ display: flex;
209
+ align-items: center;
210
+ }
211
+
212
+ :not(.header) .status.column {
213
+ display: flex;
214
+ flex-wrap: wrap;
215
+ align-items: center;
216
+ justify-content: flex-start;
217
+ gap: 12px;
218
+ }
219
+
220
+ .status.column :global(.reason-tooltip) {
221
+ height: 16px;
222
+ }
223
+ :not(.header) .status.column.no-tooltip {
224
+ flex-wrap: nowrap
225
+ }
226
+
227
+ .programs-container :global(.status-icon-pending) {
228
+ fill: var(--cds-support-04);
229
+ }
230
+
231
+ .programs-container :global(.status-icon-complete) {
232
+ fill: var(--cds-support-04);
233
+ }
234
+
235
+ .programs-container :global(.status-icon-ineligible) {
236
+ margin-left: -4px;
237
+ }
238
+
239
+ .icon-and-tooltip {
240
+ display: flex;
241
+ align-items: center;
242
+ gap: 8px;
243
+ }
244
+ .icon-and-tooltip.wide-icon {
245
+ gap: 4px;
246
+ }
247
+
248
+ .tooltip-text-row {
249
+ grid-column: span 2;
250
+ background-color: #FBF1DA;
251
+ padding: 0.5rem 1rem;
252
+ display: none;
253
+ }
254
+
255
+ .tooltip-text-row.visible {
256
+ display: block;
257
+ }
258
+
259
+ .tooltip-text-item {
260
+ display: flex;
261
+ align-items: center;
262
+ gap: 0.5rem;
263
+ padding: 0.125rem 16px;
264
+ color: var(--cds-text-01);
265
+ }
266
+
267
+ @media print {
268
+ .tooltip-text-row {
269
+ display: block !important;
270
+ }
271
+ }
272
+
273
+ :global(.opt-out-modal .bx--modal-content) {
274
+ margin-bottom: 1rem;
275
+ }
276
+ </style>
@@ -0,0 +1,44 @@
1
+ <script lang="ts">
2
+ import { Tooltip } from 'carbon-components-svelte'
3
+ import { isNotBlank } from 'txstate-utils'
4
+ import type { ApplicationForDetails } from '@reqquest/ui'
5
+ import { isIneligibleByApplicantRequirement } from '../appreq-utils.js'
6
+
7
+ export let application: ApplicationForDetails
8
+ export let optedOutPrograms: Record<string, boolean>
9
+
10
+ // Programs that died before submission get extra context above the failing reason: what the
11
+ // program is and a summary of what it would have required. An applicant disqualified early may
12
+ // never have seen the program's prompts, so the statusReason alone lacks context.
13
+ $: programDescriptions = isIneligibleByApplicantRequirement(application) && !optedOutPrograms[application.id]
14
+ ? [application.applicantDescription, application.eligibilityDescription].filter(isNotBlank)
15
+ : []
16
+ </script>
17
+
18
+ {#if application.warningReasons.length || application.ineligibleReasons.length || application.metReasons.length || programDescriptions.length}
19
+ <Tooltip align="end" direction="bottom" triggerText="" class="reason-tooltip">
20
+ {#if application.ineligibleReasons.length || programDescriptions.length}
21
+ {#each application.ineligibleReasons as reason (reason)}
22
+ <p>{reason}</p>
23
+ {/each}
24
+ {#each programDescriptions as description (description)}
25
+ <p class="program-description">{description}</p>
26
+ {/each}
27
+ {:else if application.warningReasons.length}
28
+ <p><strong>Warnings:</strong></p>
29
+ {#each application.warningReasons as reason (reason)}
30
+ <p>{reason}</p>
31
+ {/each}
32
+ {:else if application.metReasons.length}
33
+ {#each application.metReasons as reason (reason)}
34
+ <p>{reason}</p>
35
+ {/each}
36
+ {/if}
37
+ </Tooltip>
38
+ {/if}
39
+
40
+ <style>
41
+ .program-description {
42
+ margin-top: 0.75rem;
43
+ }
44
+ </style>