@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.
- package/client/announcement-watch.ts +109 -0
- package/client/bootstrap.ts +9 -0
- package/client/components/announcement-popup.ts +92 -0
- package/client/components/announcement-receipts.ts +196 -0
- package/client/components/announcement-sheet.ts +214 -0
- package/client/components/audience-picker.ts +221 -0
- package/client/index.ts +1 -0
- package/client/pages/announcement-editor.ts +505 -0
- package/client/pages/announcement-list.ts +200 -0
- package/client/pages/announcement-manage-list.ts +113 -0
- package/client/pages/announcement-view.ts +172 -0
- package/client/route.ts +17 -0
- package/client/tsconfig.json +13 -0
- package/client/utils/announcement-api.ts +142 -0
- package/client/utils/announcement-badge.ts +46 -0
- package/client/utils/announcement-words.ts +26 -0
- package/dist-client/announcement-watch.d.ts +6 -0
- package/dist-client/announcement-watch.js +105 -0
- package/dist-client/announcement-watch.js.map +1 -0
- package/dist-client/bootstrap.d.ts +1 -0
- package/dist-client/bootstrap.js +8 -0
- package/dist-client/bootstrap.js.map +1 -0
- package/dist-client/components/announcement-popup.d.ts +20 -0
- package/dist-client/components/announcement-popup.js +98 -0
- package/dist-client/components/announcement-popup.js.map +1 -0
- package/dist-client/components/announcement-receipts.d.ts +30 -0
- package/dist-client/components/announcement-receipts.js +220 -0
- package/dist-client/components/announcement-receipts.js.map +1 -0
- package/dist-client/components/announcement-sheet.d.ts +17 -0
- package/dist-client/components/announcement-sheet.js +224 -0
- package/dist-client/components/announcement-sheet.js.map +1 -0
- package/dist-client/components/audience-picker.d.ts +32 -0
- package/dist-client/components/audience-picker.js +223 -0
- package/dist-client/components/audience-picker.js.map +1 -0
- package/dist-client/index.d.ts +1 -0
- package/dist-client/index.js +2 -0
- package/dist-client/index.js.map +1 -0
- package/dist-client/pages/announcement-editor.d.ts +59 -0
- package/dist-client/pages/announcement-editor.js +523 -0
- package/dist-client/pages/announcement-editor.js.map +1 -0
- package/dist-client/pages/announcement-list.d.ts +54 -0
- package/dist-client/pages/announcement-list.js +209 -0
- package/dist-client/pages/announcement-list.js.map +1 -0
- package/dist-client/pages/announcement-manage-list.d.ts +46 -0
- package/dist-client/pages/announcement-manage-list.js +115 -0
- package/dist-client/pages/announcement-manage-list.js.map +1 -0
- package/dist-client/pages/announcement-view.d.ts +30 -0
- package/dist-client/pages/announcement-view.js +182 -0
- package/dist-client/pages/announcement-view.js.map +1 -0
- package/dist-client/route.d.ts +1 -0
- package/dist-client/route.js +18 -0
- package/dist-client/route.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -0
- package/dist-client/utils/announcement-api.d.ts +149 -0
- package/dist-client/utils/announcement-api.js +52 -0
- package/dist-client/utils/announcement-api.js.map +1 -0
- package/dist-client/utils/announcement-badge.d.ts +4 -0
- package/dist-client/utils/announcement-badge.js +47 -0
- package/dist-client/utils/announcement-badge.js.map +1 -0
- package/dist-client/utils/announcement-words.d.ts +11 -0
- package/dist-client/utils/announcement-words.js +23 -0
- package/dist-client/utils/announcement-words.js.map +1 -0
- package/dist-server/announcement-read.d.ts +6 -0
- package/dist-server/announcement-read.js +45 -0
- package/dist-server/announcement-read.js.map +1 -0
- package/dist-server/attachment-reader.d.ts +1 -0
- package/dist-server/attachment-reader.js +13 -0
- package/dist-server/attachment-reader.js.map +1 -0
- package/dist-server/index.d.ts +2 -0
- package/dist-server/index.js +20 -0
- package/dist-server/index.js.map +1 -0
- package/dist-server/role-tasks.d.ts +2 -0
- package/dist-server/role-tasks.js +13 -0
- package/dist-server/role-tasks.js.map +1 -0
- package/dist-server/service/announcement/announcement-resolver.d.ts +38 -0
- package/dist-server/service/announcement/announcement-resolver.js +664 -0
- package/dist-server/service/announcement/announcement-resolver.js.map +1 -0
- package/dist-server/service/announcement/announcement-rules.d.ts +150 -0
- package/dist-server/service/announcement/announcement-rules.js +227 -0
- package/dist-server/service/announcement/announcement-rules.js.map +1 -0
- package/dist-server/service/announcement/announcement-service.d.ts +54 -0
- package/dist-server/service/announcement/announcement-service.js +248 -0
- package/dist-server/service/announcement/announcement-service.js.map +1 -0
- package/dist-server/service/announcement/announcement-types.d.ts +54 -0
- package/dist-server/service/announcement/announcement-types.js +203 -0
- package/dist-server/service/announcement/announcement-types.js.map +1 -0
- package/dist-server/service/announcement/announcement.d.ts +124 -0
- package/dist-server/service/announcement/announcement.js +462 -0
- package/dist-server/service/announcement/announcement.js.map +1 -0
- package/dist-server/service/announcement/index.d.ts +4 -0
- package/dist-server/service/announcement/index.js +8 -0
- package/dist-server/service/announcement/index.js.map +1 -0
- package/dist-server/service/index.d.ts +7 -0
- package/dist-server/service/index.js +23 -0
- package/dist-server/service/index.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +51 -4
- package/server/announcement-read.ts +44 -0
- package/server/attachment-reader.ts +11 -0
- package/server/index.ts +16 -0
- package/server/role-tasks.ts +12 -0
- package/server/service/announcement/announcement-resolver.ts +506 -0
- package/server/service/announcement/announcement-rules.ts +239 -0
- package/server/service/announcement/announcement-service.ts +240 -0
- package/server/service/announcement/announcement-types.ts +138 -0
- package/server/service/announcement/announcement.ts +367 -0
- package/server/service/announcement/index.ts +5 -0
- package/server/service/index.ts +19 -0
- package/server/tsconfig.json +11 -0
- package/test/announcement-persistence.test.ts +271 -0
- package/test/announcement-read.test.ts +49 -0
- package/test/announcement-rules.test.ts +217 -0
- package/test/open-doors.test.ts +45 -0
- package/test/translations.test.ts +54 -0
- package/things-factory.config.js +13 -0
- package/translations/en.json +169 -0
- package/translations/es.json +169 -0
- package/translations/ja.json +169 -0
- package/translations/ko.json +169 -0
- package/translations/ms.json +169 -0
- package/translations/zh.json +169 -0
- package/README.md +0 -3
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Keeps the person up with announcements while the application is open (ADR-0163 decision 5): the popups due at sign-in,
|
|
3
|
+
* a new one popping up the moment it is published to them, and the menu badge.
|
|
4
|
+
*
|
|
5
|
+
* The notice of an announcement reaches the screen through notification's subscription; the announcements themselves
|
|
6
|
+
* are asked of the server - a screen that was closed when one went out still finds it here.
|
|
7
|
+
*/
|
|
8
|
+
import './components/announcement-popup.js'
|
|
9
|
+
|
|
10
|
+
import gql from 'graphql-tag'
|
|
11
|
+
import { html } from 'lit'
|
|
12
|
+
|
|
13
|
+
import { subscribe } from '@operato/graphql'
|
|
14
|
+
import { openPopup } from '@operato/layout'
|
|
15
|
+
|
|
16
|
+
import { announcementApi, type AnnouncementItem } from './utils/announcement-api.js'
|
|
17
|
+
import { refreshAnnouncementBadge } from './utils/announcement-badge.js'
|
|
18
|
+
import { t } from './utils/announcement-words.js'
|
|
19
|
+
|
|
20
|
+
/* Closed in this browser session - asked again at the next sign-in (one still to be acknowledged comes back then). */
|
|
21
|
+
const CLOSED_KEY = 'announcement.popups-closed'
|
|
22
|
+
|
|
23
|
+
function closedInSession(): Set<string> {
|
|
24
|
+
try {
|
|
25
|
+
return new Set(JSON.parse(sessionStorage.getItem(CLOSED_KEY) || '[]'))
|
|
26
|
+
} catch {
|
|
27
|
+
return new Set()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function rememberClosed(ids: string[]) {
|
|
32
|
+
try {
|
|
33
|
+
sessionStorage.setItem(CLOSED_KEY, JSON.stringify([...new Set([...closedInSession(), ...ids])]))
|
|
34
|
+
} catch {
|
|
35
|
+
/* a browser that keeps nothing shows them again - no harm */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let popup: { close: () => void; closed: boolean } | undefined
|
|
40
|
+
|
|
41
|
+
/* A reading changed on a screen or in the popup - the badge counts again. */
|
|
42
|
+
window.addEventListener('announcements-changed', () => refreshAnnouncementBadge())
|
|
43
|
+
let subscription: { unsubscribe(): void } | undefined
|
|
44
|
+
let watching = false
|
|
45
|
+
|
|
46
|
+
/** Opens the popups due now that this session has not closed - one window at a time. */
|
|
47
|
+
export async function showDuePopups(): Promise<void> {
|
|
48
|
+
if (popup && !popup.closed) return
|
|
49
|
+
let due: AnnouncementItem[]
|
|
50
|
+
try {
|
|
51
|
+
due = await announcementApi.popups()
|
|
52
|
+
} catch {
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
const closed = closedInSession()
|
|
56
|
+
const items = due.filter(one => !closed.has(one.id))
|
|
57
|
+
if (!items.length) return
|
|
58
|
+
const handle: any = openPopup(html`<announcement-popup .items=${items} @done=${() => handle.close()}></announcement-popup>`, {
|
|
59
|
+
backdrop: true,
|
|
60
|
+
size: 'medium',
|
|
61
|
+
title: t('announcement.title.popup')
|
|
62
|
+
})
|
|
63
|
+
popup = handle
|
|
64
|
+
handle.result?.then?.(() => {
|
|
65
|
+
rememberClosed(items.map(one => one.id))
|
|
66
|
+
refreshAnnouncementBadge()
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Starts once the person is known. */
|
|
71
|
+
export async function startAnnouncementWatch(): Promise<void> {
|
|
72
|
+
if (watching) return
|
|
73
|
+
watching = true
|
|
74
|
+
refreshAnnouncementBadge()
|
|
75
|
+
showDuePopups()
|
|
76
|
+
try {
|
|
77
|
+
subscription = await subscribe(
|
|
78
|
+
{
|
|
79
|
+
query: gql`
|
|
80
|
+
subscription {
|
|
81
|
+
notification(subjects: ["info"]) {
|
|
82
|
+
type
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
`
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
next({ data }: any) {
|
|
89
|
+
if (data?.notification?.type !== 'announcement') return
|
|
90
|
+
refreshAnnouncementBadge()
|
|
91
|
+
showDuePopups()
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
} catch {
|
|
96
|
+
/* without the live line the badge and popups still come at the next sign-in */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function stopAnnouncementWatch(): void {
|
|
101
|
+
subscription?.unsubscribe()
|
|
102
|
+
subscription = undefined
|
|
103
|
+
popup?.close()
|
|
104
|
+
popup = undefined
|
|
105
|
+
watching = false
|
|
106
|
+
try {
|
|
107
|
+
sessionStorage.removeItem(CLOSED_KEY)
|
|
108
|
+
} catch {}
|
|
109
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { auth } from '@things-factory/auth-base/dist-client/auth.js'
|
|
2
|
+
|
|
3
|
+
import { startAnnouncementWatch, stopAnnouncementWatch } from './announcement-watch.js'
|
|
4
|
+
|
|
5
|
+
/* Announcements follow the person - the popups due and the badge come when they are known (ADR-0163 decision 5). */
|
|
6
|
+
export default function bootstrap() {
|
|
7
|
+
auth.on('profile', () => startAnnouncementWatch())
|
|
8
|
+
auth.on('presignout', () => stopAnnouncementWatch())
|
|
9
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The announcements that pop up (the user, 2026-10-26: 「공지 사항이 있을 때 자동 팝업」, ADR-0163 decision 5) - one
|
|
3
|
+
* window, one announcement at a time. Seeing one reads it; one that asks to be acknowledged is acknowledged here or
|
|
4
|
+
* shows again at the next sign-in.
|
|
5
|
+
*/
|
|
6
|
+
import '@material/web/button/filled-button.js'
|
|
7
|
+
import '@material/web/button/text-button.js'
|
|
8
|
+
import '@operato/popup/ox-popup-actions.js'
|
|
9
|
+
import './announcement-sheet.js'
|
|
10
|
+
|
|
11
|
+
import { css, html, LitElement, nothing } from 'lit'
|
|
12
|
+
import { customElement, property, state } from 'lit/decorators.js'
|
|
13
|
+
|
|
14
|
+
import { i18next, localize } from '@operato/i18n'
|
|
15
|
+
import { navigate } from '@operato/shell'
|
|
16
|
+
import { CommonPopupStyles, PopupBodyStyles } from '@operato/styles'
|
|
17
|
+
|
|
18
|
+
import { announcementApi, type AnnouncementItem } from '../utils/announcement-api.js'
|
|
19
|
+
import { t } from '../utils/announcement-words.js'
|
|
20
|
+
|
|
21
|
+
@customElement('announcement-popup')
|
|
22
|
+
export class AnnouncementPopup extends localize(i18next)(LitElement) {
|
|
23
|
+
static styles = [
|
|
24
|
+
CommonPopupStyles,
|
|
25
|
+
PopupBodyStyles,
|
|
26
|
+
css`
|
|
27
|
+
:host {
|
|
28
|
+
height: 100%;
|
|
29
|
+
}
|
|
30
|
+
h3 {
|
|
31
|
+
margin: 0 0 var(--spacing-small, 4px);
|
|
32
|
+
font-size: var(--ui-t-title, 18px);
|
|
33
|
+
}
|
|
34
|
+
.count {
|
|
35
|
+
margin-inline-end: auto;
|
|
36
|
+
align-self: center;
|
|
37
|
+
font-size: var(--ui-t-small, 12px);
|
|
38
|
+
color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
|
|
39
|
+
}
|
|
40
|
+
`
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
@property({ attribute: false }) items: AnnouncementItem[] = []
|
|
44
|
+
@state() private index = 0
|
|
45
|
+
|
|
46
|
+
render() {
|
|
47
|
+
const one = this.items[this.index]
|
|
48
|
+
if (!one) return nothing
|
|
49
|
+
const last = this.index >= this.items.length - 1
|
|
50
|
+
return html`<div class="body">
|
|
51
|
+
<h3>${one.title}</h3>
|
|
52
|
+
<announcement-sheet .announcement=${one} @announcement-follow=${() => this.close()} @announcement-acknowledged=${(e: CustomEvent) => this.replace(e.detail)}></announcement-sheet>
|
|
53
|
+
</div>
|
|
54
|
+
<ox-popup-actions>
|
|
55
|
+
${this.items.length > 1 ? html`<span class="count">${this.index + 1} / ${this.items.length}</span>` : nothing}
|
|
56
|
+
<md-text-button @click=${() => this.open(one)}>${t('announcement.button.open')}</md-text-button>
|
|
57
|
+
${this.index > 0 ? html`<md-text-button @click=${() => this.show(this.index - 1)}>${t('announcement.button.previous')}</md-text-button>` : nothing}
|
|
58
|
+
${last
|
|
59
|
+
? html`<md-filled-button slot="confirm" @click=${() => this.close()}>${t('announcement.button.close')}</md-filled-button>`
|
|
60
|
+
: html`<md-filled-button slot="confirm" @click=${() => this.show(this.index + 1)}>${t('announcement.button.next')}</md-filled-button>`}
|
|
61
|
+
</ox-popup-actions>`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
firstUpdated() {
|
|
65
|
+
this.show(0)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Shows one, and reads it - seeing it in the popup is reading it. */
|
|
69
|
+
private show(index: number) {
|
|
70
|
+
this.index = index
|
|
71
|
+
const one = this.items[index]
|
|
72
|
+
if (one && !one.mine?.readAt) {
|
|
73
|
+
announcementApi
|
|
74
|
+
.read(one.id)
|
|
75
|
+
.then(done => this.replace({ ...one, ...done }))
|
|
76
|
+
.catch(() => {})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private replace(one: AnnouncementItem) {
|
|
81
|
+
this.items = this.items.map(item => (item.id === one.id ? { ...item, ...one } : item))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private open(one: AnnouncementItem) {
|
|
85
|
+
this.close()
|
|
86
|
+
navigate(`announcement-view?id=${one.id}`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private close() {
|
|
90
|
+
this.dispatchEvent(new CustomEvent('done', { bubbles: true, composed: true }))
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The small forms a writer opens from an announcement (ADR-0163 decision 4): who read it and who acknowledged it, with
|
|
3
|
+
* a reminder to those who have not; and the reason it is withdrawn.
|
|
4
|
+
*/
|
|
5
|
+
import '@material/web/button/filled-button.js'
|
|
6
|
+
import '@material/web/button/text-button.js'
|
|
7
|
+
import '@operato/popup/ox-popup-actions.js'
|
|
8
|
+
|
|
9
|
+
import { css, html, LitElement, nothing } from 'lit'
|
|
10
|
+
import { customElement, property, state } from 'lit/decorators.js'
|
|
11
|
+
|
|
12
|
+
import { i18next, localize } from '@operato/i18n'
|
|
13
|
+
import { notify } from '@operato/layout'
|
|
14
|
+
import { CommonPopupStyles, PopupBodyStyles } from '@operato/styles'
|
|
15
|
+
|
|
16
|
+
import { announcementApi, type AnnouncementItem } from '../utils/announcement-api.js'
|
|
17
|
+
import { reasonOf, t, when } from '../utils/announcement-words.js'
|
|
18
|
+
|
|
19
|
+
type Row = Awaited<ReturnType<typeof announcementApi.receipts>>[number]
|
|
20
|
+
|
|
21
|
+
const FormStyles = css`
|
|
22
|
+
:host {
|
|
23
|
+
height: 100%;
|
|
24
|
+
line-height: 1.5;
|
|
25
|
+
}
|
|
26
|
+
.tabs {
|
|
27
|
+
display: flex;
|
|
28
|
+
gap: var(--spacing-small, 4px);
|
|
29
|
+
margin-bottom: var(--spacing-medium, 8px);
|
|
30
|
+
}
|
|
31
|
+
.tabs button {
|
|
32
|
+
border: 1px solid var(--ui-line-soft, var(--md-sys-color-outline-variant));
|
|
33
|
+
background: none;
|
|
34
|
+
border-radius: 999px;
|
|
35
|
+
padding: 2px var(--spacing-medium, 8px);
|
|
36
|
+
font: inherit;
|
|
37
|
+
font-size: var(--ui-t-small, 12px);
|
|
38
|
+
cursor: pointer;
|
|
39
|
+
color: inherit;
|
|
40
|
+
}
|
|
41
|
+
.tabs button[aria-pressed='true'] {
|
|
42
|
+
background: var(--md-sys-color-secondary-container);
|
|
43
|
+
border-color: transparent;
|
|
44
|
+
}
|
|
45
|
+
table {
|
|
46
|
+
width: 100%;
|
|
47
|
+
border-collapse: collapse;
|
|
48
|
+
font-size: var(--ui-t-body, 14px);
|
|
49
|
+
font-variant-numeric: tabular-nums;
|
|
50
|
+
}
|
|
51
|
+
th,
|
|
52
|
+
td {
|
|
53
|
+
text-align: left;
|
|
54
|
+
padding: var(--spacing-small, 4px) var(--spacing-medium, 8px);
|
|
55
|
+
border-bottom: 1px solid var(--ui-line-soft, var(--md-sys-color-outline-variant));
|
|
56
|
+
}
|
|
57
|
+
th {
|
|
58
|
+
font-weight: 600;
|
|
59
|
+
font-size: var(--ui-t-small, 12px);
|
|
60
|
+
color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
|
|
61
|
+
}
|
|
62
|
+
.muted {
|
|
63
|
+
color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
|
|
64
|
+
}
|
|
65
|
+
textarea {
|
|
66
|
+
width: 100%;
|
|
67
|
+
min-height: 96px;
|
|
68
|
+
box-sizing: border-box;
|
|
69
|
+
font: inherit;
|
|
70
|
+
}
|
|
71
|
+
`
|
|
72
|
+
|
|
73
|
+
@customElement('announcement-receipts')
|
|
74
|
+
export class AnnouncementReceipts extends localize(i18next)(LitElement) {
|
|
75
|
+
static styles = [CommonPopupStyles, PopupBodyStyles, FormStyles]
|
|
76
|
+
@property({ attribute: false }) announcement!: AnnouncementItem
|
|
77
|
+
@state() private rows: Row[] = []
|
|
78
|
+
@state() private which: 'all' | 'unread' | 'unacknowledged' | 'later' = 'all'
|
|
79
|
+
@state() private busy = false
|
|
80
|
+
|
|
81
|
+
connectedCallback() {
|
|
82
|
+
super.connectedCallback()
|
|
83
|
+
this.load()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private async load() {
|
|
87
|
+
try {
|
|
88
|
+
this.rows = await announcementApi.receipts(this.announcement.id)
|
|
89
|
+
} catch (error) {
|
|
90
|
+
notify({ level: 'error', message: reasonOf(error) })
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
render() {
|
|
95
|
+
const one = this.announcement
|
|
96
|
+
const listed = this.rows.filter(row => row.listed)
|
|
97
|
+
const counts = {
|
|
98
|
+
all: listed.length,
|
|
99
|
+
unread: listed.filter(row => !row.readAt).length,
|
|
100
|
+
unacknowledged: listed.filter(row => !row.acknowledgedAt).length,
|
|
101
|
+
later: this.rows.filter(row => !row.listed).length
|
|
102
|
+
}
|
|
103
|
+
const tabs = (['all', 'unread', ...(one.requiresAcknowledgement ? ['unacknowledged'] : []), ...(counts.later ? ['later'] : [])] as (keyof typeof counts)[]).map(
|
|
104
|
+
key => html`<button aria-pressed=${String(this.which === key)} @click=${() => (this.which = key)}>${t(`announcement.receipts.${key}`)} ${counts[key]}</button>`
|
|
105
|
+
)
|
|
106
|
+
const shown =
|
|
107
|
+
this.which === 'later'
|
|
108
|
+
? this.rows.filter(row => !row.listed)
|
|
109
|
+
: listed.filter(row => (this.which === 'unread' ? !row.readAt : this.which === 'unacknowledged' ? !row.acknowledgedAt : true))
|
|
110
|
+
const owed = one.requiresAcknowledgement ? counts.unacknowledged : counts.unread
|
|
111
|
+
return html`<div class="body">
|
|
112
|
+
<div class="tabs">${tabs}</div>
|
|
113
|
+
<table>
|
|
114
|
+
<thead>
|
|
115
|
+
<tr>
|
|
116
|
+
<th>${t('announcement.receipts.name')}</th>
|
|
117
|
+
<th>${t('announcement.receipts.read-at')}</th>
|
|
118
|
+
${one.requiresAcknowledgement ? html`<th>${t('announcement.receipts.acknowledged-at')}</th>` : nothing}
|
|
119
|
+
<th>${t('announcement.receipts.notified-at')}</th>
|
|
120
|
+
</tr>
|
|
121
|
+
</thead>
|
|
122
|
+
<tbody>
|
|
123
|
+
${shown.map(
|
|
124
|
+
row => html`<tr>
|
|
125
|
+
<td>${row.name ?? row.email ?? row.userId}</td>
|
|
126
|
+
<td class=${row.readAt ? '' : 'muted'}>${row.readAt ? when(row.readAt) : t('announcement.reader-state.unread')}</td>
|
|
127
|
+
${one.requiresAcknowledgement ? html`<td class=${row.acknowledgedAt ? '' : 'muted'}>${row.acknowledgedAt ? when(row.acknowledgedAt) : '—'}</td>` : nothing}
|
|
128
|
+
<td class="muted">${when(row.notifiedAt)}</td>
|
|
129
|
+
</tr>`
|
|
130
|
+
)}
|
|
131
|
+
</tbody>
|
|
132
|
+
</table>
|
|
133
|
+
</div>
|
|
134
|
+
<ox-popup-actions>
|
|
135
|
+
<md-text-button slot="cancel" @click=${() => this.close()}>${t('announcement.button.close')}</md-text-button>
|
|
136
|
+
<md-filled-button slot="confirm" ?disabled=${this.busy || !owed || one.status !== 'PUBLISHED'} @click=${() => this.remind()}
|
|
137
|
+
>${t('announcement.button.remind-count', { count: owed })}</md-filled-button
|
|
138
|
+
>
|
|
139
|
+
</ox-popup-actions>`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private async remind() {
|
|
143
|
+
this.busy = true
|
|
144
|
+
try {
|
|
145
|
+
const told = await announcementApi.remind(this.announcement.id)
|
|
146
|
+
notify({ message: t('announcement.text.reminded', { count: told }) })
|
|
147
|
+
await this.load()
|
|
148
|
+
} catch (error) {
|
|
149
|
+
notify({ level: 'error', message: reasonOf(error) })
|
|
150
|
+
} finally {
|
|
151
|
+
this.busy = false
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private close() {
|
|
156
|
+
this.dispatchEvent(new CustomEvent('done', { bubbles: true, composed: true }))
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Why it is withdrawn - the readers' copy goes, the reason stays with its record. */
|
|
161
|
+
@customElement('announcement-withdraw-form')
|
|
162
|
+
export class AnnouncementWithdrawForm extends localize(i18next)(LitElement) {
|
|
163
|
+
static styles = [CommonPopupStyles, PopupBodyStyles, FormStyles]
|
|
164
|
+
@property({ attribute: false }) announcement!: AnnouncementItem
|
|
165
|
+
@state() private reason = ''
|
|
166
|
+
@state() private busy = false
|
|
167
|
+
|
|
168
|
+
render() {
|
|
169
|
+
return html`<div class="body">
|
|
170
|
+
<p>${t('announcement.text.withdraw-lead')}</p>
|
|
171
|
+
<textarea .value=${this.reason} maxlength="500" placeholder=${t('announcement.text.withdraw-reason')} @input=${(e: any) => (this.reason = e.target.value)}></textarea>
|
|
172
|
+
</div>
|
|
173
|
+
<ox-popup-actions>
|
|
174
|
+
<md-text-button slot="cancel" @click=${() => this.close()}>${t('announcement.button.cancel')}</md-text-button>
|
|
175
|
+
<md-filled-button slot="confirm" ?disabled=${this.busy || !this.reason.trim()} @click=${() => this.withdraw()}>${t('announcement.button.withdraw')}</md-filled-button>
|
|
176
|
+
</ox-popup-actions>`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private async withdraw() {
|
|
180
|
+
this.busy = true
|
|
181
|
+
try {
|
|
182
|
+
await announcementApi.withdraw(this.announcement.id, this.reason)
|
|
183
|
+
notify({ message: t('announcement.text.withdrawn-done') })
|
|
184
|
+
this.dispatchEvent(new CustomEvent('announcement-withdrawn', { bubbles: true, composed: true }))
|
|
185
|
+
this.close()
|
|
186
|
+
} catch (error) {
|
|
187
|
+
notify({ level: 'error', message: reasonOf(error) })
|
|
188
|
+
} finally {
|
|
189
|
+
this.busy = false
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private close() {
|
|
194
|
+
this.dispatchEvent(new CustomEvent('done', { bubbles: true, composed: true }))
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* One announcement as its reader sees it - what kind it is and from whom, its body, its files, the screens it points to,
|
|
3
|
+
* and the acknowledgement it asks for (ADR-0163 decisions 3, 5). The screen and the popup both draw it.
|
|
4
|
+
*/
|
|
5
|
+
import '@material/web/icon/icon.js'
|
|
6
|
+
import '@material/web/button/filled-button.js'
|
|
7
|
+
|
|
8
|
+
import { css, html, LitElement, nothing } from 'lit'
|
|
9
|
+
import { customElement, property, state } from 'lit/decorators.js'
|
|
10
|
+
import { unsafeHTML } from 'lit/directives/unsafe-html.js'
|
|
11
|
+
|
|
12
|
+
import { i18next, localize } from '@operato/i18n'
|
|
13
|
+
import { notify } from '@operato/layout'
|
|
14
|
+
import { navigate } from '@operato/shell'
|
|
15
|
+
import { renderMarkdown } from '@things-factory/ai-assistant/dist-client/index.js'
|
|
16
|
+
|
|
17
|
+
import { announcementApi, type AnnouncementItem } from '../utils/announcement-api.js'
|
|
18
|
+
import { categoryWord, importanceWord, reasonOf, t, when } from '../utils/announcement-words.js'
|
|
19
|
+
|
|
20
|
+
/** A screen of the application opens in place; an https page opens apart. */
|
|
21
|
+
export function followLink(url: string) {
|
|
22
|
+
if (/^https:\/\//i.test(url)) window.open(url, '_blank', 'noopener')
|
|
23
|
+
else navigate(url)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@customElement('announcement-sheet')
|
|
27
|
+
export class AnnouncementSheet extends localize(i18next)(LitElement) {
|
|
28
|
+
static styles = css`
|
|
29
|
+
:host {
|
|
30
|
+
display: flex;
|
|
31
|
+
flex-direction: column;
|
|
32
|
+
gap: var(--spacing-medium, 8px);
|
|
33
|
+
color: var(--md-sys-color-on-surface);
|
|
34
|
+
}
|
|
35
|
+
.meta {
|
|
36
|
+
display: flex;
|
|
37
|
+
flex-wrap: wrap;
|
|
38
|
+
align-items: center;
|
|
39
|
+
gap: var(--spacing-small, 4px) var(--spacing-medium, 8px);
|
|
40
|
+
font-size: var(--ui-t-small, 12px);
|
|
41
|
+
color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
|
|
42
|
+
}
|
|
43
|
+
.chip {
|
|
44
|
+
display: inline-flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
gap: 2px;
|
|
47
|
+
padding: 0 var(--spacing-small, 4px);
|
|
48
|
+
border-radius: var(--border-radius, 4px);
|
|
49
|
+
border: 1px solid var(--ui-line-soft, var(--md-sys-color-outline-variant));
|
|
50
|
+
line-height: 1.6;
|
|
51
|
+
}
|
|
52
|
+
.chip.URGENT {
|
|
53
|
+
color: var(--md-sys-color-error);
|
|
54
|
+
border-color: currentColor;
|
|
55
|
+
}
|
|
56
|
+
.chip.IMPORTANT {
|
|
57
|
+
color: var(--md-sys-color-primary);
|
|
58
|
+
border-color: currentColor;
|
|
59
|
+
}
|
|
60
|
+
.chip md-icon {
|
|
61
|
+
--md-icon-size: 14px;
|
|
62
|
+
}
|
|
63
|
+
.markdown {
|
|
64
|
+
font-size: var(--ui-t-body, 14px);
|
|
65
|
+
line-height: 1.6;
|
|
66
|
+
overflow-wrap: anywhere;
|
|
67
|
+
}
|
|
68
|
+
.markdown :is(h1, h2, h3, h4) {
|
|
69
|
+
margin: var(--spacing-medium, 8px) 0 var(--spacing-small, 4px);
|
|
70
|
+
font-size: var(--ui-t-title, 16px);
|
|
71
|
+
}
|
|
72
|
+
.markdown :is(p, ul, ol, blockquote, pre, table) {
|
|
73
|
+
margin: 0 0 var(--spacing-medium, 8px);
|
|
74
|
+
}
|
|
75
|
+
.markdown :is(ul, ol) {
|
|
76
|
+
padding-inline-start: 1.4em;
|
|
77
|
+
list-style: revert;
|
|
78
|
+
display: block;
|
|
79
|
+
}
|
|
80
|
+
.markdown a {
|
|
81
|
+
color: var(--md-sys-color-primary);
|
|
82
|
+
}
|
|
83
|
+
.markdown table {
|
|
84
|
+
border-collapse: collapse;
|
|
85
|
+
}
|
|
86
|
+
.markdown :is(th, td) {
|
|
87
|
+
border: 1px solid var(--ui-line-soft, var(--md-sys-color-outline-variant));
|
|
88
|
+
padding: 2px var(--spacing-small, 4px);
|
|
89
|
+
}
|
|
90
|
+
h4 {
|
|
91
|
+
margin: var(--spacing-medium, 8px) 0 var(--spacing-small, 4px);
|
|
92
|
+
font-size: var(--ui-t-small, 12px);
|
|
93
|
+
color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
|
|
94
|
+
font-weight: 600;
|
|
95
|
+
}
|
|
96
|
+
ul.files {
|
|
97
|
+
margin: 0;
|
|
98
|
+
padding: 0;
|
|
99
|
+
list-style: none;
|
|
100
|
+
display: flex;
|
|
101
|
+
flex-direction: column;
|
|
102
|
+
gap: 2px;
|
|
103
|
+
}
|
|
104
|
+
a,
|
|
105
|
+
.link {
|
|
106
|
+
display: inline-flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
gap: var(--spacing-small, 4px);
|
|
109
|
+
color: var(--md-sys-color-primary);
|
|
110
|
+
text-decoration: none;
|
|
111
|
+
cursor: pointer;
|
|
112
|
+
background: none;
|
|
113
|
+
border: 0;
|
|
114
|
+
padding: 0;
|
|
115
|
+
font: inherit;
|
|
116
|
+
}
|
|
117
|
+
a:hover,
|
|
118
|
+
.link:hover {
|
|
119
|
+
text-decoration: underline;
|
|
120
|
+
}
|
|
121
|
+
a md-icon,
|
|
122
|
+
.link md-icon {
|
|
123
|
+
--md-icon-size: 16px;
|
|
124
|
+
}
|
|
125
|
+
.size {
|
|
126
|
+
color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
|
|
127
|
+
font-size: var(--ui-t-small, 12px);
|
|
128
|
+
}
|
|
129
|
+
.acknowledge {
|
|
130
|
+
display: flex;
|
|
131
|
+
flex-wrap: wrap;
|
|
132
|
+
align-items: center;
|
|
133
|
+
justify-content: space-between;
|
|
134
|
+
gap: var(--spacing-medium, 8px);
|
|
135
|
+
padding: var(--spacing-medium, 8px) var(--spacing-large, 12px);
|
|
136
|
+
border-radius: var(--border-radius, 4px);
|
|
137
|
+
background: var(--md-sys-color-surface-container, rgba(0, 0, 0, 0.04));
|
|
138
|
+
font-size: var(--ui-t-body, 14px);
|
|
139
|
+
}
|
|
140
|
+
.acknowledge.done {
|
|
141
|
+
color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
|
|
142
|
+
}
|
|
143
|
+
`
|
|
144
|
+
|
|
145
|
+
@property({ attribute: false }) announcement!: AnnouncementItem
|
|
146
|
+
@state() private busy = false
|
|
147
|
+
|
|
148
|
+
render() {
|
|
149
|
+
const one = this.announcement
|
|
150
|
+
if (!one) return nothing
|
|
151
|
+
const files = one.attachments ?? []
|
|
152
|
+
const links = one.links ?? []
|
|
153
|
+
return html`<div class="meta">
|
|
154
|
+
${one.pinned ? html`<span class="chip"><md-icon>push_pin</md-icon>${t('announcement.field.pinned')}</span>` : nothing}
|
|
155
|
+
${one.importance !== 'NORMAL' ? html`<span class="chip ${one.importance}">${importanceWord(one.importance)}</span>` : nothing}
|
|
156
|
+
<span class="chip">${categoryWord(one.category)}</span>
|
|
157
|
+
<span>${one.publisher?.name ?? one.creator?.name ?? ''}</span>
|
|
158
|
+
<span>${when(one.publishedAt ?? one.startsAt)}</span>
|
|
159
|
+
${one.endsAt ? html`<span>${t('announcement.text.until', { when: when(one.endsAt) })}</span>` : nothing}
|
|
160
|
+
${one.version > 1 ? html`<span>${t('announcement.text.version', { version: one.version })}</span>` : nothing}
|
|
161
|
+
</div>
|
|
162
|
+
${one.body ? html`<div class="markdown">${unsafeHTML(renderMarkdown(one.body))}</div>` : nothing}
|
|
163
|
+
${files.length
|
|
164
|
+
? html`<h4>${t('announcement.field.attachments')}</h4>
|
|
165
|
+
<ul class="files">
|
|
166
|
+
${files.map(
|
|
167
|
+
file =>
|
|
168
|
+
html`<li>
|
|
169
|
+
<a href=${file.fullpath} target="_blank" rel="noopener" download=${file.name}><md-icon>attach_file</md-icon>${file.name}</a>
|
|
170
|
+
${file.size ? html`<span class="size">${Math.max(1, Math.round(file.size / 1024))} KB</span>` : nothing}
|
|
171
|
+
</li>`
|
|
172
|
+
)}
|
|
173
|
+
</ul>`
|
|
174
|
+
: nothing}
|
|
175
|
+
${links.length
|
|
176
|
+
? html`<h4>${t('announcement.field.links')}</h4>
|
|
177
|
+
<ul class="files">
|
|
178
|
+
${links.map(link => html`<li><button class="link" @click=${() => this.follow(link.url)}><md-icon>open_in_new</md-icon>${link.label}</button></li>`)}
|
|
179
|
+
</ul>`
|
|
180
|
+
: nothing}
|
|
181
|
+
${this.renderAcknowledge()}`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private renderAcknowledge() {
|
|
185
|
+
const one = this.announcement
|
|
186
|
+
if (!one.requiresAcknowledgement || one.status !== 'PUBLISHED') return nothing
|
|
187
|
+
const by = one.acknowledgeBy ? t('announcement.text.acknowledge-by', { when: when(one.acknowledgeBy, false) }) : ''
|
|
188
|
+
if (one.mine?.acknowledgedAt) return html`<div class="acknowledge done">${t('announcement.text.acknowledged-at', { when: when(one.mine.acknowledgedAt) })}</div>`
|
|
189
|
+
/* Only its audience acknowledges; a writer looking at one addressed to others sees what is asked. */
|
|
190
|
+
if (!one.addressed) return html`<div class="acknowledge">${t('announcement.text.acknowledge-asked')} ${by}</div>`
|
|
191
|
+
return html`<div class="acknowledge">
|
|
192
|
+
<span>${t('announcement.text.acknowledge-asked')} ${by}</span>
|
|
193
|
+
<md-filled-button ?disabled=${this.busy} @click=${() => this.acknowledge()}>${t('announcement.button.acknowledge')}</md-filled-button>
|
|
194
|
+
</div>`
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private follow(url: string) {
|
|
198
|
+
this.dispatchEvent(new CustomEvent('announcement-follow', { bubbles: true, composed: true }))
|
|
199
|
+
followLink(url)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private async acknowledge() {
|
|
203
|
+
this.busy = true
|
|
204
|
+
try {
|
|
205
|
+
const done = await announcementApi.acknowledge(this.announcement.id)
|
|
206
|
+
this.announcement = { ...this.announcement, ...done }
|
|
207
|
+
this.dispatchEvent(new CustomEvent('announcement-acknowledged', { bubbles: true, composed: true, detail: this.announcement }))
|
|
208
|
+
} catch (error) {
|
|
209
|
+
notify({ level: 'error', message: reasonOf(error) })
|
|
210
|
+
} finally {
|
|
211
|
+
this.busy = false
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|