Initial Nuxt frontend import
Some checks failed
continuous-integration/drone/push Build is failing

- Complete GGZ Ecademy Nuxt.js user portal
- Learning products browser and management
- Member management interface
- User authentication and roles
- Multi-language support (NL/EN)
- Vuex store for state management
- Component-based architecture
This commit is contained in:
Joris Slagter
2025-12-02 17:48:48 +01:00
parent 0f691e83e3
commit 791aebc346
290 changed files with 113801 additions and 0 deletions

View File

@@ -0,0 +1,520 @@
<template>
<v-row>
<v-col>
<h2 class="ma-4">{{ title }}</h2>
<!-- <h2 class="ma-4">{{ title }} [{{ $store.getters.productHasChanges.toString() }}]</h2> -->
<div class="d-flex justify-space-between">
<div class="d-flex">
<small
v-for="(tab, index) in tabs"
:key="index"
@click="selectedTab = tab"
class="ma-lg-4 ma-sm-2 dot"
:class="{ 'accent--text activeTab': selectedTab === tab }"
>{{ $t(`learning.product_overview.${tab}`) }}</small
>
</div>
<current-date-time showIcon />
</div>
<button ref="scrollStartingPoint" width="0.1px" height="0.1px"></button>
<basic v-if="isTabSelected('basic')" :editMode="canEdit" />
<texts v-if="isTabSelected('texts')" :editMode="canEdit" />
<notifications
v-if="isTabSelected('notifications')"
:editMode="canEdit"
:users="users"
:isCreateMode="isNew"
/>
<organize v-if="isTabSelected('organize')" :editMode="canEdit" />
<accreditation
v-if="isTabSelected('accreditation')"
:editMode="canEdit"
:isCreateMode="isNew"
/>
<administration
v-if="isTabSelected('administration')"
:editMode="canEdit"
/>
<versions
v-if="isTabSelected('version')"
:editMode="canEdit"
:isCreateMode="isNew"
/>
<links v-if="isTabSelected('links') && canEdit" :editMode="canEdit" />
</v-col>
<v-footer
fixed
style="z-index: 4"
height="90"
color="primary"
v-if="$store.getters.isAdmin || $store.getters.isOperator"
>
<v-btn title text small :to="localePath('/manager/learning')">
<v-icon>icon-arrow-left</v-icon>
</v-btn>
<!-- <div class="mx-10" v-if="$store.getters.isLearningProductValidated"> -->
<div class="mx-10">
<v-btn
v-cloak
class="ma-2 white--text"
color="accent"
depressed
v-if="!canEdit && !isNew"
rounded
nuxt
:to="localePath(`${$nuxt.$route.path}?edit`)"
>
{{ $t('general.edit') }}</v-btn
>
<v-btn
v-cloak
class="ma-2 white--text"
color="txt"
depressed
v-if="!canEdit && !isNew"
rounded
nuxt
:to="localePath('/manager/learning')"
>
{{ $t('general.close') }}</v-btn
>
<v-btn
class="ma-2 white--text"
:color="$vuetify.theme.dark ? 'secondary' : 'txt'"
depressed
@click="
$router.push(localePath(`/manager/learning/${local.draft.slug}`))
"
v-if="canSave && local.draft"
rounded
>{{ $t('learning.product_overview.open_existing_draft') }}</v-btn
>
<v-btn
class="ma-2 white--text"
:color="$vuetify.theme.dark ? 'secondary' : 'txt'"
depressed
@click="save(true)"
v-if="canSave && !local.draft"
rounded
>{{ $t('general.save') }}</v-btn
>
<v-btn
class="ma-2 white--text"
:color="$vuetify.theme.dark ? 'secondary' : 'txt'"
depressed
@click="save(false)"
v-if="canSave && !local.draft"
rounded
>{{ $t('general.save_and_close') }}</v-btn
>
<v-btn
v-cloak
class="ma-2 white--text"
color="accent"
depressed
@click="save(false, true)"
v-if="canSave || isDraft"
rounded
>{{ $t('general.publish_and_close') }}</v-btn
>
</div>
<v-spacer />
<v-btn
class="ma-2"
tile
text
small
v-if="remote.sharepoint_link"
:href="remote.sharepoint_link"
target="_blank"
>
<v-icon class="mx-2">icon-sharepoint</v-icon>
{{ $t('footer_bar.documents') }}
</v-btn>
<v-btn
class="ma-2"
tile
text
small
v-if="remote.support_link"
:href="remote.support_link"
target="_blank"
>
<v-icon class="mx-2">icon-info</v-icon>
{{ $t('footer_bar.support_site') }}
</v-btn>
<v-btn
class="ma-2"
tile
text
small
v-if="remote.support_tickets_link"
:href="remote.support_tickets_link"
target="_blank"
>
<v-icon class="mx-2">icon-support</v-icon>
{{ $t('footer_bar.support_tickets') }}
</v-btn>
<!-- <v-btn class="ma-2" tile text small>
<v-icon class="mx-2" small>icon-share</v-icon>
{{ $t('general.share_url') }}
</v-btn> -->
<v-dialog v-model="dialog" persistent max-width="740">
<template v-slot:activator="{ on }">
<v-btn
class="ma-2"
tile
text
small
v-if="!isNew && canEdit"
v-on="on"
>
<v-icon class="mx-2">icon-remove</v-icon>
</v-btn>
</template>
<v-card class="primary pa-10" flat>
<v-card-title class="headline">
{{
$t('learning.product_overview.delete_confirmation', {
productName: local.title,
})
}}
</v-card-title>
<v-card-actions>
<div class="ma-4">
<v-btn
color="accent"
class="mx-2"
@click="deleteProduct()"
rounded
depressed
>{{ $t('general.delete') }}</v-btn
>
<v-btn
class="mx-2"
color="info"
@click="close"
rounded
depressed
>{{ $t('general.cancel') }}</v-btn
>
</div>
</v-card-actions>
</v-card>
</v-dialog>
</v-footer>
</v-row>
</template>
<script>
import currentDateTime from '@/components/CurrentDateTime/CurrentDateTime'
import basic from '@/components/Learning/ProductOverview/Basic'
import texts from '@/components/Learning/ProductOverview/Texts'
import notifications from '@/components/Learning/ProductOverview/Notifications'
import organize from '@/components/Learning/ProductOverview/Organize'
import accreditation from '@/components/Learning/ProductOverview/Accreditation'
import administration from '@/components/Learning/ProductOverview/Administration'
import versions from '@/components/Learning/ProductOverview/Versions'
import links from '@/components/Learning/ProductOverview/Links'
export default {
layout: `${process.env.CUSTOMER}Admin`,
middleware: ['denyToOnlyMembers'],
components: {
currentDateTime,
basic,
texts,
notifications,
organize,
accreditation,
administration,
versions,
links,
},
data() {
return {
dialog: false,
users: [],
tabs: [
'all',
'basic',
'texts',
'notifications',
'organize',
'accreditation',
'administration',
'version',
'links',
],
scrollFlag: false,
selectedTab: 'all',
}
},
async asyncData({ $axios, store }) {
try {
let response = await $axios.get('/filters')
await store.commit('learning/SORT_FILTERS', response.data)
response = await $axios.get('/synonyms')
await store.commit('learning/SET_SYNONYMS_LIST', response.data)
response = await $axios.get('/checklist-categories')
await store.commit('learning/SET_CHECKLIST', response.data)
if (store.getters.isAdmin || store.getters.isOperator) {
response = await $axios.get('/admin/users/getList')
return { users: response.data }
}
return { users: [] }
} catch (error) {
console.log('Data -> error', error)
}
},
async mounted() {
if (!this.isValidSlug) this.$router.push('/manager')
if (this.isNew) return await this.$store.dispatch('learning/resetProduct')
if (this.isSlugNumber) this.getItem(this.slug)
},
updated() {
if (!this.scrollFlag) {
this.scrollToTop()
this.scrollFlag = true
}
},
computed: {
canEdit() {
return (
this.isNew ||
(this.$route.query.edit === null &&
(this.$store.getters.isAdmin || this.$store.getters.isOperator))
)
},
remote() {
return this.$store.state.learning.remote
},
local() {
return this.$store.state.learning.local
},
title() {
if (this.isNew) return 'New'
return this.local.title || ''
},
slug() {
return this.$route.params.product
},
isNew() {
return this.slug.toLowerCase() === 'new'
},
isDraft() {
return !this.local.published
},
isSlugNumber() {
return !isNaN(this.slug.split(['-'], 1))
},
isValidSlug() {
return this.isNew || this.isSlugNumber
},
isDownloaded() {
return Object.keys(this.remote).length > 0
},
canSave() {
if (this.isNew) return true
return this.isSlugNumber && this.isDownloaded && this.hasChanges
},
hasChanges() {
return this.$store.getters.productHasChanges
},
},
watch: {
dialog(val) {
val || this.close()
},
},
async beforeRouteLeave(to, from, next) {
await this.$store.dispatch('learning/resetProduct')
next()
},
methods: {
scrollToTop() {
this.$nextTick(() => this.$refs.scrollStartingPoint.focus())
},
close() {
this.dialog = false
},
isTabSelected(tab) {
return this.selectedTab === tab || this.selectedTab === 'all'
},
async getItem(url) {
if (!this.isSlugNumber) return
try {
this.$nextTick(() => this.$nuxt.$loading.start())
let response = await this.$axios.get(`/learning-products/${url}`)
await this.$store.commit('learning/SET_PRODUCT', response.data)
if (response.data.synonyms.length > 0) {
const attachedSynonymsIds = response.data.synonyms.map(({ id }) => id)
this.$store.commit('learning/SELECT_SYNONYM', attachedSynonymsIds)
}
this.$nuxt.$loading.finish()
} catch (error) {
this.$nuxt.$loading.finish()
this.$notifier.showMessage({
content: `Error getting the learning product: ` + error.message,
color: 'error',
icon: 'icon-message',
})
}
},
/**
* Scenarios
* ---------------------------------------------------
* Make draft without parent_id - Done
* Make published - Done
*
* Edit draft without parent_id
* Edit published without parent_id
*
* Make draft with parent_id (from published) - Done
* Edit draft with parent_id (from published)
* Make published from draft with parent_id - backend
*
*/
async save(stay = false, published = false) {
this.$nextTick(() => this.$nuxt.$loading.start())
try {
const slug = await this.$store.dispatch(
'learning/storeProduct',
published
)
await this.$store.dispatch('learning/pullProducts')
await this.$nuxt.$loading.finish()
this.$notifier.showMessage({
content: `Data saved successfully`,
color: 'success',
icon: 'mdi-check',
})
if (!stay) {
await this.$router.push(this.localePath('/manager/learning'))
} else {
await this.$router.push(
this.localePath(`/manager/learning/${slug}?edit`)
)
}
} catch (error) {
console.log('save -> error', error)
this.$nuxt.$loading.finish()
this.$notifier.showMessage({
content: `${
this.slug.toLowerCase() === 'new' ? 'Creating' : 'Editing'
} page: ${error.response ? error.response.data.message : error}.`,
...(error.response && {
errors: error.response.data.errors,
}),
color: 'error',
icon: 'mdi-alert',
})
}
this.close()
},
async deleteProduct(productId = this.local.id) {
if (!productId) return
this.dialog = false
await this.$store.dispatch('learning/deleteProduct', productId)
this.$router.push(this.localePath('/manager/learning'))
},
},
}
</script>
<style scoped>
.v-card >>> .v-btn__content,
.v-select .v-select__selection--comma,
.v-chip__content,
.v-list-item:not(.v-list-item--active):not(.v-list-item--disabled),
.v-select.v-select--chips:not(.v-text-field--single-line).v-text-field--enclosed
.v-select__selections,
.v-list-item .v-list-item__title,
.v-list-item .v-list-item__subtitle,
.v-list-item span,
.v-input--is-disabled input,
.v-input--is-disable,
.dot,
h2,
p {
/* color: var(--v-txt-base); */
}
.v-card >>> .v-chip--disabled {
opacity: 1 !important;
}
.v-card >>> .v-chip {
/* padding-left: 0 !important; */
background-color: var(--v-secondary-base) !important;
font-size: 16px !important;
}
.v-card >>> .v-chip .mdi-close-circle::before {
font-family: 'mijnggz';
content: '\e907' !important;
color: var(--v-secAccent-base) !important;
font-size: 8px;
margin-left: 2px;
margin-right: 4px;
margin-top: 2px;
}
.v-card >>> .v-chip .mdi-close-circle:hover::before {
color: #bb1d28 !important;
}
.dot {
cursor: pointer;
}
.dot::before {
content: '\A';
width: 5px;
height: 5px;
border-radius: 50%;
background: #e54e0f;
display: block;
position: relative;
top: 30px;
left: 50%;
opacity: 0;
}
.dot:hover::before {
opacity: 0.5 !important;
}
.dot.activeTab::before {
opacity: 1 !important;
}
</style>

