@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,14 @@
1
+ import { redirect } from '@sveltejs/kit'
2
+ import type { PageLoad } from './$types'
3
+ import { resolve } from '$app/paths'
4
+
5
+ export const load: PageLoad = async ({ parent }) => {
6
+ const { access } = await parent()
7
+ // access is missing only when the layout failed; it renders layoutError in place of the page, so don't mask it
8
+ if (!access) return {} as never
9
+ if (access.viewReviewerInterface) throw redirect(303, resolve('/dashboards/reviewer'))
10
+ if (access.viewAppRequestList) throw redirect(303, resolve('/requests'))
11
+ if (access.viewRoleManagement) throw redirect(303, resolve('/roles'))
12
+ if (access.viewPeriodManagement) throw redirect(303, resolve('/periods'))
13
+ throw redirect(303, resolve('/dashboards/applicant'))
14
+ }
@@ -0,0 +1,164 @@
1
+ <script lang="ts">
2
+ import { api, AnnouncementBanner } from "$internal"
3
+ import type { FormStore } from '@txstate-mws/svelte-forms'
4
+ import IntroPanel from "$internal/components/IntroPanel.svelte"
5
+ import { FieldCheckbox, FieldDateTime, FieldRadio, FieldTextArea, FieldTextInput, FieldToggle, Form, TagSet } from "@txstate-mws/carbon-svelte"
6
+ import type { PageData } from "../announcement/$types"
7
+ import { Button } from 'carbon-components-svelte'
8
+ import { invalidateAll } from "$app/navigation"
9
+ import { toasts } from "@txstate-mws/svelte-components"
10
+ import TextClearFormat from "carbon-icons-svelte/lib/TextClearFormat.svelte";
11
+
12
+
13
+ export let data: PageData
14
+ $: ({ anouncement, canManage } = data)
15
+
16
+ let store: FormStore | undefined
17
+
18
+ async function submit (data: any) {
19
+ const { id, addLink, isActive, ...rest } = data
20
+ const { success, messages, data: newData } = id ? await api.updateAnnouncement(id, rest, false) : await api.createAnnouncement(rest, false)
21
+ if (success) toasts.add({ message: 'This announcement has been saved.', title: 'Success', type: 'success' })
22
+ return {
23
+ success,
24
+ messages,
25
+ data: newData
26
+ }
27
+ }
28
+
29
+ async function validate (data: any) {
30
+ const { id, addLink, isActive, ...rest } = data
31
+ const { messages } = id ? await api.updateAnnouncement(id, rest) : await api.createAnnouncement(rest)
32
+ return messages
33
+ }
34
+
35
+ async function saved () {
36
+ await invalidateAll()
37
+ }
38
+
39
+ async function reset () {
40
+ if (anouncement?.id) await api.deleteAnnouncement(anouncement.id)
41
+ await invalidateAll()
42
+ store?.reset()
43
+ store?.setField('type', 'toggle')
44
+ }
45
+
46
+ $: isActive = anouncement?.isActive ?? false
47
+
48
+ </script>
49
+ <IntroPanel
50
+ title="Applicant dashboard message"
51
+ subtitle='This is a time-sensitive message that will automatically display to applicants when enabled or scheduled.'
52
+ >
53
+ <div class="flex items-center gap-2">
54
+ <TagSet
55
+ tags={isActive ? [{ label: 'Active', type: 'green' }] : [{ label: 'Inactive', type: 'purple' }]}
56
+ tagType="status"
57
+ tagSize="sm"
58
+ />
59
+ <span class="text-sm">{isActive ? 'This message is currently displaying to applicants.' : 'This message is not currently displaying to applicants.'}</span>
60
+ </div>
61
+ </IntroPanel>
62
+
63
+ <Form
64
+ let:data
65
+ submit={canManage ? submit : undefined}
66
+ validate={canManage ? validate : undefined}
67
+ bind:store
68
+ on:saved={saved}
69
+ hideFallbackMessage
70
+ preload={anouncement ? { ...anouncement, addLink: anouncement?.link || anouncement?.linkText } : undefined}
71
+ >
72
+ <FieldRadio
73
+ path='type'
74
+ required
75
+ disabled={!canManage}
76
+ labelText="Display duration"
77
+ defaultValue='toggle'
78
+ class="md:w-[645px]"
79
+ items={[
80
+ { label: 'Toggle on and off', value: 'toggle' },
81
+ { label: 'Date range', value: 'date' }
82
+ ]} />
83
+ {#if data.type === 'toggle'}
84
+ <FieldToggle path='enabled' disabled={!canManage} hideLabel labelText='Enable/Disable' labelA='Message inactive' labelB='Message active' />
85
+ {:else}
86
+ <div class="md:w-[645px] flow datetime-full">
87
+ <FieldDateTime path='start' disabled={!canManage} labelText='Start date' helperText='Leave blank to start displaying as soon as it is saved.' defaultTime='24:00'/>
88
+ <FieldDateTime path='end' disabled={!canManage} labelText='End date' helperText='How long the message displays. Leave blank to display indefinitely.' defaultTime='24:00' />
89
+ <div class="flex justify-end mt-0">
90
+ <Button size='small' kind='ghost' disabled={!canManage} on:click={() => { store?.setField('start', undefined); store?.setField('end', undefined)}} icon={TextClearFormat}>Clear dates</Button>
91
+ </div>
92
+ </div>
93
+ {/if}
94
+
95
+ <div class="md:w-[645px] flow">
96
+ <FieldTextArea rows={1} required disabled={!canManage} path='subject' labelText='Message title' maxCount={40} class="md:w-[645px] textarea"/>
97
+ <FieldTextArea required disabled={!canManage} path='body' labelText='Message text' maxCount={125} class="md:w-[645px]"/>
98
+ </div>
99
+
100
+ <FieldCheckbox path='addLink' disabled={!canManage} labelText='Add link' on:change={(e: any) => {
101
+ if (!e.target.checked) {
102
+ store?.setField('linkText', undefined)
103
+ store?.setField('link', undefined)
104
+ }
105
+ }} />
106
+
107
+ {#if data.addLink}
108
+ <div class="flex flex-col sm:flex-row gap-4 md:w-[645px] addLink">
109
+ <FieldTextArea required disabled={!canManage} class='textarea w-full' maxCount={30} rows={1} path='linkText' labelText='Link text' />
110
+ <FieldTextInput required disabled={!canManage} class='w-full' path='link' labelText='URL' />
111
+ </div>
112
+ {/if}
113
+
114
+
115
+ {#if data.subject && data.body}
116
+ <div class='w-full'>
117
+ <AnnouncementBanner subject={data.subject} body={data.body} link={data.link} linkText={data.linkText} />
118
+ </div>
119
+ {:else}
120
+ <div class="bg-[var(--cds-ui-01)] p-4 gap-8 md:w-[645px]">
121
+ <p>No message to display</p>
122
+ <p class="text-sm text-[--cds-text-02]">Nothing will be shown to applicants until a message is configured.</p>
123
+ </div>
124
+ {/if}
125
+
126
+ <div slot='submit' class="flex gap-4">
127
+ {#if canManage}
128
+ <Button type='submit'>Save</Button>
129
+ <Button kind='ghost' on:click={reset} icon={TextClearFormat}>Reset</Button>
130
+ {/if}
131
+ </div>
132
+ </Form>
133
+
134
+ <style>
135
+ /* Stretch FieldDateTime (date input + time input + am/pm select) across the full form width. */
136
+ .datetime-full :global(.picker-row) {
137
+ gap: .5rem;
138
+ }
139
+ .datetime-full :global(.picker-row > *) {
140
+ flex-grow: 1;
141
+ width: 100%;
142
+ }
143
+ .datetime-full :global(.bx--text-input__field-wrapper) {
144
+ width: 100% !important;
145
+ }
146
+ .datetime-full :global(.bx--date-picker-input__wrapper),
147
+ .datetime-full :global(.bx--date-picker--single),
148
+ .datetime-full :global(.bx--date-picker-container),
149
+ .datetime-full :global(.flatpickr-wrapper),
150
+ .datetime-full :global(input.bx--date-picker__input),
151
+ .datetime-full :global(.bx--time-picker .bx--select-input),
152
+ .datetime-full :global(.bx--time-picker),
153
+ .datetime-full :global(.bx--time-picker__input),
154
+ .datetime-full :global(.bx--time-picker__input-field) {
155
+ width: 100%;
156
+ }
157
+
158
+ :global(.bx--text-area.textarea) {
159
+ resize: none;
160
+ }
161
+ .addLink :global(.cs-field) {
162
+ width: 100%;
163
+ }
164
+ </style>
@@ -0,0 +1,16 @@
1
+ import { error } from "@sveltejs/kit";
2
+ import type { PageLoad } from "../$types";
3
+ import { api } from "$internal";
4
+
5
+ export const load: PageLoad = async ({ parent }) => {
6
+ const parentData = await parent()
7
+ // access is missing only when the layout failed; it renders layoutError in place of the page, so don't mask it
8
+ if (!parentData.access) return {} as never
9
+ if (!parentData?.access?.viewAnnouncementManagement) throw error(403)
10
+ const canManage = !!parentData?.access?.manageAnnouncements
11
+
12
+ // no filter: the editor needs the announcement whether or not it is currently displaying
13
+ const anouncement = await api.getAnnouncement()
14
+
15
+ return { anouncement, canManage }
16
+ }
@@ -0,0 +1,494 @@
1
+ <script lang="ts">
2
+ import { goto, invalidate } from '$app/navigation'
3
+ import { resolve } from '$app/paths'
4
+ import type { ApplicationForDetails, AppRequestForDetails, Scalars } from '@reqquest/ui'
5
+ import {
6
+ AnnouncementBanner, api, ApplicationDetailsView, AppRequestCard, IntroPanel, type AppRequestForExportResponse, type DashboardAppRequest,
7
+ getPeriodDisplayInfo, getPeriodStatus, getStatusActionType, getSubmitButtonText
8
+ } from '$internal'
9
+ import { CardGrid, FieldMultiselect, FilterUI, Panel, PanelDialog, Toasts } from '@txstate-mws/carbon-svelte'
10
+ import { toasts } from '@txstate-mws/svelte-components'
11
+ import { Button, Dropdown, InlineNotification, Modal, Tooltip } from 'carbon-components-svelte'
12
+ import Close from 'carbon-icons-svelte/lib/Close.svelte'
13
+ import Reset from 'carbon-icons-svelte/lib/Reset.svelte'
14
+ import DocumentExport from 'carbon-icons-svelte/lib/DocumentExport.svelte'
15
+ import { uiRegistry } from '$local'
16
+ import type { PageData } from './$types'
17
+
18
+ interface FilterState {
19
+ tab?: 'recent_applications' | 'past_applications'
20
+ search?: string
21
+ periodIds?: string[]
22
+ status?: string[]
23
+ }
24
+
25
+ export let data: PageData
26
+ $: ({ appRequests, availablePeriods, availableStatuses, access, openPeriods, recentCutoffIso, recentDays, announcement } = data)
27
+
28
+ let sidePanelOpen = false
29
+ let loading = false
30
+
31
+ // Selected Items
32
+ let selectedAppRequest: AppRequestForDetails | undefined = undefined
33
+ let selectedPeriodId: string | undefined = undefined
34
+ let lastInsertedId: string | undefined
35
+
36
+ // Application Details Data (loaded when side panel opens)
37
+ let appData: Scalars['JsonData'] = {}
38
+ let prequalPrompts: AppRequestForExportResponse['prequalPrompts'] | undefined = undefined
39
+ let postqualPrompts: AppRequestForExportResponse['postqualPrompts'] | undefined = undefined
40
+ let applications: ApplicationForDetails[] = []
41
+ let filterDataSearch: FilterState | undefined
42
+
43
+ // Modal State
44
+ let cancelConfirmation: { open: boolean, requestId: string | null, isWithdraw: boolean } = { open: false, requestId: null, isWithdraw: false }
45
+
46
+ // Auto-select first displayable period
47
+ $: displayablePeriods = openPeriods.length > 0 ? getOpenAndUpcomingPeriods(openPeriods, recentCutoffIso) : []
48
+ $: if (displayablePeriods.length > 0 && !selectedPeriodId) {
49
+ selectedPeriodId = displayablePeriods[0].id
50
+ }
51
+ $: currentPeriod = getCurrentPeriod(displayablePeriods, selectedPeriodId)
52
+ $: periodInfo = currentPeriod ? getPeriodDisplayInfo(currentPeriod) : null
53
+
54
+ // ==========================================
55
+ // Period Management Helper Functions
56
+ // ==========================================
57
+ function getCurrentPeriod (periods: typeof openPeriods, selectedId: string | undefined) {
58
+ if (periods.length === 1) return periods[0]
59
+ if (selectedId) return periods.find(p => p.id === selectedId) ?? null
60
+ return null
61
+ }
62
+
63
+ function getOpenAndUpcomingPeriods (periods: typeof openPeriods, recentCutoffIso: string): typeof openPeriods {
64
+ const recentCutoff = new Date(recentCutoffIso)
65
+
66
+ return periods
67
+ .filter(period => {
68
+ const status = getPeriodStatus(period)
69
+ // Show periods that are open/upcoming AND have been reviewed
70
+ if ((status === 'open' || status === 'upcoming') && period.reviewed) return true
71
+ // Also show closed periods that closed within the recent window
72
+ if (status === 'closed' && period.closeDate) {
73
+ const closeDate = new Date(period.closeDate)
74
+ return closeDate >= recentCutoff && period.reviewed
75
+ }
76
+ return false
77
+ })
78
+ .sort((a, b) => new Date(a.openDate).getTime() - new Date(b.openDate).getTime())
79
+ }
80
+
81
+ function showError (message: string) {
82
+ toasts.add(message, 'error', 7000)
83
+ }
84
+
85
+ function isSubmitted (status: string): boolean {
86
+ return status !== 'STARTED' && status !== 'READY_TO_SUBMIT'
87
+ }
88
+
89
+ // ==========================================
90
+ // API Action Handlers
91
+ // ==========================================
92
+
93
+ async function handleApiAction (
94
+ action: () => Promise<any>,
95
+ errorMessage: string,
96
+ successMessage?: string
97
+ ) {
98
+ try {
99
+ const result = await action()
100
+ if (result.success) {
101
+ await invalidate('api:getApplicantRequests')
102
+ if (successMessage) {
103
+ toasts.add(successMessage, 'success', 5000)
104
+ }
105
+ } else {
106
+ const error = result.messages?.find((m: { type: string, message: string }) => m.type === 'error')?.message
107
+ showError(error ?? errorMessage)
108
+ }
109
+ } catch (error) {
110
+ showError(errorMessage)
111
+ }
112
+ }
113
+
114
+ // ==========================================
115
+ // UI Card Action Builders
116
+ // ==========================================
117
+
118
+ // TODO probably switch this to hide actions instead of disable, but it is nice to see them for now.
119
+ // In general these actions are based off of the text info in https://www.figma.com/design/VXFWNXZ6UNlDxmGNwVoEuu/ReqQuest?node-id=954-101490&m=dev
120
+ function buildCardActions (request: DashboardAppRequest) {
121
+ const submitted = isSubmitted(request.status)
122
+ const cancelWithdrawLabel = submitted ? `Withdraw ${uiRegistry.getWord('appRequest')}` : `Cancel ${uiRegistry.getWord('appRequest')}`
123
+
124
+ return [
125
+ {
126
+ label: `View ${uiRegistry.getWord('appRequest')}`,
127
+ onClick: async () => await openSidePanel(request)
128
+ },
129
+ // {
130
+ // label: 'Download Offer',
131
+ // onClick: async () => await downloadOffer(request.id),
132
+ // icon: Download,
133
+ // disabled: !request.actions.offer
134
+ // },
135
+ // {
136
+ // label: 'Appeal Decision',
137
+ // onClick: async () => await appealDecision(request.id),
138
+ // icon: Warning,
139
+ // disabled: !request.actions.returnToApplicant
140
+ // },
141
+ {
142
+ label: `Export ${uiRegistry.getWord('appRequest')}`,
143
+ onClick: async () => await exportApplication(request.id),
144
+ icon: DocumentExport
145
+ },
146
+ {
147
+ label: cancelWithdrawLabel,
148
+ onClick: () => { cancelConfirmation = { open: true, requestId: request.id, isWithdraw: submitted } },
149
+ icon: Close,
150
+ disabled: !request.actions.cancel
151
+ },
152
+ {
153
+ label: 'Reinstate Application',
154
+ onClick: async () => await reinstateApplication(request.id),
155
+ icon: Reset,
156
+ disabled: !request.actions.reopen
157
+ }
158
+ ]
159
+ }
160
+
161
+ // ==========================================
162
+ // Application Actions
163
+ // ==========================================
164
+ // const appealDecision = async (requestId: string) =>
165
+ // await handleApiAction(
166
+ // async () => await api.returnAppRequest(requestId),
167
+ // 'Failed to appeal decision',
168
+ // 'Appeal submitted successfully'
169
+ // )
170
+
171
+ const reinstateApplication = async (requestId: string) =>
172
+ await handleApiAction(
173
+ async () => await api.reopenAppRequest(requestId),
174
+ 'Failed to reinstate application',
175
+ 'Application reinstated successfully'
176
+ )
177
+
178
+ // async function downloadOffer (requestId: string) {
179
+ // // TODO: Implement actual download logic
180
+ // console.log('Download offer for', requestId)
181
+ // }
182
+
183
+ async function exportApplication (requestId: string) {
184
+ await goto(resolve(`/requests/${requestId}/export`))
185
+ }
186
+
187
+ // ==========================================
188
+ // Panel Dialog Management
189
+ // ==========================================
190
+
191
+ async function openSidePanel (appRequest: DashboardAppRequest) {
192
+ sidePanelOpen = true
193
+ loading = true
194
+
195
+ try {
196
+ // Fetch additional application details for the side panel
197
+ const details = await api.getAppRequestForExport(appRequest.id)
198
+
199
+ selectedAppRequest = details.appRequest as AppRequestForDetails | undefined
200
+
201
+ appData = details.appRequest.data
202
+ applications = details.applicationsReview
203
+ prequalPrompts = details.prequalPrompts
204
+ postqualPrompts = details.postqualPrompts
205
+ } catch (error) {
206
+ showError('Failed to load application details')
207
+ console.error('Error loading application details:', error)
208
+ } finally {
209
+ loading = false
210
+ }
211
+ }
212
+
213
+ function closeSidePanel () {
214
+ sidePanelOpen = false
215
+ selectedAppRequest = undefined
216
+ appData = {}
217
+ prequalPrompts = undefined
218
+ postqualPrompts = undefined
219
+ }
220
+
221
+ async function handleCancelConfirm () {
222
+ if (!cancelConfirmation.requestId) return
223
+
224
+ const actionType = cancelConfirmation.isWithdraw ? 'withdraw' : 'cancel'
225
+ await handleApiAction(
226
+ async () => await api.cancelAppRequest(cancelConfirmation.requestId!),
227
+ `Failed to ${actionType} ${uiRegistry.getWord('appRequest').toLowerCase()}`,
228
+ `${uiRegistry.getWord('appRequest')} ${actionType === 'withdraw' ? 'withdrawn' : 'cancelled'} successfully`
229
+ )
230
+
231
+ cancelConfirmation = { open: false, requestId: null, isWithdraw: false }
232
+ }
233
+
234
+ // Handle submit action based on status action type, used for PanelDialog, specifically for the App View-only side panel
235
+ async function handleSubmitAction () {
236
+ if (!selectedAppRequest) return
237
+
238
+ const { status, id } = selectedAppRequest
239
+ const actionType = getStatusActionType(status)
240
+
241
+ switch (actionType) {
242
+ case 'navigate':
243
+ await goto(resolve(`/requests/${id}/apply`))
244
+ break
245
+ case 'export':
246
+ await exportApplication(id)
247
+ break
248
+ // case 'download':
249
+ // await downloadOffer(id)
250
+ // break
251
+ case 'none':
252
+ // No action for completed/final statuses
253
+ break
254
+ }
255
+ }
256
+
257
+ // ==========================================
258
+ // Form Handlers
259
+ // ==========================================
260
+
261
+ async function clickCreateAppRequest () {
262
+ // Use the currently selected period
263
+ if (!selectedPeriodId) return
264
+ const { success } = await submitAppRequest({ periodId: selectedPeriodId })
265
+ if (success) await onSaved()
266
+ }
267
+
268
+ async function onSaved () {
269
+ await invalidate('api:getAccess')
270
+ await goto(resolve(`/requests/${lastInsertedId}/apply`))
271
+ }
272
+
273
+ async function submitAppRequest (data: { periodId: string }) {
274
+ const response = await api.createAppRequest(data.periodId, access.user!.login)
275
+ if (response.success) lastInsertedId = response.id
276
+ return {
277
+ ...response,
278
+ data
279
+ }
280
+ }
281
+
282
+ // ==========================================
283
+ // FilterUI Helper Functions
284
+ // ==========================================
285
+
286
+ // Reactive statements for filter options
287
+ $: periodOptions = availablePeriods.map(p => ({ value: p.id, label: p.name }))
288
+ $: statusOptions = availableStatuses.map(label => ({ value: label, label }))
289
+ $: isPastTab = filterDataSearch?.tab === 'past_applications'
290
+ $: pastFiltersReady = isPastTab && (periodOptions.length > 0 || statusOptions.length > 0)
291
+ $: showIntroPanel = !isPastTab
292
+ </script>
293
+
294
+ <div class="flow applicant-dashboard" class:past-tab={isPastTab}>
295
+ <!-- Filter UI for recent/past applications -->
296
+ <FilterUI
297
+ search={isPastTab}
298
+ tabs={[
299
+ { label: `Recent ${uiRegistry.getPlural('appRequest')}`, value: 'recent_applications' },
300
+ { label: `Past ${uiRegistry.getPlural('appRequest')}`, value: 'past_applications' }
301
+ ]}
302
+ on:apply={e => { filterDataSearch = e.detail }}
303
+ on:mount={e => { filterDataSearch = e.detail }}>
304
+ <svelte:fragment slot="quickfilters">
305
+ {#if pastFiltersReady}
306
+ <FieldMultiselect path="periodIds" label={uiRegistry.getWord('period')} items={periodOptions} />
307
+ <FieldMultiselect path="status" label="Status" items={statusOptions} />
308
+ {/if}
309
+ </svelte:fragment>
310
+ </FilterUI>
311
+
312
+ <!-- Time sensitive banner -->
313
+ {#if announcement}
314
+ <AnnouncementBanner subject={announcement.subject} body={announcement.body} link={announcement.link} linkText={announcement.linkText} />
315
+ {/if}
316
+
317
+ {#if showIntroPanel}
318
+ <IntroPanel
319
+ title={uiRegistry.config.applicantDashboardIntroHeader}
320
+ subtitle={uiRegistry.config.applicantDashboardIntroDetail}
321
+
322
+ >
323
+ <svelte:fragment slot="block-end">
324
+ {#if displayablePeriods.length > 0}
325
+ <!-- Show period info and action for selected period -->
326
+ {#if currentPeriod && periodInfo}
327
+ <div class="flex flex-wrap gap-4 items-center">
328
+ <!-- Period Information Section -->
329
+ <section class="text-base text-center flex-col gap-2">
330
+ <dl class="flex gap-4 text-sm">
331
+ <div class="flex flex-col bg-[var(--cds-ui-03,#d9d9d9)] py-4 px-4 justify-center">
332
+ <dt class="font-bold">{uiRegistry.getWord('appRequest')} opens:</dt>
333
+ <dd>
334
+ <time datetime={periodInfo.openDateMachineFormat}>
335
+ {periodInfo.openLabel}
336
+ </time>
337
+ </dd>
338
+ </div>
339
+ {#if currentPeriod.closeDate}
340
+ <div class="flex flex-col bg-[var(--cds-ui-03,#d9d9d9)] py-4 px-4 justify-center">
341
+ <dt class="font-bold">{uiRegistry.getWord('appRequest')} closes:</dt>
342
+ <dd>
343
+ <time datetime={periodInfo.closeDateMachineFormat}>
344
+ {periodInfo.closeDate}
345
+ </time>
346
+ </dd>
347
+ </div>
348
+ {/if}
349
+ </dl>
350
+ </section>
351
+ <section class="flex flex-wrap gap-4 items-end intro-actions">
352
+ <!-- Period selector (if multiple) -->
353
+ {#if displayablePeriods.length > 1}
354
+ <div class="min-w-[250px] intro-dropdown-wrapper">
355
+ <Dropdown
356
+ size="lg"
357
+ labelText="Select a {uiRegistry.getWord('period').toLowerCase()}"
358
+ hideLabel
359
+ placeholder="Choose a {uiRegistry.getWord('period').toLowerCase()}"
360
+ bind:selectedId={selectedPeriodId}
361
+ items={displayablePeriods.map(p => ({
362
+ id: p.id,
363
+ text: p.name
364
+ }))}
365
+ />
366
+ </div>
367
+ {/if}
368
+
369
+ <!-- Action Button. Only show start button if no existing application and user can act -->
370
+ {#if periodInfo.canStartNew && access.createAppRequestSelf}
371
+ <Button
372
+ on:click={clickCreateAppRequest}
373
+ kind="primary"
374
+ size="field"
375
+ >
376
+ Start {uiRegistry.getWord('appRequest').toLowerCase()}
377
+ </Button>
378
+ {:else if (!periodInfo.canStartNew || !access.createAppRequestSelf) && appRequests.length > 0}
379
+ <Tooltip>
380
+ <p>{uiRegistry.getWord('appRequest')} started, see dashboard card for details.</p>
381
+ </Tooltip>
382
+ {/if}
383
+ </section>
384
+
385
+ </div>
386
+ {/if}
387
+ {:else}
388
+ <p>No {uiRegistry.getWord('appRequest').toLowerCase()} {uiRegistry.getPlural('period').toLowerCase()} are currently available.</p>
389
+ {/if}
390
+ </svelte:fragment>
391
+ <svelte:fragment>
392
+ {#if uiRegistry.config.slots?.applicantDashboardIntroSlot}
393
+ <svelte:component this={uiRegistry.config.slots?.applicantDashboardIntroSlot} {appRequests} {api} />
394
+ {/if}
395
+ </svelte:fragment>
396
+ </IntroPanel>
397
+ {/if}
398
+ <!-- Application Cards -->
399
+ <Panel title={filterDataSearch?.tab === 'past_applications' ? `All past ${uiRegistry.getPlural('appRequest').toLowerCase()}` : `All recent ${uiRegistry.getPlural('appRequest').toLowerCase()}`}>
400
+ {#if appRequests.length === 0}
401
+ {#if filterDataSearch?.tab === 'past_applications'}
402
+ <InlineNotification
403
+ kind="info"
404
+ title="No results found."
405
+ subtitle={pastFiltersReady ? 'You may need to refine your searched terms, filters or try again.' : undefined}
406
+ lowContrast
407
+ hideCloseButton
408
+ />
409
+ {:else}
410
+ <InlineNotification
411
+ kind="info"
412
+ title="No recent {uiRegistry.getPlural('appRequest').toLowerCase()} found."
413
+ subtitle="You have no {uiRegistry.getPlural('appRequest').toLowerCase()} created or modified in the last {recentDays} days. Check under Past {uiRegistry.getPlural('appRequest').toLowerCase()} tab."
414
+ lowContrast
415
+ hideCloseButton
416
+ />
417
+ {/if}
418
+ {:else}
419
+ <CardGrid cardSize="500px">
420
+ {#each appRequests as request (request.id)}
421
+ <div class="max-w-[680px]">
422
+ <AppRequestCard
423
+ {request}
424
+ actions={buildCardActions(request)}
425
+ showAcceptanceButtons={true}
426
+ />
427
+ </div>
428
+ {/each}
429
+ </CardGrid>
430
+ {/if}
431
+ </Panel>
432
+
433
+ <!-- Inlined Application Details Panel -->
434
+ <PanelDialog
435
+ size="large"
436
+ open={sidePanelOpen}
437
+ title={selectedAppRequest?.period?.name ?? `${uiRegistry.getWord('appRequest')} Details`}
438
+ cancelText="Close"
439
+ submitText={selectedAppRequest?.status ? getSubmitButtonText(selectedAppRequest.status) : ''}
440
+ on:cancel={closeSidePanel}
441
+ on:submit={handleSubmitAction}>
442
+ {#if selectedAppRequest}
443
+ <ApplicationDetailsView
444
+ appRequest={selectedAppRequest}
445
+ {applications}
446
+ {appData}
447
+ {prequalPrompts}
448
+ {postqualPrompts}
449
+ {loading}
450
+ {uiRegistry}
451
+ title={selectedAppRequest.period?.name ? `View your ${selectedAppRequest.period.name} ${uiRegistry.getWord('appRequest').toLowerCase()}` : `View your ${uiRegistry.getWord('appRequest').toLowerCase()}`}
452
+ showCorrectionsInline
453
+ />
454
+ {/if}
455
+ </PanelDialog>
456
+
457
+ <Modal
458
+ bind:open={cancelConfirmation.open}
459
+ modalHeading={cancelConfirmation.isWithdraw ? `Withdraw ${uiRegistry.getWord('appRequest')}` : `Cancel ${uiRegistry.getWord('appRequest')}`}
460
+ primaryButtonText={cancelConfirmation.isWithdraw ? `Yes, Withdraw ${uiRegistry.getWord('appRequest')}` : `Yes, Cancel ${uiRegistry.getWord('appRequest')}`}
461
+ secondaryButtonText={`Keep ${uiRegistry.getWord('appRequest')}`}
462
+ on:click:button--secondary={() => { cancelConfirmation = { open: false, requestId: null, isWithdraw: false } }}
463
+ on:submit={handleCancelConfirm}
464
+ on:close={() => { cancelConfirmation = { open: false, requestId: null, isWithdraw: false } }}
465
+ >
466
+ <p>
467
+ {#if cancelConfirmation.isWithdraw}
468
+ Withdrawing your {uiRegistry.getWord('appRequest').toLowerCase()} will remove it from consideration and any approved benefits will be lost. You can reopen and edit this {uiRegistry.getWord('appRequest').toLowerCase()} to resubmit it, as long as the {uiRegistry.getWord('appRequest').toLowerCase()} window is still open.
469
+ {:else}
470
+ Canceling your {uiRegistry.getWord('appRequest').toLowerCase()} will remove it from consideration. You can reinstate and submit it later, as long as the {uiRegistry.getWord('appRequest').toLowerCase()} window is still open.
471
+ {/if}
472
+ </p>
473
+ </Modal>
474
+
475
+ <Toasts />
476
+ </div>
477
+
478
+ <style>
479
+ .applicant-dashboard {
480
+ --repel-vertical-alignment:top;
481
+ }
482
+
483
+ .intro-dropdown-wrapper :global(.bx--dropdown__wrapper.bx--list-box__wrapper) {
484
+ display: flex;
485
+ flex-direction: column;
486
+ margin-top:-5px;
487
+ }
488
+ .intro-actions :global(.bx--list-box__field) {
489
+ background: var(--cds-field-02,#ffffff);
490
+ }
491
+ .applicant-dashboard.past-tab :global(.quickfilters-form) {
492
+ min-height: 2.5rem;
493
+ }
494
+ </style>