@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.
- package/app/env.template +5 -0
- package/app/runtime/apply-env.sh +10 -0
- package/app/runtime/nginx.conf +37 -0
- package/app/src/app.css +21 -0
- package/app/src/app.d.ts +13 -0
- package/app/src/app.html +12 -0
- package/app/src/internal/api.ts +1707 -0
- package/app/src/internal/appreq-utils.ts +33 -0
- package/app/src/internal/components/AnnouncementBanner.svelte +47 -0
- package/app/src/internal/components/AppRequestCard.svelte +173 -0
- package/app/src/internal/components/ApplicantOptOutModal.svelte +78 -0
- package/app/src/internal/components/ApplicantProgramList.svelte +276 -0
- package/app/src/internal/components/ApplicantProgramListTooltip.svelte +44 -0
- package/app/src/internal/components/ApplicantPromptPage.svelte +136 -0
- package/app/src/internal/components/ApplicantPromptSkeleton.svelte +12 -0
- package/app/src/internal/components/ApplicationDetailsView.svelte +369 -0
- package/app/src/internal/components/ButtonLoadingIcon.svelte +28 -0
- package/{dist → app/src/internal}/components/DelayedSkeleton.svelte +2 -2
- package/app/src/internal/components/FieldNestedMultiselect.svelte +270 -0
- package/app/src/internal/components/IntroPanel.svelte +41 -0
- package/app/src/internal/components/MissingDefinitionNotification.svelte +20 -0
- package/app/src/internal/components/PeriodPanel.svelte +125 -0
- package/app/src/internal/components/ProgramStatusCell.svelte +134 -0
- package/app/src/internal/components/RenderDisplayComponent.svelte +42 -0
- package/app/src/internal/components/ReviewerActions.svelte +171 -0
- package/app/src/internal/components/ReviewerQuestions.svelte +444 -0
- package/app/src/internal/components/StatusMessageList.svelte +54 -0
- package/app/src/internal/components/SystemDeterminationNotice.svelte +37 -0
- package/app/src/internal/components/TabLinks.svelte +265 -0
- package/app/src/internal/components/WarningIconYellow.svelte +22 -0
- package/app/src/internal/components/index.ts +19 -0
- package/app/src/internal/csv.ts +30 -0
- package/app/src/internal/index.ts +8 -0
- package/app/src/internal/prompt-save-queue.ts +91 -0
- package/app/src/internal/prompt-utils.ts +40 -0
- package/app/src/internal/status-utils.ts +503 -0
- package/app/src/internal/stores/IStateStore.ts +5 -0
- package/app/src/internal/util.ts +27 -0
- package/app/src/routes/+error.svelte +4 -0
- package/app/src/routes/+layout.svelte +134 -0
- package/app/src/routes/+layout.ts +18 -0
- package/app/src/routes/+page.ts +14 -0
- package/app/src/routes/announcement/+page.svelte +164 -0
- package/app/src/routes/announcement/+page.ts +16 -0
- package/app/src/routes/dashboards/applicant/+page.svelte +494 -0
- package/app/src/routes/dashboards/applicant/+page.ts +95 -0
- package/app/src/routes/dashboards/reviewer/+page.svelte +143 -0
- package/app/src/routes/dashboards/reviewer/+page.ts +43 -0
- package/app/src/routes/periods/+page.svelte +158 -0
- package/app/src/routes/periods/+page.ts +13 -0
- package/app/src/routes/periods/[id]/configure/+page.svelte +150 -0
- package/app/src/routes/periods/[id]/configure/+page.ts +10 -0
- package/app/src/routes/requests/+page.svelte +246 -0
- package/app/src/routes/requests/+page.ts +24 -0
- package/app/src/routes/requests/[id]/+layout.ts +11 -0
- package/app/src/routes/requests/[id]/accept/+layout.svelte +80 -0
- package/app/src/routes/requests/[id]/accept/+layout.ts +12 -0
- package/app/src/routes/requests/[id]/accept/+page.ts +13 -0
- package/app/src/routes/requests/[id]/accept/[promptId]/+page.svelte +7 -0
- package/app/src/routes/requests/[id]/accept/[promptId]/+page.ts +10 -0
- package/app/src/routes/requests/[id]/accept/review/+page.svelte +87 -0
- package/app/src/routes/requests/[id]/apply/+layout.svelte +181 -0
- package/app/src/routes/requests/[id]/apply/+layout.ts +12 -0
- package/app/src/routes/requests/[id]/apply/+page.ts +14 -0
- package/app/src/routes/requests/[id]/apply/ProgressNavContainer.svelte +42 -0
- package/app/src/routes/requests/[id]/apply/[promptId]/+page.svelte +23 -0
- package/app/src/routes/requests/[id]/apply/[promptId]/+page.ts +11 -0
- package/app/src/routes/requests/[id]/apply/programs/+page.svelte +71 -0
- package/app/src/routes/requests/[id]/apply/review/+page.svelte +102 -0
- package/app/src/routes/requests/[id]/approve/+layout.svelte +114 -0
- package/app/src/routes/requests/[id]/approve/+page.ts +14 -0
- package/app/src/routes/requests/[id]/approve/ApproveLayout.svelte +164 -0
- package/app/src/routes/requests/[id]/approve/[programKey]/+page.svelte +396 -0
- package/app/src/routes/requests/[id]/approve/[programKey]/+page.ts +15 -0
- package/app/src/routes/requests/[id]/approve/activity/+page.svelte +66 -0
- package/app/src/routes/requests/[id]/approve/activity/+page.ts +12 -0
- package/app/src/routes/requests/[id]/approve/export/+page.ts +12 -0
- package/app/src/routes/requests/[id]/approve/export/+page@[id].svelte +41 -0
- package/app/src/routes/requests/[id]/export/+page.svelte +32 -0
- package/app/src/routes/requests/[id]/export/+page.ts +12 -0
- package/app/src/routes/roles/+layout.svelte +16 -0
- package/app/src/routes/roles/+layout.ts +14 -0
- package/app/src/routes/roles/+page.svelte +225 -0
- package/app/src/routes/roles/+page.ts +13 -0
- package/app/src/routes/roles/[id]/+layout.ts +10 -0
- package/app/src/routes/roles/[id]/+page.svelte +335 -0
- package/app/src/routes/roles/[id]/+page.ts +10 -0
- package/app/src/routes/roles/[id]/ControlWithTooltip.svelte +15 -0
- package/app/src/routes/roles/[id]/TagsWithTooltip.svelte +42 -0
- package/app/src/routes/roles/users/+layout.ts +13 -0
- package/app/src/routes/roles/users/+page.svelte +46 -0
- package/app/src/routes/roles/users/+page.ts +90 -0
- package/app/static/favicon.png +0 -0
- package/bin/reqquest-ui.js +117 -0
- package/cli/config.js +105 -0
- package/cli/sync.js +296 -0
- package/cli/util.js +29 -0
- package/dist/components.md +0 -4
- package/package.json +29 -15
- 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
|
+
}
|