View File

@@ -0,0 +1,324 @@
<template>
<v-row>
<v-col>
<div class="d-flex justify-space-between">
<h2 class="ml-6 txt--text">
{{ $t(`learning.filters.${filter.title}`) | capitalize }}
({{ filter.items.length }})
</h2>
<current-date-time showIcon class="mr-6" />
</div>
<accordion-card :title="$t('general.list') | capitalize">
<v-row v-for="(item, index) in filter.items" :key="item.title + index">
<v-col cols="12" sm="3" md="3">
<v-subheader class="ml-10 txt--text font-weight-black"
>{{ index + 1 }}.
</v-subheader>
</v-col>
<v-col cols="12" sm="6" md="6">
<v-text-field
outlined
:name="item.title + index"
:value="item.title"
append-icon="icon-close"
@change="setDynamicTitle($event, index)"
@click:append="askForDelete(item)"
:append-outer-icon="
hasItemChanges(index) ? 'mdi-content-save' : ''
"
@click:append-outer="update(item.id, index)"
:error="hasItemChanges(index)"
>
</v-text-field>
</v-col>
<v-col cols="12" sm="3" md="3">
<!-- <v-btn color="success" :to="`/manager/learning/filters/${slug}/${item.id}`">edit</v-btn> -->
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="12" md="3">
<v-subheader class="ml-10 txt--text font-weight-black">
Nieuw
</v-subheader>
</v-col>
<v-col cols="12" sm="12" md="6">
<v-text-field v-model="newItem" outlined> </v-text-field>
</v-col>
<v-col cols="12" sm="12" md="3"> </v-col>
</v-row>
<v-row>
<v-col cols="12" sm="12" md="3"> </v-col>
<v-col cols="12" sm="12" md="6">
<v-btn
class="cta-secondary"
block
depressed
@click="addFilterItem"
min-height="60px"
>
<v-icon x-small class="mx-4">icon-add</v-icon>
{{ $t('general.add') | capitalize }}</v-btn
>
</v-col>
<v-col cols="12" sm="12" md="3"> </v-col>
</v-row>
</accordion-card>
<v-dialog v-model="dialog" persistent max-width="740">
<v-card class="primary pa-10" flat>
<v-card-title class="headline">
{{
$t('learning.filters.delete_item_confirmation', {
itemName: itemSelected.title,
})
}}
</v-card-title>
<v-card-actions>
<div class="ma-4">
<v-btn
color="accent"
class="mx-2"
@click="deleteItem(itemSelected.id)"
rounded
depressed
>{{ $t('general.delete') }}</v-btn
>
<v-btn
class="mx-2"
color="info"
@click="close"
rounded
depressed
>{{ $t('general.cancel') }}</v-btn
>
</div>
</v-card-actions>
</v-card>
</v-dialog>
</v-col>
<v-footer fixed style="z-index: 4" height="90" color="primary">
<v-btn text nuxt :to="localePath('/manager/learning/filters')">
<v-icon>icon-arrow-left</v-icon>
</v-btn>
<!-- <v-btn
v-cloak
class="ma-2 white--text"
color="accent"
depressed
rounded
v-if="itemsHaveChanges"
@click="saveAll"
>
SAVE</v-btn
> -->
</v-footer>
</v-row>
</template>
<script>
import accordionCard from '@/components/UI/AccordionCard/AccordionCard'
import currentDateTime from '@/components/CurrentDateTime/CurrentDateTime'
import PageHeader from '~/components/UI/PageHeader/PageHeader'
import Vue from 'vue'
export default {
layout: `${process.env.CUSTOMER}Admin`,
middleware: 'adminsOrOperators',
components: {
accordionCard,
PageHeader,
currentDateTime,
},
data() {
return {
newItem: '',
filter: { items: [] },
items: [],
itemSelected: {},
dialog: false,
}
},
mounted() {
if (!this.isSlugNumber) this.$router.push('/manager')
if (this.isNew) return
if (this.isSlugNumber) this.getFilter(this.slug)
},
computed: {
slug() {
return this.$route.params.filter
},
isSlugNumber() {
return !isNaN(this.slug.split(['-'], 1))
},
remoteItemsTitles() {
if (!this.filter || !(this.filter.items.length > 0)) return []
return this.filter.items.map(({ title }) => title)
},
},
methods: {
async getFilter(url) {
if (!this.isSlugNumber) return
this.$nextTick(() => this.$nuxt.$loading.start())
try {
const response = await this.$axios.get(`/filters/${url}`)
this.filter = { ...response.data }
this.items = response.data.items.map(({ title }) => title)
this.$nuxt.$loading.finish()
} catch (error) {
this.$nuxt.$loading.finish()
this.$notifier.showMessage({
content: `Error getting the filter: ` + error.message,
color: 'error',
icon: 'icon-message',
})
}
},
async getFilters() {
const response = await this.$axios.get('/filters')
const filters = response.data
await this.$store.commit('learning/SORT_FILTERS', filters)
},
async addFilterItem() {
const data = {
filter_id: this.filter.id,
title: this.newItem,
}
this.$nextTick(() => this.$nuxt.$loading.start())
try {
await this.$axios.post(`/filter-items`, data)
await this.getFilters()
this.getFilter(this.slug)
this.newItem = ''
this.$nuxt.$loading.finish()
} catch (error) {
this.$nuxt.$loading.finish()
this.$notifier.showMessage({
content: `Error creating the filter item: ` + error.message,
color: 'error',
icon: 'icon-message',
})
}
},
setDynamicTitle(title, index) {
Vue.set(this.items, index, title)
},
hasItemChanges(index) {
return this.items[index] !== this.remoteItemsTitles[index]
},
close() {
this.dialog = false
},
save() {
this.close()
},
async update(id, index) {
if (
id === null ||
id === undefined ||
index === null ||
index === undefined ||
!this.items[index]
)
return
const data = {
id: id,
title: this.items[index],
}
this.$nextTick(() => this.$nuxt.$loading.start())
try {
await this.$axios.post(`/filter-items`, data)
await this.getFilters()
this.getFilter(this.slug)
this.$nuxt.$loading.finish()
this.$notifier.showMessage({
content: `Filter Item updated successfully`,
color: 'success',
icon: 'icon-message',
})
} catch (error) {
this.$nuxt.$loading.finish()
this.$notifier.showMessage({
content: `Error updating the filter item: ` + error.message,
color: 'error',
icon: 'icon-message',
})
}
},
askForDelete(item) {
this.itemSelected = item
this.dialog = true
},
async deleteItem(itemId) {
if (!itemId) {
this.$notifier.showMessage({
content: `No item to delete selected`,
color: 'error',
icon: 'icon-message',
})
}
this.$nextTick(() => this.$nuxt.$loading.start())
try {
await this.$axios.delete(`/filter-items/${itemId}`)
this.dialog = false
this.itemSelected = {}
await this.getFilter(this.slug)
await this.getFilters()
this.$nuxt.$loading.finish()
this.$notifier.showMessage({
content: `Filter Item deleted`,
color: 'success',
icon: 'mdi-delete',
})
} catch (error) {
this.dialog = false
this.$nuxt.$loading.finish()
this.$notifier.showMessage({
content: `Error trying to delete the selected Filter Item`,
color: 'error',
icon: 'mdi-delete',
})
}
},
},
}
</script>
<style lang="scss" scoped>
.v-dialog >>> .v-cardcard__title {
word-break: break-word;
}
</style>

