@reqquest/ui 1.17.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/app/env.template +5 -0
  2. package/app/runtime/apply-env.sh +10 -0
  3. package/app/runtime/nginx.conf +37 -0
  4. package/app/src/app.css +21 -0
  5. package/app/src/app.d.ts +13 -0
  6. package/app/src/app.html +12 -0
  7. package/app/src/internal/api.ts +1707 -0
  8. package/app/src/internal/appreq-utils.ts +33 -0
  9. package/app/src/internal/components/AnnouncementBanner.svelte +47 -0
  10. package/app/src/internal/components/AppRequestCard.svelte +173 -0
  11. package/app/src/internal/components/ApplicantOptOutModal.svelte +78 -0
  12. package/app/src/internal/components/ApplicantProgramList.svelte +276 -0
  13. package/app/src/internal/components/ApplicantProgramListTooltip.svelte +44 -0
  14. package/app/src/internal/components/ApplicantPromptPage.svelte +136 -0
  15. package/app/src/internal/components/ApplicantPromptSkeleton.svelte +12 -0
  16. package/app/src/internal/components/ApplicationDetailsView.svelte +369 -0
  17. package/app/src/internal/components/ButtonLoadingIcon.svelte +28 -0
  18. package/{dist → app/src/internal}/components/DelayedSkeleton.svelte +2 -2
  19. package/app/src/internal/components/FieldNestedMultiselect.svelte +270 -0
  20. package/app/src/internal/components/IntroPanel.svelte +41 -0
  21. package/app/src/internal/components/MissingDefinitionNotification.svelte +20 -0
  22. package/app/src/internal/components/PeriodPanel.svelte +125 -0
  23. package/app/src/internal/components/ProgramStatusCell.svelte +134 -0
  24. package/app/src/internal/components/RenderDisplayComponent.svelte +42 -0
  25. package/app/src/internal/components/ReviewerActions.svelte +171 -0
  26. package/app/src/internal/components/ReviewerQuestions.svelte +508 -0
  27. package/app/src/internal/components/StatusMessageList.svelte +54 -0
  28. package/app/src/internal/components/SystemDeterminationNotice.svelte +37 -0
  29. package/app/src/internal/components/TabLinks.svelte +265 -0
  30. package/app/src/internal/components/WarningIconYellow.svelte +22 -0
  31. package/app/src/internal/components/index.ts +19 -0
  32. package/app/src/internal/csv.ts +30 -0
  33. package/app/src/internal/index.ts +8 -0
  34. package/app/src/internal/prompt-save-queue.ts +91 -0
  35. package/app/src/internal/prompt-utils.ts +40 -0
  36. package/app/src/internal/status-utils.ts +503 -0
  37. package/app/src/internal/stores/IStateStore.ts +5 -0
  38. package/app/src/internal/util.ts +27 -0
  39. package/app/src/routes/+error.svelte +4 -0
  40. package/app/src/routes/+layout.svelte +134 -0
  41. package/app/src/routes/+layout.ts +18 -0
  42. package/app/src/routes/+page.ts +14 -0
  43. package/app/src/routes/announcement/+page.svelte +164 -0
  44. package/app/src/routes/announcement/+page.ts +16 -0
  45. package/app/src/routes/dashboards/applicant/+page.svelte +494 -0
  46. package/app/src/routes/dashboards/applicant/+page.ts +95 -0
  47. package/app/src/routes/dashboards/reviewer/+page.svelte +143 -0
  48. package/app/src/routes/dashboards/reviewer/+page.ts +43 -0
  49. package/app/src/routes/periods/+page.svelte +158 -0
  50. package/app/src/routes/periods/+page.ts +13 -0
  51. package/app/src/routes/periods/[id]/configure/+page.svelte +150 -0
  52. package/app/src/routes/periods/[id]/configure/+page.ts +10 -0
  53. package/app/src/routes/requests/+page.svelte +246 -0
  54. package/app/src/routes/requests/+page.ts +24 -0
  55. package/app/src/routes/requests/[id]/+layout.ts +11 -0
  56. package/app/src/routes/requests/[id]/accept/+layout.svelte +80 -0
  57. package/app/src/routes/requests/[id]/accept/+layout.ts +12 -0
  58. package/app/src/routes/requests/[id]/accept/+page.ts +13 -0
  59. package/app/src/routes/requests/[id]/accept/[promptId]/+page.svelte +7 -0
  60. package/app/src/routes/requests/[id]/accept/[promptId]/+page.ts +10 -0
  61. package/app/src/routes/requests/[id]/accept/review/+page.svelte +87 -0
  62. package/app/src/routes/requests/[id]/apply/+layout.svelte +181 -0
  63. package/app/src/routes/requests/[id]/apply/+layout.ts +12 -0
  64. package/app/src/routes/requests/[id]/apply/+page.ts +14 -0
  65. package/app/src/routes/requests/[id]/apply/ProgressNavContainer.svelte +42 -0
  66. package/app/src/routes/requests/[id]/apply/[promptId]/+page.svelte +23 -0
  67. package/app/src/routes/requests/[id]/apply/[promptId]/+page.ts +11 -0
  68. package/app/src/routes/requests/[id]/apply/programs/+page.svelte +71 -0
  69. package/app/src/routes/requests/[id]/apply/review/+page.svelte +102 -0
  70. package/app/src/routes/requests/[id]/approve/+layout.svelte +114 -0
  71. package/app/src/routes/requests/[id]/approve/+page.ts +14 -0
  72. package/app/src/routes/requests/[id]/approve/ApproveLayout.svelte +164 -0
  73. package/app/src/routes/requests/[id]/approve/[programKey]/+page.svelte +396 -0
  74. package/app/src/routes/requests/[id]/approve/[programKey]/+page.ts +15 -0
  75. package/app/src/routes/requests/[id]/approve/activity/+page.svelte +66 -0
  76. package/app/src/routes/requests/[id]/approve/activity/+page.ts +12 -0
  77. package/app/src/routes/requests/[id]/approve/export/+page.ts +12 -0
  78. package/app/src/routes/requests/[id]/approve/export/+page@[id].svelte +41 -0
  79. package/app/src/routes/requests/[id]/export/+page.svelte +32 -0
  80. package/app/src/routes/requests/[id]/export/+page.ts +12 -0
  81. package/app/src/routes/roles/+layout.svelte +16 -0
  82. package/app/src/routes/roles/+layout.ts +14 -0
  83. package/app/src/routes/roles/+page.svelte +225 -0
  84. package/app/src/routes/roles/+page.ts +13 -0
  85. package/app/src/routes/roles/[id]/+layout.ts +10 -0
  86. package/app/src/routes/roles/[id]/+page.svelte +335 -0
  87. package/app/src/routes/roles/[id]/+page.ts +10 -0
  88. package/app/src/routes/roles/[id]/ControlWithTooltip.svelte +15 -0
  89. package/app/src/routes/roles/[id]/TagsWithTooltip.svelte +42 -0
  90. package/app/src/routes/roles/users/+layout.ts +13 -0
  91. package/app/src/routes/roles/users/+page.svelte +46 -0
  92. package/app/src/routes/roles/users/+page.ts +90 -0
  93. package/app/static/favicon.png +0 -0
  94. package/bin/reqquest-ui.js +117 -0
  95. package/cli/config.js +105 -0
  96. package/cli/sync.js +296 -0
  97. package/cli/util.js +29 -0
  98. package/dist/components.md +5 -4
  99. package/dist/registry.d.ts +17 -0
  100. package/dist/registry.js +3 -0
  101. package/package.json +29 -15
  102. package/dist/components/DelayedSkeleton.svelte.d.ts +0 -21
