@conduction/nextcloud-vue 0.1.0-beta.4 → 0.1.0-beta.6

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 (71) hide show
  1. package/dist/nextcloud-vue.cjs +67614 -0
  2. package/dist/nextcloud-vue.cjs.js +9559 -8983
  3. package/dist/nextcloud-vue.cjs.js.map +1 -1
  4. package/dist/nextcloud-vue.cjs.map +1 -0
  5. package/dist/nextcloud-vue.css +1231 -1231
  6. package/dist/nextcloud-vue.esm.js +9559 -8983
  7. package/dist/nextcloud-vue.esm.js.map +1 -1
  8. package/package.json +14 -5
  9. package/src/components/CnActionsBar/CnActionsBar.vue +235 -235
  10. package/src/components/CnAdvancedFormDialog/CnAdvancedFormDialog.vue +579 -579
  11. package/src/components/CnAdvancedFormDialog/CnDataTab.vue +217 -217
  12. package/src/components/CnAdvancedFormDialog/CnMetadataTab.vue +121 -121
  13. package/src/components/CnAdvancedFormDialog/CnPropertiesTab.vue +418 -418
  14. package/src/components/CnAdvancedFormDialog/CnPropertyValueCell.vue +247 -247
  15. package/src/components/CnCardGrid/CnCardGrid.vue +152 -152
  16. package/src/components/CnCellRenderer/CnCellRenderer.vue +132 -132
  17. package/src/components/CnChartWidget/CnChartWidget.vue +320 -320
  18. package/src/components/CnConfigurationCard/CnConfigurationCard.vue +77 -77
  19. package/src/components/CnCopyDialog/CnCopyDialog.vue +250 -250
  20. package/src/components/CnDashboardGrid/CnDashboardGrid.vue +225 -225
  21. package/src/components/CnDashboardPage/CnDashboardPage.vue +390 -390
  22. package/src/components/CnDataTable/CnDataTable.vue +349 -349
  23. package/src/components/CnDeleteDialog/CnDeleteDialog.vue +170 -170
  24. package/src/components/CnDetailCard/CnDetailCard.vue +214 -214
  25. package/src/components/CnDetailPage/CnDetailPage.vue +285 -281
  26. package/src/components/CnFacetSidebar/CnFacetSidebar.vue +231 -231
  27. package/src/components/CnFilterBar/CnFilterBar.vue +152 -152
  28. package/src/components/CnFormDialog/CnFormDialog.vue +302 -11
  29. package/src/components/CnIcon/CnIcon.vue +89 -89
  30. package/src/components/CnIndexPage/CnIndexPage.vue +884 -874
  31. package/src/components/CnIndexSidebar/CnIndexSidebar.vue +503 -503
  32. package/src/components/CnItemCard/CnItemCard.vue +132 -132
  33. package/src/components/CnKpiGrid/CnKpiGrid.vue +89 -89
  34. package/src/components/CnMassActionBar/CnMassActionBar.vue +160 -160
  35. package/src/components/CnMassCopyDialog/CnMassCopyDialog.vue +320 -320
  36. package/src/components/CnMassDeleteDialog/CnMassDeleteDialog.vue +238 -238
  37. package/src/components/CnMassExportDialog/CnMassExportDialog.vue +190 -190
  38. package/src/components/CnMassImportDialog/CnMassImportDialog.vue +491 -491
  39. package/src/components/CnNoteCard/CnNoteCard.vue +149 -149
  40. package/src/components/CnNotesCard/CnNotesCard.vue +413 -413
  41. package/src/components/CnObjectCard/CnObjectCard.vue +292 -292
  42. package/src/components/CnObjectCard/eslint-setup.md +235 -0
  43. package/src/components/CnObjectCard/package.json-or.json +132 -0
  44. package/src/components/CnObjectSidebar/CnObjectSidebar.vue +876 -876
  45. package/src/components/CnPageHeader/CnPageHeader.vue +57 -57
  46. package/src/components/CnPagination/CnPagination.vue +252 -252
  47. package/src/components/CnRegisterMapping/CnRegisterMapping.vue +792 -792
  48. package/src/components/CnRowActions/CnRowActions.vue +95 -73
  49. package/src/components/CnSchemaFormDialog/CnSchemaConfigurationTab.vue +226 -226
  50. package/src/components/CnSchemaFormDialog/CnSchemaFormDialog.vue +787 -787
  51. package/src/components/CnSchemaFormDialog/CnSchemaPropertiesTab.vue +305 -305
  52. package/src/components/CnSchemaFormDialog/CnSchemaPropertyActions.vue +1398 -1398
  53. package/src/components/CnSchemaFormDialog/CnSchemaSecurityTab.vue +236 -236
  54. package/src/components/CnSettingsCard/CnSettingsCard.vue +92 -92
  55. package/src/components/CnSettingsSection/CnSettingsSection.vue +266 -266
  56. package/src/components/CnStatsBlock/CnStatsBlock.vue +420 -420
  57. package/src/components/CnStatusBadge/CnStatusBadge.vue +77 -77
  58. package/src/components/CnTabbedFormDialog/CnTabbedFormDialog.vue +540 -540
  59. package/src/components/CnTasksCard/CnTasksCard.vue +373 -373
  60. package/src/components/CnTileWidget/CnTileWidget.vue +159 -159
  61. package/src/components/CnTimelineStages/CnTimelineStages.vue +292 -292
  62. package/src/components/CnUserActionMenu/CnUserActionMenu.vue +435 -435
  63. package/src/components/CnVersionInfoCard/CnVersionInfoCard.vue +312 -312
  64. package/src/components/CnWidgetRenderer/CnWidgetRenderer.vue +180 -180
  65. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +211 -211
  66. package/src/index.js +1 -1
  67. package/src/types/notification.d.ts +13 -13
  68. package/src/types/organisation.d.ts +15 -15
  69. package/src/types/schema.d.ts +13 -13
  70. package/src/types/task.d.ts +6 -6
  71. package/src/utils/headers.js +5 -3
