@things-factory/announcement 0.0.0-stage → 10.1.222

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 (122) hide show
  1. package/client/announcement-watch.ts +109 -0
  2. package/client/bootstrap.ts +9 -0
  3. package/client/components/announcement-popup.ts +92 -0
  4. package/client/components/announcement-receipts.ts +196 -0
  5. package/client/components/announcement-sheet.ts +214 -0
  6. package/client/components/audience-picker.ts +221 -0
  7. package/client/index.ts +1 -0
  8. package/client/pages/announcement-editor.ts +505 -0
  9. package/client/pages/announcement-list.ts +200 -0
  10. package/client/pages/announcement-manage-list.ts +113 -0
  11. package/client/pages/announcement-view.ts +172 -0
  12. package/client/route.ts +17 -0
  13. package/client/tsconfig.json +13 -0
  14. package/client/utils/announcement-api.ts +142 -0
  15. package/client/utils/announcement-badge.ts +46 -0
  16. package/client/utils/announcement-words.ts +26 -0
  17. package/dist-client/announcement-watch.d.ts +6 -0
  18. package/dist-client/announcement-watch.js +105 -0
  19. package/dist-client/announcement-watch.js.map +1 -0
  20. package/dist-client/bootstrap.d.ts +1 -0
  21. package/dist-client/bootstrap.js +8 -0
  22. package/dist-client/bootstrap.js.map +1 -0
  23. package/dist-client/components/announcement-popup.d.ts +20 -0
  24. package/dist-client/components/announcement-popup.js +98 -0
  25. package/dist-client/components/announcement-popup.js.map +1 -0
  26. package/dist-client/components/announcement-receipts.d.ts +30 -0
  27. package/dist-client/components/announcement-receipts.js +220 -0
  28. package/dist-client/components/announcement-receipts.js.map +1 -0
  29. package/dist-client/components/announcement-sheet.d.ts +17 -0
  30. package/dist-client/components/announcement-sheet.js +224 -0
  31. package/dist-client/components/announcement-sheet.js.map +1 -0
  32. package/dist-client/components/audience-picker.d.ts +32 -0
  33. package/dist-client/components/audience-picker.js +223 -0
  34. package/dist-client/components/audience-picker.js.map +1 -0
  35. package/dist-client/index.d.ts +1 -0
  36. package/dist-client/index.js +2 -0
  37. package/dist-client/index.js.map +1 -0
  38. package/dist-client/pages/announcement-editor.d.ts +59 -0
  39. package/dist-client/pages/announcement-editor.js +523 -0
  40. package/dist-client/pages/announcement-editor.js.map +1 -0
  41. package/dist-client/pages/announcement-list.d.ts +54 -0
  42. package/dist-client/pages/announcement-list.js +209 -0
  43. package/dist-client/pages/announcement-list.js.map +1 -0
  44. package/dist-client/pages/announcement-manage-list.d.ts +46 -0
  45. package/dist-client/pages/announcement-manage-list.js +115 -0
  46. package/dist-client/pages/announcement-manage-list.js.map +1 -0
  47. package/dist-client/pages/announcement-view.d.ts +30 -0
  48. package/dist-client/pages/announcement-view.js +182 -0
  49. package/dist-client/pages/announcement-view.js.map +1 -0
  50. package/dist-client/route.d.ts +1 -0
  51. package/dist-client/route.js +18 -0
  52. package/dist-client/route.js.map +1 -0
  53. package/dist-client/tsconfig.tsbuildinfo +1 -0
  54. package/dist-client/utils/announcement-api.d.ts +149 -0
  55. package/dist-client/utils/announcement-api.js +52 -0
  56. package/dist-client/utils/announcement-api.js.map +1 -0
  57. package/dist-client/utils/announcement-badge.d.ts +4 -0
  58. package/dist-client/utils/announcement-badge.js +47 -0
  59. package/dist-client/utils/announcement-badge.js.map +1 -0
  60. package/dist-client/utils/announcement-words.d.ts +11 -0
  61. package/dist-client/utils/announcement-words.js +23 -0
  62. package/dist-client/utils/announcement-words.js.map +1 -0
  63. package/dist-server/announcement-read.d.ts +6 -0
  64. package/dist-server/announcement-read.js +45 -0
  65. package/dist-server/announcement-read.js.map +1 -0
  66. package/dist-server/attachment-reader.d.ts +1 -0
  67. package/dist-server/attachment-reader.js +13 -0
  68. package/dist-server/attachment-reader.js.map +1 -0
  69. package/dist-server/index.d.ts +2 -0
  70. package/dist-server/index.js +20 -0
  71. package/dist-server/index.js.map +1 -0
  72. package/dist-server/role-tasks.d.ts +2 -0
  73. package/dist-server/role-tasks.js +13 -0
  74. package/dist-server/role-tasks.js.map +1 -0
  75. package/dist-server/service/announcement/announcement-resolver.d.ts +38 -0
  76. package/dist-server/service/announcement/announcement-resolver.js +664 -0
  77. package/dist-server/service/announcement/announcement-resolver.js.map +1 -0
  78. package/dist-server/service/announcement/announcement-rules.d.ts +150 -0
  79. package/dist-server/service/announcement/announcement-rules.js +227 -0
  80. package/dist-server/service/announcement/announcement-rules.js.map +1 -0
  81. package/dist-server/service/announcement/announcement-service.d.ts +54 -0
  82. package/dist-server/service/announcement/announcement-service.js +248 -0
  83. package/dist-server/service/announcement/announcement-service.js.map +1 -0
  84. package/dist-server/service/announcement/announcement-types.d.ts +54 -0
  85. package/dist-server/service/announcement/announcement-types.js +203 -0
  86. package/dist-server/service/announcement/announcement-types.js.map +1 -0
  87. package/dist-server/service/announcement/announcement.d.ts +124 -0
  88. package/dist-server/service/announcement/announcement.js +462 -0
  89. package/dist-server/service/announcement/announcement.js.map +1 -0
  90. package/dist-server/service/announcement/index.d.ts +4 -0
  91. package/dist-server/service/announcement/index.js +8 -0
  92. package/dist-server/service/announcement/index.js.map +1 -0
  93. package/dist-server/service/index.d.ts +7 -0
  94. package/dist-server/service/index.js +23 -0
  95. package/dist-server/service/index.js.map +1 -0
  96. package/dist-server/tsconfig.tsbuildinfo +1 -0
  97. package/package.json +51 -4
  98. package/server/announcement-read.ts +44 -0
  99. package/server/attachment-reader.ts +11 -0
  100. package/server/index.ts +16 -0
  101. package/server/role-tasks.ts +12 -0
  102. package/server/service/announcement/announcement-resolver.ts +506 -0
  103. package/server/service/announcement/announcement-rules.ts +239 -0
  104. package/server/service/announcement/announcement-service.ts +240 -0
  105. package/server/service/announcement/announcement-types.ts +138 -0
  106. package/server/service/announcement/announcement.ts +367 -0
  107. package/server/service/announcement/index.ts +5 -0
  108. package/server/service/index.ts +19 -0
  109. package/server/tsconfig.json +11 -0
  110. package/test/announcement-persistence.test.ts +271 -0
  111. package/test/announcement-read.test.ts +49 -0
  112. package/test/announcement-rules.test.ts +217 -0
  113. package/test/open-doors.test.ts +45 -0
  114. package/test/translations.test.ts +54 -0
  115. package/things-factory.config.js +13 -0
  116. package/translations/en.json +169 -0
  117. package/translations/es.json +169 -0
  118. package/translations/ja.json +169 -0
  119. package/translations/ko.json +169 -0
  120. package/translations/ms.json +169 -0
  121. package/translations/zh.json +169 -0
  122. package/README.md +0 -3
