mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-19 11:14:06 +00:00
Add super-admin Administration section and restructure global vs company settings
- Add Administration sidebar section (super-admin only) with Companies, Users, and Global Settings pages - Add super-admin middleware, controllers, and API routes under /api/v1/super-admin/ - Allow super-admins to manage all companies and users across tenants - Add user impersonation with short-lived tokens, audit logging, and UI banner - Move system-level settings (Mail, PDF, Backup, Update, File Disk) from per-company to Administration > Global Settings - Convert save_pdf_to_disk from CompanySetting to global Setting - Add per-company mail configuration overrides (optional, falls back to global) - Add CompanyMailConfigService to apply company mail config before sending emails
This commit is contained in:
121
resources/scripts/admin/admin-router.js
vendored
121
resources/scripts/admin/admin-router.js
vendored
@@ -47,17 +47,10 @@ const ExpenseCategory = () =>
|
||||
import('@/scripts/admin/views/settings/ExpenseCategorySetting.vue')
|
||||
const ExchangeRateSetting = () =>
|
||||
import('@/scripts/admin/views/settings/ExchangeRateProviderSetting.vue')
|
||||
const MailConfig = () =>
|
||||
import('@/scripts/admin/views/settings/MailConfigSetting.vue')
|
||||
const FileDisk = () =>
|
||||
import('@/scripts/admin/views/settings/FileDiskSetting.vue')
|
||||
const Backup = () => import('@/scripts/admin/views/settings/BackupSetting.vue')
|
||||
const UpdateApp = () =>
|
||||
import('@/scripts/admin/views/settings/UpdateAppSetting.vue')
|
||||
const RolesSettings = () =>
|
||||
import('@/scripts/admin/views/settings/RolesSettings.vue')
|
||||
const PDFGenerationSettings = () =>
|
||||
import('@/scripts/admin/views/settings/PDFGenerationSetting.vue')
|
||||
const CompanyMailConfig = () =>
|
||||
import('@/scripts/admin/views/settings/CompanyMailConfigSetting.vue')
|
||||
|
||||
// Items
|
||||
const ItemsIndex = () => import('@/scripts/admin/views/items/Index.vue')
|
||||
@@ -115,6 +108,28 @@ const ModuleView = () => import('@/scripts/admin/views/modules/View.vue')
|
||||
const InvoicePublicPage = () =>
|
||||
import('@/scripts/components/InvoicePublicPage.vue')
|
||||
|
||||
// Administration (Super Admin)
|
||||
const AdminCompaniesIndex = () =>
|
||||
import('@/scripts/admin/views/administration/companies/Index.vue')
|
||||
const AdminCompaniesEdit = () =>
|
||||
import('@/scripts/admin/views/administration/companies/Edit.vue')
|
||||
const AdminUsersIndex = () =>
|
||||
import('@/scripts/admin/views/administration/users/Index.vue')
|
||||
const AdminUsersEdit = () =>
|
||||
import('@/scripts/admin/views/administration/users/Edit.vue')
|
||||
const AdminSettingsIndex = () =>
|
||||
import('@/scripts/admin/views/administration/settings/SettingsIndex.vue')
|
||||
const AdminMailConfig = () =>
|
||||
import('@/scripts/admin/views/settings/MailConfigSetting.vue')
|
||||
const AdminPDFGeneration = () =>
|
||||
import('@/scripts/admin/views/settings/PDFGenerationSetting.vue')
|
||||
const AdminBackup = () =>
|
||||
import('@/scripts/admin/views/settings/BackupSetting.vue')
|
||||
const AdminUpdateApp = () =>
|
||||
import('@/scripts/admin/views/settings/UpdateAppSetting.vue')
|
||||
const AdminFileDisk = () =>
|
||||
import('@/scripts/admin/views/settings/FileDiskSetting.vue')
|
||||
|
||||
export default [
|
||||
{
|
||||
path: '/installation',
|
||||
@@ -304,36 +319,11 @@ export default [
|
||||
meta: { ability: abilities.VIEW_EXPENSE },
|
||||
component: ExpenseCategory,
|
||||
},
|
||||
|
||||
{
|
||||
path: 'mail-configuration',
|
||||
name: 'mailconfig',
|
||||
name: 'company.mailconfig',
|
||||
meta: { isOwner: true },
|
||||
component: MailConfig,
|
||||
},
|
||||
{
|
||||
path: 'file-disk',
|
||||
name: 'file-disk',
|
||||
meta: { isOwner: true },
|
||||
component: FileDisk,
|
||||
},
|
||||
{
|
||||
path: 'backup',
|
||||
name: 'backup',
|
||||
meta: { isOwner: true },
|
||||
component: Backup,
|
||||
},
|
||||
{
|
||||
path: 'update-app',
|
||||
name: 'updateapp',
|
||||
meta: { isOwner: true },
|
||||
component: UpdateApp,
|
||||
},
|
||||
{
|
||||
path: 'pdf-generation',
|
||||
name: 'pdf.generation',
|
||||
meta: { isOwner: true },
|
||||
component: PDFGenerationSettings,
|
||||
component: CompanyMailConfig,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -495,6 +485,65 @@ export default [
|
||||
meta: { ability: abilities.VIEW_FINANCIAL_REPORT },
|
||||
component: ReportsIndex,
|
||||
},
|
||||
|
||||
// Administration (Super Admin)
|
||||
{
|
||||
path: 'administration/companies',
|
||||
name: 'admin.companies.index',
|
||||
meta: { isSuperAdmin: true },
|
||||
component: AdminCompaniesIndex,
|
||||
},
|
||||
{
|
||||
path: 'administration/companies/:id/edit',
|
||||
name: 'admin.companies.edit',
|
||||
meta: { isSuperAdmin: true },
|
||||
component: AdminCompaniesEdit,
|
||||
},
|
||||
{
|
||||
path: 'administration/users',
|
||||
name: 'admin.users.index',
|
||||
meta: { isSuperAdmin: true },
|
||||
component: AdminUsersIndex,
|
||||
},
|
||||
{
|
||||
path: 'administration/users/:id/edit',
|
||||
name: 'admin.users.edit',
|
||||
meta: { isSuperAdmin: true },
|
||||
component: AdminUsersEdit,
|
||||
},
|
||||
{
|
||||
path: 'administration/settings',
|
||||
name: 'admin.settings',
|
||||
meta: { isSuperAdmin: true },
|
||||
component: AdminSettingsIndex,
|
||||
children: [
|
||||
{
|
||||
path: 'mail-configuration',
|
||||
name: 'admin.settings.mail',
|
||||
component: AdminMailConfig,
|
||||
},
|
||||
{
|
||||
path: 'pdf-generation',
|
||||
name: 'admin.settings.pdf',
|
||||
component: AdminPDFGeneration,
|
||||
},
|
||||
{
|
||||
path: 'backup',
|
||||
name: 'admin.settings.backup',
|
||||
component: AdminBackup,
|
||||
},
|
||||
{
|
||||
path: 'update-app',
|
||||
name: 'admin.settings.update',
|
||||
component: AdminUpdateApp,
|
||||
},
|
||||
{
|
||||
path: 'file-disk',
|
||||
name: 'admin.settings.filedisk',
|
||||
component: AdminFileDisk,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '/:catchAll(.*)', component: NotFoundPage },
|
||||
|
||||
41
resources/scripts/admin/components/ImpersonationBanner.vue
Normal file
41
resources/scripts/admin/components/ImpersonationBanner.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isImpersonating"
|
||||
class="fixed top-0 left-0 right-0 z-50 flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-orange-600"
|
||||
>
|
||||
<BaseIcon name="ExclamationTriangleIcon" class="w-4 h-4 mr-2" />
|
||||
<span>{{ $t('administration.users.impersonating_banner') }}</span>
|
||||
<button
|
||||
class="ml-4 px-3 py-1 text-xs font-semibold text-orange-600 bg-white rounded hover:bg-orange-50"
|
||||
:disabled="isStopping"
|
||||
@click="stopImpersonating"
|
||||
>
|
||||
{{ $t('administration.users.stop_impersonating') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import Ls from '@/scripts/services/ls.js'
|
||||
import { useAdministrationStore } from '@/scripts/admin/stores/administration'
|
||||
|
||||
const administrationStore = useAdministrationStore()
|
||||
let isStopping = ref(false)
|
||||
|
||||
const isImpersonating = computed(() => {
|
||||
return Ls.get('admin.impersonating') === 'true'
|
||||
})
|
||||
|
||||
async function stopImpersonating() {
|
||||
isStopping.value = true
|
||||
|
||||
try {
|
||||
await administrationStore.stopImpersonating()
|
||||
} catch {
|
||||
// Token already cleaned up in store action
|
||||
}
|
||||
|
||||
window.location.href = '/admin/administration/users'
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<BaseDropdown>
|
||||
<template #activator>
|
||||
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-gray-500" />
|
||||
</template>
|
||||
|
||||
<router-link :to="`/admin/administration/companies/${row.id}/edit`">
|
||||
<BaseDropdownItem>
|
||||
<BaseIcon
|
||||
name="PencilIcon"
|
||||
class="w-5 h-5 mr-3 text-gray-400 group-hover:text-gray-500"
|
||||
/>
|
||||
{{ $t('general.edit') }}
|
||||
</BaseDropdownItem>
|
||||
</router-link>
|
||||
</BaseDropdown>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
row: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
table: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
loadData: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<BaseDropdown>
|
||||
<template #activator>
|
||||
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-gray-500" />
|
||||
</template>
|
||||
|
||||
<router-link :to="`/admin/administration/users/${row.id}/edit`">
|
||||
<BaseDropdownItem>
|
||||
<BaseIcon
|
||||
name="PencilIcon"
|
||||
class="w-5 h-5 mr-3 text-gray-400 group-hover:text-gray-500"
|
||||
/>
|
||||
{{ $t('general.edit') }}
|
||||
</BaseDropdownItem>
|
||||
</router-link>
|
||||
|
||||
<BaseDropdownItem
|
||||
v-if="row.id !== userStore.currentUser?.id"
|
||||
@click="onImpersonate"
|
||||
>
|
||||
<BaseIcon
|
||||
name="ArrowRightEndOnRectangleIcon"
|
||||
class="w-5 h-5 mr-3 text-gray-400 group-hover:text-gray-500"
|
||||
/>
|
||||
{{ $t('administration.users.impersonate') }}
|
||||
</BaseDropdownItem>
|
||||
</BaseDropdown>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/scripts/admin/stores/user'
|
||||
import { useAdministrationStore } from '@/scripts/admin/stores/administration'
|
||||
import { useDialogStore } from '@/scripts/stores/dialog'
|
||||
|
||||
const props = defineProps({
|
||||
row: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
table: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
loadData: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const userStore = useUserStore()
|
||||
const administrationStore = useAdministrationStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
function onImpersonate() {
|
||||
dialogStore
|
||||
.openDialog({
|
||||
title: t('general.are_you_sure'),
|
||||
message: t('administration.users.impersonate_confirm', {
|
||||
name: props.row.name,
|
||||
}),
|
||||
yesLabel: t('administration.users.impersonate'),
|
||||
noLabel: t('general.cancel'),
|
||||
variant: 'danger',
|
||||
size: 'lg',
|
||||
hideNoButton: false,
|
||||
})
|
||||
.then((confirmed) => {
|
||||
if (confirmed) {
|
||||
administrationStore.impersonateUser(props.row.id).then(() => {
|
||||
window.location.href = '/admin/dashboard'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -96,6 +96,14 @@ import { required, email, maxLength, helpers } from '@vuelidate/validators'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import { useModalStore } from '@/scripts/stores/modal'
|
||||
import { useMailDriverStore } from '@/scripts/admin/stores/mail-driver'
|
||||
import { useCompanyMailStore } from '@/scripts/admin/stores/company-mail'
|
||||
|
||||
const props = defineProps({
|
||||
storeType: {
|
||||
type: String,
|
||||
default: 'global',
|
||||
},
|
||||
})
|
||||
|
||||
let isSaving = ref(false)
|
||||
let formData = reactive({
|
||||
@@ -106,6 +114,11 @@ let formData = reactive({
|
||||
|
||||
const modalStore = useModalStore()
|
||||
const mailDriverStore = useMailDriverStore()
|
||||
const companyMailStore = useCompanyMailStore()
|
||||
|
||||
const activeStore = computed(() => {
|
||||
return props.storeType === 'company' ? companyMailStore : mailDriverStore
|
||||
})
|
||||
const { t } = useI18n()
|
||||
|
||||
const modalActive = computed(() => {
|
||||
@@ -153,7 +166,7 @@ async function onTestMailSend() {
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
let response = await mailDriverStore.sendTestMail(formData)
|
||||
let response = await activeStore.value.sendTestMail(formData)
|
||||
if (response.data) {
|
||||
closeTestModal()
|
||||
isSaving.value = false
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<div v-if="isAppLoaded" class="h-full">
|
||||
<NotificationRoot />
|
||||
|
||||
<ImpersonationBanner />
|
||||
|
||||
<SiteHeader />
|
||||
|
||||
<SiteSidebar />
|
||||
@@ -34,6 +36,7 @@ import SiteHeader from '@/scripts/admin/layouts/partials/TheSiteHeader.vue'
|
||||
import SiteSidebar from '@/scripts/admin/layouts/partials/TheSiteSidebar.vue'
|
||||
import NotificationRoot from '@/scripts/components/notifications/NotificationRoot.vue'
|
||||
import ExchangeRateBulkUpdateModal from '@/scripts/admin/components/modal-components/ExchangeRateBulkUpdateModal.vue'
|
||||
import ImpersonationBanner from '@/scripts/admin/components/ImpersonationBanner.vue'
|
||||
|
||||
const globalStore = useGlobalStore()
|
||||
const route = useRoute()
|
||||
@@ -52,6 +55,8 @@ onMounted(() => {
|
||||
globalStore.bootstrap().then((res) => {
|
||||
if (route.meta.ability && !userStore.hasAbilities(route.meta.ability)) {
|
||||
router.push({ name: 'account.settings' })
|
||||
} else if (route.meta.isSuperAdmin && !userStore.currentUser.is_super_admin) {
|
||||
router.push({ name: 'dashboard' })
|
||||
} else if (route.meta.isOwner && !userStore.currentUser.is_owner) {
|
||||
router.push({ name: 'account.settings' })
|
||||
}
|
||||
|
||||
@@ -72,10 +72,16 @@
|
||||
</div>
|
||||
|
||||
<nav
|
||||
v-for="menu in globalStore.menuGroups"
|
||||
:key="menu"
|
||||
v-for="(menu, index) in globalStore.menuGroups"
|
||||
:key="index"
|
||||
class="mt-5 space-y-1"
|
||||
>
|
||||
<div
|
||||
v-if="menu[0] && menu[0].group_label"
|
||||
class="px-4 pt-4 pb-1 text-xs font-semibold text-gray-400 uppercase tracking-wider border-t border-gray-200"
|
||||
>
|
||||
{{ $t(menu[0].group_label) }}
|
||||
</div>
|
||||
<router-link
|
||||
v-for="item in menu"
|
||||
:key="item.name"
|
||||
@@ -126,10 +132,16 @@
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-for="menu in globalStore.menuGroups"
|
||||
:key="menu"
|
||||
v-for="(menu, index) in globalStore.menuGroups"
|
||||
:key="index"
|
||||
class="p-0 m-0 mt-6 list-none"
|
||||
>
|
||||
<div
|
||||
v-if="menu[0] && menu[0].group_label"
|
||||
class="px-6 pt-4 pb-1 text-xs font-semibold text-gray-400 uppercase tracking-wider border-t border-gray-200"
|
||||
>
|
||||
{{ $t(menu[0].group_label) }}
|
||||
</div>
|
||||
<router-link
|
||||
v-for="item in menu"
|
||||
:key="item"
|
||||
|
||||
162
resources/scripts/admin/stores/administration.js
vendored
Normal file
162
resources/scripts/admin/stores/administration.js
vendored
Normal file
@@ -0,0 +1,162 @@
|
||||
import http from '@/scripts/http'
|
||||
import Ls from '@/scripts/services/ls.js'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification'
|
||||
import { handleError } from '@/scripts/helpers/error-handling'
|
||||
|
||||
export const useAdministrationStore = defineStore('administration', {
|
||||
state: () => ({
|
||||
companies: [],
|
||||
totalCompanies: 0,
|
||||
selectedCompany: null,
|
||||
|
||||
users: [],
|
||||
totalUsers: 0,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
fetchCompanies(params) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http
|
||||
.get('/api/v1/super-admin/companies', { params })
|
||||
.then((response) => {
|
||||
this.companies = response.data.data
|
||||
this.totalCompanies = response.data.meta
|
||||
? response.data.meta.total
|
||||
: response.data.data.length
|
||||
resolve(response)
|
||||
})
|
||||
.catch((err) => {
|
||||
handleError(err)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
fetchCompany(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http
|
||||
.get(`/api/v1/super-admin/companies/${id}`)
|
||||
.then((response) => {
|
||||
this.selectedCompany = response.data.data
|
||||
resolve(response)
|
||||
})
|
||||
.catch((err) => {
|
||||
handleError(err)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
fetchUsers(params) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http
|
||||
.get('/api/v1/super-admin/users', { params })
|
||||
.then((response) => {
|
||||
this.users = response.data.data
|
||||
this.totalUsers = response.data.meta
|
||||
? response.data.meta.total
|
||||
: response.data.data.length
|
||||
resolve(response)
|
||||
})
|
||||
.catch((err) => {
|
||||
handleError(err)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
fetchUser(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http
|
||||
.get(`/api/v1/super-admin/users/${id}`)
|
||||
.then((response) => {
|
||||
resolve(response)
|
||||
})
|
||||
.catch((err) => {
|
||||
handleError(err)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
updateUser(id, data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http
|
||||
.put(`/api/v1/super-admin/users/${id}`, data)
|
||||
.then((response) => {
|
||||
const { global } = window.i18n
|
||||
const notificationStore = useNotificationStore()
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: global.t('administration.users.updated_message'),
|
||||
})
|
||||
resolve(response)
|
||||
})
|
||||
.catch((err) => {
|
||||
handleError(err)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
impersonateUser(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http
|
||||
.post(`/api/v1/super-admin/users/${id}/impersonate`)
|
||||
.then((response) => {
|
||||
Ls.set('admin.impersonating', 'true')
|
||||
Ls.set('auth.token', `Bearer ${response.data.token}`)
|
||||
|
||||
resolve(response)
|
||||
})
|
||||
.catch((err) => {
|
||||
handleError(err)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
stopImpersonating() {
|
||||
return new Promise((resolve, reject) => {
|
||||
http
|
||||
.post('/api/v1/super-admin/stop-impersonating')
|
||||
.then((response) => {
|
||||
Ls.remove('admin.impersonating')
|
||||
Ls.remove('auth.token')
|
||||
|
||||
resolve(response)
|
||||
})
|
||||
.catch((err) => {
|
||||
Ls.remove('admin.impersonating')
|
||||
Ls.remove('auth.token')
|
||||
|
||||
handleError(err)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
updateCompany(id, data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http
|
||||
.put(`/api/v1/super-admin/companies/${id}`, data)
|
||||
.then((response) => {
|
||||
const { global } = window.i18n
|
||||
const notificationStore = useNotificationStore()
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: global.t(
|
||||
'administration.companies.updated_message'
|
||||
),
|
||||
})
|
||||
resolve(response)
|
||||
})
|
||||
.catch((err) => {
|
||||
handleError(err)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
91
resources/scripts/admin/stores/company-mail.js
vendored
Normal file
91
resources/scripts/admin/stores/company-mail.js
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
import http from '@/scripts/http'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification'
|
||||
import { handleError } from '@/scripts/helpers/error-handling'
|
||||
|
||||
export const useCompanyMailStore = defineStore('company-mail', {
|
||||
state: () => ({
|
||||
mailConfigData: null,
|
||||
mail_driver: '',
|
||||
mail_drivers: [],
|
||||
}),
|
||||
|
||||
actions: {
|
||||
fetchMailDrivers() {
|
||||
return new Promise((resolve, reject) => {
|
||||
http
|
||||
.get('/api/v1/mail/drivers')
|
||||
.then((response) => {
|
||||
if (response.data) {
|
||||
this.mail_drivers = response.data
|
||||
}
|
||||
resolve(response)
|
||||
})
|
||||
.catch((err) => {
|
||||
handleError(err)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
fetchMailConfig() {
|
||||
return new Promise((resolve, reject) => {
|
||||
http
|
||||
.get('/api/v1/company/mail/company-config')
|
||||
.then((response) => {
|
||||
if (response.data) {
|
||||
this.mailConfigData = response.data
|
||||
this.mail_driver = response.data.mail_driver || ''
|
||||
}
|
||||
resolve(response)
|
||||
})
|
||||
.catch((err) => {
|
||||
handleError(err)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
updateMailConfig(data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http
|
||||
.post('/api/v1/company/mail/company-config', data)
|
||||
.then((response) => {
|
||||
const { global } = window.i18n
|
||||
const notificationStore = useNotificationStore()
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: global.t('settings.mail.company_mail_config_updated'),
|
||||
})
|
||||
resolve(response)
|
||||
})
|
||||
.catch((err) => {
|
||||
handleError(err)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
sendTestMail(data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http
|
||||
.post('/api/v1/company/mail/company-test', data)
|
||||
.then((response) => {
|
||||
const { global } = window.i18n
|
||||
const notificationStore = useNotificationStore()
|
||||
if (response.data.success) {
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: global.t('general.send_mail_successfully'),
|
||||
})
|
||||
}
|
||||
resolve(response)
|
||||
})
|
||||
.catch((err) => {
|
||||
handleError(err)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
255
resources/scripts/admin/views/administration/companies/Edit.vue
Normal file
255
resources/scripts/admin/views/administration/companies/Edit.vue
Normal file
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<BasePage v-if="!isLoading">
|
||||
<BasePageHeader :title="$t('administration.companies.edit_company')">
|
||||
<BaseBreadcrumb>
|
||||
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
|
||||
<BaseBreadcrumbItem
|
||||
:title="$t('administration.companies.title')"
|
||||
to="/admin/administration/companies"
|
||||
/>
|
||||
<BaseBreadcrumbItem
|
||||
:title="$t('administration.companies.edit_company')"
|
||||
to="#"
|
||||
active
|
||||
/>
|
||||
</BaseBreadcrumb>
|
||||
</BasePageHeader>
|
||||
|
||||
<form @submit.prevent="submitForm">
|
||||
<BaseCard class="mt-6">
|
||||
<BaseInputGrid class="mt-5">
|
||||
<BaseInputGroup
|
||||
:label="$t('administration.companies.company_name')"
|
||||
:error="v$.name.$error && v$.name.$errors[0].$message"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model="formData.name"
|
||||
:invalid="v$.name.$error"
|
||||
@blur="v$.name.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('administration.companies.owner')"
|
||||
:error="v$.owner_id.$error && v$.owner_id.$errors[0].$message"
|
||||
required
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="formData.owner_id"
|
||||
label="name"
|
||||
:invalid="v$.owner_id.$error"
|
||||
:options="searchUsers"
|
||||
value-prop="id"
|
||||
:can-deselect="false"
|
||||
:can-clear="false"
|
||||
searchable
|
||||
:filter-results="false"
|
||||
:min-chars="0"
|
||||
:resolve-on-load="true"
|
||||
:delay="300"
|
||||
object
|
||||
track-by="name"
|
||||
@select="onOwnerSelect"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup :label="$t('settings.company_info.tax_id')">
|
||||
<BaseInput v-model="formData.tax_id" type="text" />
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup :label="$t('settings.company_info.vat_id')">
|
||||
<BaseInput v-model="formData.vat_id" type="text" />
|
||||
</BaseInputGroup>
|
||||
</BaseInputGrid>
|
||||
|
||||
<BaseDivider class="my-6" />
|
||||
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">
|
||||
{{ $t('administration.companies.address') }}
|
||||
</h3>
|
||||
|
||||
<BaseInputGrid>
|
||||
<BaseInputGroup :label="$t('settings.company_info.phone')">
|
||||
<BaseInput v-model="formData.address.phone" />
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup :label="$t('settings.company_info.country')">
|
||||
<BaseMultiselect
|
||||
v-model="formData.address.country_id"
|
||||
label="name"
|
||||
:options="globalStore.countries"
|
||||
value-prop="id"
|
||||
:can-deselect="true"
|
||||
:can-clear="false"
|
||||
searchable
|
||||
track-by="name"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup :label="$t('settings.company_info.state')">
|
||||
<BaseInput v-model="formData.address.state" type="text" />
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup :label="$t('settings.company_info.city')">
|
||||
<BaseInput v-model="formData.address.city" type="text" />
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup :label="$t('settings.company_info.zip')">
|
||||
<BaseInput v-model="formData.address.zip" />
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup :label="$t('settings.company_info.address')">
|
||||
<BaseTextarea
|
||||
v-model="formData.address.address_street_1"
|
||||
rows="2"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</BaseInputGrid>
|
||||
|
||||
<BaseButton
|
||||
:loading="isSaving"
|
||||
:disabled="isSaving"
|
||||
type="submit"
|
||||
class="mt-6"
|
||||
>
|
||||
<template #left="slotProps">
|
||||
<BaseIcon
|
||||
v-if="!isSaving"
|
||||
:class="slotProps.class"
|
||||
name="ArrowDownOnSquareIcon"
|
||||
/>
|
||||
</template>
|
||||
{{ $t('general.save') }}
|
||||
</BaseButton>
|
||||
</BaseCard>
|
||||
</form>
|
||||
</BasePage>
|
||||
|
||||
<BaseGlobalLoader v-else />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalStore } from '@/scripts/admin/stores/global'
|
||||
import { useAdministrationStore } from '@/scripts/admin/stores/administration'
|
||||
import { required, helpers } from '@vuelidate/validators'
|
||||
import { useVuelidate } from '@vuelidate/core'
|
||||
import http from '@/scripts/http'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const globalStore = useGlobalStore()
|
||||
const administrationStore = useAdministrationStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
let isLoading = ref(true)
|
||||
let isSaving = ref(false)
|
||||
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
owner_id: null,
|
||||
vat_id: '',
|
||||
tax_id: '',
|
||||
address: {
|
||||
address_street_1: '',
|
||||
address_street_2: '',
|
||||
country_id: null,
|
||||
state: '',
|
||||
city: '',
|
||||
phone: '',
|
||||
zip: '',
|
||||
},
|
||||
})
|
||||
|
||||
const rules = computed(() => {
|
||||
return {
|
||||
name: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
owner_id: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const v$ = useVuelidate(
|
||||
rules,
|
||||
computed(() => formData)
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await globalStore.fetchCountries()
|
||||
const response = await administrationStore.fetchCompany(route.params.id)
|
||||
const company = response.data.data
|
||||
|
||||
formData.name = company.name
|
||||
formData.vat_id = company.vat_id || ''
|
||||
formData.tax_id = company.tax_id || ''
|
||||
|
||||
if (company.owner) {
|
||||
formData.owner_id = {
|
||||
id: company.owner.id,
|
||||
name: company.owner.name,
|
||||
email: company.owner.email,
|
||||
}
|
||||
}
|
||||
|
||||
if (company.address) {
|
||||
formData.address.address_street_1 = company.address.address_street_1 || ''
|
||||
formData.address.address_street_2 = company.address.address_street_2 || ''
|
||||
formData.address.country_id = company.address.country_id
|
||||
formData.address.state = company.address.state || ''
|
||||
formData.address.city = company.address.city || ''
|
||||
formData.address.phone = company.address.phone || ''
|
||||
formData.address.zip = company.address.zip || ''
|
||||
}
|
||||
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
async function searchUsers(query) {
|
||||
const response = await http.get('/api/v1/search', {
|
||||
params: {
|
||||
search: query,
|
||||
type: 'User',
|
||||
},
|
||||
})
|
||||
|
||||
return response.data.users.map((user) => ({
|
||||
id: user.id,
|
||||
name: `${user.name} (${user.email})`,
|
||||
email: user.email,
|
||||
}))
|
||||
}
|
||||
|
||||
function onOwnerSelect(option) {
|
||||
formData.owner_id = option
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
v$.value.$touch()
|
||||
|
||||
if (v$.value.$invalid) {
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
const data = {
|
||||
name: formData.name,
|
||||
owner_id: formData.owner_id?.id || formData.owner_id,
|
||||
vat_id: formData.vat_id,
|
||||
tax_id: formData.tax_id,
|
||||
address: formData.address,
|
||||
}
|
||||
|
||||
await administrationStore.updateCompany(route.params.id, data)
|
||||
|
||||
isSaving.value = false
|
||||
|
||||
router.push({ name: 'admin.companies.index' })
|
||||
}
|
||||
</script>
|
||||
188
resources/scripts/admin/views/administration/companies/Index.vue
Normal file
188
resources/scripts/admin/views/administration/companies/Index.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<BasePage>
|
||||
<BasePageHeader :title="$t('administration.companies.title')">
|
||||
<BaseBreadcrumb>
|
||||
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
|
||||
<BaseBreadcrumbItem
|
||||
:title="$t('administration.companies.title')"
|
||||
to="#"
|
||||
active
|
||||
/>
|
||||
</BaseBreadcrumb>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end space-x-5">
|
||||
<BaseButton variant="primary-outline" @click="toggleFilter">
|
||||
{{ $t('general.filter') }}
|
||||
<template #right="slotProps">
|
||||
<BaseIcon
|
||||
v-if="!showFilters"
|
||||
name="FunnelIcon"
|
||||
:class="slotProps.class"
|
||||
/>
|
||||
<BaseIcon v-else name="XMarkIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
</BasePageHeader>
|
||||
|
||||
<BaseFilterWrapper :show="showFilters" class="mt-3" @clear="clearFilter">
|
||||
<BaseInputGroup
|
||||
:label="$t('administration.companies.company_name')"
|
||||
class="flex-1 mt-2"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="filters.search"
|
||||
type="text"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</BaseFilterWrapper>
|
||||
|
||||
<BaseEmptyPlaceholder
|
||||
v-show="showEmptyScreen"
|
||||
:title="$t('administration.companies.no_companies')"
|
||||
:description="$t('administration.companies.list_description')"
|
||||
>
|
||||
<BaseIcon name="BuildingOfficeIcon" class="mt-5 mb-4 h-16 w-16 text-gray-300" />
|
||||
</BaseEmptyPlaceholder>
|
||||
|
||||
<div v-show="!showEmptyScreen" class="relative table-container">
|
||||
<BaseTable
|
||||
ref="table"
|
||||
:data="fetchData"
|
||||
:columns="companyTableColumns"
|
||||
class="mt-3"
|
||||
>
|
||||
<template #cell-name="{ row }">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'admin.companies.edit',
|
||||
params: { id: row.data.id },
|
||||
}"
|
||||
class="font-medium text-primary-500"
|
||||
>
|
||||
{{ row.data.name }}
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<template #cell-owner="{ row }">
|
||||
<span v-if="row.data.owner">{{ row.data.owner.name }}</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-owner_email="{ row }">
|
||||
<span v-if="row.data.owner">{{ row.data.owner.email }}</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<AdminCompanyDropdown
|
||||
:row="row.data"
|
||||
:table="table"
|
||||
:load-data="refreshTable"
|
||||
/>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</div>
|
||||
</BasePage>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAdministrationStore } from '@/scripts/admin/stores/administration'
|
||||
import AdminCompanyDropdown from '@/scripts/admin/components/dropdowns/AdminCompanyIndexDropdown.vue'
|
||||
|
||||
const administrationStore = useAdministrationStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
let showFilters = ref(false)
|
||||
let isFetchingInitialData = ref(true)
|
||||
let table = ref(null)
|
||||
|
||||
let filters = reactive({
|
||||
search: '',
|
||||
})
|
||||
|
||||
const companyTableColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
key: 'name',
|
||||
label: t('administration.companies.company_name'),
|
||||
thClass: 'extra',
|
||||
tdClass: 'font-medium text-gray-900',
|
||||
},
|
||||
{
|
||||
key: 'owner',
|
||||
label: t('administration.companies.owner'),
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
key: 'owner_email',
|
||||
label: t('general.email'),
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
tdClass: 'text-right text-sm font-medium',
|
||||
sortable: false,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const showEmptyScreen = computed(() => {
|
||||
return !administrationStore.totalCompanies && !isFetchingInitialData.value
|
||||
})
|
||||
|
||||
watch(
|
||||
filters,
|
||||
() => {
|
||||
refreshTable()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function refreshTable() {
|
||||
table.value && table.value.refresh()
|
||||
}
|
||||
|
||||
async function fetchData({ page, filter, sort }) {
|
||||
let data = {
|
||||
search: filters.search || '',
|
||||
orderByField: sort.fieldName || 'name',
|
||||
orderBy: sort.order || 'asc',
|
||||
page,
|
||||
}
|
||||
|
||||
isFetchingInitialData.value = true
|
||||
|
||||
let response = await administrationStore.fetchCompanies(data)
|
||||
|
||||
isFetchingInitialData.value = false
|
||||
|
||||
return {
|
||||
data: response.data.data,
|
||||
pagination: {
|
||||
totalPages: response.data.meta.last_page,
|
||||
currentPage: page,
|
||||
totalCount: response.data.meta.total,
|
||||
limit: 10,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilter() {
|
||||
filters.search = ''
|
||||
}
|
||||
|
||||
function toggleFilter() {
|
||||
if (showFilters.value) {
|
||||
clearFilter()
|
||||
}
|
||||
|
||||
showFilters.value = !showFilters.value
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<BasePage>
|
||||
<BasePageHeader :title="$t('administration.settings.title')" class="mb-6">
|
||||
<BaseBreadcrumb>
|
||||
<BaseBreadcrumbItem :title="$t('general.home')" to="/admin/dashboard" />
|
||||
<BaseBreadcrumbItem
|
||||
:title="$t('administration.settings.title')"
|
||||
to="#"
|
||||
active
|
||||
/>
|
||||
</BaseBreadcrumb>
|
||||
</BasePageHeader>
|
||||
|
||||
<div class="w-full mb-6 select-wrapper xl:hidden">
|
||||
<BaseMultiselect
|
||||
v-model="currentSetting"
|
||||
:options="menuItems"
|
||||
:can-deselect="false"
|
||||
value-prop="title"
|
||||
track-by="title"
|
||||
label="title"
|
||||
object
|
||||
@update:modelValue="navigateToSetting"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<div class="hidden mt-1 xl:block min-w-[240px]">
|
||||
<BaseList>
|
||||
<BaseListItem
|
||||
v-for="(menuItem, index) in menuItems"
|
||||
:key="index"
|
||||
:title="menuItem.title"
|
||||
:to="menuItem.link"
|
||||
:active="hasActiveUrl(menuItem.link)"
|
||||
:index="index"
|
||||
class="py-3"
|
||||
>
|
||||
<template #icon>
|
||||
<BaseIcon :name="menuItem.icon" />
|
||||
</template>
|
||||
</BaseListItem>
|
||||
</BaseList>
|
||||
</div>
|
||||
|
||||
<div class="w-full overflow-hidden">
|
||||
<RouterView />
|
||||
</div>
|
||||
</div>
|
||||
</BasePage>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watchEffect } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseList from '@/scripts/components/list/BaseList.vue'
|
||||
import BaseListItem from '@/scripts/components/list/BaseListItem.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
let currentSetting = ref({})
|
||||
|
||||
const menuItems = computed(() => [
|
||||
{
|
||||
title: t('settings.mail.mail_config'),
|
||||
link: '/admin/administration/settings/mail-configuration',
|
||||
icon: 'EnvelopeIcon',
|
||||
},
|
||||
{
|
||||
title: t('settings.menu_title.pdf_generation'),
|
||||
link: '/admin/administration/settings/pdf-generation',
|
||||
icon: 'DocumentIcon',
|
||||
},
|
||||
{
|
||||
title: t('settings.menu_title.backup'),
|
||||
link: '/admin/administration/settings/backup',
|
||||
icon: 'CircleStackIcon',
|
||||
},
|
||||
{
|
||||
title: t('settings.menu_title.update_app'),
|
||||
link: '/admin/administration/settings/update-app',
|
||||
icon: 'ArrowPathIcon',
|
||||
},
|
||||
{
|
||||
title: t('settings.menu_title.file_disk'),
|
||||
link: '/admin/administration/settings/file-disk',
|
||||
icon: 'FolderIcon',
|
||||
},
|
||||
])
|
||||
|
||||
watchEffect(() => {
|
||||
if (route.path === '/admin/administration/settings') {
|
||||
router.push('/admin/administration/settings/mail-configuration')
|
||||
}
|
||||
|
||||
const item = menuItems.value.find((item) => {
|
||||
return route.path.indexOf(item.link) > -1
|
||||
})
|
||||
|
||||
currentSetting.value = item
|
||||
})
|
||||
|
||||
function hasActiveUrl(url) {
|
||||
return route.path.indexOf(url) > -1
|
||||
}
|
||||
|
||||
function navigateToSetting(setting) {
|
||||
return router.push(setting.link)
|
||||
}
|
||||
</script>
|
||||
181
resources/scripts/admin/views/administration/users/Edit.vue
Normal file
181
resources/scripts/admin/views/administration/users/Edit.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<BasePage v-if="!isLoading">
|
||||
<BasePageHeader :title="$t('administration.users.edit_user')">
|
||||
<BaseBreadcrumb>
|
||||
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
|
||||
<BaseBreadcrumbItem
|
||||
:title="$t('administration.users.title')"
|
||||
to="/admin/administration/users"
|
||||
/>
|
||||
<BaseBreadcrumbItem
|
||||
:title="$t('administration.users.edit_user')"
|
||||
to="#"
|
||||
active
|
||||
/>
|
||||
</BaseBreadcrumb>
|
||||
</BasePageHeader>
|
||||
|
||||
<form @submit.prevent="submitForm">
|
||||
<BaseCard class="mt-6">
|
||||
<BaseInputGrid class="mt-5">
|
||||
<BaseInputGroup
|
||||
:label="$t('users.name')"
|
||||
:error="v$.name.$error && v$.name.$errors[0].$message"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model="formData.name"
|
||||
:invalid="v$.name.$error"
|
||||
@blur="v$.name.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('general.email')"
|
||||
:error="v$.email.$error && v$.email.$errors[0].$message"
|
||||
required
|
||||
>
|
||||
<BaseInput
|
||||
v-model="formData.email"
|
||||
type="email"
|
||||
:invalid="v$.email.$error"
|
||||
@blur="v$.email.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup :label="$t('users.phone')">
|
||||
<BaseInput v-model="formData.phone" type="text" />
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('users.password')"
|
||||
:error="v$.password.$error && v$.password.$errors[0].$message"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="formData.password"
|
||||
type="password"
|
||||
:invalid="v$.password.$error"
|
||||
autocomplete="new-password"
|
||||
@blur="v$.password.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</BaseInputGrid>
|
||||
|
||||
<div v-if="userData" class="mt-6 text-sm text-gray-500">
|
||||
<p>
|
||||
<strong>{{ $t('administration.users.role') }}:</strong>
|
||||
{{ userData.role }}
|
||||
</p>
|
||||
<p v-if="userData.companies && userData.companies.length" class="mt-1">
|
||||
<strong>{{ $t('navigation.companies') }}:</strong>
|
||||
{{ userData.companies.map((c) => c.name).join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BaseButton
|
||||
:loading="isSaving"
|
||||
:disabled="isSaving"
|
||||
type="submit"
|
||||
class="mt-6"
|
||||
>
|
||||
<template #left="slotProps">
|
||||
<BaseIcon
|
||||
v-if="!isSaving"
|
||||
:class="slotProps.class"
|
||||
name="ArrowDownOnSquareIcon"
|
||||
/>
|
||||
</template>
|
||||
{{ $t('general.save') }}
|
||||
</BaseButton>
|
||||
</BaseCard>
|
||||
</form>
|
||||
</BasePage>
|
||||
|
||||
<BaseGlobalLoader v-else />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAdministrationStore } from '@/scripts/admin/stores/administration'
|
||||
import { required, email, minLength, helpers } from '@vuelidate/validators'
|
||||
import { useVuelidate } from '@vuelidate/core'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const administrationStore = useAdministrationStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
let isLoading = ref(true)
|
||||
let isSaving = ref(false)
|
||||
let userData = ref(null)
|
||||
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const rules = computed(() => {
|
||||
return {
|
||||
name: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
email: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
email: helpers.withMessage(t('validation.email_incorrect'), email),
|
||||
},
|
||||
password: {
|
||||
minLength: helpers.withMessage(
|
||||
t('validation.password_min_length', { count: 8 }),
|
||||
minLength(8)
|
||||
),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const v$ = useVuelidate(
|
||||
rules,
|
||||
computed(() => formData)
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const response = await administrationStore.fetchUser(route.params.id)
|
||||
const user = response.data.data
|
||||
userData.value = user
|
||||
|
||||
formData.name = user.name
|
||||
formData.email = user.email
|
||||
formData.phone = user.phone || ''
|
||||
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
async function submitForm() {
|
||||
v$.value.$touch()
|
||||
|
||||
if (v$.value.$invalid) {
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
const data = {
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
}
|
||||
|
||||
if (formData.password) {
|
||||
data.password = formData.password
|
||||
}
|
||||
|
||||
await administrationStore.updateUser(route.params.id, data)
|
||||
|
||||
isSaving.value = false
|
||||
|
||||
router.push({ name: 'admin.users.index' })
|
||||
}
|
||||
</script>
|
||||
252
resources/scripts/admin/views/administration/users/Index.vue
Normal file
252
resources/scripts/admin/views/administration/users/Index.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<BasePage>
|
||||
<BasePageHeader :title="$t('administration.users.title')">
|
||||
<BaseBreadcrumb>
|
||||
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
|
||||
<BaseBreadcrumbItem
|
||||
:title="$t('administration.users.title')"
|
||||
to="#"
|
||||
active
|
||||
/>
|
||||
</BaseBreadcrumb>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end space-x-5">
|
||||
<BaseButton variant="primary-outline" @click="toggleFilter">
|
||||
{{ $t('general.filter') }}
|
||||
<template #right="slotProps">
|
||||
<BaseIcon
|
||||
v-if="!showFilters"
|
||||
name="FunnelIcon"
|
||||
:class="slotProps.class"
|
||||
/>
|
||||
<BaseIcon v-else name="XMarkIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</template>
|
||||
</BasePageHeader>
|
||||
|
||||
<BaseFilterWrapper :show="showFilters" class="mt-3" @clear="clearFilter">
|
||||
<BaseInputGroup
|
||||
:label="$t('users.name')"
|
||||
class="flex-1 mt-2 mr-4"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="filters.display_name"
|
||||
type="text"
|
||||
name="name"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup :label="$t('general.email')" class="flex-1 mt-2 mr-4">
|
||||
<BaseInput
|
||||
v-model="filters.email"
|
||||
type="text"
|
||||
name="email"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup :label="$t('users.phone')" class="flex-1 mt-2">
|
||||
<BaseInput
|
||||
v-model="filters.phone"
|
||||
type="text"
|
||||
name="phone"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</BaseFilterWrapper>
|
||||
|
||||
<BaseEmptyPlaceholder
|
||||
v-show="showEmptyScreen"
|
||||
:title="$t('administration.users.no_users')"
|
||||
:description="$t('administration.users.list_description')"
|
||||
>
|
||||
<BaseIcon
|
||||
name="UsersIcon"
|
||||
class="mt-5 mb-4 h-16 w-16 text-gray-300"
|
||||
/>
|
||||
</BaseEmptyPlaceholder>
|
||||
|
||||
<div v-show="!showEmptyScreen" class="relative table-container">
|
||||
<BaseTable
|
||||
ref="table"
|
||||
:data="fetchData"
|
||||
:columns="userTableColumns"
|
||||
class="mt-3"
|
||||
>
|
||||
<template #cell-name="{ row }">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'admin.users.edit',
|
||||
params: { id: row.data.id },
|
||||
}"
|
||||
class="font-medium text-primary-500"
|
||||
>
|
||||
{{ row.data.name }}
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<template #cell-email="{ row }">
|
||||
<span>{{ row.data.email }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-role="{ row }">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="getRoleBadgeClass(row.data.role)"
|
||||
>
|
||||
{{ row.data.role }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-companies="{ row }">
|
||||
<div v-if="row.data.companies && row.data.companies.length" class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="company in row.data.companies.slice(0, 3)"
|
||||
:key="company.id"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700"
|
||||
>
|
||||
{{ company.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.data.companies.length > 3"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500"
|
||||
>
|
||||
+{{ row.data.companies.length - 3 }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<AdminUserDropdown
|
||||
:row="row.data"
|
||||
:table="table"
|
||||
:load-data="refreshTable"
|
||||
/>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</div>
|
||||
</BasePage>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAdministrationStore } from '@/scripts/admin/stores/administration'
|
||||
import AdminUserDropdown from '@/scripts/admin/components/dropdowns/AdminUserIndexDropdown.vue'
|
||||
|
||||
const administrationStore = useAdministrationStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
let showFilters = ref(false)
|
||||
let isFetchingInitialData = ref(true)
|
||||
let table = ref(null)
|
||||
|
||||
let filters = reactive({
|
||||
display_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
})
|
||||
|
||||
const userTableColumns = computed(() => {
|
||||
return [
|
||||
{
|
||||
key: 'name',
|
||||
label: t('users.name'),
|
||||
thClass: 'extra',
|
||||
tdClass: 'font-medium text-gray-900',
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: t('general.email'),
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: t('administration.users.role'),
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
key: 'companies',
|
||||
label: t('navigation.companies'),
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
tdClass: 'text-right text-sm font-medium',
|
||||
sortable: false,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const showEmptyScreen = computed(() => {
|
||||
return !administrationStore.totalUsers && !isFetchingInitialData.value
|
||||
})
|
||||
|
||||
watch(
|
||||
filters,
|
||||
() => {
|
||||
refreshTable()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function refreshTable() {
|
||||
table.value && table.value.refresh()
|
||||
}
|
||||
|
||||
async function fetchData({ page, filter, sort }) {
|
||||
let data = {
|
||||
display_name: filters.display_name || '',
|
||||
email: filters.email || '',
|
||||
phone: filters.phone || '',
|
||||
orderByField: sort.fieldName || 'created_at',
|
||||
orderBy: sort.order || 'desc',
|
||||
page,
|
||||
}
|
||||
|
||||
isFetchingInitialData.value = true
|
||||
|
||||
let response = await administrationStore.fetchUsers(data)
|
||||
|
||||
isFetchingInitialData.value = false
|
||||
|
||||
return {
|
||||
data: response.data.data,
|
||||
pagination: {
|
||||
totalPages: response.data.meta.last_page,
|
||||
currentPage: page,
|
||||
totalCount: response.data.meta.total,
|
||||
limit: 10,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getRoleBadgeClass(role) {
|
||||
switch (role) {
|
||||
case 'super admin':
|
||||
return 'bg-purple-100 text-purple-800'
|
||||
case 'admin':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilter() {
|
||||
filters.display_name = ''
|
||||
filters.email = ''
|
||||
filters.phone = ''
|
||||
}
|
||||
|
||||
function toggleFilter() {
|
||||
if (showFilters.value) {
|
||||
clearFilter()
|
||||
}
|
||||
|
||||
showFilters.value = !showFilters.value
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<MailTestModal :store-type="'company'" />
|
||||
|
||||
<BaseSettingCard
|
||||
:title="$t('settings.mail.company_mail_config')"
|
||||
:description="$t('settings.mail.company_mail_config_desc')"
|
||||
>
|
||||
<div class="mt-8">
|
||||
<BaseSwitchSection
|
||||
v-model="useCustomMailConfig"
|
||||
:title="$t('settings.mail.use_custom_mail_config')"
|
||||
:description="$t('settings.mail.use_custom_mail_config_desc')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="useCustomMailConfig && companyMailStore.mailConfigData" class="mt-8">
|
||||
<component
|
||||
:is="mailDriver"
|
||||
:config-data="companyMailStore.mailConfigData"
|
||||
:is-saving="isSaving"
|
||||
:mail-drivers="companyMailStore.mail_drivers"
|
||||
:is-fetching-initial-data="isFetchingInitialData"
|
||||
@on-change-driver="(val) => changeDriver(val)"
|
||||
@submit-data="saveEmailConfig"
|
||||
>
|
||||
<BaseButton
|
||||
variant="primary-outline"
|
||||
type="button"
|
||||
class="ml-2"
|
||||
:content-loading="isFetchingInitialData"
|
||||
@click="openMailTestModal"
|
||||
>
|
||||
{{ $t('general.test_mail_conf') }}
|
||||
</BaseButton>
|
||||
</component>
|
||||
</div>
|
||||
|
||||
<div v-if="!useCustomMailConfig" class="mt-4 p-4 rounded-md bg-gray-50 text-sm text-gray-500">
|
||||
{{ $t('settings.mail.using_global_mail_config') }}
|
||||
</div>
|
||||
</BaseSettingCard>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Smtp from '@/scripts/admin/views/settings/mail-driver/SmtpMailDriver.vue'
|
||||
import Mailgun from '@/scripts/admin/views/settings/mail-driver/MailgunMailDriver.vue'
|
||||
import Ses from '@/scripts/admin/views/settings/mail-driver/SesMailDriver.vue'
|
||||
import Basic from '@/scripts/admin/views/settings/mail-driver/BasicMailDriver.vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useCompanyMailStore } from '@/scripts/admin/stores/company-mail'
|
||||
import { useModalStore } from '@/scripts/stores/modal'
|
||||
import MailTestModal from '@/scripts/admin/components/modal-components/MailTestModal.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
let isSaving = ref(false)
|
||||
let isFetchingInitialData = ref(false)
|
||||
|
||||
const companyMailStore = useCompanyMailStore()
|
||||
const modalStore = useModalStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const useCustomMailConfig = ref(false)
|
||||
|
||||
loadData()
|
||||
|
||||
async function loadData() {
|
||||
isFetchingInitialData.value = true
|
||||
await companyMailStore.fetchMailDrivers()
|
||||
await companyMailStore.fetchMailConfig()
|
||||
useCustomMailConfig.value =
|
||||
companyMailStore.mailConfigData?.use_custom_mail_config === 'YES'
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
|
||||
function changeDriver(value) {
|
||||
companyMailStore.mail_driver = value
|
||||
companyMailStore.mailConfigData.mail_driver = value
|
||||
}
|
||||
|
||||
const mailDriver = computed(() => {
|
||||
if (companyMailStore.mail_driver === 'smtp') return Smtp
|
||||
if (companyMailStore.mail_driver === 'mailgun') return Mailgun
|
||||
if (companyMailStore.mail_driver === 'sendmail') return Basic
|
||||
if (companyMailStore.mail_driver === 'ses') return Ses
|
||||
if (companyMailStore.mail_driver === 'mail') return Basic
|
||||
return Smtp
|
||||
})
|
||||
|
||||
watch(useCustomMailConfig, async (newVal, oldVal) => {
|
||||
if (oldVal === undefined) return
|
||||
|
||||
if (!newVal) {
|
||||
isSaving.value = true
|
||||
await companyMailStore.updateMailConfig({
|
||||
use_custom_mail_config: 'NO',
|
||||
mail_driver: '',
|
||||
})
|
||||
isSaving.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function saveEmailConfig(value) {
|
||||
try {
|
||||
isSaving.value = true
|
||||
await companyMailStore.updateMailConfig({
|
||||
...value,
|
||||
use_custom_mail_config: 'YES',
|
||||
})
|
||||
isSaving.value = false
|
||||
} catch (e) {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openMailTestModal() {
|
||||
modalStore.openModal({
|
||||
title: t('general.test_mail_conf'),
|
||||
componentName: 'MailTestModal',
|
||||
size: 'sm',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -84,7 +84,7 @@
|
||||
|
||||
<script setup>
|
||||
import { useDiskStore } from '@/scripts/admin/stores/disk'
|
||||
import { useCompanyStore } from '@/scripts/admin/stores/company'
|
||||
import { useGlobalStore } from '@/scripts/admin/stores/global'
|
||||
import { useDialogStore } from '@/scripts/stores/dialog'
|
||||
import { useModalStore } from '@/scripts/stores/modal'
|
||||
import { ref, computed, reactive, onMounted, inject } from 'vue'
|
||||
@@ -95,7 +95,7 @@ const utils = inject('utils')
|
||||
|
||||
const modalStore = useModalStore()
|
||||
const diskStore = useDiskStore()
|
||||
const companyStore = useCompanyStore()
|
||||
const globalStore = useGlobalStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -139,7 +139,7 @@ const fileDiskColumns = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
const savePdfToDisk = ref(companyStore.selectedCompanySettings.save_pdf_to_disk)
|
||||
const savePdfToDisk = ref(globalStore.globalSettings?.save_pdf_to_disk || 'NO')
|
||||
|
||||
const savePdfToDiskField = computed({
|
||||
get: () => {
|
||||
@@ -156,7 +156,7 @@ const savePdfToDiskField = computed({
|
||||
|
||||
savePdfToDisk.value = value
|
||||
|
||||
await companyStore.updateCompanySettings({
|
||||
await globalStore.updateGlobalSettings({
|
||||
data,
|
||||
message: 'general.setting_updated',
|
||||
})
|
||||
|
||||
4
resources/scripts/router/index.js
vendored
4
resources/scripts/router/index.js
vendored
@@ -27,6 +27,10 @@ router.beforeEach((to) => {
|
||||
if (!userStore.hasAbilities(ability)) {
|
||||
return { name: 'account.settings' }
|
||||
}
|
||||
} else if (to.meta.isSuperAdmin && isAppLoaded) {
|
||||
if (!userStore.currentUser.is_super_admin) {
|
||||
return { name: 'dashboard' }
|
||||
}
|
||||
} else if (to.meta.isOwner && isAppLoaded) {
|
||||
if (!userStore.currentUser.is_owner) {
|
||||
return { name: 'dashboard' }
|
||||
|
||||
Reference in New Issue
Block a user