View File

@@ -0,0 +1,136 @@
<template>
<div>
<page-header class="d-flex justify-space-between">
<span>
{{ $t('learning.filters.title') }}
</span>
<!-- <v-btn
depressed
:to="localePath('/manager/learning/filters/new')"
color="accent"
rounded
>
<v-icon left>mdi-plus</v-icon>
<small>{{ $t('general.add') }}</small>
</v-btn> -->
</page-header>
<v-data-table
:headers="headers"
:items="filters"
:items-per-page="100"
hide-default-footer
class="pa-4 secondary"
>
<!-- @click:row="handleClick" -->
<!-- Translates dynamically headers -->
<template v-for="h in headers" v-slot:[`header.${h.value}`]="{ header }">
{{ $t(h.text) }}
</template>
<template v-slot:item.title="{ item }">
<strong class="txt--text">{{
$t(`learning.filters.${item.title}`) | capitalize
}}</strong>
</template>
<template v-slot:item.actions="{ item }">
<v-btn
class="mx-4 white--text button-hover"
style="height: 100%"
:color="$vuetify.theme.dark ? 'info' : 'txt'"
rounded
depressed
nuxt
small
@click="handleClick(item)"
>{{ $t('general.edit') }}</v-btn
>
<v-menu offset-y>
<template v-slot:activator="{ on }">
<v-hover v-slot:default="{ hover }">
<v-btn
class="dots"
v-on="on"
depressed
:outlined="hover"
small
fab
:color="hover ? 'info' : ''"
>
<v-icon>mdi-dots-horizontal</v-icon>
</v-btn>
</v-hover>
</template>
<v-list width="200">
<v-list-item @click="handleClick(item)">
<v-list-item-icon class="mr-1">
<v-icon small>mdi-pencil</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-subtitle>
{{ $t('general.edit') | capitalize }}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</template>
</v-data-table>
</div>
</template>
<script>
import PageHeader from '~/components/UI/PageHeader/PageHeader'
import accordionCard from '@/components/UI/AccordionCard/AccordionCard'
export default {
layout: `${process.env.CUSTOMER}Admin`,
middleware: 'adminsOrOperators',
components: {
PageHeader,
accordionCard,
},
data() {
return {
headers: [
{ text: 'naam', value: 'title' },
{ text: 'aantal', value: 'items_count' },
// { text: 'sinds', value: 'created_at' },
// { text: 'update', value: 'last_updated_at' },
{ text: '', value: 'actions', sortable: false },
],
filters: [],
}
},
async asyncData({ $axios }) {
const { data } = await $axios.get('/filters-with-count')
const filtersFiltered = data.filter((f) => f.title !== 'quality_standards')
return { filters: filtersFiltered }
},
methods: {
handleClick(item) {
this.$router.push(
this.localePath(`/manager/learning/filters/${item.slug}`)
)
},
},
}
</script>
<style lang="scss" scoped>
table tr .button-hover.v-btn {
opacity: 0;
}
table tr:hover .button-hover.v-btn {
opacity: 1;
}
.dots {
background-color: unset !important;
}
</style>