@@ -0,0 +1,200 @@
1
+ import '@material/web/icon/icon.js'
2
+ import '@operato/data-grist/ox-grist.js'
3
+ import '@operato/data-grist/ox-filters-form.js'
4
+ import '@operato/input/ox-select.js'
5
+ import '@operato/popup/ox-popup-list.js'
6
+
7
+ import gql from 'graphql-tag'
8
+ import { css, html } from 'lit'
9
+ import { customElement, query, state } from 'lit/decorators.js'
10
+
11
+ import { DataGrist, FetchOption } from '@operato/data-grist'
12
+ import { client } from '@operato/graphql'
13
+ import { i18next, localize } from '@operato/i18n'
14
+ import { navigate, PageView } from '@operato/shell'
15
+ import { CommonGristStyles, CommonHeaderStyles, ScrollbarStyles, WorkSurfacePageStyles } from '@operato/styles'
16
+ import { isMobileDevice } from '@operato/utils'
17
+ import { hasPrivilege } from '@things-factory/auth-base/dist-client'
18
+
19
+ import { LIST_FIELDS } from '../utils/announcement-api.js'
20
+ import { CATEGORIES, categoryWord, importanceWord, readerStateWord, t, when } from '../utils/announcement-words.js'
21
+
22
+ /**
23
+ * 공지 — the announcements addressed to the person: the current ones, pinned, urgent and important first, or the past
24
+ * ones (ADR-0163 decision 5). Those not read yet are bold; a row opens the announcement.
25
+ */
26
+ @customElement('announcement-list')
27
+ export class AnnouncementList extends localize(i18next)(PageView) {
28
+ static styles = [
29
+ WorkSurfacePageStyles,
30
+ CommonGristStyles,
31
+ CommonHeaderStyles,
32
+ ScrollbarStyles,
33
+ css`
34
+ .filters {
35
+ display: flex;
36
+ flex-wrap: wrap;
37
+ align-items: center;
38
+ gap: var(--spacing-medium, 8px);
39
+ }
40
+ ox-select {
41
+ min-width: 140px;
42
+ }
43
+ `
44
+ ]
45
+
46
+ @state() gristConfig: any
47
+ @state() period: 'current' | 'past' = 'current'
48
+ /* A writer manages and writes from here - the menu offers one entry, and a reader sees no group of one. */
49
+ @state() writer = false
50
+ @query('ox-grist') private grist!: DataGrist
51
+
52
+ private onChanged = () => this.grist?.fetch()
53
+
54
+ get context() {
55
+ return {
56
+ title: t('announcement.title.list'),
57
+ search: {
58
+ handler: (search: string) => {
59
+ this.grist.searchText = search
60
+ },
61
+ value: this.grist?.searchText || ''
62
+ },
63
+ filter: {
64
+ handler: () => {
65
+ this.grist.toggleHeadroom()
66
+ }
67
+ },
68
+ actions: this.writer
69
+ ? [
70
+ { title: t('announcement.title.manage'), icon: 'edit_note', action: () => navigate('announcement-manage-list') },
71
+ { title: t('announcement.button.new'), icon: 'add', emphasis: { raised: true }, action: () => navigate('announcement-editor') }
72
+ ]
73
+ : []
74
+ }
75
+ }
76
+
77
+ render() {
78
+ const periods = [
79
+ { value: 'current', display: t('announcement.text.period-current') },
80
+ { value: 'past', display: t('announcement.text.period-past') }
81
+ ]
82
+ return html`<ox-grist class="page-fill" search-mode="term" .mode=${isMobileDevice() ? 'CARD' : 'GRID'} .config=${this.gristConfig} .fetchHandler=${this.fetchHandler.bind(this)}>
83
+ <div slot="headroom" class="header">
84
+ <div class="filters">
85
+ <ox-select
86
+ .value=${this.period}
87
+ aria-label=${t('announcement.text.period')}
88
+ @change=${(e: CustomEvent) => {
89
+ const next = e.detail === 'past' ? 'past' : 'current'
90
+ if (next === this.period) return
91
+ this.period = next
92
+ this.grist.fetch()
93
+ }}
94
+ >
95
+ <span slot="label">${periods.find(one => one.value === this.period)?.display}</span>
96
+ <ox-popup-list align-left nowrap with-search>${periods.map(one => html`<div option value=${one.value}>${one.display}</div>`)}</ox-popup-list>
97
+ </ox-select>
98
+ <ox-filters-form without-search autofocus></ox-filters-form>
99
+ </div>
100
+ </div>
101
+ </ox-grist>`
102
+ }
103
+
104
+ connectedCallback() {
105
+ super.connectedCallback()
106
+ window.addEventListener('announcements-changed', this.onChanged)
107
+ }
108
+
109
+ disconnectedCallback() {
110
+ window.removeEventListener('announcements-changed', this.onChanged)
111
+ super.disconnectedCallback()
112
+ }
113
+
114
+ async pageUpdated(changes: any) {
115
+ if (this.active && changes.active && this.gristConfig) this.grist?.fetch()
116
+ }
117
+
118
+ async pageInitialized() {
119
+ hasPrivilege({ category: 'announcement', privilege: 'mutation', domainOwnerGranted: true }).then(writer => {
120
+ this.writer = !!writer
121
+ this.updateContext()
122
+ })
123
+ const open = (record: any) => record?.id && navigate(`announcement-view?id=${record.id}`)
124
+ const owed = (record: any) => record?.readerState === 'unread' || record?.readerState === 'to-acknowledge'
125
+ this.gristConfig = {
126
+ rows: { appendable: false, selectable: false, handlers: { click: (_c: any, _d: any, _col: any, record: any) => open(record) } },
127
+ pagination: { limit: 50, pages: [50, 100] },
128
+ emptyNote: { title: t('announcement.text.empty'), description: t('announcement.text.empty-description') },
129
+ list: { fields: ['title', 'readerState'], details: ['category', 'publishedAt'] },
130
+ columns: [
131
+ {
132
+ type: 'string',
133
+ name: 'pinned',
134
+ header: '',
135
+ width: 34,
136
+ record: {
137
+ editable: false,
138
+ renderer: (_v: any, _c: any, record: any) =>
139
+ record?.pinned && !this.isPast()
140
+ ? html`<md-icon style="--md-icon-size:16px" title=${t('announcement.field.pinned')}>push_pin</md-icon>`
141
+ : record?.importance === 'URGENT'
142
+ ? html`<md-icon style="--md-icon-size:16px;color:var(--md-sys-color-error)" title=${importanceWord('URGENT')}>priority_high</md-icon>`
143
+ : ''
144
+ }
145
+ },
146
+ {
147
+ type: 'string',
148
+ name: 'category',
149
+ header: t('announcement.field.category'),
150
+ width: 90,
151
+ record: { editable: false, renderer: (value: string) => categoryWord(value) },
152
+ filter: { type: 'select', operator: 'eq', options: [{ display: '', value: '' }, ...CATEGORIES.map(code => ({ display: categoryWord(code), value: code }))] }
153
+ },
154
+ {
155
+ type: 'string',
156
+ name: 'title',
157
+ header: t('announcement.field.title'),
158
+ width: 320,
159
+ record: {
160
+ editable: false,
161
+ renderer: (value: string, _c: any, record: any) =>
162
+ html`<span style=${owed(record) ? 'font-weight:600' : ''}>${record?.importance === 'IMPORTANT' ? `[${importanceWord('IMPORTANT')}] ` : ''}${value}</span>`
163
+ }
164
+ },
165
+ {
166
+ type: 'string',
167
+ name: 'readerState',
168
+ header: t('announcement.field.reader-state'),
169
+ width: 100,
170
+ record: { editable: false, renderer: (value: string) => html`<span style=${value === 'unread' || value === 'to-acknowledge' ? 'color:var(--md-sys-color-primary);font-weight:600' : ''}>${readerStateWord(value)}</span>` }
171
+ },
172
+ { type: 'string', name: 'publisher', header: t('announcement.field.publisher'), width: 110, record: { editable: false, renderer: (value: any) => value?.name ?? '' } },
173
+ { type: 'string', name: 'publishedAt', header: t('announcement.field.published-at'), width: 110, record: { editable: false, renderer: (value: string) => when(value, false) } },
174
+ { type: 'string', name: 'endsAt', header: t('announcement.field.ends-at'), width: 110, record: { editable: false, renderer: (value: string) => when(value, false) } }
175
+ ]
176
+ }
177
+ }
178
+
179
+ private isPast() {
180
+ return this.period === 'past'
181
+ }
182
+
183
+ async fetchHandler({ page = 1, limit = 50, filters = [], search }: FetchOption) {
184
+ const response = await client.query({
185
+ query: gql`
186
+ query ($filters: [Filter!], $pagination: Pagination, $search: String) {
187
+ responses: announcements(filters: $filters, pagination: $pagination, search: $search) {
188
+ items { ${LIST_FIELDS} }
189
+ total
190
+ }
191
+ }
192
+ `,
193
+ /* The period is the reader's own filter - the assistant narrows the same list the same way. */
194
+ variables: { filters: [...filters, { name: 'period', operator: 'eq', value: this.period }], pagination: { page, limit }, search: search?.trim() || undefined },
195
+ fetchPolicy: 'network-only'
196
+ })
197
+ if (response.errors?.length) throw response
198
+ return { total: response.data.responses.total, records: response.data.responses.items }
199
+ }
200
+ }
@@ -0,0 +1,113 @@
1
+ import '@operato/data-grist/ox-grist.js'
2
+ import '@operato/data-grist/ox-filters-form.js'
3
+
4
+ import gql from 'graphql-tag'
5
+ import { html } from 'lit'
6
+ import { customElement, query, state } from 'lit/decorators.js'
7
+
8
+ import { DataGrist, FetchOption } from '@operato/data-grist'
9
+ import { client } from '@operato/graphql'
10
+ import { i18next, localize } from '@operato/i18n'
11
+ import { navigate, PageView } from '@operato/shell'
12
+ import { CommonGristStyles, CommonHeaderStyles, ScrollbarStyles, WorkSurfacePageStyles } from '@operato/styles'
13
+ import { isMobileDevice } from '@operato/utils'
14
+
15
+ import { CATEGORIES, categoryWord, importanceWord, statusWord, t, when } from '../utils/announcement-words.js'
16
+
17
+ const STATUSES = ['DRAFT', 'SCHEDULED', 'PUBLISHED', 'WITHDRAWN']
18
+
19
+ /**
20
+ * 공지 관리 — every announcement of the domain for its writers: the ones out, scheduled and withdrawn, and one's own
21
+ * drafts, with how many each reached and how many read it (ADR-0163 decision 4). A draft opens in the editor; the rest
22
+ * open as their readers see them, with the writer's actions.
23
+ */
24
+ @customElement('announcement-manage-list')
25
+ export class AnnouncementManageList extends localize(i18next)(PageView) {
26
+ static styles = [WorkSurfacePageStyles, CommonGristStyles, CommonHeaderStyles, ScrollbarStyles]
27
+
28
+ @state() gristConfig: any
29
+ @query('ox-grist') private grist!: DataGrist
30
+
31
+ get context() {
32
+ return {
33
+ title: t('announcement.title.manage'),
34
+ /* Opened from 공지사항; the menu marks it. */
35
+ menu: 'announcement-list',
36
+ search: {
37
+ handler: (search: string) => {
38
+ this.grist.searchText = search
39
+ },
40
+ value: this.grist?.searchText || ''
41
+ },
42
+ filter: {
43
+ handler: () => {
44
+ this.grist.toggleHeadroom()
45
+ }
46
+ },
47
+ actions: [{ title: t('announcement.button.new'), icon: 'add', emphasis: { raised: true }, action: () => navigate('announcement-editor') }],
48
+ exportable: { name: t('announcement.title.manage'), data: () => this.grist.exportRecords() }
49
+ }
50
+ }
51
+
52
+ render() {
53
+ return html`<ox-grist class="page-fill" search-mode="term" .mode=${isMobileDevice() ? 'CARD' : 'GRID'} .config=${this.gristConfig} .fetchHandler=${this.fetchHandler.bind(this)}>
54
+ <div slot="headroom" class="header">
55
+ <div class="filters"><ox-filters-form without-search autofocus></ox-filters-form></div>
56
+ </div>
57
+ </ox-grist>`
58
+ }
59
+
60
+ async pageUpdated(changes: any) {
61
+ if (this.active && changes.active && this.gristConfig) this.grist?.fetch()
62
+ }
63
+
64
+ async pageInitialized() {
65
+ const open = (record: any) => record?.id && navigate(record.status === 'DRAFT' ? `announcement-editor?id=${record.id}` : `announcement-view?id=${record.id}`)
66
+ const readonly = (type: string, name: string, header: string, width: number, extra: any = {}) => ({ type, name, header, width, sortable: true, ...extra, record: { editable: false, ...extra.record } })
67
+ this.gristConfig = {
68
+ rows: { appendable: false, selectable: false, handlers: { click: (_c: any, _d: any, _col: any, record: any) => open(record) } },
69
+ pagination: { limit: 50, pages: [50, 100, 500] },
70
+ emptyNote: { title: t('announcement.text.manage-empty'), description: t('announcement.text.manage-empty-description') },
71
+ list: { fields: ['title', 'status'], details: ['publishedAt', 'readCount'] },
72
+ columns: [
73
+ readonly('string', 'status', t('announcement.field.status'), 80, {
74
+ record: { renderer: (value: string) => statusWord(value) },
75
+ filter: { type: 'select', operator: 'eq', options: [{ display: '', value: '' }, ...STATUSES.map(code => ({ display: statusWord(code), value: code }))] }
76
+ }),
77
+ readonly('string', 'category', t('announcement.field.category'), 90, {
78
+ record: { renderer: (value: string) => categoryWord(value) },
79
+ filter: { type: 'select', operator: 'eq', options: [{ display: '', value: '' }, ...CATEGORIES.map(code => ({ display: categoryWord(code), value: code }))] }
80
+ }),
81
+ readonly('string', 'importance', t('announcement.field.importance'), 70, { record: { renderer: (value: string) => importanceWord(value) } }),
82
+ readonly('string', 'title', t('announcement.field.title'), 320),
83
+ readonly('string', 'startsAt', t('announcement.field.starts-at'), 140, { record: { renderer: (value: string) => when(value) } }),
84
+ readonly('string', 'endsAt', t('announcement.field.ends-at'), 140, { record: { renderer: (value: string) => when(value) } }),
85
+ readonly('integer', 'audienceSize', t('announcement.field.audience-size'), 70, { sortable: false, record: { align: 'right' } }),
86
+ readonly('integer', 'readCount', t('announcement.field.read-count'), 70, { sortable: false, record: { align: 'right' } }),
87
+ readonly('integer', 'acknowledgedCount', t('announcement.field.acknowledged-count'), 70, {
88
+ sortable: false,
89
+ record: { align: 'right', renderer: (value: number, _c: any, record: any) => (record?.requiresAcknowledgement ? (value ?? '') : '') }
90
+ }),
91
+ readonly('string', 'updatedAt', t('announcement.field.updated-at'), 140, { record: { renderer: (value: string) => when(value) } })
92
+ ],
93
+ sorters: [{ name: 'updatedAt', desc: true }]
94
+ }
95
+ }
96
+
97
+ async fetchHandler({ page = 1, limit = 50, sortings = [], filters = [], search }: FetchOption) {
98
+ const response = await client.query({
99
+ query: gql`
100
+ query ($filters: [Filter!], $pagination: Pagination, $sortings: [Sorting!], $search: String) {
101
+ responses: managedAnnouncements(filters: $filters, pagination: $pagination, sortings: $sortings, search: $search) {
102
+ items { id title category importance status startsAt endsAt requiresAcknowledgement publishedAt audienceSize readCount acknowledgedCount updatedAt }
103
+ total
104
+ }
105
+ }
106
+ `,
107
+ variables: { filters, pagination: { page, limit }, sortings, search: search?.trim() || undefined },
108
+ fetchPolicy: 'network-only'
109
+ })
110
+ if (response.errors?.length) throw response
111
+ return { total: response.data.responses.total, records: response.data.responses.items }
112
+ }
113
+ }
@@ -0,0 +1,172 @@
1
+ import '@operato/list-state/ox-page-state.js'
2
+ import '../components/announcement-sheet.js'
3
+ import '../components/announcement-receipts.js'
4
+
5
+ import { css, html, nothing } from 'lit'
6
+ import { customElement, state } from 'lit/decorators.js'
7
+
8
+ import { i18next, localize } from '@operato/i18n'
9
+ import { notify, openPopup } from '@operato/layout'
10
+ import { navigate, PageView } from '@operato/shell'
11
+ import { DocumentPageStyles } from '@operato/styles'
12
+
13
+ import { announcementApi, type AnnouncementItem } from '../utils/announcement-api.js'
14
+ import { reasonOf, statusWord, t, when } from '../utils/announcement-words.js'
15
+
16
+ /**
17
+ * 공지 보기 — one announcement. Opening it reads it for its audience (ADR-0163 decision 5). Its writers also see whom it
18
+ * reached and who read it, and change, remind or withdraw it from here (decision 4).
19
+ */
20
+ @customElement('announcement-view')
21
+ export class AnnouncementView extends localize(i18next)(PageView) {
22
+ static styles = [
23
+ DocumentPageStyles,
24
+ css`
25
+ .notice {
26
+ padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
27
+ border-radius: var(--border-radius, 4px);
28
+ background: var(--md-sys-color-surface-container, rgba(0, 0, 0, 0.04));
29
+ font-size: var(--ui-t-body, 14px);
30
+ }
31
+ .audience {
32
+ display: flex;
33
+ flex-wrap: wrap;
34
+ gap: var(--spacing-small, 4px) var(--spacing-medium, 8px);
35
+ font-size: var(--ui-t-small, 12px);
36
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
37
+ }
38
+ ul.deliveries {
39
+ margin: var(--spacing-small, 4px) 0 0;
40
+ padding: 0;
41
+ list-style: none;
42
+ font-size: var(--ui-t-small, 12px);
43
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
44
+ }
45
+ h2 {
46
+ margin: 0;
47
+ font-size: var(--ui-t-title-large, 22px);
48
+ }
49
+ `
50
+ ]
51
+
52
+ @state() announcement: AnnouncementItem | null = null
53
+ @state() loading = false
54
+ @state() error: unknown = null
55
+
56
+ get context() {
57
+ const one = this.announcement
58
+ const actions: any[] = []
59
+ if (one?.editable) {
60
+ actions.push({ title: t('announcement.button.edit'), icon: 'edit', action: () => navigate(`announcement-editor?id=${one.id}`) })
61
+ if (one.status === 'PUBLISHED') {
62
+ actions.push({ title: t('announcement.button.receipts'), icon: 'groups', action: () => this.openReceipts() })
63
+ actions.push({ title: t('announcement.button.remind'), icon: 'notifications_active', action: () => this.remind() })
64
+ }
65
+ if (one.status === 'PUBLISHED' || one.status === 'SCHEDULED') actions.push({ title: t('announcement.button.withdraw'), icon: 'unpublished', action: () => this.withdraw() })
66
+ actions.push({ title: t('announcement.button.copy'), icon: 'content_copy', action: () => navigate(`announcement-editor?from=${one.id}`) })
67
+ }
68
+ return {
69
+ title: t('announcement.title.view'),
70
+ ...(one && one.status !== 'PUBLISHED' ? { description: statusWord(one.status) } : {}),
71
+ frame: 'document',
72
+ /* Opened from the list of announcements; the menu marks it. */
73
+ menu: 'announcement-list',
74
+ actions
75
+ }
76
+ }
77
+
78
+ async pageUpdated(changes: any, lifecycle: any) {
79
+ if (!this.active || !(changes.active || changes.params)) return
80
+ const id = lifecycle?.params?.id
81
+ if (id) await this.load(id)
82
+ }
83
+
84
+ render() {
85
+ const one = this.announcement
86
+ return html`<ox-page-state .loading=${this.loading} .error=${this.error} @retry=${() => one && this.load(one.id)}>
87
+ ${one
88
+ ? html`<div class="page">
89
+ <section class="page-section">
90
+ <h2>${one.title}</h2>
91
+ ${one.status === 'WITHDRAWN' ? html`<p class="notice">${t('announcement.text.withdrawn', { when: when(one.withdrawnAt), reason: one.withdrawReason ?? '' })}</p>` : nothing}
92
+ ${one.status === 'SCHEDULED' ? html`<p class="notice">${t('announcement.text.scheduled', { when: when(one.startsAt) })}</p>` : nothing}
93
+ <announcement-sheet .announcement=${one} @announcement-acknowledged=${() => this.load(one.id)}></announcement-sheet>
94
+ </section>
95
+ ${one.editable && one.audiences
96
+ ? html`<section class="page-section">
97
+ <header class="page-section-header"><h3>${t('announcement.field.audience')}</h3></header>
98
+ <div class="audience">
99
+ ${one.audiences.map(target => html`<span>${t(`announcement.scope.${target.scope}`)} · ${target.targetName ?? target.targetId}</span>`)}
100
+ </div>
101
+ ${(one.deliveries ?? []).length
102
+ ? html`<ul class="deliveries">
103
+ ${[...(one.deliveries ?? [])].reverse().map(
104
+ sent => html`<li>
105
+ ${when(sent.at)} · ${t(`announcement.delivery.${sent.kind}`)} ·
106
+ ${sent.failed ? t('announcement.text.delivery-not-sent', { count: sent.receivers }) : t('announcement.text.delivery-app', { count: sent.receivers })}
107
+ ${sent.email.sent || sent.email.failed ? html` · ${t('announcement.text.delivery-email', { sent: sent.email.sent, failed: sent.email.failed })}` : nothing}
108
+ ${sent.teams.sent || sent.teams.failed ? html` · ${t('announcement.text.delivery-teams', { sent: sent.teams.sent, failed: sent.teams.failed })}` : nothing}
109
+ </li>`
110
+ )}
111
+ </ul>`
112
+ : nothing}
113
+ ${one.status === 'PUBLISHED'
114
+ ? html`<p class="audience">
115
+ ${t('announcement.text.reach', { size: one.audienceSize ?? 0, read: one.readCount ?? 0 })}
116
+ ${one.requiresAcknowledgement ? t('announcement.text.reach-acknowledged', { acknowledged: one.acknowledgedCount ?? 0 }) : ''}
117
+ </p>`
118
+ : nothing}
119
+ </section>`
120
+ : nothing}
121
+ </div>`
122
+ : nothing}
123
+ </ox-page-state>`
124
+ }
125
+
126
+ private async load(id: string) {
127
+ this.loading = true
128
+ this.error = null
129
+ try {
130
+ let one = await announcementApi.one(id)
131
+ if (!one) throw new Error(t('announcement.refusal.unknown-announcement'))
132
+ /* Opening it reads it - asked again after, so its writer's counts include this reading. */
133
+ if (one.addressed && !one.mine?.readAt) {
134
+ await announcementApi.read(id)
135
+ one = (await announcementApi.one(id)) ?? one
136
+ }
137
+ this.announcement = one
138
+ } catch (error) {
139
+ this.announcement = null
140
+ this.error = error
141
+ } finally {
142
+ this.loading = false
143
+ this.updateContext()
144
+ }
145
+ }
146
+
147
+ private openReceipts() {
148
+ const one = this.announcement!
149
+ const popup: any = openPopup(html`<announcement-receipts .announcement=${one} @done=${() => popup.close()}></announcement-receipts>`, {
150
+ backdrop: true,
151
+ size: 'large',
152
+ title: `${t('announcement.button.receipts')} · ${one.title}`
153
+ })
154
+ }
155
+
156
+ private async remind() {
157
+ try {
158
+ const told = await announcementApi.remind(this.announcement!.id)
159
+ notify({ message: t('announcement.text.reminded', { count: told }) })
160
+ } catch (error) {
161
+ notify({ level: 'error', message: reasonOf(error) })
162
+ }
163
+ }
164
+
165
+ private withdraw() {
166
+ const one = this.announcement!
167
+ const popup: any = openPopup(
168
+ html`<announcement-withdraw-form .announcement=${one} @announcement-withdrawn=${() => this.load(one.id)} @done=${() => popup.close()}></announcement-withdraw-form>`,
169
+ { backdrop: true, size: 'small', title: `${t('announcement.button.withdraw')} · ${one.title}` }
170
+ )
171
+ }
172
+ }
@@ -0,0 +1,17 @@
1
+ /* The pages this module brings to an application that puts them in its menu (ADR-0163 decision 9). */
2
+ export default function route(page: string) {
3
+ switch (page) {
4
+ case 'announcement-list':
5
+ import('./pages/announcement-list.js')
6
+ return page
7
+ case 'announcement-view':
8
+ import('./pages/announcement-view.js')
9
+ return page
10
+ case 'announcement-manage-list':
11
+ import('./pages/announcement-manage-list.js')
12
+ return page
13
+ case 'announcement-editor':
14
+ import('./pages/announcement-editor.js')
15
+ return page
16
+ }
17
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../tsconfig-base.json",
3
+ "compilerOptions": {
4
+ "experimentalDecorators": true,
5
+ "skipLibCheck": true,
6
+ "strict": true,
7
+ "declaration": true,
8
+ "module": "esnext",
9
+ "outDir": "../dist-client",
10
+ "baseUrl": "./"
11
+ },
12
+ "include": ["./**/*"]
13
+ }
@@ -0,0 +1,142 @@
1
+ /*
2
+ * The announcement doors as the screens call them (ADR-0163). Every call throws the server's words when it is refused.
3
+ */
4
+ import gql from 'graphql-tag'
5
+
6
+ import { client, uploadContext } from '@operato/graphql'
7
+
8
+ export type Audience = { scope: 'DOMAIN' | 'DEPARTMENT' | 'ROLE' | 'USER' | string; targetId: string; targetName?: string | null }
9
+ export type AnnouncementLink = { label: string; url: string }
10
+ export type FileRow = { id: string; name: string; fullpath: string; size?: number; mimetype?: string }
11
+ export type Receipt = { readAt?: string | null; acknowledgedAt?: string | null }
12
+ export type Delivery = { at: string; kind: string; receivers: number; email: { sent: number; failed: number }; teams: { sent: number; failed: number }; failed?: boolean }
13
+
14
+ export interface AnnouncementItem {
15
+ id: string
16
+ title: string
17
+ body?: string | null
18
+ category: string
19
+ importance: 'NORMAL' | 'IMPORTANT' | 'URGENT' | string
20
+ status: 'DRAFT' | 'SCHEDULED' | 'PUBLISHED' | 'WITHDRAWN' | string
21
+ startsAt?: string | null
22
+ endsAt?: string | null
23
+ pinned: boolean
24
+ requiresAcknowledgement: boolean
25
+ acknowledgeBy?: string | null
26
+ popup: boolean
27
+ sendEmail?: boolean
28
+ deliveries?: Delivery[] | null
29
+ links?: AnnouncementLink[] | null
30
+ version: number
31
+ publishedAt?: string | null
32
+ audienceSize?: number | null
33
+ withdrawnAt?: string | null
34
+ withdrawReason?: string | null
35
+ readerState?: 'unread' | 'read' | 'to-acknowledge' | 'acknowledged'
36
+ readCount?: number | null
37
+ acknowledgedCount?: number | null
38
+ editable?: boolean
39
+ addressed?: boolean
40
+ mine?: Receipt | null
41
+ publisher?: { id: string; name?: string } | null
42
+ creator?: { id: string; name?: string } | null
43
+ audiences?: Audience[]
44
+ attachments?: FileRow[]
45
+ }
46
+
47
+ export interface AnnouncementInput {
48
+ title: string
49
+ body?: string
50
+ category?: string
51
+ importance?: string
52
+ startsAt?: string | null
53
+ endsAt?: string | null
54
+ pinned?: boolean
55
+ requiresAcknowledgement?: boolean
56
+ acknowledgeBy?: string | null
57
+ popup?: boolean
58
+ sendEmail?: boolean
59
+ links?: AnnouncementLink[]
60
+ audiences?: Audience[]
61
+ }
62
+
63
+ /** The fields a list row needs. */
64
+ export const LIST_FIELDS = `id title category importance status startsAt endsAt pinned requiresAcknowledgement acknowledgeBy popup publishedAt version readerState publisher { id name }`
65
+
66
+ /** Everything one announcement shows. */
67
+ export const FULL_FIELDS = `${LIST_FIELDS} body links sendEmail deliveries audienceSize withdrawnAt withdrawReason editable addressed mine { readAt acknowledgedAt }
68
+ creator { id name } audiences { scope targetId targetName } attachments { id name fullpath size mimetype }`
69
+
70
+ function answer<T>(response: any, field: string): T {
71
+ if (response.errors?.length) throw new Error(response.errors.map((one: any) => one.message).join(' · '))
72
+ return response.data?.[field] as T
73
+ }
74
+
75
+ async function ask<T>(query: string, variables: Record<string, unknown>, field: string): Promise<T> {
76
+ return answer<T>(await client.query({ query: gql(query), variables, fetchPolicy: 'network-only' }), field)
77
+ }
78
+
79
+ async function change<T>(mutation: string, variables: Record<string, unknown>, field: string, uploading?: (fraction: number) => void): Promise<T> {
80
+ return answer<T>(await client.mutate({ mutation: gql(mutation), variables, ...(uploading ? { context: uploadContext(uploading) } : {}) }), field)
81
+ }
82
+
83
+ /** Tells the badge and an open list that a reading changed. */
84
+ export const announcementsChanged = () => window.dispatchEvent(new CustomEvent('announcements-changed'))
85
+
86
+ export const announcementApi = {
87
+ one: (id: string) => ask<AnnouncementItem | null>(`query ($id: String!) { announcement(id: $id) { ${FULL_FIELDS} readCount acknowledgedCount } }`, { id }, 'announcement'),
88
+ summary: () => ask<{ unread: number; toAcknowledge: number; pending: number }>(`query { announcementSummary { unread toAcknowledge pending } }`, {}, 'announcementSummary'),
89
+ popups: () => ask<AnnouncementItem[]>(`query { announcementPopups { ${FULL_FIELDS} } }`, {}, 'announcementPopups'),
90
+ read: async (id: string) => {
91
+ const one = await change<AnnouncementItem>(`mutation ($id: String!) { readAnnouncement(id: $id) { id readerState mine { readAt acknowledgedAt } } }`, { id }, 'readAnnouncement')
92
+ announcementsChanged()
93
+ return one
94
+ },
95
+ acknowledge: async (id: string) => {
96
+ const one = await change<AnnouncementItem>(`mutation ($id: String!) { acknowledgeAnnouncement(id: $id) { id readerState mine { readAt acknowledgedAt } } }`, { id }, 'acknowledgeAnnouncement')
97
+ announcementsChanged()
98
+ return one
99
+ },
100
+ receipts: (id: string) =>
101
+ ask<{ userId: string; name?: string; email?: string; listed: boolean; notifiedAt?: string; readAt?: string; acknowledgedAt?: string }[]>(
102
+ `query ($id: String!) { announcementReceipts(id: $id) { userId name email listed notifiedAt readAt acknowledgedAt } }`,
103
+ { id },
104
+ 'announcementReceipts'
105
+ ),
106
+ revisions: (id: string) =>
107
+ ask<{ version: number; title: string; body?: string; rest?: any; createdAt: string; creator?: { name?: string } }[]>(
108
+ `query ($id: String!) { announcementRevisions(id: $id) { version title body rest createdAt creator { name } } }`,
109
+ { id },
110
+ 'announcementRevisions'
111
+ ),
112
+ choices: () =>
113
+ ask<{ domainId: string; domainName: string; departments: { id: string; name: string; note?: string }[]; roles: { id: string; name: string; note?: string }[] }>(
114
+ `query { announcementAudienceChoices { domainId domainName departments { id name note } roles { id name note } } }`,
115
+ {},
116
+ 'announcementAudienceChoices'
117
+ ),
118
+ people: (text: string) => ask<{ id: string; name: string; note?: string }[]>(`query ($text: String!) { announcementPeople(text: $text) { id name note } }`, { text }, 'announcementPeople'),
119
+ audienceSize: (audiences: Audience[]) =>
120
+ ask<number>(`query ($audiences: [AnnouncementAudienceInput!]!) { announcementAudienceSize(audiences: $audiences) }`, { audiences }, 'announcementAudienceSize'),
121
+ create: (input: AnnouncementInput, attach: File[], uploading?: (fraction: number) => void) =>
122
+ change<AnnouncementItem>(
123
+ `mutation ($input: AnnouncementInput!, $attach: [Upload!]) { createAnnouncement(input: $input, attach: $attach) { id } }`,
124
+ { input, attach },
125
+ 'createAnnouncement',
126
+ attach.length ? uploading ?? (() => {}) : undefined
127
+ ),
128
+ update: (id: string, input: AnnouncementInput, attach: File[], remove: string[], notifyAgain: boolean, uploading?: (fraction: number) => void) =>
129
+ change<AnnouncementItem>(
130
+ `mutation ($id: String!, $input: AnnouncementInput!, $attach: [Upload!], $remove: [String!], $notifyAgain: Boolean) {
131
+ updateAnnouncement(id: $id, input: $input, attach: $attach, remove: $remove, notifyAgain: $notifyAgain) { id status }
132
+ }`,
133
+ { id, input, attach, remove, notifyAgain },
134
+ 'updateAnnouncement',
135
+ attach.length ? uploading ?? (() => {}) : undefined
136
+ ),
137
+ publish: (id: string) => change<AnnouncementItem>(`mutation ($id: String!) { publishAnnouncement(id: $id) { id status startsAt } }`, { id }, 'publishAnnouncement'),
138
+ withdraw: (id: string, reason: string) =>
139
+ change<AnnouncementItem>(`mutation ($id: String!, $reason: String!) { withdrawAnnouncement(id: $id, reason: $reason) { id status } }`, { id, reason }, 'withdrawAnnouncement'),
140
+ remove: (id: string) => change<boolean>(`mutation ($id: String!) { deleteAnnouncement(id: $id) }`, { id }, 'deleteAnnouncement'),
141
+ remind: (id: string) => change<number>(`mutation ($id: String!) { remindAnnouncement(id: $id) }`, { id }, 'remindAnnouncement')
142
+ }