@raclettejs/core 0.1.32 → 0.1.33-canary.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/CHANGELOG.md +14 -0
- package/dist/cli.js +4 -4
- package/dist/cli.js.map +2 -2
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/services/backend/.yarn/install-state.gz +0 -0
- package/services/backend/src/corePlugins/raclette__core/backend/datatypes/user/routes/route.user.patchOwn.ts +1 -1
- package/services/backend/src/corePlugins/raclette__core/backend/datatypes/user/user.service.ts +22 -1
- package/services/backend/yarn.lock +1 -1
- package/services/frontend/.yarn/install-state.gz +0 -0
- package/services/frontend/package.json +2 -2
- package/services/frontend/src/core/lib/data/dataApi.ts +1 -2
- package/services/frontend/src/orchestrator/ProductOrchestrator.vue +8 -1
- package/services/frontend/src/orchestrator/assets/styles/layers.css +1 -4
- package/services/frontend/src/orchestrator/assets/styles/vuetifyStyles.scss +4 -1
- package/services/frontend/src/orchestrator/components/dataExport/DataExporter.vue +303 -0
- package/services/frontend/src/orchestrator/components/dataImport/ImportSelectionDialog.vue +169 -0
- package/services/frontend/src/orchestrator/components/dataImport/ImportSummaryDialog.vue +211 -0
- package/services/frontend/src/orchestrator/components/dataTable/BaseDataTable.vue +473 -0
- package/services/frontend/src/orchestrator/components/index.ts +10 -0
- package/services/frontend/src/orchestrator/components/input/FileUpload.vue +83 -0
- package/services/frontend/src/orchestrator/composables/index.ts +1 -0
- package/services/frontend/src/orchestrator/composables/useExport/formats/builtins.ts +124 -0
- package/services/frontend/src/orchestrator/composables/useExport/index.ts +176 -0
- package/services/frontend/src/orchestrator/composables/useExport/types.ts +141 -0
- package/services/frontend/src/orchestrator/i18n/de-DE.json +26 -1
- package/services/frontend/src/orchestrator/i18n/en-EU.json +26 -1
- package/services/frontend/src/orchestrator/i18n/sk.json +26 -1
- package/services/frontend/src/orchestrator/i18n/tl-TL.json +14 -0
- package/services/frontend/src/orchestrator/setup/vuetify.ts +0 -1
- package/services/frontend/yarn.lock +4854 -0
- package/types/index.ts +1 -1
package/services/backend/src/corePlugins/raclette__core/backend/datatypes/user/user.service.ts
CHANGED
|
@@ -135,7 +135,8 @@ export class UserService {
|
|
|
135
135
|
options: QueryOptions = {},
|
|
136
136
|
): Promise<UserType[]> {
|
|
137
137
|
try {
|
|
138
|
-
if (filter.isDeleted !== true && filter.isDeleted !==
|
|
138
|
+
if (filter.isDeleted !== true && filter.isDeleted !== "true")
|
|
139
|
+
filter.isDeleted = false
|
|
139
140
|
// Start building the query
|
|
140
141
|
let query = this.userModel.find(filter)
|
|
141
142
|
|
|
@@ -319,7 +320,27 @@ export class UserService {
|
|
|
319
320
|
throw error
|
|
320
321
|
}
|
|
321
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* Hard delete a user with payload wrapping and event emission
|
|
325
|
+
*/
|
|
326
|
+
async hardDeleteUser(
|
|
327
|
+
fastify: PluginFastifyInstance,
|
|
328
|
+
requestData: FrontendPayloadRequestData,
|
|
329
|
+
id: string,
|
|
330
|
+
): Promise<FrontendPayload<UserType[]>> {
|
|
331
|
+
const user = await this._hardDeleteUser(fastify, id)
|
|
332
|
+
|
|
333
|
+
const payload = await createUserPayload(fastify, [user], {
|
|
334
|
+
...requestData,
|
|
335
|
+
type: "dataHardDeleted",
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
payload.body.deleted = [user._id]
|
|
322
339
|
|
|
340
|
+
fastify.emit("userHardDeleted", payload)
|
|
341
|
+
|
|
342
|
+
return payload
|
|
343
|
+
}
|
|
323
344
|
/**
|
|
324
345
|
* Core function to update user's last accessed project (returns raw data)
|
|
325
346
|
*/
|
|
Binary file
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
"vuetify": "4.0.5"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
+
"@eslint/js": "9.35.0",
|
|
44
45
|
"@types/node": "24.5.1",
|
|
45
46
|
"@types/ramda": "0.31.1",
|
|
46
47
|
"@vitejs/plugin-vue": "6.0.5",
|
|
47
48
|
"@vue/tsconfig": "0.9.0",
|
|
48
49
|
"eslint": "9.35.0",
|
|
49
|
-
"@eslint/js": "9.35.0",
|
|
50
50
|
"eslint-config-prettier": "9.1.2",
|
|
51
51
|
"eslint-plugin-import": "2.32.0",
|
|
52
52
|
"eslint-plugin-prefer-arrow-functions": "3.9.1",
|
|
@@ -60,4 +60,4 @@
|
|
|
60
60
|
"vue-eslint-parser": "9.4.3",
|
|
61
61
|
"vue-tsc": "3.2.6"
|
|
62
62
|
}
|
|
63
|
-
}
|
|
63
|
+
}
|
|
@@ -112,13 +112,14 @@
|
|
|
112
112
|
rel="noopener noreferrer"
|
|
113
113
|
target="_blank"
|
|
114
114
|
title="raclette"
|
|
115
|
-
class="tw:text-[0.9rem]"
|
|
115
|
+
class="tw:text-[0.9rem] tw:text-neutral-500 tw:no-underline hover:tw:no-underline hover:tw:text-neutral-700 tw:transition-colors"
|
|
116
116
|
>
|
|
117
117
|
Cooked with <span class="raclette">raclette 🧀</span></a
|
|
118
118
|
>
|
|
119
119
|
<span
|
|
120
120
|
v-if="configService.getAdvertiseYourself().length"
|
|
121
121
|
v-html="configService.getAdvertiseYourself()"
|
|
122
|
+
class="footerLink tw:text-[0.9rem] tw:text-neutral-500 tw:no-underline hover:tw:no-underline hover:tw:text-neutral-700 tw:transition-colors"
|
|
122
123
|
></span>
|
|
123
124
|
</v-footer>
|
|
124
125
|
</v-main>
|
|
@@ -285,3 +286,9 @@ window
|
|
|
285
286
|
}
|
|
286
287
|
})
|
|
287
288
|
</script>
|
|
289
|
+
<style lang="css">
|
|
290
|
+
.footerLink a {
|
|
291
|
+
text-decoration: none;
|
|
292
|
+
color: var(--tw-color-neutral-500);
|
|
293
|
+
}
|
|
294
|
+
</style>
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-menu
|
|
3
|
+
v-model="isOpen"
|
|
4
|
+
:close-on-content-click="false"
|
|
5
|
+
location="bottom end"
|
|
6
|
+
offset="8"
|
|
7
|
+
:max-width="520"
|
|
8
|
+
:min-width="360"
|
|
9
|
+
>
|
|
10
|
+
<template #activator="{ props: menuProps }">
|
|
11
|
+
<slot v-if="$slots.default" v-bind="triggerSlotProps" />
|
|
12
|
+
<v-btn
|
|
13
|
+
v-else
|
|
14
|
+
v-bind="{ ...menuProps, ...$attrs }"
|
|
15
|
+
:disabled="disabled"
|
|
16
|
+
:variant="$attrs.variant || 'tonal'"
|
|
17
|
+
color="info"
|
|
18
|
+
prepend-icon="mdi-export-variant"
|
|
19
|
+
class="tw:font-medium"
|
|
20
|
+
density="compact"
|
|
21
|
+
@click="open"
|
|
22
|
+
>
|
|
23
|
+
{{ buttonLabel }}
|
|
24
|
+
</v-btn>
|
|
25
|
+
</template>
|
|
26
|
+
|
|
27
|
+
<v-card rounded="lg" elevation="8" :style="{ width: '360px' }">
|
|
28
|
+
<!-- Header -->
|
|
29
|
+
<div class="tw:flex tw:items-center tw:justify-between tw:px-4 tw:pt-4 tw:pb-2">
|
|
30
|
+
<div class="tw:flex tw:items-center tw:gap-2">
|
|
31
|
+
<v-icon icon="mdi-export-variant" size="18" color="primary" />
|
|
32
|
+
<span class="tw:text-sm tw:font-semibold tw:tracking-wide tw:uppercase tw:opacity-70">
|
|
33
|
+
{{ $t("core.dataExporter.title") }}
|
|
34
|
+
</span>
|
|
35
|
+
</div>
|
|
36
|
+
<v-btn icon="mdi-close" variant="text" size="x-small" @click="isOpen = false" />
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<v-divider />
|
|
40
|
+
|
|
41
|
+
<!-- Format tabs -->
|
|
42
|
+
<div class="tw:px-4 tw:pt-3 tw:pb-1">
|
|
43
|
+
<v-chip-group
|
|
44
|
+
v-model="activeFormatId"
|
|
45
|
+
mandatory
|
|
46
|
+
selected-class="text-primary"
|
|
47
|
+
class="tw:gap-1"
|
|
48
|
+
>
|
|
49
|
+
<v-chip
|
|
50
|
+
v-for="fmt in resolvedFormats"
|
|
51
|
+
:key="fmt.id"
|
|
52
|
+
:value="fmt.id"
|
|
53
|
+
size="small"
|
|
54
|
+
variant="tonal"
|
|
55
|
+
class="tw:font-mono tw:text-xs"
|
|
56
|
+
>
|
|
57
|
+
{{ fmt.label }}
|
|
58
|
+
</v-chip>
|
|
59
|
+
</v-chip-group>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<!-- Data scope switch — only shown when both items AND meta exist -->
|
|
63
|
+
<div
|
|
64
|
+
v-if="showScopeSwitch"
|
|
65
|
+
class="tw:px-4 tw:py-1 tw:flex tw:items-center tw:justify-between tw:gap-2"
|
|
66
|
+
>
|
|
67
|
+
<span class="tw:text-xs tw:opacity-60 tw:uppercase tw:tracking-wide">
|
|
68
|
+
{{ $t("core.dataExporter.scopeLabel") }}
|
|
69
|
+
</span>
|
|
70
|
+
<v-btn-toggle v-model="selectedScope" divided density="compact" mandatory>
|
|
71
|
+
<v-btn value="combined" size="x-small">
|
|
72
|
+
{{ $t("core.dataExporter.scopeCombined") }}
|
|
73
|
+
</v-btn>
|
|
74
|
+
<v-btn value="items" size="x-small">
|
|
75
|
+
{{ $t("core.dataExporter.scopeItems") }}
|
|
76
|
+
</v-btn>
|
|
77
|
+
<v-btn value="meta" size="x-small">
|
|
78
|
+
{{ $t("core.dataExporter.scopeMeta") }}
|
|
79
|
+
</v-btn>
|
|
80
|
+
</v-btn-toggle>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<!-- No formats warning -->
|
|
84
|
+
<div v-if="resolvedFormats.length === 0" class="tw:px-4 tw:py-6 tw:text-center">
|
|
85
|
+
<v-icon icon="mdi-alert-circle-outline" color="warning" class="tw:mb-2" />
|
|
86
|
+
<p class="tw:text-sm tw:opacity-60">{{ $t("core.dataExporter.noValidFormats") }}</p>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<!-- Preview pane -->
|
|
90
|
+
<div v-else class="tw:px-4 tw:pt-1 tw:pb-4">
|
|
91
|
+
<!-- Preview toggle -->
|
|
92
|
+
<v-btn
|
|
93
|
+
variant="text"
|
|
94
|
+
density="compact"
|
|
95
|
+
class="tw:mb-1.5 tw:w-full tw:justify-start tw:normal-case tw:text-xs tw:opacity-60 hover:tw:opacity-90"
|
|
96
|
+
@click="previewOpen = !previewOpen"
|
|
97
|
+
>
|
|
98
|
+
<template #prepend>
|
|
99
|
+
<v-icon
|
|
100
|
+
:icon="previewOpen ? 'mdi-chevron-down' : 'mdi-chevron-right'"
|
|
101
|
+
size="14"
|
|
102
|
+
/>
|
|
103
|
+
</template>
|
|
104
|
+
{{ $t("core.dataExporter.preview") }}
|
|
105
|
+
</v-btn>
|
|
106
|
+
|
|
107
|
+
<!-- Fixed-height container prevents width reflow when toggling -->
|
|
108
|
+
<div class="tw:overflow-hidden" :style="{ height: previewOpen ? 'auto' : '0px', minHeight: previewOpen ? '0px' : undefined }">
|
|
109
|
+
<v-expand-transition>
|
|
110
|
+
<div v-show="previewOpen">
|
|
111
|
+
<!-- Serialization error -->
|
|
112
|
+
<v-alert
|
|
113
|
+
v-if="!serializeResult.ok"
|
|
114
|
+
type="error"
|
|
115
|
+
variant="tonal"
|
|
116
|
+
density="compact"
|
|
117
|
+
class="tw:mb-2 tw:font-mono tw:text-xs"
|
|
118
|
+
>
|
|
119
|
+
<pre class="tw:whitespace-pre-wrap tw:break-all">{{ serializeResult.error }}</pre>
|
|
120
|
+
</v-alert>
|
|
121
|
+
|
|
122
|
+
<!-- Preview code block -->
|
|
123
|
+
<div
|
|
124
|
+
v-else
|
|
125
|
+
class="tw:rounded-lg tw:overflow-hidden tw:border tw:border-black/10 dark:tw:border-white/10"
|
|
126
|
+
>
|
|
127
|
+
<!-- Toolbar -->
|
|
128
|
+
<div
|
|
129
|
+
class="tw:flex tw:items-center tw:justify-between tw:px-3 tw:py-1.5 tw:bg-black/5 dark:tw:bg-white/5 tw:border-b tw:border-black/10 dark:tw:border-white/10"
|
|
130
|
+
>
|
|
131
|
+
<span class="tw:font-mono tw:text-[10px] tw:uppercase tw:tracking-widest tw:opacity-50">
|
|
132
|
+
{{ activeFormatId }}
|
|
133
|
+
</span>
|
|
134
|
+
<span class="tw:font-mono tw:text-[10px] tw:opacity-40">
|
|
135
|
+
{{ previewLines.lineCount }}
|
|
136
|
+
{{ $t("core.dataExporter.lines") }}
|
|
137
|
+
</span>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<!-- Code content -->
|
|
141
|
+
<pre
|
|
142
|
+
class="tw:text-xs tw:font-mono tw:p-3 tw:max-h-52 tw:overflow-auto tw:leading-relaxed tw:bg-black/[0.03] dark:tw:bg-white/[0.03] tw:m-0"
|
|
143
|
+
>{{ previewLines.text }}</pre>
|
|
144
|
+
|
|
145
|
+
<!-- Truncation notice -->
|
|
146
|
+
<div
|
|
147
|
+
v-if="previewLines.truncated"
|
|
148
|
+
class="tw:text-[10px] tw:text-center tw:py-1 tw:opacity-40 tw:bg-black/5 dark:tw:bg-white/5 tw:border-t tw:border-black/10 dark:tw:border-white/10"
|
|
149
|
+
>
|
|
150
|
+
{{ $t("core.dataExporter.previewTruncatedAt", { count: PREVIEW_LINE_LIMIT }) }}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
</v-expand-transition>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<!-- Action Buttons -->
|
|
158
|
+
<div class="tw:flex tw:gap-2 tw:mt-3">
|
|
159
|
+
<v-btn
|
|
160
|
+
v-if="clipboardSupported"
|
|
161
|
+
:disabled="!serializeResult.ok"
|
|
162
|
+
:color="copyState === 'success' ? 'success' : copyState === 'error' ? 'error' : 'primary'"
|
|
163
|
+
variant="tonal"
|
|
164
|
+
size="small"
|
|
165
|
+
class="tw:flex-1 tw:font-medium"
|
|
166
|
+
:prepend-icon="
|
|
167
|
+
copyState === 'success' ? 'mdi-check'
|
|
168
|
+
: copyState === 'error' ? 'mdi-alert-circle'
|
|
169
|
+
: 'mdi-content-copy'
|
|
170
|
+
"
|
|
171
|
+
@click="copyToClipboard"
|
|
172
|
+
>
|
|
173
|
+
{{
|
|
174
|
+
copyState === "success" ? $t("core.dataExporter.copied")
|
|
175
|
+
: copyState === "error" ? $t("core.dataExporter.copyFailed")
|
|
176
|
+
: $t("core.dataExporter.copy")
|
|
177
|
+
}}
|
|
178
|
+
</v-btn>
|
|
179
|
+
<v-btn
|
|
180
|
+
:disabled="!serializeResult.ok"
|
|
181
|
+
color="primary"
|
|
182
|
+
variant="flat"
|
|
183
|
+
size="small"
|
|
184
|
+
class="tw:flex-1 tw:font-medium"
|
|
185
|
+
prepend-icon="mdi-download"
|
|
186
|
+
@click="downloadFile"
|
|
187
|
+
>
|
|
188
|
+
{{ $t("core.dataExporter.download") }}
|
|
189
|
+
</v-btn>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</v-card>
|
|
193
|
+
</v-menu>
|
|
194
|
+
</template>
|
|
195
|
+
|
|
196
|
+
<script setup lang="ts">
|
|
197
|
+
import { ref, computed, watch } from "vue"
|
|
198
|
+
import type {
|
|
199
|
+
RacletteExportProps,
|
|
200
|
+
FormatEntry,
|
|
201
|
+
ExportSuccessPayload,
|
|
202
|
+
ExportErrorPayload,
|
|
203
|
+
TriggerSlotProps,
|
|
204
|
+
ExportDataScope,
|
|
205
|
+
} from "@racletteOrchestrator/composables/useExport/types"
|
|
206
|
+
import { useExport } from "@racletteOrchestrator/composables/useExport"
|
|
207
|
+
|
|
208
|
+
defineOptions({ inheritAttrs: false })
|
|
209
|
+
|
|
210
|
+
const props = withDefaults(defineProps<RacletteExportProps>(), {
|
|
211
|
+
filename: "export",
|
|
212
|
+
formats: () => ["json", "yaml", "csv", "text"] as FormatEntry[],
|
|
213
|
+
buttonLabel: "Export",
|
|
214
|
+
disabled: false,
|
|
215
|
+
previewCollapsed: true,
|
|
216
|
+
showDataScopeSwitch: true,
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const emit = defineEmits<{
|
|
220
|
+
"export-success": [payload: ExportSuccessPayload]
|
|
221
|
+
"export-error": [payload: ExportErrorPayload]
|
|
222
|
+
}>()
|
|
223
|
+
|
|
224
|
+
// ─── Popover State ────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
const isOpen = ref(false)
|
|
227
|
+
const previewOpen = ref(!props.previewCollapsed)
|
|
228
|
+
|
|
229
|
+
const hasItems = computed(() => props.data.items !== undefined)
|
|
230
|
+
const hasMeta = computed(() => props.data.meta !== undefined)
|
|
231
|
+
|
|
232
|
+
// Only show the toggle when BOTH sources exist and the prop allows it
|
|
233
|
+
const showScopeSwitch = computed(
|
|
234
|
+
() => props.showDataScopeSwitch && hasItems.value && hasMeta.value,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
// Derive the correct default: items if available, else meta, else combined
|
|
238
|
+
const defaultScope = computed<ExportDataScope>(() => {
|
|
239
|
+
if (hasItems.value) return "items"
|
|
240
|
+
if (hasMeta.value) return "meta"
|
|
241
|
+
return "combined"
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
const selectedScope = ref<ExportDataScope>(defaultScope.value)
|
|
245
|
+
|
|
246
|
+
// Keep scope valid when data shape changes at runtime
|
|
247
|
+
watch(defaultScope, (next) => {
|
|
248
|
+
selectedScope.value = next
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
const scopedData = computed(() => {
|
|
252
|
+
if (selectedScope.value === "items") return props.data.items
|
|
253
|
+
if (selectedScope.value === "meta") return props.data.meta ?? {}
|
|
254
|
+
return props.data
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
const open = () => {
|
|
258
|
+
if (!props.disabled) isOpen.value = true
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ─── Export Logic ─────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
const {
|
|
264
|
+
resolvedFormats,
|
|
265
|
+
activeFormatId,
|
|
266
|
+
serializeResult,
|
|
267
|
+
copyToClipboard,
|
|
268
|
+
downloadFile,
|
|
269
|
+
copyState,
|
|
270
|
+
clipboardSupported,
|
|
271
|
+
} = useExport({
|
|
272
|
+
data: () => scopedData.value,
|
|
273
|
+
formats: () => props.formats,
|
|
274
|
+
defaultFormat: () => props.defaultFormat,
|
|
275
|
+
filename: () => props.filename,
|
|
276
|
+
enabled: () => isOpen.value,
|
|
277
|
+
onSuccess: (p) => emit("export-success", p),
|
|
278
|
+
onError: (p) => emit("export-error", p),
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
// ─── Trigger Slot Props ───────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
const triggerSlotProps = computed<TriggerSlotProps>(() => ({
|
|
284
|
+
open,
|
|
285
|
+
isOpen: isOpen.value,
|
|
286
|
+
disabled: props.disabled,
|
|
287
|
+
}))
|
|
288
|
+
|
|
289
|
+
// ─── Preview lines ────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
const PREVIEW_LINE_LIMIT = 200
|
|
292
|
+
|
|
293
|
+
const previewLines = computed<{ text: string; lineCount: number; truncated: boolean }>(() => {
|
|
294
|
+
if (!serializeResult.value.ok) return { text: "", lineCount: 0, truncated: false }
|
|
295
|
+
const lines = serializeResult.value.value.split("\n")
|
|
296
|
+
const truncated = lines.length > PREVIEW_LINE_LIMIT
|
|
297
|
+
return {
|
|
298
|
+
text: truncated ? lines.slice(0, PREVIEW_LINE_LIMIT).join("\n") : serializeResult.value.value,
|
|
299
|
+
lineCount: lines.length,
|
|
300
|
+
truncated,
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
</script>
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-dialog v-model="dialog" max-width="760" scrollable>
|
|
3
|
+
<v-card>
|
|
4
|
+
<v-card-title class="tw:flex tw:justify-between tw:items-center tw:gap-2">
|
|
5
|
+
<span class="tw:text-lg tw:font-semibold">{{ title }}</span>
|
|
6
|
+
<v-btn icon="mdi-close" size="small" variant="text" @click="close" />
|
|
7
|
+
</v-card-title>
|
|
8
|
+
|
|
9
|
+
<v-divider />
|
|
10
|
+
|
|
11
|
+
<v-card-text class="tw:py-4">
|
|
12
|
+
<div class="tw:flex tw:justify-between tw:items-center tw:mb-4 tw:gap-2">
|
|
13
|
+
<v-checkbox
|
|
14
|
+
:model-value="selectAll"
|
|
15
|
+
@update:model-value="toggleSelectAll"
|
|
16
|
+
:label="$t('core.selectAll')"
|
|
17
|
+
hide-details
|
|
18
|
+
density="compact"
|
|
19
|
+
/>
|
|
20
|
+
|
|
21
|
+
<v-chip color="primary" variant="flat" size="small">
|
|
22
|
+
{{ selectedCount }} / {{ items.length }} {{ $t("core.selected") }}
|
|
23
|
+
</v-chip>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<v-list class="tw:max-h-96 tw:overflow-y-auto tw:border tw:rounded-md tw:py-0">
|
|
27
|
+
<v-list-item
|
|
28
|
+
v-for="item in items"
|
|
29
|
+
:key="item._id"
|
|
30
|
+
@click="toggleSelection(String(item._id))"
|
|
31
|
+
class="tw:cursor-pointer"
|
|
32
|
+
>
|
|
33
|
+
<template #prepend>
|
|
34
|
+
<v-checkbox-btn
|
|
35
|
+
:model-value="selected.includes(String(item._id))"
|
|
36
|
+
@update:model-value="(checked) => setSelection(String(item._id), checked)"
|
|
37
|
+
@click.stop
|
|
38
|
+
/>
|
|
39
|
+
</template>
|
|
40
|
+
|
|
41
|
+
<v-list-item-title class="tw:text-sm">
|
|
42
|
+
{{ getDisplayValue(item) }}
|
|
43
|
+
</v-list-item-title>
|
|
44
|
+
</v-list-item>
|
|
45
|
+
</v-list>
|
|
46
|
+
|
|
47
|
+
<v-alert
|
|
48
|
+
v-if="selectedCount === 0"
|
|
49
|
+
type="warning"
|
|
50
|
+
variant="tonal"
|
|
51
|
+
density="compact"
|
|
52
|
+
class="tw:mt-4"
|
|
53
|
+
>
|
|
54
|
+
{{ $t("core.noItemsSelected") }}
|
|
55
|
+
</v-alert>
|
|
56
|
+
</v-card-text>
|
|
57
|
+
|
|
58
|
+
<v-divider />
|
|
59
|
+
|
|
60
|
+
<v-card-actions class="tw:px-4 tw:py-3">
|
|
61
|
+
<v-spacer />
|
|
62
|
+
<v-btn variant="text" @click="close">
|
|
63
|
+
{{ $t("core.cancel") }}
|
|
64
|
+
</v-btn>
|
|
65
|
+
|
|
66
|
+
<v-btn
|
|
67
|
+
v-if="selectedCount"
|
|
68
|
+
@click="confirm"
|
|
69
|
+
:disabled="selectedCount === 0"
|
|
70
|
+
color="primary"
|
|
71
|
+
variant="flat"
|
|
72
|
+
>
|
|
73
|
+
{{ confirmText }}
|
|
74
|
+
</v-btn>
|
|
75
|
+
</v-card-actions>
|
|
76
|
+
</v-card>
|
|
77
|
+
</v-dialog>
|
|
78
|
+
</template>
|
|
79
|
+
|
|
80
|
+
<script setup lang="ts">
|
|
81
|
+
import { ref, computed, watch } from "vue"
|
|
82
|
+
|
|
83
|
+
interface Props {
|
|
84
|
+
modelValue: boolean
|
|
85
|
+
items: any[]
|
|
86
|
+
displayAttribute: string
|
|
87
|
+
title: string
|
|
88
|
+
confirmText: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const props = defineProps<Props>()
|
|
92
|
+
|
|
93
|
+
const emit = defineEmits<{
|
|
94
|
+
"update:modelValue": [value: boolean]
|
|
95
|
+
confirm: [selectedIds: string[]]
|
|
96
|
+
}>()
|
|
97
|
+
|
|
98
|
+
const dialog = computed({
|
|
99
|
+
get: () => props.modelValue,
|
|
100
|
+
set: (value) => emit("update:modelValue", value),
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const selected = ref<string[]>([])
|
|
104
|
+
const selectAll = ref(false)
|
|
105
|
+
|
|
106
|
+
const selectedCount = computed(() => selected.value.length)
|
|
107
|
+
|
|
108
|
+
const getDisplayValue = (item: any) => {
|
|
109
|
+
return item[props.displayAttribute] || item._id
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const toggleSelection = (id: string) => {
|
|
113
|
+
const index = selected.value.indexOf(id)
|
|
114
|
+
if (index > -1) {
|
|
115
|
+
selected.value.splice(index, 1)
|
|
116
|
+
} else {
|
|
117
|
+
selected.value.push(id)
|
|
118
|
+
}
|
|
119
|
+
updateSelectAllState()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const setSelection = (id: string, checked: boolean | null) => {
|
|
123
|
+
const isChecked = Boolean(checked)
|
|
124
|
+
const exists = selected.value.includes(id)
|
|
125
|
+
if (isChecked && !exists) {
|
|
126
|
+
selected.value.push(id)
|
|
127
|
+
} else if (!isChecked && exists) {
|
|
128
|
+
selected.value = selected.value.filter((selectedId) => selectedId !== id)
|
|
129
|
+
}
|
|
130
|
+
updateSelectAllState()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const toggleSelectAll = (value: boolean | null) => {
|
|
134
|
+
const enabled = Boolean(value)
|
|
135
|
+
if (enabled) {
|
|
136
|
+
selected.value = props.items.map((item) => String(item._id))
|
|
137
|
+
} else {
|
|
138
|
+
selected.value = []
|
|
139
|
+
}
|
|
140
|
+
selectAll.value = enabled
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const updateSelectAllState = () => {
|
|
144
|
+
selectAll.value =
|
|
145
|
+
selected.value.length === props.items.length && props.items.length > 0
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const close = () => {
|
|
149
|
+
dialog.value = false
|
|
150
|
+
selected.value = []
|
|
151
|
+
selectAll.value = false
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const confirm = () => {
|
|
155
|
+
if (selectedCount.value > 0) {
|
|
156
|
+
emit("confirm", selected.value)
|
|
157
|
+
close()
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Initialize selectAll state when items change
|
|
162
|
+
watch(
|
|
163
|
+
() => props.items,
|
|
164
|
+
() => {
|
|
165
|
+
updateSelectAllState()
|
|
166
|
+
},
|
|
167
|
+
{ immediate: true },
|
|
168
|
+
)
|
|
169
|
+
</script>
|