View File

@@ -0,0 +1,328 @@
<template>
<div>
<div class="pa-lg-6">
<page-header class="d-flex justify-space-between">
<div class="subtitle-1">
<span
:class="{ 'display-1': selector === 'published' }"
:style="{ cursor: 'pointer' }"
@click="selector = 'published'"
class="mr-lg-4"
>
{{ $t('learning.products') }}
<small class="font-weight-light">({{ published.length }})</small>
</span>
<span
:class="{ 'display-1': selector === 'drafts' }"
:style="{ cursor: 'pointer' }"
@click="selector = 'drafts'"
class="mx-lg-4"
v-if="$store.getters.isAdmin || $store.getters.isOperator"
>
{{ $t('learning.drafts') }}
<small class="font-weight-light">({{ drafts.length }})</small>
</span>
<span
:class="{ 'display-1': selector === 'deleted' }"
:style="{ cursor: 'pointer' }"
@click="selector = 'deleted'"
class="mx-lg-4"
v-if="$store.getters.isAdmin || $store.getters.isOperator"
>
{{ $t('general.deleted') }}
<small class="font-weight-light">({{ deleted.length }})</small>
</span>
</div>
<div>
<settings />
<v-btn
color="accent"
depressed
rounded
to="/manager/learning/new"
v-if="$store.getters.isAdmin || $store.getters.isOperator"
>
<v-icon left x-small>icon-add</v-icon>
<small>{{ $t('general.add') }}</small>
</v-btn>
</div>
</page-header>
<div class="d-flex justify-space-between">
<filters />
<filters-selected />
</div>
</div>
<courses-table
:deleted="deleted"
:drafts="drafts"
:published="published"
:selector="selector"
@reload-learning-products="getProducts()"
/>
<v-footer
fixed
style="z-index: 4"
height="90"
color="primary"
v-if="$store.getters.isAdmin || $store.getters.isOperator"
>
<v-btn text nuxt :to="localePath('/manager/learning')">
<v-icon>icon-arrow-left</v-icon>
</v-btn>
<div class="mx-10">
<v-btn
color="accent"
depressed
rounded
to="/manager/learning/new"
class="ml-10"
>
<v-icon left size="8">icon-add</v-icon>
<small>{{ $t('general.add') }}</small>
</v-btn>
</div>
<v-spacer />
<!-- <v-btn
class="ma-2"
tile
text
small
color="success"
nuxt
target="_blank"
:to="`${this.localePath(
'/manager/learning'
)}?filters=${sharedUrlSuffix}`"
id="url_to_share"
v-show="showCopiedButton"
>
<v-icon class="mx-2" small>icon-selectionbox-checked</v-icon>
URL gekopieerd
</v-btn>
<v-tooltip top v-if="!showCopiedButton">
<template v-slot:activator="{ on, attrs }">
<v-btn
class="ma-2"
tile
text
small
@click="copyUrl"
v-bind="attrs"
v-on="on"
>
<v-icon class="mx-2" small>icon-share</v-icon>
Delen Lijst
</v-btn>
</template>
<span> {{ $t('general.tooltip_share') }}</span>
</v-tooltip> -->
<v-btn class="ma-2" tile text small @click="exportCsv">
<v-icon class="mx-2" x-small>icon-export</v-icon>
{{ $t('general.export_csv') | capitalize }}
</v-btn>
<settings class="ml-10" />
</v-footer>
</div>
</template>
<script>
import Util from '@/util'
import PageHeader from '~/components/UI/PageHeader/PageHeader'
import Settings from '~/components/Learning/Settings'
import Filters from '~/components/Learning/Filters'
import FiltersSelected from '~/components/Learning/FiltersSelected'
import CoursesTable from '~/components/Learning/CoursesTable'
export default {
layout: `${process.env.CUSTOMER}Admin`,
middleware: ['denyToOnlyMembers'],
components: {
PageHeader,
Settings,
Filters,
CoursesTable,
FiltersSelected,
},
data() {
return {
selector: 'published',
showCopiedButton: false,
}
},
computed: {
learningProducts() {
return this.$store.getters.learningProducts
},
hasProducts() {
return this.$store.getters.hasLearningProducts
},
published() {
return this.learningProductsFiltered.filter(
(product) => product.published && !product.deleted_at
)
},
drafts() {
return this.learningProductsFiltered.filter(
(product) => !product.deleted_at && !product.published
)
},
deleted() {
return this.learningProductsFiltered.filter(
(product) => product.deleted_at
)
},
filtersSelected() {
return this.$store.getters.filtersSelected
},
learningProductsFiltered() {
if (!this.filtersSelected.length > 0) return this.learningProducts
return this.learningProducts.filter((product) => {
return Util.findItemsWithExactValuesInArray(
this.filtersSelected,
product.filterItemsSelected
)
})
},
// sharedUrlSuffix() {
// let filtersSelectedIdsStringyfied = ''
// this.filtersSelected.map(
// (id) => (filtersSelectedIdsStringyfied += `${id},`)
// )
// return filtersSelectedIdsStringyfied
// },
// sharedUrlParsed() {
// // if is a shared Url
// if (!this.$route.query.filters) return null
// // parse content, convert every element to number
// let filterItemIds = this.$route.query.filters.split(',').map(Number)
// // validate it and return
// return filterItemIds.filter(
// (el) => !isNaN(el) && this.$store.getters.filtersItemsMap.has(el)
// )
// },
},
async asyncData({ $axios, store }) {
try {
if (!store.getters.hasLearningProducts) {
await store.dispatch('learning/pullProducts')
}
const response = await $axios.get('/filters')
const filters = response.data
await store.commit('learning/SORT_FILTERS', filters)
} catch (error) {
console.log('Data -> error', error)
}
},
async mounted() {
if (this.sharedUrlParsed) {
this.$store.commit('learning/SELECT_FILTERS', this.sharedUrlParsed)
}
// If filters sorting preferences saved in localstorage
if (localStorage.getItem('learning_products_filters')) {
// Get them
const filtersOrder = JSON.parse(
localStorage.getItem('learning_products_filters')
)
// Get filters from store
const filters = [...this.$store.state.learning.filters]
// Sort by preferences
const filtersSorted = filters.sort(
(a, b) => filtersOrder.indexOf(a.id) - filtersOrder.indexOf(b.id)
)
// send to store, sorted
await this.$store.commit('learning/SORT_FILTERS', filtersSorted)
}
},
methods: {
async getProducts() {
try {
const response = await this.$axios.get('/learning-products')
await this.$store.commit('learning/STORE_PRODUCTS', response.data)
} catch (error) {
console.log('getProducts -> error', error)
}
},
async copyUrl() {
if (this.showCopiedButton) return
// Create <a> link with v-show false and Copy from there to have the full path?
const url = document.getElementById('url_to_share').href
try {
await navigator.clipboard.writeText(url)
this.showCopiedButton = true
setTimeout(() => (this.showCopiedButton = false), 5000)
} catch (error) {
console.log('copyUrl -> error', error)
}
},
exportCsv() {
const data = [...this.learningProductsFiltered]
const headersNeeded = [
'title',
'code',
'partner',
'owner',
'status',
'lead_time',
'product_type',
'theme',
'course',
]
const dataFiltered = this.$store.getters[
'utils/filterArrayObjsByArrayOfProperties'
](data, headersNeeded)
let dataCsv =
this.$store.getters['utils/arrayOfObjectToCsv'](dataFiltered)
// Translate every entry in 'headersNeeded'
let headersTranslated =
headersNeeded.map((h) => this.$t(`csv.learning.${h}`)).join(',') + '\n'
// Remove 1st row (string)
dataCsv = dataCsv.split('\n').slice(1).join('\n')
// Add as 1st row the one translated
dataCsv = headersTranslated + dataCsv
// Simulate click to download file
const universalBOM = '\uFEFF'
const blob = new Blob([universalBOM + dataCsv], { type: 'text/csv' })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.setAttribute('hidden', '')
link.setAttribute('download', 'producten.csv')
link.click()
URL.revokeObjectURL(link.href)
},
},
}
</script>

View File

@@ -0,0 +1,272 @@
<template>
<v-row>
<v-col>
<div class="d-flex justify-space-between">
<h2 class="ml-6 txt--text">{{ title }}</h2>
<current-date-time showIcon class="mr-6" />
</div>
<accordion-card disableAccordion>
<v-row>
<v-col cols="12" sm="12" md="3">
<v-subheader class="ml-10 txt--text font-weight-black">
Kwaliteitsstandaard
</v-subheader>
</v-col>
<v-col cols="12" sm="12" md="6">
<v-text-field v-model="item.title" outlined> </v-text-field>
</v-col>
<v-col cols="12" sm="12" md="3"> </v-col>
</v-row>
<v-row>
<v-col cols="12" sm="12" md="3">
<v-subheader class="ml-10 txt--text font-weight-black">
Externe link
</v-subheader>
</v-col>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="item.link"
outlined
prepend-inner-icon="icon-link"
>
</v-text-field>
</v-col>
<v-col cols="12" sm="12" md="3"> Voeg link toe</v-col>
</v-row>
</accordion-card>
</v-col>
<v-footer fixed style="z-index: 4" height="90" color="primary">
<v-btn text nuxt :to="localePath('/manager/learning/quality-standards')">
<v-icon>icon-arrow-left</v-icon>
</v-btn>
<v-btn
v-cloak
class="ma-2 white--text"
color="txt"
depressed
rounded
@click="save(true)"
v-if="canSave && !isNew"
>
Opslaan</v-btn
>
<v-btn
v-cloak
class="ma-2 white--text"
color="accent"
depressed
rounded
@click="save(false)"
v-if="canSave"
>
Opslaan en sluiten</v-btn
>
<v-spacer />
<v-dialog v-model="dialog" persistent max-width="740">
<template v-slot:activator="{ on }">
<v-btn class="ma-2" tile text small v-if="!isNew" v-on="on">
<v-icon class="mx-2">icon-remove</v-icon>
</v-btn>
</template>
<v-card class="primary pa-10" flat>
<v-card-title class="headline">
{{
$t('learning.product_overview.delete_confirmation', {
productName: item.title,
})
}}
</v-card-title>
<v-card-actions>
<div class="ma-4">
<v-btn
color="accent"
class="mx-2"
@click="deleteItem(item.id)"
rounded
depressed
>{{ $t('general.delete') }}</v-btn
>
<v-btn
class="mx-2"
color="info"
@click="close"
rounded
depressed
>{{ $t('general.cancel') }}</v-btn
>
</div>
</v-card-actions>
</v-card>
</v-dialog>
</v-footer>
</v-row>
</template>
<script>
import accordionCard from '@/components/UI/AccordionCard/AccordionCard'
import currentDateTime from '@/components/CurrentDateTime/CurrentDateTime'
import PageHeader from '~/components/UI/PageHeader/PageHeader'
export default {
layout: `${process.env.CUSTOMER}Admin`,
middleware: 'adminsOrOperators',
components: {
accordionCard,
PageHeader,
currentDateTime,
},
data() {
return {
remoteItem: {},
item: { title: null },
dialog: false,
}
},
mounted() {
if (this.isSlugNumber) this.getFilter(this.slug)
// if (!this.isNew) this.$router.push('/manager')
},
computed: {
slug() {
return this.$route.params.filter
},
isSlugNumber() {
return !isNaN(this.slug.split(['-'], 1))
},
isNew() {
return this.slug.toLowerCase() === 'new'
},
title() {
if (!this.item || !this.item.title) return 'Nieuw'
return this.item.title || ''
},
canSave() {
if (this.isNew && this.itemFilled) return true
if (this.itemFilled && this.itemHasChanges) return true
return false
},
itemFilled() {
if (!this.item || !this.item.title) return false
return true
},
itemHasChanges() {
return JSON.stringify(this.remoteItem) !== JSON.stringify(this.item)
},
},
methods: {
async getFilter(url) {
if (!this.isSlugNumber) return
this.$nextTick(() => this.$nuxt.$loading.start())
try {
const { data } = await this.$axios.get(`/filter-items/${url}`)
this.remoteItem = { ...data }
this.item = { ...data }
this.$nuxt.$loading.finish()
} catch (error) {
this.$nuxt.$loading.finish()
this.$notifier.showMessage({
content: `Error getting the filter: ` + error.message,
color: 'error',
icon: 'icon-message',
})
this.$router.push('/manager/learning/quality-standards/')
}
},
close() {
this.dialog = false
},
async save(stay = false) {
if (this.dialog) this.close()
this.$nextTick(() => this.$nuxt.$loading.start())
const QUALITY_STANDARD_ID = 14
const data = {
...this.item,
filter_id: this.item.filter_id || QUALITY_STANDARD_ID,
}
try {
await this.$axios.post(`/filter-items`, data)
this.remoteItem = { ...data }
this.$nuxt.$loading.finish()
if (!stay) this.$router.push('/manager/learning/quality-standards/')
} catch (error) {
this.$nuxt.$loading.finish()
this.$notifier.showMessage({
content: `Error creating the filter item: ` + error.message,
color: 'error',
icon: 'icon-message',
})
}
},
async deleteItem(itemId) {
if (!itemId) {
this.$notifier.showMessage({
content: `No item to delete selected`,
color: 'error',
icon: 'icon-message',
})
}
this.$nextTick(() => this.$nuxt.$loading.start())
try {
await this.$axios.delete(`/filter-items/${itemId}`)
this.dialog = false
this.$nuxt.$loading.finish()
this.$notifier.showMessage({
content: `Filter Item deleted`,
color: 'success',
icon: 'mdi-delete',
})
this.$router.push('/manager/learning/quality-standards/')
} catch (error) {
this.dialog = false
this.$nuxt.$loading.finish()
this.$notifier.showMessage({
content: `Error trying to delete the selected Filter Item`,
color: 'error',
icon: 'mdi-delete',
})
this.$router.push('/manager/learning/quality-standards/')
}
},
},
}
</script>
<style lang="scss" scoped>
.v-dialog >>> .v-cardcard__title {
word-break: break-word;
}
</style>

View File

@@ -0,0 +1,202 @@
<template>
<div>
<page-header class="d-flex justify-space-between">
<span>
{{ $t('learning.quality_standards') }}
</span>
<v-btn
depressed
:to="localePath('/manager/learning/quality-standards/new')"
color="accent"
rounded
>
<v-icon left>mdi-plus</v-icon>
<small>{{ $t('general.add') }}</small>
</v-btn>
</page-header>
<v-data-table
:headers="headers"
:items="filter.items"
:items-per-page="10"
class="pa-4 secondary"
>
<!-- Translates dynamically headers -->
<template v-for="h in headers" v-slot:[`header.${h.value}`]="{ header }">
{{ $t(h.text) }}
</template>
<template v-slot:item.name="{ item }">
<strong class="txt--text" v-text="item.name"></strong>
</template>
<template v-slot:item.actions="{ item }">
<v-menu offset-y>
<template v-slot:activator="{ on }">
<v-hover v-slot:default="{ hover }">
<v-btn
v-on="on"
depressed
:outlined="hover"
small
fab
:color="hover ? 'info' : ''"
>
<v-icon>mdi-dots-horizontal</v-icon>
</v-btn>
</v-hover>
</template>
<v-list width="200">
<v-list-item
nuxt
:to="localePath(`/manager/learning/quality-standards/${item.id}`)"
>
<v-list-item-icon class="mr-1">
<v-icon small>mdi-pencil</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-subtitle>
{{ $t('general.edit') | capitalize }}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-icon class="mr-1">
<v-icon small>icon-link</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-subtitle>
Link naar informatie
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-dialog max-width="740" persistent v-model="dialog">
<template v-slot:activator="{ on }">
<v-list-item v-on="on">
<v-list-item-icon class="mr-1">
<v-icon small>icon-remove</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-subtitle>{{
$t('general.delete') | capitalize
}}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</template>
<v-card class="primary pa-10" flat>
<v-card-title class="headline">
{{
$t('learning.product_overview.delete_confirmation', {
productName: item.title,
})
}}
</v-card-title>
<v-card-actions>
<div class="ma-4">
<v-btn
@click="deleteItem(item.id)"
class="mx-2"
color="accent"
depressed
rounded
>{{ $t('general.delete') }}</v-btn
>
<v-btn
@click="close"
class="mx-2"
color="info"
depressed
rounded
>{{ $t('general.cancel') }}</v-btn
>
</div>
</v-card-actions>
</v-card>
</v-dialog>
</v-list>
</v-menu>
</template>
</v-data-table>
</div>
</template>
<script>
import PageHeader from '~/components/UI/PageHeader/PageHeader'
import accordionCard from '@/components/UI/AccordionCard/AccordionCard'
export default {
layout: `${process.env.CUSTOMER}Admin`,
components: {
PageHeader,
accordionCard,
},
data() {
return {
headers: [
{ text: 'naam', sortable: false, value: 'title' },
{ text: '', value: 'actions' },
],
filter: {},
dialog: false,
}
},
async asyncData({ $axios }) {
try {
const { data } = await $axios.get(`/filters`)
const qualityStandards = data.find((f) => f.title === 'quality_standards')
return { filter: { ...qualityStandards } }
} catch (error) {
console.log(error)
}
},
methods: {
close() {
this.dialog = false
},
async deleteItem(itemId) {
if (!itemId) {
this.$notifier.showMessage({
content: `No item to delete selected`,
color: 'error',
icon: 'icon-message',
})
}
this.$nextTick(() => this.$nuxt.$loading.start())
try {
await this.$axios.delete(`/filter-items/${itemId}`)
this.dialog = false
this.filter.items = this.filter.items.filter((i) => i.id !== itemId)
this.$nuxt.$loading.finish()
this.$notifier.showMessage({
content: `Filter Item deleted`,
color: 'success',
icon: 'mdi-delete',
})
} catch (error) {
this.dialog = false
this.$nuxt.$loading.finish()
this.$notifier.showMessage({
content: `Error trying to delete the selected Filter Item`,
color: 'error',
icon: 'mdi-delete',
})
}
},
},
}
</script>

View File

@@ -0,0 +1,179 @@
<template>
<div>
<page-header class="d-flex justify-space-between">
<span>
{{ $t('learning.synonyms') }}
</span>
</page-header>
<accordion-card disableAccordion>
<v-row>
<v-col cols="12" sm="12" md="3"></v-col>
<v-col cols="12" sm="12" md="6">
<v-chip
v-for="(synonym, i) in synonyms"
:key="`synonym-key${i}`"
@click:close="askForDelete(synonym)"
class="ma-2"
color="secondary"
close
>
{{ synonym.title }}
</v-chip>
</v-col>
<v-col cols="12" sm="12" md="3"> </v-col>
</v-row>
<v-row>
<v-col cols="12" sm="12" md="3">
<v-subheader class="txt--text font-weight-black ml-10">
Nieuw
</v-subheader>
</v-col>
<v-col cols="12" sm="12" md="6">
<v-text-field v-model="newSynonym" outlined v-on:keyup.enter="add">
</v-text-field>
</v-col>
<v-col cols="12" sm="12" md="3"> </v-col>
</v-row>
</accordion-card>
<v-dialog v-model="dialog" persistent max-width="740" v-if="itemSelected">
<v-card class="primary pa-10" flat>
<v-card-title class="headline">
{{
$t('learning.filters.delete_item_confirmation', {
itemName: itemSelected.title || null,
})
}}
</v-card-title>
<v-card-actions>
<div class="ma-4">
<v-btn
color="accent"
class="mx-2"
@click="deleteItem(itemSelected)"
rounded
depressed
>{{ $t('general.delete') }}</v-btn
>
<v-btn class="mx-2" color="info" @click="close" rounded depressed>{{
$t('general.cancel')
}}</v-btn>
</div>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import PageHeader from '~/components/UI/PageHeader/PageHeader'
import accordionCard from '@/components/UI/AccordionCard/AccordionCard'
export default {
layout: `${process.env.CUSTOMER}Admin`,
middleware: 'adminsOrOperators',
components: {
PageHeader,
accordionCard,
},
data() {
return {
synonyms: [],
newSynonym: '',
dialog: false,
itemSelected: null,
}
},
async asyncData({ $axios }) {
const response = await $axios.get('/synonyms')
return { synonyms: response.data }
},
methods: {
async add() {
if (!this.newSynonym) return
if (this.synonyms.includes(this.newSynonym)) return
this.$nextTick(() => this.$nuxt.$loading.start())
try {
const response = await this.$axios.post(`/synonyms`, {
title: this.newSynonym,
})
this.synonyms.push(response.data)
this.newSynonym = ''
this.$nuxt.$loading.finish()
this.$notifier.showMessage({
content: `Synonym '${this.newSynonym}' added`,
color: 'success',
icon: 'icon-message',
})
} catch (error) {
console.log('add -> error', error)
this.$nuxt.$loading.finish()
this.$notifier.showMessage({
content: `Error creating the filter item: ` + error.message,
color: 'error',
icon: 'icon-message',
})
}
},
close() {
this.dialog = false
},
askForDelete(item) {
this.itemSelected = item
this.dialog = true
},
async deleteItem(synonym) {
if (!synonym) {
this.$notifier.showMessage({
content: `No synonym to delete passed`,
color: 'error',
icon: 'icon-message',
})
return
}
const indexSynonym = this.synonyms.findIndex(
(s) => s.title === synonym.title
)
if (indexSynonym === -1) return
this.$nextTick(() => this.$nuxt.$loading.start())
try {
await this.$axios.delete(`/synonyms/${synonym.id}`)
this.dialog = false
this.itemSelected = null
this.synonyms.splice(indexSynonym, 1)
this.$nuxt.$loading.finish()
this.$notifier.showMessage({
content: `Synonym deleted`,
color: 'success',
icon: 'mdi-delete',
})
} catch (error) {
this.dialog = false
console.log('remove -> error', error)
this.$nuxt.$loading.finish()
this.$notifier.showMessage({
content: `Error trying to delete the selected Synonym`,
color: 'error',
icon: 'mdi-delete',
})
}
},
},
}
</script>