@raclettejs/workbench 0.1.17 → 0.1.19
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/CHANGELOG.md +19 -0
- package/config/compositions.js +18 -14
- package/config/interactionLinks.js +30 -10
- package/i18n/de-DE.json +27 -1
- package/i18n/en-EU.json +27 -1
- package/i18n/sk-SK.json +26 -0
- package/package.json +3 -3
- package/plugins/pacifico__compositions/frontend/widgets/CompositionListWidget.vue +207 -45
- package/plugins/pacifico__interactionLinks/frontend/widgets/InteractionLinkListWidget.vue +226 -42
- package/plugins/pacifico__project/frontend/components/ProjectConfiguration.vue +107 -0
- package/plugins/pacifico__project/frontend/widgets/ProjectDetailWidget.vue +34 -0
- package/plugins/pacifico__project/raclette.plugin.ts +8 -0
- package/plugins/pacifico__tags/frontend/widgets/TagListWidget.vue +203 -34
- package/plugins/pacifico__users/frontend/widgets/UserListWidget.vue +20 -2
- package/raclette.config.js +0 -3
- package/services/frontend/src/app/components/BaseDataTable.vue +3 -0
- package/services/frontend/src/app/components/FileUpload.vue +98 -0
- package/services/frontend/src/app/components/SelectionDialog.vue +182 -0
- package/services/frontend/src/app/components/SummaryDialog.vue +140 -0
- package/services/frontend/src/app/components/dynamicForm/typedInputs/CompositionInput.vue +1 -0
- package/services/frontend/src/app/components/dynamicForm/typedInputs/TriggerInput.vue +4 -6
- package/services/frontend/src/app/lib/jsonTools.ts +81 -0
- package/yarn.lock +8 -8
|
@@ -1,48 +1,142 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
<v-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
<v-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
2
|
+
<div>
|
|
3
|
+
<BaseDataTable
|
|
4
|
+
:items="parsedTags"
|
|
5
|
+
:loading="tagsLoading"
|
|
6
|
+
:headers="headers"
|
|
7
|
+
create-interaction-link-id="tagCreateInteractionLink"
|
|
8
|
+
edit-interaction-link-id="tagEditInteractionLink"
|
|
9
|
+
:data-name="$t('workbench.tagList.dataName')"
|
|
10
|
+
:data-article="$t('workbench.tagList.dataArticle')"
|
|
11
|
+
@delete="deleteTag"
|
|
12
|
+
:itemsDeleted="showDeleted"
|
|
13
|
+
>
|
|
14
|
+
<template #actions>
|
|
15
|
+
<v-tooltip
|
|
16
|
+
:text="showDeleted ? $t('core.hideDeleted') : $t('core.showDeleted')"
|
|
17
|
+
>
|
|
18
|
+
<template v-slot:activator="{ props }">
|
|
19
|
+
<v-btn-toggle
|
|
20
|
+
density="compact"
|
|
21
|
+
v-bind="props"
|
|
22
|
+
v-model="showDeleted"
|
|
23
|
+
mandatory
|
|
24
|
+
:color="showDeleted ? 'error' : 'success'"
|
|
25
|
+
>
|
|
26
|
+
<v-btn :value="true">
|
|
27
|
+
<v-icon>mdi-table-eye-off</v-icon>
|
|
28
|
+
</v-btn>
|
|
29
|
+
<v-btn :value="false"><v-icon>mdi-table-eye</v-icon></v-btn>
|
|
30
|
+
</v-btn-toggle>
|
|
31
|
+
</template>
|
|
32
|
+
</v-tooltip>
|
|
33
|
+
<v-btn-group density="compact">
|
|
34
|
+
<v-tooltip :text="$t('core.export')">
|
|
35
|
+
<template v-slot:activator="{ props }">
|
|
36
|
+
<v-btn color="info" v-bind="props" flat @click="openExportDialog">
|
|
37
|
+
<v-icon>mdi-database-export</v-icon>
|
|
38
|
+
</v-btn>
|
|
39
|
+
</template>
|
|
40
|
+
</v-tooltip>
|
|
41
|
+
<v-tooltip :text="$t('core.import')">
|
|
42
|
+
<template v-slot:activator="{ props }">
|
|
43
|
+
<v-btn v-bind="props" color="warning">
|
|
44
|
+
<FileUpload
|
|
45
|
+
ref="uploadRef"
|
|
46
|
+
:onFileLoaded="handleFileUpload"
|
|
47
|
+
accept=".json"
|
|
48
|
+
readAs="text"
|
|
49
|
+
/></v-btn>
|
|
50
|
+
</template>
|
|
51
|
+
</v-tooltip>
|
|
52
|
+
</v-btn-group>
|
|
53
|
+
</template>
|
|
54
|
+
<template #prepend-row-actions="{ item }">
|
|
55
|
+
<v-icon
|
|
56
|
+
v-if="showDeleted"
|
|
57
|
+
color="green"
|
|
58
|
+
class="mr-2"
|
|
59
|
+
@click="restore(item._id)"
|
|
60
|
+
:title="
|
|
61
|
+
$t('workbench.baseDataTable.restoreDataTitle', {
|
|
62
|
+
dataName: item.title,
|
|
63
|
+
})
|
|
64
|
+
"
|
|
65
|
+
icon="mdi-delete-restore"
|
|
66
|
+
/>
|
|
67
|
+
</template>
|
|
68
|
+
</BaseDataTable>
|
|
69
|
+
<!-- Export Selection Dialog -->
|
|
70
|
+
<SelectionDialog
|
|
71
|
+
v-model="showExportDialog"
|
|
72
|
+
:items="exportableItems"
|
|
73
|
+
display-attribute="title"
|
|
74
|
+
:title="$t('core.selectItemsToExport')"
|
|
75
|
+
:confirm-text="$t('core.export')"
|
|
76
|
+
@confirm="handleExportConfirm"
|
|
77
|
+
/>
|
|
78
|
+
|
|
79
|
+
<!-- Import Selection Dialog -->
|
|
80
|
+
<SelectionDialog
|
|
81
|
+
v-model="showImportDialog"
|
|
82
|
+
:items="importableItems"
|
|
83
|
+
display-attribute="title"
|
|
84
|
+
:title="$t('core.selectItemsToImport')"
|
|
85
|
+
:confirm-text="$t('core.import')"
|
|
86
|
+
@confirm="handleImportConfirm"
|
|
87
|
+
/>
|
|
88
|
+
|
|
89
|
+
<!-- Export Summary Dialog -->
|
|
90
|
+
<SummaryDialog
|
|
91
|
+
v-model="showExportSummary"
|
|
92
|
+
display-attribute="title"
|
|
93
|
+
:items="exportedItems"
|
|
94
|
+
:title="$t('core.exportSummary')"
|
|
95
|
+
:message="$t('core.exportSuccessMessage')"
|
|
96
|
+
type="success"
|
|
97
|
+
/>
|
|
98
|
+
|
|
99
|
+
<!-- Import Summary Dialog -->
|
|
100
|
+
<SummaryDialog
|
|
101
|
+
v-model="showImportSummary"
|
|
102
|
+
display-attribute="title"
|
|
103
|
+
:items="importedItems"
|
|
104
|
+
:title="$t('core.importSummary')"
|
|
105
|
+
:message="$t('core.importSuccessMessage')"
|
|
106
|
+
type="success"
|
|
107
|
+
:show-confirm="true"
|
|
108
|
+
:confirm-text="$t('core.saveImported')"
|
|
109
|
+
@confirm="finalizeImport"
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
34
112
|
</template>
|
|
35
113
|
|
|
36
114
|
<script setup lang="ts">
|
|
37
115
|
import { usePluginApi } from "@raclettejs/core/orchestrator/composables"
|
|
38
|
-
import { computed, ref, watch } from "vue"
|
|
116
|
+
import { computed, ref, watch, useTemplateRef } from "vue"
|
|
39
117
|
import type { Tag } from "@raclettejs/core"
|
|
40
118
|
import { useI18n } from "vue-i18n"
|
|
41
119
|
import BaseDataTable from "@app/components/BaseDataTable.vue"
|
|
42
120
|
import { formatDistance } from "date-fns"
|
|
121
|
+
import { downloadJson, mapJsonValues } from "@app/lib/jsonTools"
|
|
122
|
+
import FileUpload from "@app/components/FileUpload.vue"
|
|
123
|
+
import SelectionDialog from "@app/components/SelectionDialog.vue"
|
|
124
|
+
import SummaryDialog from "@app/components/SummaryDialog.vue"
|
|
43
125
|
const showDeleted = ref(false)
|
|
44
126
|
const { t } = useI18n()
|
|
127
|
+
// Dialog states
|
|
128
|
+
const showExportDialog = ref(false)
|
|
129
|
+
const showImportDialog = ref(false)
|
|
130
|
+
const showExportSummary = ref(false)
|
|
131
|
+
const showImportSummary = ref(false)
|
|
45
132
|
|
|
133
|
+
// Data for dialogs
|
|
134
|
+
const exportableItems = ref<any[]>([])
|
|
135
|
+
const importableItems = ref<any[]>([])
|
|
136
|
+
const exportedItems = ref<any[]>([])
|
|
137
|
+
const importedItems = ref<any[]>([])
|
|
138
|
+
const pendingImportData = ref<any>(null)
|
|
139
|
+
const uploadRef = useTemplateRef("uploadRef")
|
|
46
140
|
const headers = computed<{ title: string; key: keyof Tag }[]>(() => [
|
|
47
141
|
{
|
|
48
142
|
title: t("workbench.tagList.tableHeaders.title"),
|
|
@@ -66,7 +160,7 @@ const props = defineProps<{
|
|
|
66
160
|
uuid: string
|
|
67
161
|
}>()
|
|
68
162
|
|
|
69
|
-
const { $data, $socket } = usePluginApi("raclette__core")
|
|
163
|
+
const { $data, $socket, $user } = usePluginApi("raclette__core")
|
|
70
164
|
|
|
71
165
|
const {
|
|
72
166
|
data: tags,
|
|
@@ -80,9 +174,15 @@ const {
|
|
|
80
174
|
const { execute: executeDelete } = $data.tag.delete({
|
|
81
175
|
options: { cb: () => execute({ isDeleted: showDeleted.value }) },
|
|
82
176
|
})
|
|
177
|
+
const { execute: executeBulkCreate } = await $data.tag.createBulk({
|
|
178
|
+
options: { notify: false, cb: () => execute() },
|
|
179
|
+
})
|
|
83
180
|
const { execute: executeHardDelete } = await $data.tag.deleteHard({
|
|
84
181
|
options: { cb: () => execute({ isDeleted: showDeleted.value }) },
|
|
85
182
|
})
|
|
183
|
+
const { execute: executeRestore } = await $data.tag.updateRestore({
|
|
184
|
+
options: { cb: () => execute({ isDeleted: showDeleted.value }) },
|
|
185
|
+
})
|
|
86
186
|
const parsedTags = computed(() => {
|
|
87
187
|
if (tags.value) {
|
|
88
188
|
return Object.values(tags.value as { [key: string]: Tag }).map((tag) => ({
|
|
@@ -101,7 +201,76 @@ const deleteTag = async (tag: Tag) => {
|
|
|
101
201
|
await executeDelete({ _id: tag._id })
|
|
102
202
|
}
|
|
103
203
|
}
|
|
204
|
+
const openExportDialog = () => {
|
|
205
|
+
const allTags = Object.values(tags.value || {})
|
|
206
|
+
const exportableTags = []
|
|
207
|
+
allTags.forEach((tag) => {
|
|
208
|
+
if (!tag.locked) {
|
|
209
|
+
exportableTags.push(tag)
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
exportableItems.value = exportableTags
|
|
213
|
+
showExportDialog.value = true
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const handleExportConfirm = (selectedIds: string[]) => {
|
|
217
|
+
const dataToExport = selectedIds.reduce((acc, id) => {
|
|
218
|
+
if (tags.value?.[id]) {
|
|
219
|
+
acc[id] = tags.value[id]
|
|
220
|
+
}
|
|
221
|
+
return acc
|
|
222
|
+
}, {} as any)
|
|
223
|
+
|
|
224
|
+
exportedItems.value = Object.values(dataToExport)
|
|
225
|
+
downloadJson(dataToExport, "tags")
|
|
226
|
+
showExportSummary.value = true
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Import functions
|
|
230
|
+
const handleFileUpload = (content: string, file: File) => {
|
|
231
|
+
try {
|
|
232
|
+
const parsedData = JSON.parse(content)
|
|
233
|
+
const itemsArray = Object.values(parsedData)
|
|
234
|
+
|
|
235
|
+
importableItems.value = itemsArray
|
|
236
|
+
pendingImportData.value = parsedData
|
|
237
|
+
showImportDialog.value = true
|
|
238
|
+
} catch (error) {
|
|
239
|
+
console.error("Failed to parse JSON:", error)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
104
242
|
|
|
243
|
+
const handleImportConfirm = (selectedIds: string[]) => {
|
|
244
|
+
const selectedItems = selectedIds.map((id) => pendingImportData.value[id])
|
|
245
|
+
importedItems.value = selectedItems
|
|
246
|
+
showImportSummary.value = true
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const finalizeImport = async () => {
|
|
250
|
+
const mapping = {
|
|
251
|
+
owner: $user.value._id,
|
|
252
|
+
lastEditor: $user.value._id,
|
|
253
|
+
createdBy: $user.value._id,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const dataToImport = importedItems.value.reduce((acc, item) => {
|
|
257
|
+
const mappedItem = mapJsonValues(item, mapping)
|
|
258
|
+
acc[item._id] = mappedItem
|
|
259
|
+
return acc
|
|
260
|
+
}, {} as any)
|
|
261
|
+
|
|
262
|
+
await executeBulkCreate(Object.values(dataToImport))
|
|
263
|
+
// TODO: Add your actual import logic here
|
|
264
|
+
// e.g., call an API endpoint to save the imported compositions
|
|
265
|
+
|
|
266
|
+
// Reset states
|
|
267
|
+
pendingImportData.value = null
|
|
268
|
+
importableItems.value = []
|
|
269
|
+
importedItems.value = []
|
|
270
|
+
}
|
|
271
|
+
const restore = async (_id: string) => {
|
|
272
|
+
executeRestore({ _id })
|
|
273
|
+
}
|
|
105
274
|
$socket.on("tagCreated", () => execute({ isDeleted: showDeleted.value }))
|
|
106
275
|
watch(
|
|
107
276
|
showDeleted,
|
|
@@ -31,6 +31,20 @@
|
|
|
31
31
|
</template>
|
|
32
32
|
</v-tooltip>
|
|
33
33
|
</template>
|
|
34
|
+
<template #prepend-row-actions="{ item }">
|
|
35
|
+
<v-icon
|
|
36
|
+
v-if="showDeleted"
|
|
37
|
+
color="green"
|
|
38
|
+
class="mr-2"
|
|
39
|
+
@click.stop.prevent="restore(item._id)"
|
|
40
|
+
:title="
|
|
41
|
+
$t('workbench.baseDataTable.restoreDataTitle', {
|
|
42
|
+
dataName: item.title,
|
|
43
|
+
})
|
|
44
|
+
"
|
|
45
|
+
icon="mdi-delete-restore"
|
|
46
|
+
/>
|
|
47
|
+
</template>
|
|
34
48
|
</BaseDataTable>
|
|
35
49
|
</template>
|
|
36
50
|
|
|
@@ -79,13 +93,15 @@ const { data: users, execute, isLoading: usersLoading } = $data.user.getAll()
|
|
|
79
93
|
const { data: tags, isLoading: tagsLoading } = $data.tag.getAll({
|
|
80
94
|
options: { immediate: true },
|
|
81
95
|
})
|
|
82
|
-
|
|
83
96
|
const { execute: executeDelete } = $data.user.delete({
|
|
84
97
|
options: { cb: () => execute({ isDeleted: showDeleted.value }) },
|
|
85
98
|
})
|
|
86
99
|
const { execute: executeHardDelete } = await $data.user.deleteHard({
|
|
87
100
|
options: { cb: () => execute({ isDeleted: showDeleted.value }) },
|
|
88
101
|
})
|
|
102
|
+
const { execute: executeRestore } = await $data.user.updateRestore({
|
|
103
|
+
options: { cb: () => execute({ isDeleted: showDeleted.value }) },
|
|
104
|
+
})
|
|
89
105
|
const parsedUsers = computed(() => {
|
|
90
106
|
if (users.value && tags.value) {
|
|
91
107
|
return Object.values(users.value as { [key: string]: User }).map(
|
|
@@ -105,7 +121,9 @@ const deleteUser = async (user: User) => {
|
|
|
105
121
|
await executeDelete({ _id: user._id })
|
|
106
122
|
}
|
|
107
123
|
}
|
|
108
|
-
|
|
124
|
+
const restore = async (_id: string) => {
|
|
125
|
+
executeRestore({ _id })
|
|
126
|
+
}
|
|
109
127
|
$socket.on("userCreated", () => execute({ isDeleted: showDeleted.value }))
|
|
110
128
|
watch(
|
|
111
129
|
showDeleted,
|
package/raclette.config.js
CHANGED
|
@@ -81,6 +81,7 @@
|
|
|
81
81
|
|
|
82
82
|
<!-- Actions column template -->
|
|
83
83
|
<template #item.actions="{ item }" v-if="showActionsColumn">
|
|
84
|
+
<slot name="prepend-row-actions" :item="item"> </slot>
|
|
84
85
|
<slot name="row-actions" :item="item">
|
|
85
86
|
<!-- Default delete action -->
|
|
86
87
|
<v-icon
|
|
@@ -96,6 +97,7 @@
|
|
|
96
97
|
icon="mdi-delete"
|
|
97
98
|
/>
|
|
98
99
|
</slot>
|
|
100
|
+
<slot name="append-row-actions" :item="item"> </slot>
|
|
99
101
|
</template>
|
|
100
102
|
|
|
101
103
|
<!-- Loading template -->
|
|
@@ -296,5 +298,6 @@ const isDeleteDisabled = (item: T): boolean => {
|
|
|
296
298
|
<style lang="css">
|
|
297
299
|
.itemsDeleted .v-data-table__tr {
|
|
298
300
|
background-color: rgb(233, 190, 190);
|
|
301
|
+
color: #000;
|
|
299
302
|
}
|
|
300
303
|
</style>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-file-input
|
|
3
|
+
v-model="files"
|
|
4
|
+
:accept="accept"
|
|
5
|
+
ref="fileInput"
|
|
6
|
+
hide-input
|
|
7
|
+
flat
|
|
8
|
+
prepend-icon="mdi-database-import"
|
|
9
|
+
@update:model-value="handleFileChange"
|
|
10
|
+
>
|
|
11
|
+
</v-file-input>
|
|
12
|
+
</template>
|
|
13
|
+
|
|
14
|
+
<script setup lang="ts">
|
|
15
|
+
import { ref, useTemplateRef } from "vue"
|
|
16
|
+
const fileInput = useTemplateRef("fileInput")
|
|
17
|
+
const props = defineProps({
|
|
18
|
+
/**
|
|
19
|
+
* Function to call when file content is loaded.
|
|
20
|
+
* Receives: (content: string | ArrayBuffer, file: File)
|
|
21
|
+
*/
|
|
22
|
+
onFileLoaded: {
|
|
23
|
+
type: Function,
|
|
24
|
+
required: true,
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Optional: accept only certain file types
|
|
29
|
+
*/
|
|
30
|
+
accept: {
|
|
31
|
+
type: String,
|
|
32
|
+
default: "*/*",
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How to read the file: "text" or "arrayBuffer"
|
|
37
|
+
*/
|
|
38
|
+
readAs: {
|
|
39
|
+
type: String,
|
|
40
|
+
default: "text",
|
|
41
|
+
validator: (v: string) => ["text", "arrayBuffer"].includes(v),
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const files = ref<File[]>([])
|
|
46
|
+
const trigger = () => {
|
|
47
|
+
fileInput.value?.click()
|
|
48
|
+
}
|
|
49
|
+
const handleFileChange = (newFile: File | null) => {
|
|
50
|
+
console.log("File change triggered:", newFile)
|
|
51
|
+
console.log(files.value)
|
|
52
|
+
|
|
53
|
+
if (!newFile) {
|
|
54
|
+
console.log("No file selected")
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const selectedFile = newFile
|
|
59
|
+
if (!selectedFile) {
|
|
60
|
+
console.log("Selected file is null")
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log(
|
|
65
|
+
"Reading file:",
|
|
66
|
+
selectedFile.name,
|
|
67
|
+
selectedFile.type,
|
|
68
|
+
selectedFile.size,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const reader = new FileReader()
|
|
72
|
+
|
|
73
|
+
reader.onload = () => {
|
|
74
|
+
console.log(
|
|
75
|
+
"File loaded successfully, length:",
|
|
76
|
+
(reader.result as string)?.length,
|
|
77
|
+
)
|
|
78
|
+
// Pass content first, then file
|
|
79
|
+
props.onFileLoaded(reader.result, selectedFile)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
reader.onerror = () => {
|
|
83
|
+
console.error("Error reading file:", reader.error)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
reader.onprogress = (e) => {
|
|
87
|
+
console.log("Reading progress:", e.loaded, "/", e.total)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Read the file from the parameter, not from refs
|
|
91
|
+
if (props.readAs === "arrayBuffer") {
|
|
92
|
+
reader.readAsArrayBuffer(selectedFile)
|
|
93
|
+
} else {
|
|
94
|
+
reader.readAsText(selectedFile)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
defineExpose({ trigger })
|
|
98
|
+
</script>
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- Dialog overlay -->
|
|
3
|
+
<div
|
|
4
|
+
v-if="dialog"
|
|
5
|
+
class="tw:fixed tw:inset-0 tw:flex tw:items-center tw:justify-center tw:bg-black/50 tw:z-50"
|
|
6
|
+
>
|
|
7
|
+
<!-- Dialog panel -->
|
|
8
|
+
<div
|
|
9
|
+
class="tw:bg-white tw:rounded-lg tw:shadow-xl tw:max-w-xl tw:w-full tw:overflow-auto"
|
|
10
|
+
>
|
|
11
|
+
<!-- Title bar -->
|
|
12
|
+
<div
|
|
13
|
+
class="tw:flex tw:justify-between tw:items-center tw:px-4 tw:py-3 tw:border-b tw:border-gray-200"
|
|
14
|
+
>
|
|
15
|
+
<span class="tw:text-lg tw:font-semibold">{{ title }}</span>
|
|
16
|
+
|
|
17
|
+
<button
|
|
18
|
+
@click="close"
|
|
19
|
+
class="tw:text-gray-500 hover:tw:text-gray-700 tw:p-1 tw:rounded tw:hover:bg-gray-100"
|
|
20
|
+
>
|
|
21
|
+
<i class="mdi mdi-close tw:text-xl"></i>
|
|
22
|
+
</button>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<!-- Content -->
|
|
26
|
+
<div class="tw:px-4 tw:py-4">
|
|
27
|
+
<!-- Header row -->
|
|
28
|
+
<div class="tw:flex tw:justify-between tw:items-center tw:mb-4">
|
|
29
|
+
<!-- Select all -->
|
|
30
|
+
<label class="tw:flex tw:items-center tw:space-x-2 tw:cursor-pointer">
|
|
31
|
+
<input
|
|
32
|
+
type="checkbox"
|
|
33
|
+
v-model="selectAll"
|
|
34
|
+
@change="toggleSelectAll"
|
|
35
|
+
class="tw:w-4 tw:h-4 tw:rounded tw:border-gray-300 tw:text-primary tw:focus:ring-primary"
|
|
36
|
+
/>
|
|
37
|
+
<span class="tw:text-sm tw:text-gray-700">
|
|
38
|
+
{{ $t("core.selectAll") }}
|
|
39
|
+
</span>
|
|
40
|
+
</label>
|
|
41
|
+
|
|
42
|
+
<!-- Chip -->
|
|
43
|
+
<div
|
|
44
|
+
class="tw:px-3 tw:py-1 tw:bg-primary tw:text-white tw:rounded-full tw:text-sm tw:font-medium"
|
|
45
|
+
>
|
|
46
|
+
{{ selectedCount }} / {{ items.length }} {{ $t("core.selected") }}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<!-- List -->
|
|
51
|
+
<ul class="tw:max-h-96 tw:overflow-y-auto tw:space-y-1">
|
|
52
|
+
<li
|
|
53
|
+
v-for="item in items"
|
|
54
|
+
:key="item._id"
|
|
55
|
+
@click="toggleSelection(item._id)"
|
|
56
|
+
class="tw:flex tw:items-center tw:space-x-3 tw:p-2 tw:rounded tw:cursor-pointer hover:tw:bg-gray-50"
|
|
57
|
+
>
|
|
58
|
+
<!-- Checkbox -->
|
|
59
|
+
<input
|
|
60
|
+
type="checkbox"
|
|
61
|
+
:checked="selected.includes(item._id)"
|
|
62
|
+
@click.stop="toggleSelection(item._id)"
|
|
63
|
+
class="tw:w-4 tw:h-4 tw:rounded tw:border-gray-300 tw:text-primary tw:focus:ring-primary"
|
|
64
|
+
/>
|
|
65
|
+
|
|
66
|
+
<!-- Label -->
|
|
67
|
+
<span class="tw:text-sm tw:text-gray-800">
|
|
68
|
+
{{ getDisplayValue(item) }}
|
|
69
|
+
</span>
|
|
70
|
+
</li>
|
|
71
|
+
</ul>
|
|
72
|
+
|
|
73
|
+
<!-- Warning for no selection -->
|
|
74
|
+
<div
|
|
75
|
+
v-if="selectedCount === 0"
|
|
76
|
+
class="tw:mt-4 tw:bg-yellow-50 tw:text-yellow-700 tw:p-3 tw:rounded tw:text-sm"
|
|
77
|
+
>
|
|
78
|
+
{{ $t("core.noItemsSelected") }}
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<!-- Actions -->
|
|
83
|
+
<div
|
|
84
|
+
class="tw:flex tw:justify-end tw:space-x-2 tw:px-4 tw:py-3 tw:border-t tw:border-gray-200"
|
|
85
|
+
>
|
|
86
|
+
<button
|
|
87
|
+
@click="close"
|
|
88
|
+
class="tw:px-4 tw:py-2 tw:text-sm tw:text-gray-700 tw:rounded"
|
|
89
|
+
>
|
|
90
|
+
{{ $t("core.cancel") }}
|
|
91
|
+
</button>
|
|
92
|
+
|
|
93
|
+
<button
|
|
94
|
+
v-if="selectedCount"
|
|
95
|
+
@click="confirm"
|
|
96
|
+
:disabled="selectedCount === 0"
|
|
97
|
+
class="tw:px-4 tw:py-2 tw:text-sm tw:rounded tw:text-gray-700 tw:bg-primary"
|
|
98
|
+
>
|
|
99
|
+
{{ confirmText }}
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</template>
|
|
105
|
+
|
|
106
|
+
<script setup lang="ts">
|
|
107
|
+
import { ref, computed, watch } from "vue"
|
|
108
|
+
|
|
109
|
+
interface Props {
|
|
110
|
+
modelValue: boolean
|
|
111
|
+
items: any[]
|
|
112
|
+
displayAttribute: string
|
|
113
|
+
title: string
|
|
114
|
+
confirmText: string
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const props = defineProps<Props>()
|
|
118
|
+
|
|
119
|
+
const emit = defineEmits<{
|
|
120
|
+
"update:modelValue": [value: boolean]
|
|
121
|
+
confirm: [selectedIds: string[]]
|
|
122
|
+
}>()
|
|
123
|
+
|
|
124
|
+
const dialog = computed({
|
|
125
|
+
get: () => props.modelValue,
|
|
126
|
+
set: (value) => emit("update:modelValue", value),
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const selected = ref<string[]>([])
|
|
130
|
+
const selectAll = ref(false)
|
|
131
|
+
|
|
132
|
+
const selectedCount = computed(() => selected.value.length)
|
|
133
|
+
|
|
134
|
+
const getDisplayValue = (item: any) => {
|
|
135
|
+
return item[props.displayAttribute] || item._id
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const toggleSelection = (id: string) => {
|
|
139
|
+
const index = selected.value.indexOf(id)
|
|
140
|
+
if (index > -1) {
|
|
141
|
+
selected.value.splice(index, 1)
|
|
142
|
+
} else {
|
|
143
|
+
selected.value.push(id)
|
|
144
|
+
}
|
|
145
|
+
updateSelectAllState()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const toggleSelectAll = (value: boolean) => {
|
|
149
|
+
if (value) {
|
|
150
|
+
selected.value = props.items.map((item) => item._id)
|
|
151
|
+
} else {
|
|
152
|
+
selected.value = []
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const updateSelectAllState = () => {
|
|
157
|
+
selectAll.value =
|
|
158
|
+
selected.value.length === props.items.length && props.items.length > 0
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const close = () => {
|
|
162
|
+
dialog.value = false
|
|
163
|
+
selected.value = []
|
|
164
|
+
selectAll.value = false
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const confirm = () => {
|
|
168
|
+
if (selectedCount.value > 0) {
|
|
169
|
+
emit("confirm", selected.value)
|
|
170
|
+
close()
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Initialize selectAll state when items change
|
|
175
|
+
watch(
|
|
176
|
+
() => props.items,
|
|
177
|
+
() => {
|
|
178
|
+
updateSelectAllState()
|
|
179
|
+
},
|
|
180
|
+
{ immediate: true },
|
|
181
|
+
)
|
|
182
|
+
</script>
|