@@ -1,876 +1,876 @@
1
- <!--
2
- CnObjectSidebar — Right sidebar with standardized tabs for generic object functionality.
3
-
4
- Provides Files, Notes, Tags, Tasks, and Audit Trail tabs that integrate with
5
- OpenRegister API endpoints (which bridge to Nextcloud-native APIs).
6
- All tabs are optional and overridable via props and slots.
7
- -->
8
- <template>
9
- <NcAppSidebar
10
- :title="sidebarTitle"
11
- :subtitle="sidebarSubtitle"
12
- :active.sync="activeTab"
13
- @update:open="$emit('update:open', $event)"
14
- @close="$emit('update:open', false)">
15
- <!-- Files Tab -->
16
- <NcAppSidebarTab
17
- v-if="!isTabHidden('files')"
18
- id="files"
19
- :name="filesLabel"
20
- :order="1">
21
- <template #icon>
22
- <Paperclip :size="20" />
23
- </template>
24
- <slot name="tab-files" :object-id="objectId" :object-type="objectType">
25
- <div class="cn-sidebar-tab">
26
- <!-- File upload -->
27
- <div class="cn-sidebar-tab__action">
28
- <label class="cn-sidebar-tab__upload-btn">
29
- <NcButton type="secondary" :wide="true" :disabled="filesLoading">
30
- <template #icon>
31
- <Upload :size="20" />
32
- </template>
33
- {{ uploadLabel }}
34
- </NcButton>
35
- <input
36
- ref="fileInput"
37
- type="file"
38
- multiple
39
- class="cn-sidebar-tab__file-input"
40
- @change="onFileUpload">
41
- </label>
42
- </div>
43
-
44
- <!-- File list -->
45
- <NcLoadingIcon v-if="filesLoading" />
46
- <div v-else-if="files.length === 0" class="cn-sidebar-tab__empty">
47
- {{ noFilesLabel }}
48
- </div>
49
- <div v-else class="cn-sidebar-tab__list">
50
- <NcListItem
51
- v-for="file in files"
52
- :key="file.id"
53
- :name="file.name || file.title"
54
- :bold="false"
55
- :force-display-actions="true">
56
- <template #icon>
57
- <FileOutline :size="32" />
58
- </template>
59
- <template #subname>
60
- {{ formatFileSize(file.size) }}
61
- </template>
62
- <template #actions>
63
- <NcActionButton @click="openFile(file)">
64
- <template #icon>
65
- <OpenInNew :size="20" />
66
- </template>
67
- {{ openLabel }}
68
- </NcActionButton>
69
- <NcActionButton @click="deleteFile(file)">
70
- <template #icon>
71
- <Delete :size="20" />
72
- </template>
73
- {{ deleteLabel }}
74
- </NcActionButton>
75
- </template>
76
- </NcListItem>
77
- </div>
78
- </div>
79
- </slot>
80
- </NcAppSidebarTab>
81
-
82
- <!-- Notes Tab -->
83
- <NcAppSidebarTab
84
- v-if="!isTabHidden('notes')"
85
- id="notes"
86
- :name="notesLabel"
87
- :order="2">
88
- <template #icon>
89
- <CommentTextOutline :size="20" />
90
- </template>
91
- <slot name="tab-notes" :object-id="objectId" :object-type="objectType">
92
- <div class="cn-sidebar-tab">
93
- <!-- Add note -->
94
- <div class="cn-sidebar-tab__action">
95
- <textarea
96
- v-model="newNoteText"
97
- class="cn-sidebar-tab__textarea"
98
- :placeholder="addNotePlaceholder"
99
- rows="3" />
100
- <NcButton
101
- type="primary"
102
- :disabled="!newNoteText.trim() || noteSaving"
103
- @click="addNote">
104
- <template #icon>
105
- <Send :size="20" />
106
- </template>
107
- {{ addNoteLabel }}
108
- </NcButton>
109
- </div>
110
-
111
- <!-- Notes list -->
112
- <NcLoadingIcon v-if="notesLoading" />
113
- <div v-else-if="notes.length === 0" class="cn-sidebar-tab__empty">
114
- {{ noNotesLabel }}
115
- </div>
116
- <div v-else class="cn-sidebar-tab__list">
117
- <div
118
- v-for="note in notes"
119
- :key="note.id"
120
- class="cn-sidebar-tab__note">
121
- <div class="cn-sidebar-tab__note-header">
122
- <strong>{{ note.actorDisplayName || note.author || 'Unknown' }}</strong>
123
- <span class="cn-sidebar-tab__note-time">{{ formatDate(note.creationDateTime || note.created) }}</span>
124
- </div>
125
- <p class="cn-sidebar-tab__note-body">{{ note.message || note.content }}</p>
126
- <NcButton
127
- v-if="canDeleteNote(note)"
128
- type="tertiary"
129
- class="cn-sidebar-tab__note-delete"
130
- @click="deleteNote(note)">
131
- <template #icon>
132
- <Delete :size="16" />
133
- </template>
134
- </NcButton>
135
- </div>
136
- </div>
137
- </div>
138
- </slot>
139
- </NcAppSidebarTab>
140
-
141
- <!-- Tags Tab -->
142
- <NcAppSidebarTab
143
- v-if="!isTabHidden('tags')"
144
- id="tags"
145
- :name="tagsLabel"
146
- :order="3">
147
- <template #icon>
148
- <TagOutline :size="20" />
149
- </template>
150
- <slot name="tab-tags" :object-id="objectId" :object-type="objectType">
151
- <div class="cn-sidebar-tab">
152
- <!-- Add tag -->
153
- <div class="cn-sidebar-tab__action cn-sidebar-tab__action--row">
154
- <NcTextField
155
- v-model="newTagName"
156
- :label="addTagPlaceholder"
157
- @keyup.enter="addTag" />
158
- <NcButton
159
- type="primary"
160
- :disabled="!newTagName.trim() || tagSaving"
161
- @click="addTag">
162
- <template #icon>
163
- <Plus :size="20" />
164
- </template>
165
- </NcButton>
166
- </div>
167
-
168
- <!-- Tags list -->
169
- <NcLoadingIcon v-if="tagsLoading" />
170
- <div v-else-if="tags.length === 0" class="cn-sidebar-tab__empty">
171
- {{ noTagsLabel }}
172
- </div>
173
- <div v-else class="cn-sidebar-tab__tags">
174
- <span
175
- v-for="tag in tags"
176
- :key="tag.id || tag"
177
- class="cn-sidebar-tab__tag">
178
- {{ tag.name || tag }}
179
- <button
180
- class="cn-sidebar-tab__tag-remove"
181
- :aria-label="'Remove ' + (tag.name || tag)"
182
- @click="removeTag(tag)">
183
- <Close :size="14" />
184
- </button>
185
- </span>
186
- </div>
187
- </div>
188
- </slot>
189
- </NcAppSidebarTab>
190
-
191
- <!-- Tasks Tab -->
192
- <NcAppSidebarTab
193
- v-if="!isTabHidden('tasks')"
194
- id="tasks"
195
- :name="tasksLabel"
196
- :order="4">
197
- <template #icon>
198
- <CheckboxMarkedOutline :size="20" />
199
- </template>
200
- <slot name="tab-tasks" :object-id="objectId" :object-type="objectType">
201
- <div class="cn-sidebar-tab">
202
- <NcLoadingIcon v-if="tasksLoading" />
203
- <div v-else-if="tasks.length === 0" class="cn-sidebar-tab__empty">
204
- {{ noTasksLabel }}
205
- </div>
206
- <div v-else class="cn-sidebar-tab__list">
207
- <NcListItem
208
- v-for="task in tasks"
209
- :key="task.id"
210
- :name="task.title || task.name"
211
- :bold="false">
212
- <template #icon>
213
- <CheckboxMarkedOutline v-if="task.status === 'completed'" :size="32" class="cn-sidebar-tab__task-done" />
214
- <CheckboxBlankOutline v-else :size="32" />
215
- </template>
216
- <template #subname>
217
- {{ task.assignee || '' }}{{ task.dueDate ? ' · ' + formatDate(task.dueDate) : '' }}
218
- </template>
219
- </NcListItem>
220
- </div>
221
- </div>
222
- </slot>
223
- </NcAppSidebarTab>
224
-
225
- <!-- Audit Trail Tab -->
226
- <NcAppSidebarTab
227
- v-if="!isTabHidden('auditTrail')"
228
- id="auditTrail"
229
- :name="auditTrailLabel"
230
- :order="5">
231
- <template #icon>
232
- <History :size="20" />
233
- </template>
234
- <slot name="tab-audit-trail" :object-id="objectId" :object-type="objectType">
235
- <div class="cn-sidebar-tab">
236
- <NcLoadingIcon v-if="auditTrailLoading" />
237
- <div v-else-if="auditTrails.length === 0" class="cn-sidebar-tab__empty">
238
- {{ noAuditTrailLabel }}
239
- </div>
240
- <div v-else class="cn-sidebar-tab__list">
241
- <NcListItem
242
- v-for="entry in auditTrails"
243
- :key="entry.id"
244
- :name="formatDate(entry.created)"
245
- :bold="false"
246
- :details="entry.action"
247
- :counter-number="entry.changed ? Object.keys(entry.changed).length : 0">
248
- <template #icon>
249
- <History :size="32" />
250
- </template>
251
- <template #subname>
252
- {{ entry.userName || entry.user || 'System' }}
253
- </template>
254
- </NcListItem>
255
- </div>
256
- </div>
257
- </slot>
258
- </NcAppSidebarTab>
259
-
260
- <!-- Custom tabs slot -->
261
- <slot name="extra-tabs" />
262
- </NcAppSidebar>
263
- </template>
264
-
265
- <script>
266
- import {
267
- NcAppSidebar,
268
- NcAppSidebarTab,
269
- NcButton,
270
- NcListItem,
271
- NcActionButton,
272
- NcLoadingIcon,
273
- NcTextField,
274
- } from '@nextcloud/vue'
275
-
276
- import Paperclip from 'vue-material-design-icons/Paperclip.vue'
277
- import Upload from 'vue-material-design-icons/Upload.vue'
278
- import FileOutline from 'vue-material-design-icons/FileOutline.vue'
279
- import OpenInNew from 'vue-material-design-icons/OpenInNew.vue'
280
- import Delete from 'vue-material-design-icons/Delete.vue'
281
- import CommentTextOutline from 'vue-material-design-icons/CommentTextOutline.vue'
282
- import Send from 'vue-material-design-icons/Send.vue'
283
- import TagOutline from 'vue-material-design-icons/TagOutline.vue'
284
- import Plus from 'vue-material-design-icons/Plus.vue'
285
- import Close from 'vue-material-design-icons/Close.vue'
286
- import CheckboxMarkedOutline from 'vue-material-design-icons/CheckboxMarkedOutline.vue'
287
- import CheckboxBlankOutline from 'vue-material-design-icons/CheckboxBlankOutline.vue'
288
- import History from 'vue-material-design-icons/History.vue'
289
-
290
- import { buildHeaders } from '../../utils/index.js'
291
-
292
- /**
293
- * CnObjectSidebar — Right sidebar for entity detail pages.
294
- *
295
- * Provides standardized tabs for generic object functionality (Files, Notes, Tags,
296
- * Tasks, Audit Trail) that integrate with OpenRegister API endpoints bridging to
297
- * Nextcloud-native APIs.
298
- *
299
- * @example Basic usage
300
- * <CnObjectSidebar
301
- * object-type="pipelinq_lead"
302
- * :object-id="leadId"
303
- * :register="registerConfig.register"
304
- * :schema="registerConfig.schema" />
305
- *
306
- * @example Hide specific tabs
307
- * <CnObjectSidebar
308
- * object-type="pipelinq_lead"
309
- * :object-id="leadId"
310
- * :hidden-tabs="['tasks', 'tags']" />
311
- *
312
- * @example Override a tab
313
- * <CnObjectSidebar object-type="pipelinq_lead" :object-id="leadId">
314
- * <template #tab-notes="{ objectId }">
315
- * <MyCustomNotesComponent :id="objectId" />
316
- * </template>
317
- * </CnObjectSidebar>
318
- */
319
- export default {
320
- name: 'CnObjectSidebar',
321
-
322
- components: {
323
- NcAppSidebar,
324
- NcAppSidebarTab,
325
- NcButton,
326
- NcListItem,
327
- NcActionButton,
328
- NcLoadingIcon,
329
- NcTextField,
330
- Paperclip,
331
- Upload,
332
- FileOutline,
333
- OpenInNew,
334
- Delete,
335
- CommentTextOutline,
336
- Send,
337
- TagOutline,
338
- Plus,
339
- Close,
340
- CheckboxMarkedOutline,
341
- CheckboxBlankOutline,
342
- History,
343
- },
344
-
345
- props: {
346
- /** The entity type (e.g., "pipelinq_lead", "procest_case") */
347
- objectType: {
348
- type: String,
349
- required: true,
350
- },
351
- /** The object UUID */
352
- objectId: {
353
- type: String,
354
- required: true,
355
- },
356
- /** OpenRegister register ID (for file/audit trail operations) */
357
- register: {
358
- type: String,
359
- default: '',
360
- },
361
- /** OpenRegister schema ID (for file/audit trail operations) */
362
- schema: {
363
- type: String,
364
- default: '',
365
- },
366
- /** Array of tab IDs to hide: 'files', 'notes', 'tags', 'tasks', 'auditTrail' */
367
- hiddenTabs: {
368
- type: Array,
369
- default: () => [],
370
- },
371
- /** Whether the sidebar is open */
372
- open: {
373
- type: Boolean,
374
- default: true,
375
- },
376
- /** Sidebar title (defaults to objectType) */
377
- title: {
378
- type: String,
379
- default: '',
380
- },
381
- /** Sidebar subtitle */
382
- subtitleProp: {
383
- type: String,
384
- default: '',
385
- },
386
- /** Base API URL for OpenRegister */
387
- apiBase: {
388
- type: String,
389
- default: '/apps/openregister/api',
390
- },
391
-
392
- // --- Pre-translated labels ---
393
- filesLabel: { type: String, default: 'Files' },
394
- notesLabel: { type: String, default: 'Notes' },
395
- tagsLabel: { type: String, default: 'Tags' },
396
- tasksLabel: { type: String, default: 'Tasks' },
397
- auditTrailLabel: { type: String, default: 'Audit Trail' },
398
- uploadLabel: { type: String, default: 'Upload file' },
399
- addNoteLabel: { type: String, default: 'Add note' },
400
- addNotePlaceholder: { type: String, default: 'Write a note...' },
401
- addTagPlaceholder: { type: String, default: 'Add tag...' },
402
- openLabel: { type: String, default: 'Open' },
403
- deleteLabel: { type: String, default: 'Delete' },
404
- noFilesLabel: { type: String, default: 'No files attached' },
405
- noNotesLabel: { type: String, default: 'No notes yet' },
406
- noTagsLabel: { type: String, default: 'No tags' },
407
- noTasksLabel: { type: String, default: 'No linked tasks' },
408
- noAuditTrailLabel: { type: String, default: 'No audit trail entries' },
409
- },
410
-
411
- emits: ['update:open'],
412
-
413
- data() {
414
- return {
415
- activeTab: 'files',
416
- // Files
417
- files: [],
418
- filesLoading: false,
419
- // Notes
420
- notes: [],
421
- notesLoading: false,
422
- newNoteText: '',
423
- noteSaving: false,
424
- // Tags
425
- tags: [],
426
- tagsLoading: false,
427
- newTagName: '',
428
- tagSaving: false,
429
- // Tasks
430
- tasks: [],
431
- tasksLoading: false,
432
- // Audit Trail
433
- auditTrails: [],
434
- auditTrailLoading: false,
435
- }
436
- },
437
-
438
- computed: {
439
- sidebarTitle() {
440
- return this.title || this.objectType || 'Details'
441
- },
442
- sidebarSubtitle() {
443
- return this.subtitleProp || this.objectId || ''
444
- },
445
- },
446
-
447
- watch: {
448
- objectId: {
449
- immediate: true,
450
- handler(newId) {
451
- if (newId) {
452
- this.loadAllData()
453
- }
454
- },
455
- },
456
- activeTab(tab) {
457
- // Lazy-load tab data when switching
458
- this.loadTabData(tab)
459
- },
460
- },
461
-
462
- methods: {
463
- isTabHidden(tabId) {
464
- return this.hiddenTabs.includes(tabId)
465
- },
466
-
467
- async loadAllData() {
468
- // Load files (default tab) immediately, others lazily
469
- this.loadTabData('files')
470
- },
471
-
472
- async loadTabData(tab) {
473
- switch (tab) {
474
- case 'files':
475
- if (this.files.length === 0 && !this.filesLoading) this.fetchFiles()
476
- break
477
- case 'notes':
478
- if (this.notes.length === 0 && !this.notesLoading) this.fetchNotes()
479
- break
480
- case 'tags':
481
- if (this.tags.length === 0 && !this.tagsLoading) this.fetchTags()
482
- break
483
- case 'tasks':
484
- if (this.tasks.length === 0 && !this.tasksLoading) this.fetchTasks()
485
- break
486
- case 'auditTrail':
487
- if (this.auditTrails.length === 0 && !this.auditTrailLoading) this.fetchAuditTrails()
488
- break
489
- }
490
- },
491
-
492
- // --- Files ---
493
- async fetchFiles() {
494
- if (!this.register || !this.schema) return
495
- this.filesLoading = true
496
- try {
497
- const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/files`
498
- const response = await fetch(url, { headers: buildHeaders() })
499
- if (response.ok) {
500
- const data = await response.json()
501
- this.files = data.results || data || []
502
- }
503
- } catch (err) {
504
- console.error('CnObjectSidebar: Failed to fetch files', err)
505
- } finally {
506
- this.filesLoading = false
507
- }
508
- },
509
-
510
- async onFileUpload(event) {
511
- const inputFiles = event.target.files
512
- if (!inputFiles?.length || !this.register || !this.schema) return
513
-
514
- const formData = new FormData()
515
- for (const file of inputFiles) {
516
- formData.append('files[]', file)
517
- }
518
-
519
- try {
520
- const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/files`
521
- await fetch(url, {
522
- method: 'POST',
523
- headers: {
524
- requesttoken: OC?.requestToken || '',
525
- 'OCS-APIREQUEST': 'true',
526
- },
527
- body: formData,
528
- })
529
- await this.fetchFiles()
530
- } catch (err) {
531
- console.error('CnObjectSidebar: Failed to upload file', err)
532
- }
533
-
534
- // Reset input
535
- if (this.$refs.fileInput) {
536
- this.$refs.fileInput.value = ''
537
- }
538
- },
539
-
540
- openFile(file) {
541
- if (file.accessUrl) {
542
- window.open(file.accessUrl, '_blank')
543
- } else if (file.id) {
544
- const dirPath = file.path ? file.path.substring(0, file.path.lastIndexOf('/')) : ''
545
- const cleanPath = dirPath.replace(/^\/admin\/files\//, '/')
546
- window.open(`/index.php/apps/files/files/${file.id}?dir=${encodeURIComponent(cleanPath)}&openfile=true`, '_blank')
547
- }
548
- },
549
-
550
- async deleteFile(file) {
551
- if (!this.register || !this.schema) return
552
- try {
553
- const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/files/${file.id}`
554
- await fetch(url, { method: 'DELETE', headers: buildHeaders() })
555
- this.files = this.files.filter(f => f.id !== file.id)
556
- } catch (err) {
557
- console.error('CnObjectSidebar: Failed to delete file', err)
558
- }
559
- },
560
-
561
- // --- Notes (via OpenRegister → NC Comments API) ---
562
- async fetchNotes() {
563
- if (!this.register || !this.schema) return
564
- this.notesLoading = true
565
- try {
566
- const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/notes`
567
- const response = await fetch(url, { headers: buildHeaders() })
568
- if (response.ok) {
569
- const data = await response.json()
570
- this.notes = data.results || data || []
571
- }
572
- } catch (err) {
573
- console.error('CnObjectSidebar: Failed to fetch notes', err)
574
- } finally {
575
- this.notesLoading = false
576
- }
577
- },
578
-
579
- async addNote() {
580
- if (!this.newNoteText.trim()) return
581
- this.noteSaving = true
582
- try {
583
- const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/notes`
584
- await fetch(url, {
585
- method: 'POST',
586
- headers: buildHeaders(),
587
- body: JSON.stringify({ message: this.newNoteText.trim() }),
588
- })
589
- this.newNoteText = ''
590
- await this.fetchNotes()
591
- } catch (err) {
592
- console.error('CnObjectSidebar: Failed to add note', err)
593
- } finally {
594
- this.noteSaving = false
595
- }
596
- },
597
-
598
- canDeleteNote(note) {
599
- return note.actorId === OC?.currentUser || note.author === OC?.currentUser
600
- },
601
-
602
- async deleteNote(note) {
603
- try {
604
- const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/notes/${note.id}`
605
- await fetch(url, { method: 'DELETE', headers: buildHeaders() })
606
- this.notes = this.notes.filter(n => n.id !== note.id)
607
- } catch (err) {
608
- console.error('CnObjectSidebar: Failed to delete note', err)
609
- }
610
- },
611
-
612
- // --- Tags ---
613
- // Tags are managed via the object's own data (object.tags array) and patched
614
- // back to the object. Available tags list comes from GET /api/tags.
615
- async fetchTags() {
616
- if (!this.register || !this.schema) return
617
- this.tagsLoading = true
618
- try {
619
- // Fetch the object to get its current tags
620
- const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}`
621
- const response = await fetch(url, { headers: buildHeaders() })
622
- if (response.ok) {
623
- const data = await response.json()
624
- this.tags = data.tags || data.object?.tags || []
625
- }
626
- } catch (err) {
627
- console.error('CnObjectSidebar: Failed to fetch tags', err)
628
- } finally {
629
- this.tagsLoading = false
630
- }
631
- },
632
-
633
- async addTag() {
634
- if (!this.newTagName.trim() || !this.register || !this.schema) return
635
- this.tagSaving = true
636
- try {
637
- const newTags = [...this.tags, this.newTagName.trim()]
638
- const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}`
639
- await fetch(url, {
640
- method: 'PATCH',
641
- headers: buildHeaders(),
642
- body: JSON.stringify({ tags: newTags }),
643
- })
644
- this.newTagName = ''
645
- await this.fetchTags()
646
- } catch (err) {
647
- console.error('CnObjectSidebar: Failed to add tag', err)
648
- } finally {
649
- this.tagSaving = false
650
- }
651
- },
652
-
653
- async removeTag(tag) {
654
- if (!this.register || !this.schema) return
655
- const tagName = tag.name || tag
656
- try {
657
- const newTags = this.tags.filter(t => (t.name || t) !== tagName)
658
- const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}`
659
- await fetch(url, {
660
- method: 'PATCH',
661
- headers: buildHeaders(),
662
- body: JSON.stringify({ tags: newTags }),
663
- })
664
- this.tags = newTags
665
- } catch (err) {
666
- console.error('CnObjectSidebar: Failed to remove tag', err)
667
- }
668
- },
669
-
670
- // --- Tasks (via OpenRegister → CalDAV VTODO API) ---
671
- async fetchTasks() {
672
- if (!this.register || !this.schema) return
673
- this.tasksLoading = true
674
- try {
675
- const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/tasks`
676
- const response = await fetch(url, { headers: buildHeaders() })
677
- if (response.ok) {
678
- const data = await response.json()
679
- this.tasks = data.results || data || []
680
- }
681
- } catch (err) {
682
- console.error('CnObjectSidebar: Failed to fetch tasks', err)
683
- } finally {
684
- this.tasksLoading = false
685
- }
686
- },
687
-
688
- // --- Audit Trail ---
689
- async fetchAuditTrails() {
690
- if (!this.register || !this.schema) return
691
- this.auditTrailLoading = true
692
- try {
693
- const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/audit-trails`
694
- const response = await fetch(url, { headers: buildHeaders() })
695
- if (response.ok) {
696
- const data = await response.json()
697
- this.auditTrails = data.results || data || []
698
- }
699
- } catch (err) {
700
- console.error('CnObjectSidebar: Failed to fetch audit trails', err)
701
- } finally {
702
- this.auditTrailLoading = false
703
- }
704
- },
705
-
706
- // --- Helpers ---
707
- formatFileSize(bytes) {
708
- const sizes = ['Bytes', 'KB', 'MB', 'GB']
709
- if (!bytes || bytes === 0) return 'n/a'
710
- const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)))
711
- if (i === 0) return '< 1 KB'
712
- return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + sizes[i]
713
- },
714
-
715
- formatDate(dateStr) {
716
- if (!dateStr) return ''
717
- try {
718
- return new Date(dateStr).toLocaleString(undefined, {
719
- year: 'numeric',
720
- month: 'short',
721
- day: 'numeric',
722
- hour: '2-digit',
723
- minute: '2-digit',
724
- })
725
- } catch {
726
- return dateStr
727
- }
728
- },
729
- },
730
- }
731
- </script>
732
-
733
- <style scoped>
734
- .cn-sidebar-tab {
735
- padding: 12px;
736
- }
737
-
738
- .cn-sidebar-tab__action {
739
- margin-bottom: 16px;
740
- }
741
-
742
- .cn-sidebar-tab__action--row {
743
- display: flex;
744
- gap: 8px;
745
- align-items: flex-end;
746
- }
747
-
748
- .cn-sidebar-tab__upload-btn {
749
- display: block;
750
- cursor: pointer;
751
- }
752
-
753
- .cn-sidebar-tab__file-input {
754
- display: none;
755
- }
756
-
757
- .cn-sidebar-tab__textarea {
758
- width: 100%;
759
- padding: 8px;
760
- border: 1px solid var(--color-border);
761
- border-radius: var(--border-radius);
762
- resize: vertical;
763
- font-family: inherit;
764
- font-size: 13px;
765
- margin-bottom: 8px;
766
- background: var(--color-main-background);
767
- color: var(--color-main-text);
768
- }
769
-
770
- .cn-sidebar-tab__textarea:focus {
771
- border-color: var(--color-primary-element);
772
- outline: none;
773
- }
774
-
775
- .cn-sidebar-tab__empty {
776
- text-align: center;
777
- padding: 24px 12px;
778
- color: var(--color-text-maxcontrast);
779
- font-size: 13px;
780
- }
781
-
782
- .cn-sidebar-tab__list {
783
- display: flex;
784
- flex-direction: column;
785
- gap: 2px;
786
- }
787
-
788
- /* Notes */
789
- .cn-sidebar-tab__note {
790
- padding: 10px 12px;
791
- border-bottom: 1px solid var(--color-border);
792
- position: relative;
793
- }
794
-
795
- .cn-sidebar-tab__note:last-child {
796
- border-bottom: none;
797
- }
798
-
799
- .cn-sidebar-tab__note-header {
800
- display: flex;
801
- justify-content: space-between;
802
- align-items: baseline;
803
- margin-bottom: 4px;
804
- }
805
-
806
- .cn-sidebar-tab__note-header strong {
807
- font-size: 13px;
808
- }
809
-
810
- .cn-sidebar-tab__note-time {
811
- font-size: 11px;
812
- color: var(--color-text-maxcontrast);
813
- }
814
-
815
- .cn-sidebar-tab__note-body {
816
- font-size: 13px;
817
- margin: 0;
818
- white-space: pre-wrap;
819
- word-break: break-word;
820
- }
821
-
822
- .cn-sidebar-tab__note-delete {
823
- position: absolute;
824
- top: 8px;
825
- right: 4px;
826
- opacity: 0;
827
- transition: opacity 0.15s ease;
828
- }
829
-
830
- .cn-sidebar-tab__note:hover .cn-sidebar-tab__note-delete {
831
- opacity: 1;
832
- }
833
-
834
- /* Tags */
835
- .cn-sidebar-tab__tags {
836
- display: flex;
837
- flex-wrap: wrap;
838
- gap: 6px;
839
- padding: 4px 0;
840
- }
841
-
842
- .cn-sidebar-tab__tag {
843
- display: inline-flex;
844
- align-items: center;
845
- gap: 4px;
846
- padding: 3px 8px;
847
- border-radius: var(--border-radius-pill, 16px);
848
- background: var(--color-primary-element-light, rgba(0, 130, 201, 0.1));
849
- color: var(--color-primary-element);
850
- font-size: 12px;
851
- font-weight: 500;
852
- }
853
-
854
- .cn-sidebar-tab__tag-remove {
855
- display: flex;
856
- align-items: center;
857
- justify-content: center;
858
- background: none;
859
- border: none;
860
- padding: 0;
861
- cursor: pointer;
862
- color: var(--color-primary-element);
863
- opacity: 0.6;
864
- border-radius: 50%;
865
- }
866
-
867
- .cn-sidebar-tab__tag-remove:hover {
868
- opacity: 1;
869
- background: rgba(0, 0, 0, 0.08);
870
- }
871
-
872
- /* Tasks */
873
- .cn-sidebar-tab__task-done {
874
- color: var(--color-success);
875
- }
876
- </style>
1
+ <!--
2
+ CnObjectSidebar — Right sidebar with standardized tabs for generic object functionality.
3
+
4
+ Provides Files, Notes, Tags, Tasks, and Audit Trail tabs that integrate with
5
+ OpenRegister API endpoints (which bridge to Nextcloud-native APIs).
6
+ All tabs are optional and overridable via props and slots.
7
+ -->
8
+ <template>
9
+ <NcAppSidebar
10
+ :title="sidebarTitle"
11
+ :subtitle="sidebarSubtitle"
12
+ :active.sync="activeTab"
13
+ @update:open="$emit('update:open', $event)"
14
+ @close="$emit('update:open', false)">
15
+ <!-- Files Tab -->
16
+ <NcAppSidebarTab
17
+ v-if="!isTabHidden('files')"
18
+ id="files"
19
+ :name="filesLabel"
20
+ :order="1">
21
+ <template #icon>
22
+ <Paperclip :size="20" />
23
+ </template>
24
+ <slot name="tab-files" :object-id="objectId" :object-type="objectType">
25
+ <div class="cn-sidebar-tab">
26
+ <!-- File upload -->
27
+ <div class="cn-sidebar-tab__action">
28
+ <label class="cn-sidebar-tab__upload-btn">
29
+ <NcButton type="secondary" :wide="true" :disabled="filesLoading">
30
+ <template #icon>
31
+ <Upload :size="20" />
32
+ </template>
33
+ {{ uploadLabel }}
34
+ </NcButton>
35
+ <input
36
+ ref="fileInput"
37
+ type="file"
38
+ multiple
39
+ class="cn-sidebar-tab__file-input"
40
+ @change="onFileUpload">
41
+ </label>
42
+ </div>
43
+
44
+ <!-- File list -->
45
+ <NcLoadingIcon v-if="filesLoading" />
46
+ <div v-else-if="files.length === 0" class="cn-sidebar-tab__empty">
47
+ {{ noFilesLabel }}
48
+ </div>
49
+ <div v-else class="cn-sidebar-tab__list">
50
+ <NcListItem
51
+ v-for="file in files"
52
+ :key="file.id"
53
+ :name="file.name || file.title"
54
+ :bold="false"
55
+ :force-display-actions="true">
56
+ <template #icon>
57
+ <FileOutline :size="32" />
58
+ </template>
59
+ <template #subname>
60
+ {{ formatFileSize(file.size) }}
61
+ </template>
62
+ <template #actions>
63
+ <NcActionButton @click="openFile(file)">
64
+ <template #icon>
65
+ <OpenInNew :size="20" />
66
+ </template>
67
+ {{ openLabel }}
68
+ </NcActionButton>
69
+ <NcActionButton @click="deleteFile(file)">
70
+ <template #icon>
71
+ <Delete :size="20" />
72
+ </template>
73
+ {{ deleteLabel }}
74
+ </NcActionButton>
75
+ </template>
76
+ </NcListItem>
77
+ </div>
78
+ </div>
79
+ </slot>
80
+ </NcAppSidebarTab>
81
+
82
+ <!-- Notes Tab -->
83
+ <NcAppSidebarTab
84
+ v-if="!isTabHidden('notes')"
85
+ id="notes"
86
+ :name="notesLabel"
87
+ :order="2">
88
+ <template #icon>
89
+ <CommentTextOutline :size="20" />
90
+ </template>
91
+ <slot name="tab-notes" :object-id="objectId" :object-type="objectType">
92
+ <div class="cn-sidebar-tab">
93
+ <!-- Add note -->
94
+ <div class="cn-sidebar-tab__action">
95
+ <textarea
96
+ v-model="newNoteText"
97
+ class="cn-sidebar-tab__textarea"
98
+ :placeholder="addNotePlaceholder"
99
+ rows="3" />
100
+ <NcButton
101
+ type="primary"
102
+ :disabled="!newNoteText.trim() || noteSaving"
103
+ @click="addNote">
104
+ <template #icon>
105
+ <Send :size="20" />
106
+ </template>
107
+ {{ addNoteLabel }}
108
+ </NcButton>
109
+ </div>
110
+
111
+ <!-- Notes list -->
112
+ <NcLoadingIcon v-if="notesLoading" />
113
+ <div v-else-if="notes.length === 0" class="cn-sidebar-tab__empty">
114
+ {{ noNotesLabel }}
115
+ </div>
116
+ <div v-else class="cn-sidebar-tab__list">
117
+ <div
118
+ v-for="note in notes"
119
+ :key="note.id"
120
+ class="cn-sidebar-tab__note">
121
+ <div class="cn-sidebar-tab__note-header">
122
+ <strong>{{ note.actorDisplayName || note.author || 'Unknown' }}</strong>
123
+ <span class="cn-sidebar-tab__note-time">{{ formatDate(note.creationDateTime || note.created) }}</span>
124
+ </div>
125
+ <p class="cn-sidebar-tab__note-body">{{ note.message || note.content }}</p>
126
+ <NcButton
127
+ v-if="canDeleteNote(note)"
128
+ type="tertiary"
129
+ class="cn-sidebar-tab__note-delete"
130
+ @click="deleteNote(note)">
131
+ <template #icon>
132
+ <Delete :size="16" />
133
+ </template>
134
+ </NcButton>
135
+ </div>
136
+ </div>
137
+ </div>
138
+ </slot>
139
+ </NcAppSidebarTab>
140
+
141
+ <!-- Tags Tab -->
142
+ <NcAppSidebarTab
143
+ v-if="!isTabHidden('tags')"
144
+ id="tags"
145
+ :name="tagsLabel"
146
+ :order="3">
147
+ <template #icon>
148
+ <TagOutline :size="20" />
149
+ </template>
150
+ <slot name="tab-tags" :object-id="objectId" :object-type="objectType">
151
+ <div class="cn-sidebar-tab">
152
+ <!-- Add tag -->
153
+ <div class="cn-sidebar-tab__action cn-sidebar-tab__action--row">
154
+ <NcTextField
155
+ v-model="newTagName"
156
+ :label="addTagPlaceholder"
157
+ @keyup.enter="addTag" />
158
+ <NcButton
159
+ type="primary"
160
+ :disabled="!newTagName.trim() || tagSaving"
161
+ @click="addTag">
162
+ <template #icon>
163
+ <Plus :size="20" />
164
+ </template>
165
+ </NcButton>
166
+ </div>
167
+
168
+ <!-- Tags list -->
169
+ <NcLoadingIcon v-if="tagsLoading" />
170
+ <div v-else-if="tags.length === 0" class="cn-sidebar-tab__empty">
171
+ {{ noTagsLabel }}
172
+ </div>
173
+ <div v-else class="cn-sidebar-tab__tags">
174
+ <span
175
+ v-for="tag in tags"
176
+ :key="tag.id || tag"
177
+ class="cn-sidebar-tab__tag">
178
+ {{ tag.name || tag }}
179
+ <button
180
+ class="cn-sidebar-tab__tag-remove"
181
+ :aria-label="'Remove ' + (tag.name || tag)"
182
+ @click="removeTag(tag)">
183
+ <Close :size="14" />
184
+ </button>
185
+ </span>
186
+ </div>
187
+ </div>
188
+ </slot>
189
+ </NcAppSidebarTab>
190
+
191
+ <!-- Tasks Tab -->
192
+ <NcAppSidebarTab
193
+ v-if="!isTabHidden('tasks')"
194
+ id="tasks"
195
+ :name="tasksLabel"
196
+ :order="4">
197
+ <template #icon>
198
+ <CheckboxMarkedOutline :size="20" />
199
+ </template>
200
+ <slot name="tab-tasks" :object-id="objectId" :object-type="objectType">
201
+ <div class="cn-sidebar-tab">
202
+ <NcLoadingIcon v-if="tasksLoading" />
203
+ <div v-else-if="tasks.length === 0" class="cn-sidebar-tab__empty">
204
+ {{ noTasksLabel }}
205
+ </div>
206
+ <div v-else class="cn-sidebar-tab__list">
207
+ <NcListItem
208
+ v-for="task in tasks"
209
+ :key="task.id"
210
+ :name="task.title || task.name"
211
+ :bold="false">
212
+ <template #icon>
213
+ <CheckboxMarkedOutline v-if="task.status === 'completed'" :size="32" class="cn-sidebar-tab__task-done" />
214
+ <CheckboxBlankOutline v-else :size="32" />
215
+ </template>
216
+ <template #subname>
217
+ {{ task.assignee || '' }}{{ task.dueDate ? ' · ' + formatDate(task.dueDate) : '' }}
218
+ </template>
219
+ </NcListItem>
220
+ </div>
221
+ </div>
222
+ </slot>
223
+ </NcAppSidebarTab>
224
+
225
+ <!-- Audit Trail Tab -->
226
+ <NcAppSidebarTab
227
+ v-if="!isTabHidden('auditTrail')"
228
+ id="auditTrail"
229
+ :name="auditTrailLabel"
230
+ :order="5">
231
+ <template #icon>
232
+ <History :size="20" />
233
+ </template>
234
+ <slot name="tab-audit-trail" :object-id="objectId" :object-type="objectType">
235
+ <div class="cn-sidebar-tab">
236
+ <NcLoadingIcon v-if="auditTrailLoading" />
237
+ <div v-else-if="auditTrails.length === 0" class="cn-sidebar-tab__empty">
238
+ {{ noAuditTrailLabel }}
239
+ </div>
240
+ <div v-else class="cn-sidebar-tab__list">
241
+ <NcListItem
242
+ v-for="entry in auditTrails"
243
+ :key="entry.id"
244
+ :name="formatDate(entry.created)"
245
+ :bold="false"
246
+ :details="entry.action"
247
+ :counter-number="entry.changed ? Object.keys(entry.changed).length : 0">
248
+ <template #icon>
249
+ <History :size="32" />
250
+ </template>
251
+ <template #subname>
252
+ {{ entry.userName || entry.user || 'System' }}
253
+ </template>
254
+ </NcListItem>
255
+ </div>
256
+ </div>
257
+ </slot>
258
+ </NcAppSidebarTab>
259
+
260
+ <!-- Custom tabs slot -->
261
+ <slot name="extra-tabs" />
262
+ </NcAppSidebar>
263
+ </template>
264
+
265
+ <script>
266
+ import {
267
+ NcAppSidebar,
268
+ NcAppSidebarTab,
269
+ NcButton,
270
+ NcListItem,
271
+ NcActionButton,
272
+ NcLoadingIcon,
273
+ NcTextField,
274
+ } from '@nextcloud/vue'
275
+
276
+ import Paperclip from 'vue-material-design-icons/Paperclip.vue'
277
+ import Upload from 'vue-material-design-icons/Upload.vue'
278
+ import FileOutline from 'vue-material-design-icons/FileOutline.vue'
279
+ import OpenInNew from 'vue-material-design-icons/OpenInNew.vue'
280
+ import Delete from 'vue-material-design-icons/Delete.vue'
281
+ import CommentTextOutline from 'vue-material-design-icons/CommentTextOutline.vue'
282
+ import Send from 'vue-material-design-icons/Send.vue'
283
+ import TagOutline from 'vue-material-design-icons/TagOutline.vue'
284
+ import Plus from 'vue-material-design-icons/Plus.vue'
285
+ import Close from 'vue-material-design-icons/Close.vue'
286
+ import CheckboxMarkedOutline from 'vue-material-design-icons/CheckboxMarkedOutline.vue'
287
+ import CheckboxBlankOutline from 'vue-material-design-icons/CheckboxBlankOutline.vue'
288
+ import History from 'vue-material-design-icons/History.vue'
289
+
290
+ import { buildHeaders } from '../../utils/index.js'
291
+
292
+ /**
293
+ * CnObjectSidebar — Right sidebar for entity detail pages.
294
+ *
295
+ * Provides standardized tabs for generic object functionality (Files, Notes, Tags,
296
+ * Tasks, Audit Trail) that integrate with OpenRegister API endpoints bridging to
297
+ * Nextcloud-native APIs.
298
+ *
299
+ * @example Basic usage
300
+ * <CnObjectSidebar
301
+ * object-type="pipelinq_lead"
302
+ * :object-id="leadId"
303
+ * :register="registerConfig.register"
304
+ * :schema="registerConfig.schema" />
305
+ *
306
+ * @example Hide specific tabs
307
+ * <CnObjectSidebar
308
+ * object-type="pipelinq_lead"
309
+ * :object-id="leadId"
310
+ * :hidden-tabs="['tasks', 'tags']" />
311
+ *
312
+ * @example Override a tab
313
+ * <CnObjectSidebar object-type="pipelinq_lead" :object-id="leadId">
314
+ * <template #tab-notes="{ objectId }">
315
+ * <MyCustomNotesComponent :id="objectId" />
316
+ * </template>
317
+ * </CnObjectSidebar>
318
+ */
319
+ export default {
320
+ name: 'CnObjectSidebar',
321
+
322
+ components: {
323
+ NcAppSidebar,
324
+ NcAppSidebarTab,
325
+ NcButton,
326
+ NcListItem,
327
+ NcActionButton,
328
+ NcLoadingIcon,
329
+ NcTextField,
330
+ Paperclip,
331
+ Upload,
332
+ FileOutline,
333
+ OpenInNew,
334
+ Delete,
335
+ CommentTextOutline,
336
+ Send,
337
+ TagOutline,
338
+ Plus,
339
+ Close,
340
+ CheckboxMarkedOutline,
341
+ CheckboxBlankOutline,
342
+ History,
343
+ },
344
+
345
+ props: {
346
+ /** The entity type (e.g., "pipelinq_lead", "procest_case") */
347
+ objectType: {
348
+ type: String,
349
+ required: true,
350
+ },
351
+ /** The object UUID */
352
+ objectId: {
353
+ type: String,
354
+ required: true,
355
+ },
356
+ /** OpenRegister register ID (for file/audit trail operations) */
357
+ register: {
358
+ type: String,
359
+ default: '',
360
+ },
361
+ /** OpenRegister schema ID (for file/audit trail operations) */
362
+ schema: {
363
+ type: String,
364
+ default: '',
365
+ },
366
+ /** Array of tab IDs to hide: 'files', 'notes', 'tags', 'tasks', 'auditTrail' */
367
+ hiddenTabs: {
368
+ type: Array,
369
+ default: () => [],
370
+ },
371
+ /** Whether the sidebar is open */
372
+ open: {
373
+ type: Boolean,
374
+ default: true,
375
+ },
376
+ /** Sidebar title (defaults to objectType) */
377
+ title: {
378
+ type: String,
379
+ default: '',
380
+ },
381
+ /** Sidebar subtitle */
382
+ subtitleProp: {
383
+ type: String,
384
+ default: '',
385
+ },
386
+ /** Base API URL for OpenRegister */
387
+ apiBase: {
388
+ type: String,
389
+ default: '/apps/openregister/api',
390
+ },
391
+
392
+ // --- Pre-translated labels ---
393
+ filesLabel: { type: String, default: 'Files' },
394
+ notesLabel: { type: String, default: 'Notes' },
395
+ tagsLabel: { type: String, default: 'Tags' },
396
+ tasksLabel: { type: String, default: 'Tasks' },
397
+ auditTrailLabel: { type: String, default: 'Audit Trail' },
398
+ uploadLabel: { type: String, default: 'Upload file' },
399
+ addNoteLabel: { type: String, default: 'Add note' },
400
+ addNotePlaceholder: { type: String, default: 'Write a note...' },
401
+ addTagPlaceholder: { type: String, default: 'Add tag...' },
402
+ openLabel: { type: String, default: 'Open' },
403
+ deleteLabel: { type: String, default: 'Delete' },
404
+ noFilesLabel: { type: String, default: 'No files attached' },
405
+ noNotesLabel: { type: String, default: 'No notes yet' },
406
+ noTagsLabel: { type: String, default: 'No tags' },
407
+ noTasksLabel: { type: String, default: 'No linked tasks' },
408
+ noAuditTrailLabel: { type: String, default: 'No audit trail entries' },
409
+ },
410
+
411
+ emits: ['update:open'],
412
+
413
+ data() {
414
+ return {
415
+ activeTab: 'files',
416
+ // Files
417
+ files: [],
418
+ filesLoading: false,
419
+ // Notes
420
+ notes: [],
421
+ notesLoading: false,
422
+ newNoteText: '',
423
+ noteSaving: false,
424
+ // Tags
425
+ tags: [],
426
+ tagsLoading: false,
427
+ newTagName: '',
428
+ tagSaving: false,
429
+ // Tasks
430
+ tasks: [],
431
+ tasksLoading: false,
432
+ // Audit Trail
433
+ auditTrails: [],
434
+ auditTrailLoading: false,
435
+ }
436
+ },
437
+
438
+ computed: {
439
+ sidebarTitle() {
440
+ return this.title || this.objectType || 'Details'
441
+ },
442
+ sidebarSubtitle() {
443
+ return this.subtitleProp || this.objectId || ''
444
+ },
445
+ },
446
+
447
+ watch: {
448
+ objectId: {
449
+ immediate: true,
450
+ handler(newId) {
451
+ if (newId) {
452
+ this.loadAllData()
453
+ }
454
+ },
455
+ },
456
+ activeTab(tab) {
457
+ // Lazy-load tab data when switching
458
+ this.loadTabData(tab)
459
+ },
460
+ },
461
+
462
+ methods: {
463
+ isTabHidden(tabId) {
464
+ return this.hiddenTabs.includes(tabId)
465
+ },
466
+
467
+ async loadAllData() {
468
+ // Load files (default tab) immediately, others lazily
469
+ this.loadTabData('files')
470
+ },
471
+
472
+ async loadTabData(tab) {
473
+ switch (tab) {
474
+ case 'files':
475
+ if (this.files.length === 0 && !this.filesLoading) this.fetchFiles()
476
+ break
477
+ case 'notes':
478
+ if (this.notes.length === 0 && !this.notesLoading) this.fetchNotes()
479
+ break
480
+ case 'tags':
481
+ if (this.tags.length === 0 && !this.tagsLoading) this.fetchTags()
482
+ break
483
+ case 'tasks':
484
+ if (this.tasks.length === 0 && !this.tasksLoading) this.fetchTasks()
485
+ break
486
+ case 'auditTrail':
487
+ if (this.auditTrails.length === 0 && !this.auditTrailLoading) this.fetchAuditTrails()
488
+ break
489
+ }
490
+ },
491
+
492
+ // --- Files ---
493
+ async fetchFiles() {
494
+ if (!this.register || !this.schema) return
495
+ this.filesLoading = true
496
+ try {
497
+ const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/files`
498
+ const response = await fetch(url, { headers: buildHeaders() })
499
+ if (response.ok) {
500
+ const data = await response.json()
501
+ this.files = data.results || data || []
502
+ }
503
+ } catch (err) {
504
+ console.error('CnObjectSidebar: Failed to fetch files', err)
505
+ } finally {
506
+ this.filesLoading = false
507
+ }
508
+ },
509
+
510
+ async onFileUpload(event) {
511
+ const inputFiles = event.target.files
512
+ if (!inputFiles?.length || !this.register || !this.schema) return
513
+
514
+ const formData = new FormData()
515
+ for (const file of inputFiles) {
516
+ formData.append('files[]', file)
517
+ }
518
+
519
+ try {
520
+ const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/files`
521
+ await fetch(url, {
522
+ method: 'POST',
523
+ headers: {
524
+ requesttoken: OC?.requestToken || '',
525
+ 'OCS-APIREQUEST': 'true',
526
+ },
527
+ body: formData,
528
+ })
529
+ await this.fetchFiles()
530
+ } catch (err) {
531
+ console.error('CnObjectSidebar: Failed to upload file', err)
532
+ }
533
+
534
+ // Reset input
535
+ if (this.$refs.fileInput) {
536
+ this.$refs.fileInput.value = ''
537
+ }
538
+ },
539
+
540
+ openFile(file) {
541
+ if (file.accessUrl) {
542
+ window.open(file.accessUrl, '_blank')
543
+ } else if (file.id) {
544
+ const dirPath = file.path ? file.path.substring(0, file.path.lastIndexOf('/')) : ''
545
+ const cleanPath = dirPath.replace(/^\/admin\/files\//, '/')
546
+ window.open(`/index.php/apps/files/files/${file.id}?dir=${encodeURIComponent(cleanPath)}&openfile=true`, '_blank')
547
+ }
548
+ },
549
+
550
+ async deleteFile(file) {
551
+ if (!this.register || !this.schema) return
552
+ try {
553
+ const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/files/${file.id}`
554
+ await fetch(url, { method: 'DELETE', headers: buildHeaders() })
555
+ this.files = this.files.filter(f => f.id !== file.id)
556
+ } catch (err) {
557
+ console.error('CnObjectSidebar: Failed to delete file', err)
558
+ }
559
+ },
560
+
561
+ // --- Notes (via OpenRegister → NC Comments API) ---
562
+ async fetchNotes() {
563
+ if (!this.register || !this.schema) return
564
+ this.notesLoading = true
565
+ try {
566
+ const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/notes`
567
+ const response = await fetch(url, { headers: buildHeaders() })
568
+ if (response.ok) {
569
+ const data = await response.json()
570
+ this.notes = data.results || data || []
571
+ }
572
+ } catch (err) {
573
+ console.error('CnObjectSidebar: Failed to fetch notes', err)
574
+ } finally {
575
+ this.notesLoading = false
576
+ }
577
+ },
578
+
579
+ async addNote() {
580
+ if (!this.newNoteText.trim()) return
581
+ this.noteSaving = true
582
+ try {
583
+ const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/notes`
584
+ await fetch(url, {
585
+ method: 'POST',
586
+ headers: buildHeaders(),
587
+ body: JSON.stringify({ message: this.newNoteText.trim() }),
588
+ })
589
+ this.newNoteText = ''
590
+ await this.fetchNotes()
591
+ } catch (err) {
592
+ console.error('CnObjectSidebar: Failed to add note', err)
593
+ } finally {
594
+ this.noteSaving = false
595
+ }
596
+ },
597
+
598
+ canDeleteNote(note) {
599
+ return note.actorId === OC?.currentUser || note.author === OC?.currentUser
600
+ },
601
+
602
+ async deleteNote(note) {
603
+ try {
604
+ const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/notes/${note.id}`
605
+ await fetch(url, { method: 'DELETE', headers: buildHeaders() })
606
+ this.notes = this.notes.filter(n => n.id !== note.id)
607
+ } catch (err) {
608
+ console.error('CnObjectSidebar: Failed to delete note', err)
609
+ }
610
+ },
611
+
612
+ // --- Tags ---
613
+ // Tags are managed via the object's own data (object.tags array) and patched
614
+ // back to the object. Available tags list comes from GET /api/tags.
615
+ async fetchTags() {
616
+ if (!this.register || !this.schema) return
617
+ this.tagsLoading = true
618
+ try {
619
+ // Fetch the object to get its current tags
620
+ const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}`
621
+ const response = await fetch(url, { headers: buildHeaders() })
622
+ if (response.ok) {
623
+ const data = await response.json()
624
+ this.tags = data.tags || data.object?.tags || []
625
+ }
626
+ } catch (err) {
627
+ console.error('CnObjectSidebar: Failed to fetch tags', err)
628
+ } finally {
629
+ this.tagsLoading = false
630
+ }
631
+ },
632
+
633
+ async addTag() {
634
+ if (!this.newTagName.trim() || !this.register || !this.schema) return
635
+ this.tagSaving = true
636
+ try {
637
+ const newTags = [...this.tags, this.newTagName.trim()]
638
+ const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}`
639
+ await fetch(url, {
640
+ method: 'PATCH',
641
+ headers: buildHeaders(),
642
+ body: JSON.stringify({ tags: newTags }),
643
+ })
644
+ this.newTagName = ''
645
+ await this.fetchTags()
646
+ } catch (err) {
647
+ console.error('CnObjectSidebar: Failed to add tag', err)
648
+ } finally {
649
+ this.tagSaving = false
650
+ }
651
+ },
652
+
653
+ async removeTag(tag) {
654
+ if (!this.register || !this.schema) return
655
+ const tagName = tag.name || tag
656
+ try {
657
+ const newTags = this.tags.filter(t => (t.name || t) !== tagName)
658
+ const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}`
659
+ await fetch(url, {
660
+ method: 'PATCH',
661
+ headers: buildHeaders(),
662
+ body: JSON.stringify({ tags: newTags }),
663
+ })
664
+ this.tags = newTags
665
+ } catch (err) {
666
+ console.error('CnObjectSidebar: Failed to remove tag', err)
667
+ }
668
+ },
669
+
670
+ // --- Tasks (via OpenRegister → CalDAV VTODO API) ---
671
+ async fetchTasks() {
672
+ if (!this.register || !this.schema) return
673
+ this.tasksLoading = true
674
+ try {
675
+ const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/tasks`
676
+ const response = await fetch(url, { headers: buildHeaders() })
677
+ if (response.ok) {
678
+ const data = await response.json()
679
+ this.tasks = data.results || data || []
680
+ }
681
+ } catch (err) {
682
+ console.error('CnObjectSidebar: Failed to fetch tasks', err)
683
+ } finally {
684
+ this.tasksLoading = false
685
+ }
686
+ },
687
+
688
+ // --- Audit Trail ---
689
+ async fetchAuditTrails() {
690
+ if (!this.register || !this.schema) return
691
+ this.auditTrailLoading = true
692
+ try {
693
+ const url = `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/audit-trails`
694
+ const response = await fetch(url, { headers: buildHeaders() })
695
+ if (response.ok) {
696
+ const data = await response.json()
697
+ this.auditTrails = data.results || data || []
698
+ }
699
+ } catch (err) {
700
+ console.error('CnObjectSidebar: Failed to fetch audit trails', err)
701
+ } finally {
702
+ this.auditTrailLoading = false
703
+ }
704
+ },
705
+
706
+ // --- Helpers ---
707
+ formatFileSize(bytes) {
708
+ const sizes = ['Bytes', 'KB', 'MB', 'GB']
709
+ if (!bytes || bytes === 0) return 'n/a'
710
+ const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)))
711
+ if (i === 0) return '< 1 KB'
712
+ return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + sizes[i]
713
+ },
714
+
715
+ formatDate(dateStr) {
716
+ if (!dateStr) return ''
717
+ try {
718
+ return new Date(dateStr).toLocaleString(undefined, {
719
+ year: 'numeric',
720
+ month: 'short',
721
+ day: 'numeric',
722
+ hour: '2-digit',
723
+ minute: '2-digit',
724
+ })
725
+ } catch {
726
+ return dateStr
727
+ }
728
+ },
729
+ },
730
+ }
731
+ </script>
732
+
733
+ <style scoped>
734
+ .cn-sidebar-tab {
735
+ padding: 12px;
736
+ }
737
+
738
+ .cn-sidebar-tab__action {
739
+ margin-bottom: 16px;
740
+ }
741
+
742
+ .cn-sidebar-tab__action--row {
743
+ display: flex;
744
+ gap: 8px;
745
+ align-items: flex-end;
746
+ }
747
+
748
+ .cn-sidebar-tab__upload-btn {
749
+ display: block;
750
+ cursor: pointer;
751
+ }
752
+
753
+ .cn-sidebar-tab__file-input {
754
+ display: none;
755
+ }
756
+
757
+ .cn-sidebar-tab__textarea {
758
+ width: 100%;
759
+ padding: 8px;
760
+ border: 1px solid var(--color-border);
761
+ border-radius: var(--border-radius);
762
+ resize: vertical;
763
+ font-family: inherit;
764
+ font-size: 13px;
765
+ margin-bottom: 8px;
766
+ background: var(--color-main-background);
767
+ color: var(--color-main-text);
768
+ }
769
+
770
+ .cn-sidebar-tab__textarea:focus {
771
+ border-color: var(--color-primary-element);
772
+ outline: none;
773
+ }
774
+
775
+ .cn-sidebar-tab__empty {
776
+ text-align: center;
777
+ padding: 24px 12px;
778
+ color: var(--color-text-maxcontrast);
779
+ font-size: 13px;
780
+ }
781
+
782
+ .cn-sidebar-tab__list {
783
+ display: flex;
784
+ flex-direction: column;
785
+ gap: 2px;
786
+ }
787
+
788
+ /* Notes */
789
+ .cn-sidebar-tab__note {
790
+ padding: 10px 12px;
791
+ border-bottom: 1px solid var(--color-border);
792
+ position: relative;
793
+ }
794
+
795
+ .cn-sidebar-tab__note:last-child {
796
+ border-bottom: none;
797
+ }
798
+
799
+ .cn-sidebar-tab__note-header {
800
+ display: flex;
801
+ justify-content: space-between;
802
+ align-items: baseline;
803
+ margin-bottom: 4px;
804
+ }
805
+
806
+ .cn-sidebar-tab__note-header strong {
807
+ font-size: 13px;
808
+ }
809
+
810
+ .cn-sidebar-tab__note-time {
811
+ font-size: 11px;
812
+ color: var(--color-text-maxcontrast);
813
+ }
814
+
815
+ .cn-sidebar-tab__note-body {
816
+ font-size: 13px;
817
+ margin: 0;
818
+ white-space: pre-wrap;
819
+ word-break: break-word;
820
+ }
821
+
822
+ .cn-sidebar-tab__note-delete {
823
+ position: absolute;
824
+ top: 8px;
825
+ right: 4px;
826
+ opacity: 0;
827
+ transition: opacity 0.15s ease;
828
+ }
829
+
830
+ .cn-sidebar-tab__note:hover .cn-sidebar-tab__note-delete {
831
+ opacity: 1;
832
+ }
833
+
834
+ /* Tags */
835
+ .cn-sidebar-tab__tags {
836
+ display: flex;
837
+ flex-wrap: wrap;
838
+ gap: 6px;
839
+ padding: 4px 0;
840
+ }
841
+
842
+ .cn-sidebar-tab__tag {
843
+ display: inline-flex;
844
+ align-items: center;
845
+ gap: 4px;
846
+ padding: 3px 8px;
847
+ border-radius: var(--border-radius-pill, 16px);
848
+ background: var(--color-primary-element-light, rgba(0, 130, 201, 0.1));
849
+ color: var(--color-primary-element);
850
+ font-size: 12px;
851
+ font-weight: 500;
852
+ }
853
+
854
+ .cn-sidebar-tab__tag-remove {
855
+ display: flex;
856
+ align-items: center;
857
+ justify-content: center;
858
+ background: none;
859
+ border: none;
860
+ padding: 0;
861
+ cursor: pointer;
862
+ color: var(--color-primary-element);
863
+ opacity: 0.6;
864
+ border-radius: 50%;
865
+ }
866
+
867
+ .cn-sidebar-tab__tag-remove:hover {
868
+ opacity: 1;
869
+ background: rgba(0, 0, 0, 0.08);
870
+ }
871
+
872
+ /* Tasks */
873
+ .cn-sidebar-tab__task-done {
874
+ color: var(--color-success);
875
+ }
876
+ </style>