@@ -0,0 +1,95 @@
1
+ import { api, APP_REQUEST_STATUS_CONFIG, getPastStatuses } from '$internal'
2
+ import type { DashboardAppRequest } from '$internal'
3
+ import type { AppRequestStatus } from '@reqquest/ui'
4
+ import { error } from '@sveltejs/kit'
5
+ import { extractMergedFilters } from '@txstate-mws/carbon-svelte'
6
+ import { sortby, unique } from 'txstate-utils'
7
+ import { uiRegistry } from '$local'
8
+ import type { PageLoad } from './$types'
9
+ import { excludeApplicantRequirementIneligibleApps } from '$internal'
10
+
11
+ function statusLabelsToEnums (labels: string[]): AppRequestStatus[] {
12
+ const keys = Object.keys(APP_REQUEST_STATUS_CONFIG) as AppRequestStatus[]
13
+ return keys.filter(k => labels.includes(APP_REQUEST_STATUS_CONFIG[k].tags[0].label))
14
+ }
15
+
16
+ export const load: PageLoad = async ({ url, depends, parent }) => {
17
+ const { access } = await parent()
18
+ // access is missing only when the layout failed; it renders layoutError in place of the page, so don't mask it
19
+ if (!access) return {} as never
20
+ if (!access.viewApplicantDashboard) throw error(403)
21
+
22
+ depends('api:getApplicantRequests')
23
+ depends('api:getOpenPeriods')
24
+ depends('api:getAccess')
25
+
26
+ const filters = extractMergedFilters(url)
27
+ const currentTab = filters.t ?? 'recent_applications'
28
+ const recentDays = uiRegistry.config.applicantDashboardRecentDays ?? 30
29
+ const cutoff = new Date()
30
+ cutoff.setDate(cutoff.getDate() - recentDays)
31
+ const recentCutoffIso = cutoff.toISOString()
32
+
33
+ const pastStatuses = new Set(getPastStatuses())
34
+ // an app is "past" only if it's both older than the cutoff AND finished - in a terminal status
35
+ // or closed (closing leaves the status where it was, e.g. APPROVAL); other apps with active
36
+ // statuses always stay on the recent tab regardless of age
37
+ function isPastApp (r: DashboardAppRequest) {
38
+ return r.updatedAt < recentCutoffIso && (r.closedAt != null || pastStatuses.has(r.status))
39
+ }
40
+
41
+
42
+ // fetch for all own apps, then split
43
+ const [allRequests, openPeriods, announcement] = await Promise.all([
44
+ api.getApplicantRequests({ own: true }),
45
+ api.getOpenPeriods(),
46
+ api.getAnnouncement(true)
47
+ ])
48
+
49
+ const allRequestsSansIneligibleApps = excludeApplicantRequirementIneligibleApps(allRequests)
50
+ if (currentTab === 'past_applications') {
51
+ const allPastRequests = allRequestsSansIneligibleApps.filter(isPastApp)
52
+
53
+ const hasActiveFilters = filters.periodIds?.length > 0 || filters.status?.length > 0 || !!filters.search
54
+
55
+ let displayRequests: typeof allPastRequests
56
+ if (hasActiveFilters) {
57
+ // "past" is terminal status OR closed, which the API filter cannot express, so narrow on
58
+ // the server by the user's filters and apply isPastApp here
59
+ displayRequests = (await api.getApplicantRequests({
60
+ own: true,
61
+ updatedBefore: recentCutoffIso,
62
+ ...(filters.status?.length > 0 && { status: statusLabelsToEnums(filters.status) }),
63
+ ...(filters.search && { search: filters.search }),
64
+ ...(filters.periodIds?.length > 0 && { periodIds: filters.periodIds })
65
+ })).filter(isPastApp)
66
+ } else {
67
+ displayRequests = allPastRequests
68
+ }
69
+
70
+ // get the period options available for filtering from the unfiltered past set
71
+ const periods = allPastRequests.filter(r => r.period.id).map(r => ({ id: r.period.id, name: r.period.name }))
72
+ const availablePeriods = sortby(unique(periods, 'id'), 'name')
73
+
74
+ // get status options available for filtering
75
+ const statusesInData = new Set(allPastRequests.map(r => r.status))
76
+ const configKeys = Object.keys(APP_REQUEST_STATUS_CONFIG) as AppRequestStatus[]
77
+ const availableStatuses = unique(
78
+ configKeys.filter(k => statusesInData.has(k)).map(k => APP_REQUEST_STATUS_CONFIG[k].tags[0].label)
79
+ )
80
+
81
+ return {
82
+ appRequests: hasActiveFilters ? displayRequests : allPastRequests,
83
+ availablePeriods, availableStatuses, openPeriods, access, recentCutoffIso, recentDays,
84
+ announcement
85
+ }
86
+ } else {
87
+ return {
88
+ appRequests: allRequestsSansIneligibleApps.filter(r => !isPastApp(r)),
89
+ openPeriods, access, recentCutoffIso, recentDays,
90
+ availablePeriods: [] as { id: string, name: string }[],
91
+ availableStatuses: [] as string[],
92
+ announcement
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,143 @@
1
+ <script lang="ts">
2
+ import { ColumnList, FieldCheckbox, FieldDate, FieldMultiselect, FilterUI, Pagination, IntroPanel } from '@txstate-mws/carbon-svelte'
3
+ import { Tile } from 'carbon-components-svelte'
4
+ import DocExport from 'carbon-icons-svelte/lib/DocumentExport.svelte'
5
+ import View from 'carbon-icons-svelte/lib/View.svelte'
6
+ import { DateTime, Duration } from 'luxon'
7
+ import { tick } from 'svelte'
8
+ import { pluralize, toQuery } from 'txstate-utils'
9
+ import { goto } from '$app/navigation'
10
+ import { resolve } from '$app/paths'
11
+ import { api, FieldNestedMultiselect, getProgramStatusFilterItems, getReviewerStatusTags, ProgramStatusCell, twoLineDateHtml } from '$internal'
12
+ import type { PageData } from './$types'
13
+ import { uiRegistry } from '$local'
14
+ import { _inReviewStatuses, _reviewCompleteStatuses, _reviewPendingStatuses } from './+page.js'
15
+
16
+ export let data: PageData
17
+ $: ({ appRequests, totalItems, appRequestIndexes, filters, tabCounts, applicantCounts, avgDecisionSeconds, viewMetrics, programs, periods } = data)
18
+
19
+ // tab labels carry the count of open requests in that stage
20
+ $: tabs = [
21
+ { label: `Review Pending (${tabCounts.pending})`, value: { status: _reviewPendingStatuses }, title: 'Review pending', subtitle: 'These are applications waiting to be started by the review team.' },
22
+ { label: `In Review (${tabCounts.inReview})`, value: { status: _inReviewStatuses }, title: 'In review', subtitle: 'These are applications currently being reviewed.' },
23
+ { label: `Review Complete (${tabCounts.complete})`, value: { status: _reviewCompleteStatuses }, title: 'Review complete', subtitle: 'These applications have finished review, including results already released to the applicant.' }
24
+ ]
25
+ // the active tab is whichever one's statuses the current filter carries; default to the first
26
+ $: activeTab = tabs.find(t => t.value.status.some(s => filters.status?.includes(s))) ?? tabs[0]
27
+
28
+ function formatDuration (seconds: number) {
29
+ const d = Duration.fromObject({ seconds: Math.round(seconds) }).shiftTo('days', 'hours', 'minutes').toObject()
30
+ const parts = (['days', 'hours', 'minutes'] as const).filter(unit => (d[unit] ?? 0) >= 1).map(unit => pluralize(unit.slice(0, -1), Math.floor(d[unit]!), true))
31
+ return parts.length ? parts.join(', ') : 'under a minute'
32
+ }
33
+
34
+ // export only those requests selected, otherwise export whatever the current filters show.
35
+ async function downloadCSV (ids?: string[]) {
36
+ const ticket = await api.getDownloadTicket()
37
+ const query = '?' + toQuery({ f: ids?.length ? { ids } : filters } as unknown as Parameters<typeof toQuery>[0])
38
+ location.href = `${api.baseUrl}/csv/${ticket}/requests/reviewerdashboard${DateTime.now().toFormat('yyyyLLddHHmmss')}.csv${query}`
39
+ }
40
+
41
+ </script>
42
+
43
+ <div class='intro-wide [ px-8 ]'>
44
+ <div class="review-header [ flex justify-between items-start flex-wrap-reverse gap-4 mb-4 ]">
45
+ <div class="review-tabs">
46
+ <FilterUI tabs={tabs.map(t => ({ label: t.label, value: t.value }))} tabsAriaLabel="Review stage" />
47
+ </div>
48
+ <div class="[ flex gap-2 flex-wrap ]">
49
+ <Tile class="stat-tile [ flex flex-col gap-4 ]">
50
+ <span class='[ text-lg ]'>First time application</span>
51
+ <span>{pluralize('application', applicantCounts.firstTime, true)}</span>
52
+ </Tile>
53
+ <Tile class="stat-tile [ flex flex-col gap-4 ]">
54
+ <span class='[ text-lg ]'>Returning application</span>
55
+ <span>{pluralize('application', applicantCounts.returning, true)}</span>
56
+ </Tile>
57
+ {#if viewMetrics}
58
+ <Tile class="stat-tile [ flex flex-col gap-4 ]">
59
+ <span class='[ text-lg ]'>Avg. time to finish review</span>
60
+ <span>{avgDecisionSeconds != null ? formatDuration(avgDecisionSeconds) : '--:--'}</span>
61
+ </Tile>
62
+ {/if}
63
+ </div>
64
+ </div>
65
+
66
+ <IntroPanel title={activeTab.title} subtitle={activeTab.subtitle} />
67
+
68
+ <ColumnList
69
+ autoHideColumns
70
+ searchable
71
+ showExpandAll
72
+ filterTitle="Request Filters"
73
+ listActions={[
74
+ { label: 'Export', icon: DocExport, onClick: () => downloadCSV() }
75
+ ]}
76
+ selectedActions={rows => [
77
+ { label: 'Download selected', icon: DocExport, onClick: () => downloadCSV(rows.map(r => r.id)) }
78
+ ]}
79
+ columns={[
80
+ { id: 'request', label: 'Request #', fixed: '90px', minWidth: 90, tags: row => [{ label: String(row.id) }] },
81
+ { id: 'period', label: uiRegistry.getWord('period'), render: r => r.period.name },
82
+ { id: 'login', label: uiRegistry.getWord('login'), minWidth: 100, tags: r => [{ label: r.applicant.login, type: 'green' }] },
83
+ { id: 'name', label: 'Name', get: 'applicant.fullname' },
84
+ { id: 'dateSubmitted', label: 'Date Submitted', minWidth: 120, render: r => twoLineDateHtml(r.createdAt) },
85
+ { id: 'program', label: 'Program', minWidth: 220, component: ProgramStatusCell },
86
+ { id: 'status', label: 'Application status', minWidth: 150, tags: r => getReviewerStatusTags(r.status, r.phase, r.closedAt) },
87
+ { id: 'lastUpdated', label: 'Last Updated', minWidth: 120, render: r => twoLineDateHtml(r.updatedAt) },
88
+ ...appRequestIndexes.map(index => ({
89
+ id: 'cat_' + index.category,
90
+ label: index.categoryLabel,
91
+ render: r => {
92
+ const matchingIndex = r.indexes?.find(i => i.category === index.category)
93
+ return (matchingIndex?.values.map(v => ({ label: v.label })) ?? []).join(', ')
94
+ }
95
+ }))
96
+ ]}
97
+ rows={appRequests}
98
+ title={uiRegistry.getPlural('appRequest')}
99
+ actions={r => [
100
+ {
101
+ label: 'View',
102
+ icon: View,
103
+ // Heisenbug :) Resolved after console dumps slowed it down and worked.
104
+ // defer navigation one tick past the click's synchronous handling. goto() inside the handler
105
+ // raced with the action button's click and got dropped on the first uncached click after a fresh page load, required second click to navigate.
106
+ onClick: async () => {
107
+ await tick()
108
+ await goto(resolve(`/requests/${r.id}/approve`))
109
+ }
110
+ }
111
+ ]}
112
+ >
113
+ <svelte:fragment slot="filters">
114
+ <FieldNestedMultiselect path="applicationStatuses" labelText="Program status" items={getProgramStatusFilterItems()} />
115
+ <FieldMultiselect path="programKeys" labelText="Program" label="Choose one or more" items={programs.map(p => ({ value: p.key, label: p.title }))} />
116
+ <FieldMultiselect path="periodIds" labelText={uiRegistry.getPlural('period')} label="Choose one or more" items={periods.map(p => ({ value: p.id, label: p.name }))} filterable />
117
+ <FieldDate path="submittedAfter" labelText="Submitted After" placeholder="Select a date" beginningOfDay />
118
+ <FieldDate path="submittedBefore" labelText="Submitted Before" placeholder="Select a date" endOfDay />
119
+ <!-- the dashboard lists open requests; closed ones appear only when asked for here -->
120
+ <FieldCheckbox path="closed" labelText="Closed or Cancelled only" />
121
+ </svelte:fragment>
122
+ <svelte:fragment let:row>
123
+ <div class="[ mb-2 ]"><strong>Programs</strong></div>
124
+ <ProgramStatusCell {row} rollup={false} />
125
+ </svelte:fragment>
126
+ </ColumnList>
127
+
128
+ <Pagination
129
+ {totalItems}
130
+ pageSize={25}
131
+ chooseSize
132
+ />
133
+ </div>
134
+
135
+ <style>
136
+ .intro-wide :global(.intro-panel .content-start) {
137
+ max-width: none;
138
+ }
139
+ /* the header row owns the spacing below the tabs; FilterUI's own bottom margin would double it */
140
+ .review-tabs :global(.filter-ui-container) {
141
+ margin-bottom: 0;
142
+ }
143
+ </style>
@@ -0,0 +1,43 @@
1
+ import { error, redirect } from '@sveltejs/kit'
2
+ import { extractMergedFilters, extractPaginationParams } from '@txstate-mws/carbon-svelte'
3
+ import { toQuery } from 'txstate-utils'
4
+ import { api, splitStatusFilter } from '$internal'
5
+ import { enumAppRequestStatus, type AppRequestFilter } from '@reqquest/ui'
6
+ import type { PageLoad } from './$types'
7
+
8
+ export const _reviewPendingStatuses = [enumAppRequestStatus.PREAPPROVAL, enumAppRequestStatus.APPROVAL]
9
+ export const _inReviewStatuses = [enumAppRequestStatus.REVIEW_IN_PROGRESS]
10
+ export const _reviewCompleteStatuses = [enumAppRequestStatus.REVIEW_COMPLETE, enumAppRequestStatus.ACCEPTANCE, enumAppRequestStatus.READY_TO_ACCEPT, enumAppRequestStatus.ACCEPTED, enumAppRequestStatus.NOT_ACCEPTED, enumAppRequestStatus.APPROVED, enumAppRequestStatus.NOT_APPROVED]
11
+ /** Everything the dashboard can show: the union of the three tabs. */
12
+ export const _reviewerDashboardStatuses = [..._reviewPendingStatuses, ..._inReviewStatuses, ..._reviewCompleteStatuses]
13
+ export const _defaultReviewerDashboardFilters = { t: { status: _reviewPendingStatuses } }
14
+
15
+ export const load: PageLoad = async ({ url, parent }) => {
16
+ const { access } = await parent()
17
+ // access is missing only when the layout failed; it renders layoutError in place of the page, so don't mask it
18
+ if (!access) return {} as never
19
+ if (!access.viewReviewerInterface) throw error(403)
20
+ if (!url.search) redirect(302, '?' + toQuery(_defaultReviewerDashboardFilters))
21
+ const { page, pagesize } = extractPaginationParams(url)
22
+ const { status, closed: closedOnly, ...rest } = extractMergedFilters(url)
23
+ // open requests by default; closed ones only when the Filter pop-out's "Closed or Cancelled only" is checked
24
+ const closed = closedOnly === true
25
+ const merged: AppRequestFilter = { ...rest, status: splitStatusFilter(status).status, closed }
26
+
27
+ const [{ appRequests, pageInfo, appRequestIndexes }, pending, inReview, complete, applicantCounts, avgDecisionSeconds, programs, periods] = await Promise.all([
28
+ api.getReviewerDashboardRequests(merged, {
29
+ page,
30
+ perPage: pagesize ?? 25
31
+ }),
32
+ // counts and tiles follow the same open/closed choice as the list, so they describe what it can show
33
+ api.getApplicationCount({ closed, status: _reviewPendingStatuses }),
34
+ api.getApplicationCount({ closed, status: _inReviewStatuses }),
35
+ api.getApplicationCount({ closed, status: _reviewCompleteStatuses }),
36
+ api.getAppRequestApplicantCounts({ closed, status: _reviewerDashboardStatuses }),
37
+ access.viewMetrics ? api.getReviewDecisionTiming() : Promise.resolve(null),
38
+ api.getPrograms(),
39
+ api.getPeriodList()
40
+ ])
41
+
42
+ return { appRequests, totalItems: pageInfo.appRequests!.totalItems ?? appRequests.length, filters: merged, appRequestIndexes, tabCounts: { pending, inReview, complete }, applicantCounts, avgDecisionSeconds, viewMetrics: !!access?.viewMetrics, programs, periods }
43
+ }
@@ -0,0 +1,158 @@
1
+ <script lang="ts">
2
+ import { ColumnList, FieldDateTime, FieldSelect, FieldTextInput, Panel, PanelFormDialog } from '@txstate-mws/carbon-svelte'
3
+ import { InlineNotification, Modal } from 'carbon-components-svelte'
4
+ import Edit from 'carbon-icons-svelte/lib/Edit.svelte'
5
+ import SettingsEdit from 'carbon-icons-svelte/lib/SettingsEdit.svelte'
6
+ import Add from 'carbon-icons-svelte/lib/Add.svelte'
7
+ import TrashCan from 'carbon-icons-svelte/lib/TrashCan.svelte'
8
+ import { DateTime } from 'luxon'
9
+ import { isBlank } from 'txstate-utils'
10
+ import { invalidate } from '$app/navigation'
11
+ import { base } from '$app/paths'
12
+ import { api } from '$internal'
13
+ import { uiRegistry } from '$local'
14
+ import type { PeriodUpdate } from '@reqquest/ui'
15
+ import type { PageData } from './$types'
16
+
17
+ type Period = PageData['periods'][number] & { copyPeriodId?: string }
18
+
19
+ export let data: PageData
20
+ $: ({ periods, access } = data)
21
+
22
+ function renderDate (prop: 'openDate' | 'closeDate' | 'archiveDate') {
23
+ return (period: Period) => {
24
+ const date = period[prop]
25
+ if (isBlank(date)) return ''
26
+ return new Date(date).toLocaleDateString()
27
+ }
28
+ }
29
+
30
+ let creatingPeriod = false
31
+ let editingPeriod: Period | undefined
32
+ let deleteDialog = false
33
+ let deletingPeriod: Period | undefined
34
+
35
+ async function validate (period: Period) {
36
+ const response = editingPeriod ? await api.updatePeriod(editingPeriod.id, periodToPeriodUpdate(period), true) : await api.createPeriod(periodToPeriodUpdate(period), true)
37
+ return response.messages
38
+ }
39
+
40
+ async function submit (period: Period) {
41
+ const response = editingPeriod ? await api.updatePeriod(editingPeriod.id, periodToPeriodUpdate(period), false) : await api.createPeriod(periodToPeriodUpdate(period), false, period.copyPeriodId)
42
+ return {
43
+ ...response,
44
+ data: period
45
+ }
46
+ }
47
+
48
+ function periodToPeriodUpdate (period: Period): PeriodUpdate {
49
+ return { name: period.name, code: period.code, openDate: period.openDate, closeDate: period.closeDate, archiveDate: period.archiveDate }
50
+ }
51
+
52
+ function closeDialog () {
53
+ creatingPeriod = false
54
+ editingPeriod = undefined
55
+ }
56
+
57
+ function displayPhase (period: Period) {
58
+ let { openDate: oDate, closeDate: cDate, archiveDate: aDate } = period
59
+ const now = Date.now()
60
+ const openDate = DateTime.now().toMillis()
61
+
62
+ if (now < openDate) {
63
+ return [{ label: 'Start pending' }]
64
+ } else if (cDate) {
65
+ const closeDate = DateTime.fromISO(cDate).toMillis()
66
+ if (now < closeDate) return [{ label: 'OPEN' }]
67
+ else if (now > closeDate) return [{ label: 'CLOSED' }]
68
+ else if (aDate && now > DateTime.fromISO(aDate).toMillis()) return [{ label: 'ARCHIVED' }]
69
+ }
70
+
71
+ return []
72
+ }
73
+
74
+ async function onSaved () {
75
+ closeDialog()
76
+ await invalidate('api:getPeriodList')
77
+ }
78
+
79
+ function closePeriodDeleteDialog () {
80
+ deleteDialog = false
81
+ deletingPeriod = undefined
82
+ }
83
+ function openPeriodDeleteDialog (period: Period) {
84
+ deleteDialog = true
85
+ deletingPeriod = period
86
+ }
87
+ async function executePeriodDelete () {
88
+ if (!deletingPeriod) return
89
+ const success = await api.deletePeriod(deletingPeriod.id)
90
+ if (success) {
91
+ closePeriodDeleteDialog()
92
+ invalidate('api:getPeriodList').catch(console.error)
93
+ }
94
+ }
95
+ </script>
96
+
97
+ <Panel
98
+ title={uiRegistry.getWord('period')}
99
+ actions={[
100
+ {
101
+ label: `Add ${uiRegistry.getWord('period')}`,
102
+ disabled: !access.createPeriod,
103
+ icon: Add,
104
+ onClick: () => { creatingPeriod = true; editingPeriod = undefined }
105
+ }
106
+ ]}>
107
+ <ColumnList
108
+ title={uiRegistry.getPlural('period')}
109
+ columns={[
110
+ { id: 'period', label: uiRegistry.getWord('period'), get: 'name' },
111
+ { id: 'code', label: 'Code', tags: (row) => row.code ? [{ label: row.code, type: 'green' }] : [] },
112
+ { id: 'phase', label: 'Phase', tags: displayPhase },
113
+ { id: 'openDate', label: 'Start Date', render: renderDate('openDate') },
114
+ { id: 'closeDate', label: 'Close Date', render: renderDate('closeDate') },
115
+ { id: 'archiveDate', label: 'Archive Date', render: renderDate('archiveDate') },
116
+ ]}
117
+ rows={periods}
118
+ actions={p => [
119
+ { label: 'Configure', icon: SettingsEdit, href: `${base}/periods/${p.id}/configure`, disabled: !p.actions.update },
120
+ { label: 'Edit', icon: Edit, onClick: () => { creatingPeriod = false; editingPeriod = p }, disabled: !p.actions.update },
121
+ { label: 'Delete', icon: TrashCan, disabled: !p.actions.delete, onClick: () => openPeriodDeleteDialog(p) }
122
+ ]}
123
+ actionsMaxButtons={1}
124
+ />
125
+ </Panel>
126
+
127
+ <PanelFormDialog open={creatingPeriod || !!editingPeriod} title={editingPeriod ? `Edit ${uiRegistry.getWord('period')}` : `Create ${uiRegistry.getWord('period')}`}
128
+ preload={editingPeriod} {validate} {submit} on:saved={onSaved} on:cancel={closeDialog}>
129
+ <InlineNotification
130
+ kind='info'
131
+ title="Configuration Updated"
132
+ subtitle="You can update configurations once the new period is added."
133
+ lowContrast
134
+ />
135
+ <FieldSelect
136
+ labelText={`${uiRegistry.getWord('period')} configurations copied from`}
137
+ helpText='Help Text'
138
+ items={periods.map(p => ({ value: p.id, label: p.name }))}
139
+ path='copyPeriod'
140
+ />
141
+ <FieldTextInput path="name" labelText={`${uiRegistry.getWord('period')} Name`} required notNull />
142
+ <FieldTextInput path="code" labelText="Code" />
143
+ <FieldDateTime path="openDate" labelText="Open Date" required defaultValue={new Date().toISOString()} />
144
+ <FieldDateTime path="closeDate" labelText="Close Date" />
145
+ <FieldDateTime path="archiveDate" labelText="Archive Date" />
146
+ </PanelFormDialog>
147
+ <Modal
148
+ bind:open={deleteDialog}
149
+ modalHeading={`Delete ${uiRegistry.getWord('period')}`}
150
+ primaryButtonText="Delete"
151
+ secondaryButtonText="Cancel"
152
+ size="sm"
153
+ on:click:button--secondary={closePeriodDeleteDialog}
154
+ on:submit={executePeriodDelete}
155
+ >
156
+ <p>Are you sure you want to delete the {uiRegistry.getWord('period').toLowerCase()} {deletingPeriod?.name}?</p>
157
+ <p>This action cannot be undone.</p>
158
+ </Modal>
@@ -0,0 +1,13 @@
1
+ import { api } from '$internal'
2
+ import { error } from '@sveltejs/kit'
3
+ import type { PageLoad } from './$types'
4
+
5
+ export const load: PageLoad = async ({ depends, 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.viewPeriodManagement) throw error(403)
10
+ const periods = await api.getPeriodList()
11
+ depends('api:getPeriodList')
12
+ return { periods }
13
+ }
@@ -0,0 +1,150 @@
1
+ <script lang="ts">
2
+ import { FieldTextInput, PanelFormDialog } from '@txstate-mws/carbon-svelte'
3
+ import { InlineNotification, Modal, NotificationActionButton, Tab, TabContent, Tabs } from 'carbon-components-svelte'
4
+ import { keyby } from 'txstate-utils'
5
+ import { invalidate } from '$app/navigation'
6
+ import { page } from '$app/stores'
7
+ import { api, MissingDefinitionNotification, PeriodPanel } from '$internal'
8
+ import type { PageData } from './$types'
9
+ import { uiRegistry } from '$local'
10
+
11
+ export let data: PageData
12
+ $: ({ programs, period } = data)
13
+
14
+ $: programsByKey = keyby(programs, 'key')
15
+ $: programTitles = Object.fromEntries(programs.map(p => [p.key, p.title])) as Record<string, string>
16
+ $: sharedProgramRequirements = programs.reduce<Record<string, string[] | undefined>>((acc, curr) => {
17
+ curr.requirements.forEach(r => {
18
+ acc[r.key] ??= []
19
+ acc[r.key]!.push(curr.key)
20
+ })
21
+ return acc
22
+ }, {})
23
+
24
+ // Modal State
25
+ let sharedModal: { open: boolean, requirementKey: string | null } = { open: false, requirementKey: null }
26
+
27
+ type Program = PageData['programs'][number]
28
+ type Requirement = PageData['programs'][number]['requirements'][number]
29
+ type Prompt = PageData['programs'][number]['requirements'][number]['prompts'][number]
30
+
31
+ let editingConfiguration = false
32
+ let editingConfigurationType: 'prompt' | 'requirement' | 'program' | undefined = undefined
33
+ let editingConfigurationDef: Requirement | Prompt | Program | undefined
34
+ let editingConfigurationFetched: any
35
+
36
+ function closeConfigurationDialog () {
37
+ editingConfiguration = false
38
+ editingConfigurationType = undefined
39
+ editingConfigurationDef = undefined
40
+ editingConfigurationFetched = undefined
41
+ }
42
+
43
+ function onClick (type: 'prompt' | 'requirement' | 'program', def: Requirement | Prompt | Program) {
44
+ return async () => {
45
+ editingConfigurationFetched = type === 'program' ? {} : await api.getConfigurationFetched(period.id, def.key)
46
+ editingConfiguration = true
47
+ editingConfigurationType = type
48
+ editingConfigurationDef = def
49
+ }
50
+ }
51
+
52
+ async function onSubmit (data: any) {
53
+ const { success, messages } = await api.updateConfiguration($page.params.id!, editingConfigurationDef!.key, data, false)
54
+ return {
55
+ success,
56
+ messages,
57
+ data
58
+ }
59
+ }
60
+
61
+ async function onValidate (data: any) {
62
+ const { messages } = await api.updateConfiguration($page.params.id!, editingConfigurationDef!.key, data, true)
63
+ return messages
64
+ }
65
+
66
+ async function onSaved () {
67
+ await invalidate('api:getPeriodConfigurations')
68
+ closeConfigurationDialog()
69
+ }
70
+
71
+ const openModal = (key: string) => () => {
72
+ sharedModal = { open: true, requirementKey: key }
73
+ }
74
+
75
+ async function confirmReview () {
76
+ await api.markPeriodReviewed(period.id, false)
77
+ await invalidate('api:getPeriodConfigurations')
78
+ }
79
+
80
+ $:enabledPrograms = programs.filter(p => p.enabled)
81
+ $:disabledPrograms = programs.filter(p => !p.enabled)
82
+
83
+ </script>
84
+
85
+ <!-- Configuring Period: {period.name}{#if period.code} ({period.code}){/if} -->
86
+
87
+ {#if !period.reviewed}
88
+ <InlineNotification kind='warning' lowContrast title={`${period.name} period configurations:`} subtitle={`Please confirm when ${period.name} configuration updates are complete`} >
89
+ <svelte:fragment slot="actions">
90
+ <NotificationActionButton on:click={confirmReview}>Confirm Review</NotificationActionButton>
91
+ </svelte:fragment>
92
+ </InlineNotification>
93
+ {/if}
94
+
95
+ <Tabs autoWidth>
96
+ <Tab label={`Enabled Programs (${enabledPrograms.length})`} />
97
+ <Tab label={`Disabled Programs (${disabledPrograms.length})`} />
98
+ <svelte:fragment slot='content'>
99
+ <TabContent>
100
+ {#each enabledPrograms as program (program.key)}
101
+ <PeriodPanel {program} {sharedProgramRequirements} {programTitles} {openModal} {onClick} {uiRegistry} />
102
+ {/each}
103
+ </TabContent>
104
+ <TabContent>
105
+ {#each disabledPrograms as program (program.key)}
106
+ <PeriodPanel {program} {sharedProgramRequirements} {programTitles} {openModal} {onClick} {uiRegistry} />
107
+ {/each}
108
+ </TabContent>
109
+ </svelte:fragment>
110
+ </Tabs>
111
+
112
+ {#if editingConfiguration && editingConfigurationDef != null}
113
+ {#if editingConfigurationType === 'program'}
114
+ <PanelFormDialog open submit={onSubmit} validate={onValidate} title="Rename Program" on:cancel={closeConfigurationDialog} on:saved={onSaved} preload={editingConfigurationDef.configuration.data}>
115
+ <p class="mb-4">These names apply to this period only. Leave a field blank to use the program's default name.</p>
116
+ <FieldTextInput path="title" labelText="Title" placeholder={editingConfigurationDef.title} maxlength={255} />
117
+ <FieldTextInput path="navTitle" labelText="Navigation title" helperText="Shorter name shown in navigation. Defaults to the title above when blank." placeholder={'navTitle' in editingConfigurationDef ? editingConfigurationDef.navTitle : undefined} maxlength={255} />
118
+ </PanelFormDialog>
119
+ {:else if editingConfigurationType === 'prompt'}
120
+ {@const def = uiRegistry.getPrompt(editingConfigurationDef.key)}
121
+ <PanelFormDialog open submit={onSubmit} validate={onValidate} title="Edit Configuration" on:cancel={closeConfigurationDialog} on:saved={onSaved} preload={editingConfigurationDef.configuration.data} fetched={editingConfigurationFetched} let:data>
122
+ {#if def?.configureComponent == null}
123
+ <MissingDefinitionNotification kind="prompt" definitionKey={editingConfigurationDef.key} />
124
+ {:else}
125
+ <svelte:component this={def.configureComponent} {data} fetched={editingConfigurationFetched} />
126
+ {/if}
127
+ </PanelFormDialog>
128
+ {:else}
129
+ {@const def = uiRegistry.getRequirement(editingConfigurationDef.key)}
130
+ <PanelFormDialog open submit={onSubmit} validate={onValidate} title="Edit Configuration" on:cancel={closeConfigurationDialog} on:saved={onSaved} preload={editingConfigurationDef.configuration.data} fetched={editingConfigurationFetched} let:data>
131
+ {#if def?.configureComponent == null}
132
+ <MissingDefinitionNotification kind="requirement" definitionKey={editingConfigurationDef.key} />
133
+ {:else}
134
+ <svelte:component this={def.configureComponent} {data} fetched={editingConfigurationFetched}/>
135
+ {/if}
136
+ </PanelFormDialog>
137
+ {/if}
138
+ {/if}
139
+
140
+ <Modal
141
+ passiveModal
142
+ bind:open={sharedModal.open}
143
+ modalHeading="Shared Requirements"
144
+ >
145
+ {@const sharedPrograms = sharedProgramRequirements[sharedModal.requirementKey!]?.map(key => programsByKey[key]) ?? []}
146
+ {#each sharedPrograms as p (p.key)}
147
+ <p>{p.title}</p>
148
+ <hr/>
149
+ {/each}
150
+ </Modal>
@@ -0,0 +1,10 @@
1
+ import { api } from '$internal'
2
+ import { error } from '@sveltejs/kit'
3
+ import type { PageLoad } from './$types'
4
+
5
+ export const load: PageLoad = async ({ params, depends }) => {
6
+ const { programs, period } = await api.getPeriodConfigurations(params.id)
7
+ depends('api:getPeriodConfigurations')
8
+ if (!period) throw error(404, 'Period not found')
9
+ return { programs, period }
10
+ }