@conduction/nextcloud-vue 2.105.0 → 2.106.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/dist/esm/components/CnCardGrid/CnCardGrid.vue.js +1 -1
- package/dist/esm/components/CnCardGrid/CnCardGrid.vue2.js +18 -0
- package/dist/esm/components/CnCardGrid/CnCardGrid.vue2.js.map +1 -1
- package/dist/esm/components/CnCardGrid/CnCardGrid.vue3.js +3 -1
- package/dist/esm/components/CnCardGrid/CnCardGrid.vue3.js.map +1 -1
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue.js +1 -1
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +18 -0
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js +1 -1
- package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +53 -4
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue.js +9 -0
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue.js.map +1 -0
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue2.js +24 -0
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue2.js.map +1 -0
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue3.js +49 -0
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue3.js.map +1 -0
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue.js +1 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +36 -18
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +17 -38
- package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue.js +1 -1
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnFactsWidget/CnFactsWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue.js +1 -1
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js +53 -2
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue2.js.map +1 -1
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js +119 -111
- package/dist/esm/components/CnFilesBrowser/CnFilesBrowser.vue3.js.map +1 -1
- package/dist/esm/components/CnFlowRunsWidget/CnFlowRunsWidget.vue.js +1 -1
- package/dist/esm/components/CnFlowRunsWidget/CnFlowRunsWidget.vue2.js +62 -1
- package/dist/esm/components/CnFlowRunsWidget/CnFlowRunsWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnFlowRunsWidget/CnFlowRunsWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +159 -50
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +121 -98
- package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue.js +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue2.js +24 -0
- package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue3.js +20 -4
- package/dist/esm/components/CnIndexPage/CnIndexPageWidgets.vue3.js.map +1 -1
- package/dist/esm/components/CnIndexPage/useSelfFetchList.js +22 -4
- package/dist/esm/components/CnIndexPage/useSelfFetchList.js.map +1 -1
- package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue2.js +112 -1
- package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue2.js.map +1 -1
- package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue3.js +100 -44
- package/dist/esm/components/CnIndexSidebar/CnIndexSidebar.vue3.js.map +1 -1
- package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue.js +1 -1
- package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue2.js +67 -4
- package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue3.js +1 -1
- package/dist/esm/components/CnNcWidgetWidget/CnNcWidgetWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnObjectCard/CnObjectCard.vue.js +1 -1
- package/dist/esm/components/CnObjectCard/CnObjectCard.vue2.js +295 -12
- package/dist/esm/components/CnObjectCard/CnObjectCard.vue2.js.map +1 -1
- package/dist/esm/components/CnObjectCard/CnObjectCard.vue3.js +267 -144
- package/dist/esm/components/CnObjectCard/CnObjectCard.vue3.js.map +1 -1
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue.js +1 -1
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +4 -0
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +3 -1
- package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue.js +1 -1
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js +93 -1
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js +322 -246
- package/dist/esm/components/CnRecordListWidget/CnRecordListWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue.js +1 -1
- package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue2.js +122 -7
- package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue3.js +189 -93
- package/dist/esm/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnTabs/CnTabs.vue.js +1 -1
- package/dist/esm/components/CnTabs/CnTabs.vue2.js.map +1 -1
- package/dist/esm/components/CnTabs/CnTabs.vue3.js.map +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue.js +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +42 -0
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +38 -19
- package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
- package/dist/esm/components/CnTeamWorkloadWidget/CnTeamWorkloadWidget.vue.js +9 -0
- package/dist/esm/components/CnTeamWorkloadWidget/CnTeamWorkloadWidget.vue.js.map +1 -0
- package/dist/esm/components/CnTeamWorkloadWidget/CnTeamWorkloadWidget.vue2.js +258 -0
- package/dist/esm/components/CnTeamWorkloadWidget/CnTeamWorkloadWidget.vue2.js.map +1 -0
- package/dist/esm/components/CnTeamWorkloadWidget/CnTeamWorkloadWidget.vue3.js +69 -0
- package/dist/esm/components/CnTeamWorkloadWidget/CnTeamWorkloadWidget.vue3.js.map +1 -0
- package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js +12 -0
- package/dist/esm/components/CnWidgetGrid/registerDashboardWidgets.js.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/l10n/en.json.js +21 -1
- package/dist/esm/l10n/en.json.js.map +1 -1
- package/dist/esm/l10n/nl.json.js +21 -1
- package/dist/esm/l10n/nl.json.js.map +1 -1
- package/dist/esm/library-style.js +1 -1
- package/dist/esm/nextcloud-vue.css +803 -453
- package/dist/esm/utils/dueText.js +18 -3
- package/dist/esm/utils/dueText.js.map +1 -1
- package/dist/esm/utils/libraryWidgetKeys.js +1 -0
- package/dist/esm/utils/libraryWidgetKeys.js.map +1 -1
- package/dist/esm/utils/sortOrder.js +11 -1
- package/dist/esm/utils/sortOrder.js.map +1 -1
- package/dist/esm/utils/validateManifestV2.compiled.js +3 -3
- package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
- package/dist/nextcloud-vue.cjs.js +8281 -6350
- package/dist/nextcloud-vue.cjs.js.map +2 -2
- package/dist/nextcloud-vue.css +803 -453
- package/l10n/en.json +21 -1
- package/l10n/nl.json +21 -1
- package/package.json +1 -1
- package/src/components/CnCardGrid/CnCardGrid.md +2 -0
- package/src/components/CnCardGrid/CnCardGrid.vue +20 -0
- package/src/components/CnCellRenderer/CnCellRenderer.vue +19 -1
- package/src/components/CnDetailPage/CnDetailPage.md +2 -2
- package/src/components/CnDetailPage/CnDetailPage.vue +53 -4
- package/src/components/CnDetailWidgetHost/CnDetailWidgetHeaderItems.vue +60 -0
- package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +36 -29
- package/src/components/CnFactsWidget/CnFactsWidget.vue +4 -0
- package/src/components/CnFilesBrowser/CnFilesBrowser.vue +70 -4
- package/src/components/CnFlowRunsWidget/CnFlowRunsWidget.vue +62 -1
- package/src/components/CnIndexPage/CnIndexPage.md +7 -5
- package/src/components/CnIndexPage/CnIndexPage.vue +187 -58
- package/src/components/CnIndexPage/CnIndexPageWidgets.vue +36 -1
- package/src/components/CnIndexPage/useSelfFetchList.js +22 -4
- package/src/components/CnIndexSidebar/CnIndexSidebar.md +2 -0
- package/src/components/CnIndexSidebar/CnIndexSidebar.vue +148 -1
- package/src/components/CnNcWidgetWidget/CnNcWidgetWidget.vue +68 -5
- package/src/components/CnObjectCard/CnObjectCard.md +1 -0
- package/src/components/CnObjectCard/CnObjectCard.vue +354 -13
- package/src/components/CnObjectSidebar/CnFilesTab.vue +6 -0
- package/src/components/CnRecordListWidget/CnRecordListWidget.vue +190 -4
- package/src/components/CnRelatedObjectsWidget/CnRelatedObjectsWidget.vue +209 -9
- package/src/components/CnTabs/CnTabs.vue +11 -0
- package/src/components/CnTabsWidget/CnTabsWidget.vue +91 -18
- package/src/components/CnTeamWorkloadWidget/CnTeamWorkloadWidget.md +11 -0
- package/src/components/CnTeamWorkloadWidget/CnTeamWorkloadWidget.vue +382 -0
- package/src/components/CnTeamWorkloadWidget/index.js +14 -0
- package/src/components/CnWidgetGrid/registerDashboardWidgets.js +12 -0
- package/src/components/index.js +1 -0
- package/src/css/index-page.css +14 -2
- package/src/css/look-board-detail.css +48 -4
- package/src/css/look-board-index.css +64 -2
- package/src/css/look-board.css +25 -2
- package/src/index.js +1 -1
- package/src/schemas/app-manifest-v2.schema.json +75 -6
- package/src/utils/dueText.js +18 -3
- package/src/utils/libraryWidgetKeys.js +1 -0
- package/src/utils/sortOrder.js +10 -0
- package/src/utils/validateManifestV2.compiled.js +3 -3
|
@@ -164,24 +164,37 @@
|
|
|
164
164
|
@selectObject="onSelectObject" />
|
|
165
165
|
</div>
|
|
166
166
|
</div>
|
|
167
|
-
<
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
167
|
+
<template v-else-if="entry.widget">
|
|
168
|
+
<!-- A single widget with a title of its own heads its panel
|
|
169
|
+
(DqZaakDocumenten); a title the tab already shows is not
|
|
170
|
+
repeated. -->
|
|
171
|
+
<div
|
|
172
|
+
v-if="panelHeading(entry)"
|
|
173
|
+
class="cn-tabs-widget__panel-heading"
|
|
174
|
+
data-testid="cn-tabs-widget-panel-heading">
|
|
175
|
+
<h2 class="cn-tabs-widget__panel-title">
|
|
176
|
+
{{ panelHeading(entry).title }}
|
|
177
|
+
</h2>
|
|
178
|
+
<span v-if="panelHeading(entry).subtitle" class="cn-tabs-widget__panel-subtitle">{{ panelHeading(entry).subtitle }}</span>
|
|
179
|
+
</div>
|
|
180
|
+
<CnDetailWidgetHost
|
|
181
|
+
:widget="entry.widget"
|
|
182
|
+
chrome="bare"
|
|
183
|
+
:availableWidgets="availableWidgets"
|
|
184
|
+
:objectId="objectId"
|
|
185
|
+
:object="objectData"
|
|
186
|
+
:objectType="objectType"
|
|
187
|
+
:schemaObject="schemaObject"
|
|
188
|
+
:register="register"
|
|
189
|
+
:schema="schema"
|
|
190
|
+
:store="store"
|
|
191
|
+
:surface="surface"
|
|
192
|
+
:integrationContext="integrationContext"
|
|
193
|
+
:cnRegistry="cnRegistry"
|
|
194
|
+
@geoSaved="onGeoSaved"
|
|
195
|
+
@openIntegration="onOpenIntegration"
|
|
196
|
+
@selectObject="onSelectObject" />
|
|
197
|
+
</template>
|
|
185
198
|
<CnEmptyContent v-else :name="missingLabel(entry)" />
|
|
186
199
|
</CnTab>
|
|
187
200
|
</CnTabs>
|
|
@@ -361,6 +374,11 @@ export default {
|
|
|
361
374
|
* label written in the manifest reads in the user's language.
|
|
362
375
|
*/
|
|
363
376
|
cnTranslate: { default: () => (key) => key },
|
|
377
|
+
/**
|
|
378
|
+
* CnDetailPage's report of the open body tab, so its side column can
|
|
379
|
+
* follow the tab (a side entry's `tabs`). Null on any other page.
|
|
380
|
+
*/
|
|
381
|
+
cnDetailReportTab: { default: null },
|
|
364
382
|
},
|
|
365
383
|
|
|
366
384
|
props: {
|
|
@@ -804,6 +822,16 @@ export default {
|
|
|
804
822
|
this.activeKey = this.resolvedTabs[index]?.key || ''
|
|
805
823
|
},
|
|
806
824
|
|
|
825
|
+
// Tell the detail page which tab is open (side cards per tab).
|
|
826
|
+
'activeTab.id': {
|
|
827
|
+
immediate: true,
|
|
828
|
+
handler(id) {
|
|
829
|
+
if (typeof this.cnDetailReportTab === 'function') {
|
|
830
|
+
this.cnDetailReportTab(id || '')
|
|
831
|
+
}
|
|
832
|
+
},
|
|
833
|
+
},
|
|
834
|
+
|
|
807
835
|
// A tab appeared or went. Keep the same tab active; when it is the active
|
|
808
836
|
// tab that went, the first visible one takes over and the hash follows.
|
|
809
837
|
visibleKeys() {
|
|
@@ -996,6 +1024,33 @@ export default {
|
|
|
996
1024
|
return [tab?.widgetId]
|
|
997
1025
|
},
|
|
998
1026
|
|
|
1027
|
+
/**
|
|
1028
|
+
* The title row a single widget's panel draws: the widget's own title
|
|
1029
|
+
* and its description, through the app's translate. None when the
|
|
1030
|
+
* widget has no title or no description, or the tab already shows the
|
|
1031
|
+
* title: a title alone names what the tab already names, so the
|
|
1032
|
+
* widget stays bare in its panel.
|
|
1033
|
+
*
|
|
1034
|
+
* @param {object} entry A resolved tab.
|
|
1035
|
+
* @return {{title: string, subtitle: string}|null} The heading, or null.
|
|
1036
|
+
*/
|
|
1037
|
+
panelHeading(entry) {
|
|
1038
|
+
const widget = entry && entry.widget
|
|
1039
|
+
const raw = widgetTitleOf(widget)
|
|
1040
|
+
if (!raw) {
|
|
1041
|
+
return null
|
|
1042
|
+
}
|
|
1043
|
+
const translate = typeof this.cnTranslate === 'function' ? this.cnTranslate : (key) => key
|
|
1044
|
+
const title = translate(raw)
|
|
1045
|
+
if (title === entry.label) {
|
|
1046
|
+
return null
|
|
1047
|
+
}
|
|
1048
|
+
if (!widget || typeof widget.description !== 'string' || widget.description === '') {
|
|
1049
|
+
return null
|
|
1050
|
+
}
|
|
1051
|
+
return { title, subtitle: translate(widget.description) }
|
|
1052
|
+
},
|
|
1053
|
+
|
|
999
1054
|
/**
|
|
1000
1055
|
* The label a tab shows: the authored `label`, else (board look) History
|
|
1001
1056
|
* for the activity widget, else the child widget's title, else its id.
|
|
@@ -1254,6 +1309,24 @@ export default {
|
|
|
1254
1309
|
|
|
1255
1310
|
<style scoped>
|
|
1256
1311
|
/* A stack (`widgetIds`): the panel's widget, then cards 20px apart. */
|
|
1312
|
+
.cn-tabs-widget__panel-heading {
|
|
1313
|
+
display: flex;
|
|
1314
|
+
flex-direction: column;
|
|
1315
|
+
gap: 4px;
|
|
1316
|
+
margin-bottom: 16px;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
.cn-tabs-widget__panel-title {
|
|
1320
|
+
margin: 0;
|
|
1321
|
+
font-size: 17px;
|
|
1322
|
+
font-weight: 700;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
.cn-tabs-widget__panel-subtitle {
|
|
1326
|
+
font-size: 14px;
|
|
1327
|
+
color: var(--color-text-maxcontrast);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1257
1330
|
.cn-tabs-widget__stack {
|
|
1258
1331
|
display: flex;
|
|
1259
1332
|
flex-direction: column;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
The team today from an app's workload endpoint, with each person's Nextcloud user status.
|
|
2
|
+
|
|
3
|
+
```vue
|
|
4
|
+
<CnTeamWorkloadWidget
|
|
5
|
+
:content="{
|
|
6
|
+
source: { url: '/apps/dossiq/api/work-queue/workload', responsePath: 'handlers', userField: 'handler', countField: 'openCaseCount' },
|
|
7
|
+
capacity: 12,
|
|
8
|
+
}" />
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Props: `content` holds the source, the capacity and the labels.
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
SPDX-FileCopyrightText: 2026 Conduction B.V.
|
|
3
|
+
SPDX-License-Identifier: EUPL-1.2
|
|
4
|
+
-->
|
|
5
|
+
<template>
|
|
6
|
+
<div class="cn-team-workload" data-testid="cn-team-workload">
|
|
7
|
+
<p v-if="loading && members.length === 0" class="cn-team-workload__state-line" role="status">
|
|
8
|
+
{{ loadingLabel }}
|
|
9
|
+
</p>
|
|
10
|
+
<p v-else-if="members.length === 0" class="cn-team-workload__state-line">
|
|
11
|
+
{{ emptyLabel }}
|
|
12
|
+
</p>
|
|
13
|
+
<ul v-else class="cn-team-workload__list">
|
|
14
|
+
<li
|
|
15
|
+
v-for="member in members"
|
|
16
|
+
:key="member.id"
|
|
17
|
+
class="cn-team-workload__member"
|
|
18
|
+
data-testid="cn-team-workload-member">
|
|
19
|
+
<div class="cn-team-workload__row">
|
|
20
|
+
<span class="cn-team-workload__avatar" aria-hidden="true">{{ member.initials }}</span>
|
|
21
|
+
<span class="cn-team-workload__who">
|
|
22
|
+
<strong class="cn-team-workload__name">{{ member.name }}</strong>
|
|
23
|
+
<span
|
|
24
|
+
class="cn-team-workload__state"
|
|
25
|
+
:class="{ 'cn-team-workload__state--away': !member.available }">{{ member.line }}</span>
|
|
26
|
+
</span>
|
|
27
|
+
<span class="cn-team-workload__count">{{ member.countText }}</span>
|
|
28
|
+
</div>
|
|
29
|
+
<span class="cn-team-workload__bar" aria-hidden="true">
|
|
30
|
+
<span
|
|
31
|
+
class="cn-team-workload__bar-fill"
|
|
32
|
+
:class="{ 'cn-team-workload__bar-fill--hot': member.hot }"
|
|
33
|
+
:style="{ width: member.percent + '%' }" />
|
|
34
|
+
</span>
|
|
35
|
+
</li>
|
|
36
|
+
</ul>
|
|
37
|
+
</div>
|
|
38
|
+
</template>
|
|
39
|
+
|
|
40
|
+
<script>
|
|
41
|
+
import { getCurrentUser } from '@nextcloud/auth'
|
|
42
|
+
import { translatePlural as n, translate as t } from '@nextcloud/l10n'
|
|
43
|
+
import { generateUrl } from '@nextcloud/router'
|
|
44
|
+
import { fetchStatuses } from '../../utils/nextcloudDirectory.js'
|
|
45
|
+
import { readPath } from '../../utils/readPath.js'
|
|
46
|
+
import { userLocale } from '../../utils/userLocale.js'
|
|
47
|
+
|
|
48
|
+
/** The share of the capacity from which a load bar reads as full. */
|
|
49
|
+
const HOT_PERCENT = 90
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* CnTeamWorkloadWidget: the `team-workload` widget ("Team vandaag" on board
|
|
53
|
+
* DqTeamwachtrij). Each person of the team with their initials, name, an
|
|
54
|
+
* availability line, their open count and a load bar; the card's header
|
|
55
|
+
* note says how many are present ("4 of 5 present").
|
|
56
|
+
*
|
|
57
|
+
* The counts come from the app (`content.source`, an endpoint that answers a
|
|
58
|
+
* row per person). The availability comes from Nextcloud's user status, read
|
|
59
|
+
* on the client through the OCS user status API: the person's own message
|
|
60
|
+
* with the time it ends ("In a meeting until 14:00"), else Available, Away or
|
|
61
|
+
* Busy, and Absent today without a status. Present means any status but
|
|
62
|
+
* offline.
|
|
63
|
+
*
|
|
64
|
+
* `content`:
|
|
65
|
+
* - `source`: `{ url, responsePath?, userField?, nameField?, countField? }`;
|
|
66
|
+
* the url goes through `generateUrl`, the rows are read at `responsePath`
|
|
67
|
+
* (else the body), each with the user id at `userField` (default
|
|
68
|
+
* `userId`), the name at `nameField` (default `displayName`) and the count
|
|
69
|
+
* at `countField` (default `count`).
|
|
70
|
+
* - `capacity`: the count at a full bar; without it the highest count is.
|
|
71
|
+
* - `countLabel`: `{ one, other }` with `{count}`, through the app's
|
|
72
|
+
* translate; without it the count reads "{count} open".
|
|
73
|
+
* - `emptyText`: shown without anyone to list.
|
|
74
|
+
*/
|
|
75
|
+
export default {
|
|
76
|
+
name: 'CnTeamWorkloadWidget',
|
|
77
|
+
|
|
78
|
+
inject: {
|
|
79
|
+
cnTranslate: { default: () => (key) => key },
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
props: {
|
|
83
|
+
/** The widget's content: `source`, `capacity`, `countLabel`, `emptyText`. */
|
|
84
|
+
content: {
|
|
85
|
+
type: Object,
|
|
86
|
+
default: () => ({}),
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
emits: ['header'],
|
|
91
|
+
|
|
92
|
+
data() {
|
|
93
|
+
return {
|
|
94
|
+
/** The rows the source answered. */
|
|
95
|
+
rows: [],
|
|
96
|
+
/** The user statuses by user id. */
|
|
97
|
+
statuses: {},
|
|
98
|
+
/** Whether the source is being read. */
|
|
99
|
+
loading: false,
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
computed: {
|
|
104
|
+
/** @return {string} The signed-in user's id. */
|
|
105
|
+
viewerId() {
|
|
106
|
+
return String(getCurrentUser()?.uid || '')
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The people to list, the viewer first and the rest by name, each with
|
|
111
|
+
* the line, count and bar to draw.
|
|
112
|
+
*
|
|
113
|
+
* @return {Array<object>} The members.
|
|
114
|
+
*/
|
|
115
|
+
members() {
|
|
116
|
+
const source = this.content?.source || {}
|
|
117
|
+
const userField = source.userField || 'userId'
|
|
118
|
+
const nameField = source.nameField || 'displayName'
|
|
119
|
+
const countField = source.countField || 'count'
|
|
120
|
+
const people = this.rows
|
|
121
|
+
.filter((row) => row && typeof row === 'object' && readPath(row, userField))
|
|
122
|
+
.map((row) => {
|
|
123
|
+
const id = String(readPath(row, userField))
|
|
124
|
+
const name = String(readPath(row, nameField) || id)
|
|
125
|
+
return { id, name, count: Number(readPath(row, countField)) || 0 }
|
|
126
|
+
})
|
|
127
|
+
const capacity = Number(this.content?.capacity) > 0
|
|
128
|
+
? Number(this.content.capacity)
|
|
129
|
+
: Math.max(1, ...people.map((p) => p.count))
|
|
130
|
+
return people
|
|
131
|
+
.sort((a, b) => (Number(b.id === this.viewerId) - Number(a.id === this.viewerId)) || a.name.localeCompare(b.name))
|
|
132
|
+
.map((person) => {
|
|
133
|
+
const status = this.statuses[person.id] || null
|
|
134
|
+
const percent = Math.min(100, Math.round((person.count / capacity) * 100))
|
|
135
|
+
return {
|
|
136
|
+
...person,
|
|
137
|
+
name: person.id === this.viewerId ? t('nextcloud-vue', '{name} (you)', { name: person.name }) : person.name,
|
|
138
|
+
initials: initialsOf(person.name),
|
|
139
|
+
line: this.lineOf(status),
|
|
140
|
+
available: !!status && status.status === 'online',
|
|
141
|
+
present: isPresent(status),
|
|
142
|
+
countText: this.countText(person.count),
|
|
143
|
+
percent,
|
|
144
|
+
hot: percent >= HOT_PERCENT,
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
/** @return {string} The header note: how many of the team are present. */
|
|
150
|
+
headerNote() {
|
|
151
|
+
const total = this.members.length
|
|
152
|
+
if (total === 0) {
|
|
153
|
+
return ''
|
|
154
|
+
}
|
|
155
|
+
const present = this.members.filter((member) => member.present).length
|
|
156
|
+
return t('nextcloud-vue', '{present} of {total} present', { present, total })
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
/** @return {string} What the card says while it reads. */
|
|
160
|
+
loadingLabel() {
|
|
161
|
+
return t('nextcloud-vue', 'Loading…')
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
/** @return {string} What the card says without anyone to list. */
|
|
165
|
+
emptyLabel() {
|
|
166
|
+
return this.content?.emptyText ? this.cnTranslate(this.content.emptyText) : t('nextcloud-vue', 'No one in the team')
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
watch: {
|
|
171
|
+
headerNote: {
|
|
172
|
+
immediate: true,
|
|
173
|
+
handler(note) {
|
|
174
|
+
/**
|
|
175
|
+
* @event header The card header's parts: `{ link, note, action, pill }`, the note being how many are present.
|
|
176
|
+
*/
|
|
177
|
+
this.$emit('header', { link: null, note, action: null, pill: null })
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
'content.source': {
|
|
182
|
+
deep: true,
|
|
183
|
+
handler() {
|
|
184
|
+
this.load()
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
mounted() {
|
|
190
|
+
this.load()
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
methods: {
|
|
194
|
+
/**
|
|
195
|
+
* Read the counts and the user statuses. A failed read leaves the card
|
|
196
|
+
* empty; a failed status read leaves everyone without a status.
|
|
197
|
+
*
|
|
198
|
+
* @return {Promise<void>}
|
|
199
|
+
*/
|
|
200
|
+
async load() {
|
|
201
|
+
const source = this.content?.source
|
|
202
|
+
if (!source || typeof source.url !== 'string' || source.url === '') {
|
|
203
|
+
this.rows = []
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
this.loading = true
|
|
207
|
+
try {
|
|
208
|
+
const { default: axios } = await import('@nextcloud/axios')
|
|
209
|
+
const [answer, statuses] = await Promise.all([
|
|
210
|
+
axios.get(generateUrl(source.url)),
|
|
211
|
+
fetchStatuses(axios),
|
|
212
|
+
])
|
|
213
|
+
const body = answer && answer.data
|
|
214
|
+
const rows = source.responsePath ? readPath(body, source.responsePath) : body
|
|
215
|
+
this.rows = Array.isArray(rows) ? rows : []
|
|
216
|
+
this.statuses = statuses || {}
|
|
217
|
+
} catch {
|
|
218
|
+
this.rows = []
|
|
219
|
+
} finally {
|
|
220
|
+
this.loading = false
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The availability line of a user status.
|
|
226
|
+
*
|
|
227
|
+
* @param {object|null} status The user status, or null without one.
|
|
228
|
+
* @return {string} The line.
|
|
229
|
+
*/
|
|
230
|
+
lineOf(status) {
|
|
231
|
+
if (!status) {
|
|
232
|
+
return t('nextcloud-vue', 'Absent today')
|
|
233
|
+
}
|
|
234
|
+
const message = typeof status.message === 'string' ? status.message.trim() : ''
|
|
235
|
+
if (message !== '') {
|
|
236
|
+
const clearAt = Number(status.clearAt)
|
|
237
|
+
if (!Number.isFinite(clearAt) || clearAt <= 0) {
|
|
238
|
+
return message
|
|
239
|
+
}
|
|
240
|
+
const time = new Date(clearAt * 1000).toLocaleTimeString(userLocale() || undefined, { hour: '2-digit', minute: '2-digit' })
|
|
241
|
+
return t('nextcloud-vue', '{message} until {time}', { message, time })
|
|
242
|
+
}
|
|
243
|
+
switch (status.status) {
|
|
244
|
+
case 'online':
|
|
245
|
+
return t('nextcloud-vue', 'Available')
|
|
246
|
+
case 'away':
|
|
247
|
+
return t('nextcloud-vue', 'Away')
|
|
248
|
+
case 'dnd':
|
|
249
|
+
case 'busy':
|
|
250
|
+
return t('nextcloud-vue', 'Busy')
|
|
251
|
+
default:
|
|
252
|
+
return t('nextcloud-vue', 'Absent today')
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The count as words: the content's `countLabel`, else "{count} open".
|
|
258
|
+
*
|
|
259
|
+
* @param {number} count The open count.
|
|
260
|
+
* @return {string} The text.
|
|
261
|
+
*/
|
|
262
|
+
countText(count) {
|
|
263
|
+
const label = this.content?.countLabel
|
|
264
|
+
if (label && typeof label === 'object' && typeof label.one === 'string' && typeof label.other === 'string') {
|
|
265
|
+
return this.cnTranslate(count === 1 ? label.one : label.other).replace('{count}', String(count))
|
|
266
|
+
}
|
|
267
|
+
return n('nextcloud-vue', '{count} open', '{count} open', count, { count })
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Up to two initials of a name.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} name The name.
|
|
276
|
+
* @return {string} The initials, upper case.
|
|
277
|
+
*/
|
|
278
|
+
function initialsOf(name) {
|
|
279
|
+
const parts = String(name || '').trim().split(/\s+/).filter(Boolean)
|
|
280
|
+
if (parts.length === 0) {
|
|
281
|
+
return ''
|
|
282
|
+
}
|
|
283
|
+
const last = parts.length > 1 ? parts[parts.length - 1].charAt(0) : ''
|
|
284
|
+
return (parts[0].charAt(0) + last).toUpperCase()
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Whether a person is at work today: any status but offline or invisible.
|
|
289
|
+
*
|
|
290
|
+
* @param {object|null} status The user status.
|
|
291
|
+
* @return {boolean} True when present.
|
|
292
|
+
*/
|
|
293
|
+
function isPresent(status) {
|
|
294
|
+
return !!status && status.status !== 'offline' && status.status !== 'invisible'
|
|
295
|
+
}
|
|
296
|
+
</script>
|
|
297
|
+
|
|
298
|
+
<style scoped>
|
|
299
|
+
.cn-team-workload__list {
|
|
300
|
+
display: flex;
|
|
301
|
+
flex-direction: column;
|
|
302
|
+
gap: 14px;
|
|
303
|
+
margin: 0;
|
|
304
|
+
padding: 0;
|
|
305
|
+
list-style: none;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.cn-team-workload__member {
|
|
309
|
+
display: flex;
|
|
310
|
+
flex-direction: column;
|
|
311
|
+
gap: 6px;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.cn-team-workload__row {
|
|
315
|
+
display: flex;
|
|
316
|
+
align-items: center;
|
|
317
|
+
gap: 10px;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.cn-team-workload__avatar {
|
|
321
|
+
display: flex;
|
|
322
|
+
flex: none;
|
|
323
|
+
align-items: center;
|
|
324
|
+
justify-content: center;
|
|
325
|
+
width: 32px;
|
|
326
|
+
height: 32px;
|
|
327
|
+
border-radius: 50%;
|
|
328
|
+
background: var(--color-background-dark);
|
|
329
|
+
color: var(--color-main-text);
|
|
330
|
+
font-size: 12px;
|
|
331
|
+
font-weight: 700;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.cn-team-workload__who {
|
|
335
|
+
display: flex;
|
|
336
|
+
flex: 1;
|
|
337
|
+
flex-direction: column;
|
|
338
|
+
min-width: 0;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.cn-team-workload__name {
|
|
342
|
+
font-size: 14px;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.cn-team-workload__state {
|
|
346
|
+
font-size: 13px;
|
|
347
|
+
color: var(--color-text-maxcontrast);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.cn-team-workload__state--away {
|
|
351
|
+
color: var(--color-warning-text, var(--color-text-maxcontrast));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.cn-team-workload__count {
|
|
355
|
+
font-size: 13px;
|
|
356
|
+
font-weight: 600;
|
|
357
|
+
white-space: nowrap;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.cn-team-workload__bar {
|
|
361
|
+
display: block;
|
|
362
|
+
height: 6px;
|
|
363
|
+
overflow: hidden;
|
|
364
|
+
border-radius: 3px;
|
|
365
|
+
background: var(--color-border);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.cn-team-workload__bar-fill {
|
|
369
|
+
display: block;
|
|
370
|
+
height: 100%;
|
|
371
|
+
background: var(--color-primary-element);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.cn-team-workload__bar-fill--hot {
|
|
375
|
+
background: var(--color-error);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.cn-team-workload__state-line {
|
|
379
|
+
margin: 0;
|
|
380
|
+
color: var(--color-text-maxcontrast);
|
|
381
|
+
}
|
|
382
|
+
</style>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX-FileCopyrightText: 2026 Conduction B.V.
|
|
3
|
+
* SPDX-License-Identifier: EUPL-1.2
|
|
4
|
+
*
|
|
5
|
+
* CnTeamWorkloadWidget: the `team-workload` widget (the team today, with
|
|
6
|
+
* each person's availability and open count). It is registered in
|
|
7
|
+
* `CnWidgetGrid/registerDashboardWidgets.js`, inline, because a bare
|
|
8
|
+
* side-effect import of this module may be tree-shaken away.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import CnTeamWorkloadWidget from './CnTeamWorkloadWidget.vue'
|
|
12
|
+
|
|
13
|
+
export { CnTeamWorkloadWidget }
|
|
14
|
+
export default CnTeamWorkloadWidget
|
|
@@ -40,6 +40,7 @@ import CnStatsBlockWidget from '../CnStatsBlockWidget/CnStatsBlockWidget.vue'
|
|
|
40
40
|
import CnStatsBlockWidgetForm from '../CnStatsBlockWidgetForm/CnStatsBlockWidgetForm.vue'
|
|
41
41
|
import CnTabsWidget from '../CnTabsWidget/CnTabsWidget.vue'
|
|
42
42
|
import CnTabsWidgetForm from '../CnTabsWidgetForm/CnTabsWidgetForm.vue'
|
|
43
|
+
import CnTeamWorkloadWidget from '../CnTeamWorkloadWidget/CnTeamWorkloadWidget.vue'
|
|
43
44
|
import CnWeekStripWidget from '../CnWeekStripWidget/CnWeekStripWidget.vue'
|
|
44
45
|
import CnWeekStripWidgetForm from '../CnWeekStripWidgetForm/CnWeekStripWidgetForm.vue'
|
|
45
46
|
// Typed widgets registered with an explicit renderer + config FORM. chart /
|
|
@@ -381,6 +382,17 @@ registerDashboardWidget('stacked-bar', {
|
|
|
381
382
|
icon: 'ChartBar',
|
|
382
383
|
})
|
|
383
384
|
|
|
385
|
+
// The team today: availability from the Nextcloud user status and each
|
|
386
|
+
// person's open count from the app (DqTeamwachtrij "Team vandaag"). Placed
|
|
387
|
+
// from the manifest: there is no config form yet.
|
|
388
|
+
registerDashboardWidget('team-workload', {
|
|
389
|
+
renderer: CnTeamWorkloadWidget,
|
|
390
|
+
form: null,
|
|
391
|
+
defaultContent: { source: { url: '', responsePath: '', userField: 'userId', nameField: 'displayName', countField: 'count' } },
|
|
392
|
+
displayName: 'Team workload',
|
|
393
|
+
icon: 'AccountGroup',
|
|
394
|
+
})
|
|
395
|
+
|
|
384
396
|
/**
|
|
385
397
|
* Explicit no-op that guarantees this module (and therefore every widget's
|
|
386
398
|
* self-registration side effect) is evaluated. Now optional: `CnDashboardPage`
|
package/src/components/index.js
CHANGED
|
@@ -426,6 +426,7 @@ export { CnGuardianHome } from './CnGuardianHome/index.js'
|
|
|
426
426
|
export { CnWeekStripWidget } from './CnWeekStripWidget/index.js'
|
|
427
427
|
export { CnWeekStripWidgetForm } from './CnWeekStripWidgetForm/index.js'
|
|
428
428
|
export { CnStackedBarWidget } from './CnStackedBarWidget/index.js'
|
|
429
|
+
export { CnTeamWorkloadWidget } from './CnTeamWorkloadWidget/index.js'
|
|
429
430
|
export { CnStackedBarWidgetForm } from './CnStackedBarWidgetForm/index.js'
|
|
430
431
|
export { CnSegmentedControl } from './CnSegmentedControl/index.js'
|
|
431
432
|
export { CnBrandStripe } from './CnBrandStripe/index.js'
|
package/src/css/index-page.css
CHANGED
|
@@ -411,11 +411,12 @@
|
|
|
411
411
|
.cn-index-page--with-side-panel {
|
|
412
412
|
display: grid;
|
|
413
413
|
grid-template-columns: minmax(0, 1fr) var(--cn-index-side-panel-width, 300px);
|
|
414
|
-
grid-template-rows: auto auto auto auto minmax(0, 1fr) auto;
|
|
414
|
+
grid-template-rows: auto auto auto auto auto minmax(0, 1fr) auto;
|
|
415
415
|
grid-template-areas:
|
|
416
416
|
"head head"
|
|
417
417
|
"below below"
|
|
418
418
|
"stats stats"
|
|
419
|
+
"chips side"
|
|
419
420
|
"bar side"
|
|
420
421
|
"body side"
|
|
421
422
|
"extra extra";
|
|
@@ -444,6 +445,10 @@
|
|
|
444
445
|
grid-area: bar;
|
|
445
446
|
}
|
|
446
447
|
|
|
448
|
+
.cn-index-page--with-side-panel > .cn-index-page__quick-filter-row {
|
|
449
|
+
grid-area: chips;
|
|
450
|
+
}
|
|
451
|
+
|
|
447
452
|
.cn-index-page--with-side-panel > .cn-index-page__body {
|
|
448
453
|
grid-area: body;
|
|
449
454
|
}
|
|
@@ -461,10 +466,11 @@
|
|
|
461
466
|
"below"
|
|
462
467
|
"stats"
|
|
463
468
|
"side"
|
|
469
|
+
"chips"
|
|
464
470
|
"bar"
|
|
465
471
|
"body"
|
|
466
472
|
"extra";
|
|
467
|
-
grid-template-rows: auto auto auto auto auto minmax(0, 1fr) auto;
|
|
473
|
+
grid-template-rows: auto auto auto auto auto auto minmax(0, 1fr) auto;
|
|
468
474
|
}
|
|
469
475
|
|
|
470
476
|
.cn-index-page--with-side-panel > .cn-index-page__side-panel {
|
|
@@ -472,6 +478,12 @@
|
|
|
472
478
|
}
|
|
473
479
|
}
|
|
474
480
|
|
|
481
|
+
/* `quickFilterPlacement: "row"` (DqTeamwachtrij): the chips on a row of
|
|
482
|
+
their own above the toolbar, wrapping onto more lines. */
|
|
483
|
+
.cn-index-page__quick-filter-row {
|
|
484
|
+
margin-bottom: 12px;
|
|
485
|
+
}
|
|
486
|
+
|
|
475
487
|
/* The second filter strip (`filterStrip`, board PqWachtrij "Kanaal"): 34px
|
|
476
488
|
pills, radius 17, the active one in ink, each with a muted count. */
|
|
477
489
|
.cn-index-page__filter-strip {
|