mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-22 12:44:09 +00:00
Phase 4a: Feature modules — layouts, auth, admin, dashboard,
customers, items, invoices, estimates, shared document form 77 files, 14451 lines. Typed layouts (CompanyLayout, AuthLayout, header, sidebar, company switcher), auth views (login, register, forgot/reset password), admin feature (dashboard, companies, users, settings with typed store), company features (dashboard with chart/ stats, customers CRUD, items CRUD, invoices CRUD with full store, estimates CRUD with full store), and shared document form components (items table, item row, totals, notes, tax popup, template select, exchange rate converter, calculation composable). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<BaseDropdown>
|
||||
<template #activator>
|
||||
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-muted" />
|
||||
</template>
|
||||
|
||||
<router-link :to="`/admin/administration/companies/${row.id}/edit`">
|
||||
<BaseDropdownItem>
|
||||
<BaseIcon
|
||||
name="PencilIcon"
|
||||
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
||||
/>
|
||||
{{ $t('general.edit') }}
|
||||
</BaseDropdownItem>
|
||||
</router-link>
|
||||
</BaseDropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Company } from '../../../types/domain/company'
|
||||
|
||||
interface Props {
|
||||
row: Company
|
||||
table: { refresh: () => void } | null
|
||||
loadData: (() => void) | null
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<BaseDropdown>
|
||||
<template #activator>
|
||||
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-muted" />
|
||||
</template>
|
||||
|
||||
<router-link :to="`/admin/administration/users/${row.id}/edit`">
|
||||
<BaseDropdownItem>
|
||||
<BaseIcon
|
||||
name="PencilIcon"
|
||||
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
|
||||
/>
|
||||
{{ $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-subtle group-hover:text-muted"
|
||||
/>
|
||||
{{ $t('administration.users.impersonate') }}
|
||||
</BaseDropdownItem>
|
||||
</BaseDropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '../../../stores/user.store'
|
||||
import { useDialogStore } from '../../../stores/dialog.store'
|
||||
import { useAdminStore } from '../stores/admin.store'
|
||||
import type { User } from '../../../types/domain/user'
|
||||
|
||||
interface Props {
|
||||
row: User
|
||||
table: { refresh: () => void } | null
|
||||
loadData: (() => void) | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const adminStore = useAdminStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
function onImpersonate(): void {
|
||||
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: boolean) => {
|
||||
if (confirmed) {
|
||||
adminStore.impersonateUser(props.row.id).then(() => {
|
||||
window.location.href = '/admin/dashboard'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
20
resources/scripts-v2/features/admin/index.ts
Normal file
20
resources/scripts-v2/features/admin/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export { adminRoutes } from './routes'
|
||||
|
||||
export { useAdminStore } from './stores/admin.store'
|
||||
export type {
|
||||
AdminDashboardData,
|
||||
FetchCompaniesParams,
|
||||
FetchUsersParams,
|
||||
UpdateCompanyData,
|
||||
UpdateUserData,
|
||||
} from './stores/admin.store'
|
||||
|
||||
export { default as AdminDashboardView } from './views/AdminDashboardView.vue'
|
||||
export { default as AdminCompaniesView } from './views/AdminCompaniesView.vue'
|
||||
export { default as AdminCompanyEditView } from './views/AdminCompanyEditView.vue'
|
||||
export { default as AdminUsersView } from './views/AdminUsersView.vue'
|
||||
export { default as AdminUserEditView } from './views/AdminUserEditView.vue'
|
||||
export { default as AdminSettingsView } from './views/AdminSettingsView.vue'
|
||||
|
||||
export { default as AdminCompanyDropdown } from './components/AdminCompanyDropdown.vue'
|
||||
export { default as AdminUserDropdown } from './components/AdminUserDropdown.vue'
|
||||
113
resources/scripts-v2/features/admin/routes.ts
Normal file
113
resources/scripts-v2/features/admin/routes.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const CompanyLayout = () => import('../../layouts/CompanyLayout.vue')
|
||||
const AdminDashboardView = () => import('./views/AdminDashboardView.vue')
|
||||
const AdminCompaniesView = () => import('./views/AdminCompaniesView.vue')
|
||||
const AdminCompanyEditView = () => import('./views/AdminCompanyEditView.vue')
|
||||
const AdminUsersView = () => import('./views/AdminUsersView.vue')
|
||||
const AdminUserEditView = () => import('./views/AdminUserEditView.vue')
|
||||
const AdminSettingsView = () => import('./views/AdminSettingsView.vue')
|
||||
|
||||
export const adminRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/admin/administration',
|
||||
component: CompanyLayout,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
isSuperAdmin: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'admin.dashboard',
|
||||
component: AdminDashboardView,
|
||||
meta: {
|
||||
isSuperAdmin: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'companies',
|
||||
name: 'admin.companies.index',
|
||||
component: AdminCompaniesView,
|
||||
meta: {
|
||||
isSuperAdmin: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'companies/:id/edit',
|
||||
name: 'admin.companies.edit',
|
||||
component: AdminCompanyEditView,
|
||||
meta: {
|
||||
isSuperAdmin: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'admin.users.index',
|
||||
component: AdminUsersView,
|
||||
meta: {
|
||||
isSuperAdmin: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'users/:id/edit',
|
||||
name: 'admin.users.edit',
|
||||
component: AdminUserEditView,
|
||||
meta: {
|
||||
isSuperAdmin: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'admin.settings',
|
||||
component: AdminSettingsView,
|
||||
meta: {
|
||||
isSuperAdmin: true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'mail-configuration',
|
||||
name: 'admin.settings.mail',
|
||||
meta: {
|
||||
isSuperAdmin: true,
|
||||
},
|
||||
// Loaded by settings sub-routes
|
||||
component: { template: '<router-view />' },
|
||||
},
|
||||
{
|
||||
path: 'pdf-generation',
|
||||
name: 'admin.settings.pdf',
|
||||
meta: {
|
||||
isSuperAdmin: true,
|
||||
},
|
||||
component: { template: '<router-view />' },
|
||||
},
|
||||
{
|
||||
path: 'backup',
|
||||
name: 'admin.settings.backup',
|
||||
meta: {
|
||||
isSuperAdmin: true,
|
||||
},
|
||||
component: { template: '<router-view />' },
|
||||
},
|
||||
{
|
||||
path: 'file-disk',
|
||||
name: 'admin.settings.disk',
|
||||
meta: {
|
||||
isSuperAdmin: true,
|
||||
},
|
||||
component: { template: '<router-view />' },
|
||||
},
|
||||
{
|
||||
path: 'update-app',
|
||||
name: 'admin.settings.update',
|
||||
meta: {
|
||||
isSuperAdmin: true,
|
||||
},
|
||||
component: { template: '<router-view />' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
203
resources/scripts-v2/features/admin/stores/admin.store.ts
Normal file
203
resources/scripts-v2/features/admin/stores/admin.store.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { client } from '../../../api/client'
|
||||
import { API } from '../../../api/endpoints'
|
||||
import { useNotificationStore } from '../../../stores/notification.store'
|
||||
import { handleApiError } from '../../../utils/error-handling'
|
||||
import * as ls from '../../../utils/local-storage'
|
||||
import type { Company } from '../../../types/domain/company'
|
||||
import type { User } from '../../../types/domain/user'
|
||||
import type { PaginatedResponse } from '../../../types/api'
|
||||
|
||||
export interface AdminDashboardData {
|
||||
app_version: string
|
||||
php_version: string
|
||||
database: {
|
||||
driver: string
|
||||
version: string
|
||||
}
|
||||
counts: {
|
||||
companies: number
|
||||
users: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface FetchCompaniesParams {
|
||||
search?: string
|
||||
orderByField?: string
|
||||
orderBy?: string
|
||||
page?: number
|
||||
}
|
||||
|
||||
export interface FetchUsersParams {
|
||||
display_name?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
orderByField?: string
|
||||
orderBy?: string
|
||||
page?: number
|
||||
}
|
||||
|
||||
export interface UpdateCompanyData {
|
||||
name: string
|
||||
owner_id: number
|
||||
vat_id?: string
|
||||
tax_id?: string
|
||||
address?: {
|
||||
address_street_1?: string
|
||||
address_street_2?: string
|
||||
country_id?: number | null
|
||||
state?: string
|
||||
city?: string
|
||||
phone?: string
|
||||
zip?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpdateUserData {
|
||||
name: string
|
||||
email: string
|
||||
phone?: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
export const useAdminStore = defineStore('admin', () => {
|
||||
// State
|
||||
const companies = ref<Company[]>([])
|
||||
const totalCompanies = ref<number>(0)
|
||||
const selectedCompany = ref<Company | null>(null)
|
||||
|
||||
const users = ref<User[]>([])
|
||||
const totalUsers = ref<number>(0)
|
||||
|
||||
const dashboardData = ref<AdminDashboardData | null>(null)
|
||||
|
||||
// Actions
|
||||
async function fetchDashboard(): Promise<AdminDashboardData> {
|
||||
try {
|
||||
const { data } = await client.get(API.SUPER_ADMIN_DASHBOARD)
|
||||
dashboardData.value = data
|
||||
return data
|
||||
} catch (err: unknown) {
|
||||
handleApiError(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCompanies(params: FetchCompaniesParams): Promise<PaginatedResponse<Company>> {
|
||||
try {
|
||||
const { data } = await client.get(API.SUPER_ADMIN_COMPANIES, { params })
|
||||
companies.value = data.data
|
||||
totalCompanies.value = data.meta?.total ?? data.data.length
|
||||
return data
|
||||
} catch (err: unknown) {
|
||||
handleApiError(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCompany(id: number | string): Promise<{ data: Company }> {
|
||||
try {
|
||||
const { data } = await client.get(`${API.SUPER_ADMIN_COMPANIES}/${id}`)
|
||||
selectedCompany.value = data.data
|
||||
return data
|
||||
} catch (err: unknown) {
|
||||
handleApiError(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCompany(id: number | string, payload: UpdateCompanyData): Promise<void> {
|
||||
try {
|
||||
await client.put(`${API.SUPER_ADMIN_COMPANIES}/${id}`, payload)
|
||||
|
||||
const notificationStore = useNotificationStore()
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: 'Company updated successfully.',
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
handleApiError(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUsers(params: FetchUsersParams): Promise<PaginatedResponse<User>> {
|
||||
try {
|
||||
const { data } = await client.get(API.SUPER_ADMIN_USERS, { params })
|
||||
users.value = data.data
|
||||
totalUsers.value = data.meta?.total ?? data.data.length
|
||||
return data
|
||||
} catch (err: unknown) {
|
||||
handleApiError(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUser(id: number | string): Promise<{ data: User }> {
|
||||
try {
|
||||
const { data } = await client.get(`${API.SUPER_ADMIN_USERS}/${id}`)
|
||||
return data
|
||||
} catch (err: unknown) {
|
||||
handleApiError(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUser(id: number | string, payload: UpdateUserData): Promise<void> {
|
||||
try {
|
||||
await client.put(`${API.SUPER_ADMIN_USERS}/${id}`, payload)
|
||||
|
||||
const notificationStore = useNotificationStore()
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: 'User updated successfully.',
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
handleApiError(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function impersonateUser(id: number): Promise<void> {
|
||||
try {
|
||||
const { data } = await client.post(`${API.SUPER_ADMIN_USERS}/${id}/impersonate`)
|
||||
ls.set('admin.impersonating', 'true')
|
||||
ls.set('auth.token', `Bearer ${data.token}`)
|
||||
} catch (err: unknown) {
|
||||
handleApiError(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function stopImpersonating(): Promise<void> {
|
||||
try {
|
||||
await client.post(API.SUPER_ADMIN_STOP_IMPERSONATING)
|
||||
} catch (err: unknown) {
|
||||
handleApiError(err)
|
||||
} finally {
|
||||
ls.remove('admin.impersonating')
|
||||
ls.remove('auth.token')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
companies,
|
||||
totalCompanies,
|
||||
selectedCompany,
|
||||
users,
|
||||
totalUsers,
|
||||
dashboardData,
|
||||
// Actions
|
||||
fetchDashboard,
|
||||
fetchCompanies,
|
||||
fetchCompany,
|
||||
updateCompany,
|
||||
fetchUsers,
|
||||
fetchUser,
|
||||
updateUser,
|
||||
impersonateUser,
|
||||
stopImpersonating,
|
||||
}
|
||||
})
|
||||
210
resources/scripts-v2/features/admin/views/AdminCompaniesView.vue
Normal file
210
resources/scripts-v2/features/admin/views/AdminCompaniesView.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<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-subtle" />
|
||||
</BaseEmptyPlaceholder>
|
||||
|
||||
<div v-show="!showEmptyScreen" class="relative table-container">
|
||||
<BaseTable
|
||||
ref="tableRef"
|
||||
: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-subtle">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-owner_email="{ row }">
|
||||
<span v-if="row.data.owner">{{ row.data.owner.email }}</span>
|
||||
<span v-else class="text-subtle">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<AdminCompanyDropdown
|
||||
:row="row.data"
|
||||
:table="tableRef"
|
||||
:load-data="refreshTable"
|
||||
/>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</div>
|
||||
</BasePage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAdminStore } from '../stores/admin.store'
|
||||
import AdminCompanyDropdown from '../components/AdminCompanyDropdown.vue'
|
||||
import type { Company } from '../../../types/domain/company'
|
||||
|
||||
interface TableColumn {
|
||||
key: string
|
||||
label?: string
|
||||
thClass?: string
|
||||
tdClass?: string
|
||||
sortable?: boolean
|
||||
}
|
||||
|
||||
interface FetchParams {
|
||||
page: number
|
||||
filter: Record<string, unknown>
|
||||
sort: { fieldName: string; order: string }
|
||||
}
|
||||
|
||||
interface TableResult {
|
||||
data: Company[]
|
||||
pagination: {
|
||||
totalPages: number
|
||||
currentPage: number
|
||||
totalCount: number
|
||||
limit: number
|
||||
}
|
||||
}
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const showFilters = ref<boolean>(false)
|
||||
const isFetchingInitialData = ref<boolean>(true)
|
||||
const tableRef = ref<{ refresh: () => void } | null>(null)
|
||||
|
||||
const filters = reactive({
|
||||
search: '',
|
||||
})
|
||||
|
||||
const companyTableColumns = computed<TableColumn[]>(() => [
|
||||
{
|
||||
key: 'name',
|
||||
label: t('administration.companies.company_name'),
|
||||
thClass: 'extra',
|
||||
tdClass: 'font-medium text-heading',
|
||||
},
|
||||
{
|
||||
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<boolean>(() => {
|
||||
return !adminStore.totalCompanies && !isFetchingInitialData.value
|
||||
})
|
||||
|
||||
watch(
|
||||
filters,
|
||||
() => {
|
||||
refreshTable()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function refreshTable(): void {
|
||||
tableRef.value?.refresh()
|
||||
}
|
||||
|
||||
async function fetchData({ page, sort }: FetchParams): Promise<TableResult> {
|
||||
const params = {
|
||||
search: filters.search || '',
|
||||
orderByField: sort.fieldName || 'name',
|
||||
orderBy: sort.order || 'asc',
|
||||
page,
|
||||
}
|
||||
|
||||
isFetchingInitialData.value = true
|
||||
|
||||
const response = await adminStore.fetchCompanies(params)
|
||||
|
||||
isFetchingInitialData.value = false
|
||||
|
||||
return {
|
||||
data: response.data,
|
||||
pagination: {
|
||||
totalPages: response.meta.last_page,
|
||||
currentPage: page,
|
||||
totalCount: response.meta.total,
|
||||
limit: 10,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilter(): void {
|
||||
filters.search = ''
|
||||
}
|
||||
|
||||
function toggleFilter(): void {
|
||||
if (showFilters.value) {
|
||||
clearFilter()
|
||||
}
|
||||
showFilters.value = !showFilters.value
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,274 @@
|
||||
<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-heading 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 lang="ts">
|
||||
import { reactive, ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { required, helpers } from '@vuelidate/validators'
|
||||
import { useVuelidate } from '@vuelidate/core'
|
||||
import { useGlobalStore } from '../../../stores/global.store'
|
||||
import { useAdminStore } from '../stores/admin.store'
|
||||
import { client } from '../../../api/client'
|
||||
import { API } from '../../../api/endpoints'
|
||||
|
||||
interface OwnerOption {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
interface CompanyFormData {
|
||||
name: string
|
||||
owner_id: OwnerOption | null
|
||||
vat_id: string
|
||||
tax_id: string
|
||||
address: {
|
||||
address_street_1: string
|
||||
address_street_2: string
|
||||
country_id: number | null
|
||||
state: string
|
||||
city: string
|
||||
phone: string
|
||||
zip: string
|
||||
}
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const globalStore = useGlobalStore()
|
||||
const adminStore = useAdminStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isLoading = ref<boolean>(true)
|
||||
const isSaving = ref<boolean>(false)
|
||||
|
||||
const formData = reactive<CompanyFormData>({
|
||||
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(() => ({
|
||||
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 adminStore.fetchCompany(route.params.id as string)
|
||||
const company = response.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: string): Promise<OwnerOption[]> {
|
||||
const { data } = await client.get(API.SEARCH, {
|
||||
params: {
|
||||
search: query,
|
||||
type: 'User',
|
||||
},
|
||||
})
|
||||
|
||||
return (data.users as Array<{ id: number; name: string; email: string }>).map((user) => ({
|
||||
id: user.id,
|
||||
name: `${user.name} (${user.email})`,
|
||||
email: user.email,
|
||||
}))
|
||||
}
|
||||
|
||||
function onOwnerSelect(option: OwnerOption): void {
|
||||
formData.owner_id = option
|
||||
}
|
||||
|
||||
async function submitForm(): Promise<void> {
|
||||
v$.value.$touch()
|
||||
|
||||
if (v$.value.$invalid) {
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
await adminStore.updateCompany(route.params.id as string, {
|
||||
name: formData.name,
|
||||
owner_id: formData.owner_id?.id ?? 0,
|
||||
vat_id: formData.vat_id,
|
||||
tax_id: formData.tax_id,
|
||||
address: formData.address,
|
||||
})
|
||||
|
||||
isSaving.value = false
|
||||
|
||||
router.push({ name: 'admin.companies.index' })
|
||||
}
|
||||
</script>
|
||||
105
resources/scripts-v2/features/admin/views/AdminDashboardView.vue
Normal file
105
resources/scripts-v2/features/admin/views/AdminDashboardView.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<BasePage>
|
||||
<BasePageHeader :title="$t('navigation.dashboard')">
|
||||
<BaseBreadcrumb>
|
||||
<BaseBreadcrumbItem
|
||||
:title="$t('navigation.administration')"
|
||||
to="/admin/administration/dashboard"
|
||||
active
|
||||
/>
|
||||
</BaseBreadcrumb>
|
||||
</BasePageHeader>
|
||||
|
||||
<div v-if="isLoading" class="flex justify-center py-16">
|
||||
<BaseGlobalLoader />
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-6 mt-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- App Version -->
|
||||
<BaseCard>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<BaseIcon name="ServerIcon" class="w-5 h-5 mr-2 text-subtle" />
|
||||
<span class="font-medium text-body">{{ $t('general.app_version') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<p class="text-2xl font-semibold text-heading">
|
||||
{{ data.app_version }}
|
||||
</p>
|
||||
</BaseCard>
|
||||
|
||||
<!-- PHP Version -->
|
||||
<BaseCard>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<BaseIcon name="CodeBracketIcon" class="w-5 h-5 mr-2 text-subtle" />
|
||||
<span class="font-medium text-body">PHP</span>
|
||||
</div>
|
||||
</template>
|
||||
<p class="text-2xl font-semibold text-heading">
|
||||
{{ data.php_version }}
|
||||
</p>
|
||||
</BaseCard>
|
||||
|
||||
<!-- Database -->
|
||||
<BaseCard>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<BaseIcon name="CircleStackIcon" class="w-5 h-5 mr-2 text-subtle" />
|
||||
<span class="font-medium text-body">{{ $t('general.database') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<p class="text-2xl font-semibold text-heading">
|
||||
{{ data.database?.driver?.toUpperCase() }}
|
||||
</p>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
{{ data.database?.version }}
|
||||
</p>
|
||||
</BaseCard>
|
||||
|
||||
<!-- Companies -->
|
||||
<BaseCard>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<BaseIcon name="BuildingOfficeIcon" class="w-5 h-5 mr-2 text-subtle" />
|
||||
<span class="font-medium text-body">{{ $t('navigation.companies') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<p class="text-2xl font-semibold text-heading">
|
||||
{{ data.counts?.companies }}
|
||||
</p>
|
||||
</BaseCard>
|
||||
|
||||
<!-- Users -->
|
||||
<BaseCard>
|
||||
<template #header>
|
||||
<div class="flex items-center">
|
||||
<BaseIcon name="UsersIcon" class="w-5 h-5 mr-2 text-subtle" />
|
||||
<span class="font-medium text-body">{{ $t('navigation.all_users') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<p class="text-2xl font-semibold text-heading">
|
||||
{{ data.counts?.users }}
|
||||
</p>
|
||||
</BaseCard>
|
||||
</div>
|
||||
</BasePage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAdminStore } from '../stores/admin.store'
|
||||
import type { AdminDashboardData } from '../stores/admin.store'
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
const isLoading = ref<boolean>(true)
|
||||
const data = ref<Partial<AdminDashboardData>>({})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
data.value = await adminStore.fetchDashboard()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
117
resources/scripts-v2/features/admin/views/AdminSettingsView.vue
Normal file
117
resources/scripts-v2/features/admin/views/AdminSettingsView.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<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:model-value="navigateToSetting"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-8">
|
||||
<div class="hidden mt-1 xl:block min-w-[240px] sticky top-20 self-start">
|
||||
<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 lang="ts">
|
||||
import { ref, computed, watchEffect } from 'vue'
|
||||
import { useRoute, useRouter, RouterView } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface SettingsMenuItem {
|
||||
title: string
|
||||
link: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const currentSetting = ref<SettingsMenuItem | undefined>(undefined)
|
||||
|
||||
const menuItems = computed<SettingsMenuItem[]>(() => [
|
||||
{
|
||||
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.file_disk'),
|
||||
link: '/admin/administration/settings/file-disk',
|
||||
icon: 'FolderIcon',
|
||||
},
|
||||
{
|
||||
title: t('settings.menu_title.update_app'),
|
||||
link: '/admin/administration/settings/update-app',
|
||||
icon: 'ArrowPathIcon',
|
||||
},
|
||||
])
|
||||
|
||||
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: string): boolean {
|
||||
return route.path.indexOf(url) > -1
|
||||
}
|
||||
|
||||
function navigateToSetting(setting: SettingsMenuItem): void {
|
||||
router.push(setting.link)
|
||||
}
|
||||
</script>
|
||||
187
resources/scripts-v2/features/admin/views/AdminUserEditView.vue
Normal file
187
resources/scripts-v2/features/admin/views/AdminUserEditView.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<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-muted">
|
||||
<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 lang="ts">
|
||||
import { reactive, ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { required, email, minLength, helpers } from '@vuelidate/validators'
|
||||
import { useVuelidate } from '@vuelidate/core'
|
||||
import { useAdminStore } from '../stores/admin.store'
|
||||
import type { User } from '../../../types/domain/user'
|
||||
|
||||
interface UserFormData {
|
||||
name: string
|
||||
email: string
|
||||
phone: string
|
||||
password: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const adminStore = useAdminStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isLoading = ref<boolean>(true)
|
||||
const isSaving = ref<boolean>(false)
|
||||
const userData = ref<User | null>(null)
|
||||
|
||||
const formData = reactive<UserFormData>({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const rules = computed(() => ({
|
||||
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 adminStore.fetchUser(route.params.id as string)
|
||||
const user = response.data
|
||||
userData.value = user
|
||||
|
||||
formData.name = user.name
|
||||
formData.email = user.email
|
||||
formData.phone = user.phone ?? ''
|
||||
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
async function submitForm(): Promise<void> {
|
||||
v$.value.$touch()
|
||||
|
||||
if (v$.value.$invalid) {
|
||||
return
|
||||
}
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
const data: Record<string, string> = {
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
}
|
||||
|
||||
if (formData.password) {
|
||||
data.password = formData.password
|
||||
}
|
||||
|
||||
await adminStore.updateUser(route.params.id as string, data)
|
||||
|
||||
isSaving.value = false
|
||||
|
||||
router.push({ name: 'admin.users.index' })
|
||||
}
|
||||
</script>
|
||||
271
resources/scripts-v2/features/admin/views/AdminUsersView.vue
Normal file
271
resources/scripts-v2/features/admin/views/AdminUsersView.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<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-subtle" />
|
||||
</BaseEmptyPlaceholder>
|
||||
|
||||
<div v-show="!showEmptyScreen" class="relative table-container">
|
||||
<BaseTable
|
||||
ref="tableRef"
|
||||
: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-surface-tertiary text-body"
|
||||
>
|
||||
{{ 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-surface-tertiary text-muted"
|
||||
>
|
||||
+{{ row.data.companies.length - 3 }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-subtle">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<AdminUserDropdown
|
||||
:row="row.data"
|
||||
:table="tableRef"
|
||||
:load-data="refreshTable"
|
||||
/>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</div>
|
||||
</BasePage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAdminStore } from '../stores/admin.store'
|
||||
import AdminUserDropdown from '../components/AdminUserDropdown.vue'
|
||||
import type { User } from '../../../types/domain/user'
|
||||
|
||||
interface TableColumn {
|
||||
key: string
|
||||
label?: string
|
||||
thClass?: string
|
||||
tdClass?: string
|
||||
sortable?: boolean
|
||||
}
|
||||
|
||||
interface FetchParams {
|
||||
page: number
|
||||
filter: Record<string, unknown>
|
||||
sort: { fieldName: string; order: string }
|
||||
}
|
||||
|
||||
interface TableResult {
|
||||
data: User[]
|
||||
pagination: {
|
||||
totalPages: number
|
||||
currentPage: number
|
||||
totalCount: number
|
||||
limit: number
|
||||
}
|
||||
}
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const showFilters = ref<boolean>(false)
|
||||
const isFetchingInitialData = ref<boolean>(true)
|
||||
const tableRef = ref<{ refresh: () => void } | null>(null)
|
||||
|
||||
const filters = reactive({
|
||||
display_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
})
|
||||
|
||||
const userTableColumns = computed<TableColumn[]>(() => [
|
||||
{
|
||||
key: 'name',
|
||||
label: t('users.name'),
|
||||
thClass: 'extra',
|
||||
tdClass: 'font-medium text-heading',
|
||||
},
|
||||
{
|
||||
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<boolean>(() => {
|
||||
return !adminStore.totalUsers && !isFetchingInitialData.value
|
||||
})
|
||||
|
||||
watch(
|
||||
filters,
|
||||
() => {
|
||||
refreshTable()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function refreshTable(): void {
|
||||
tableRef.value?.refresh()
|
||||
}
|
||||
|
||||
async function fetchData({ page, sort }: FetchParams): Promise<TableResult> {
|
||||
const params = {
|
||||
display_name: filters.display_name || '',
|
||||
email: filters.email || '',
|
||||
phone: filters.phone || '',
|
||||
orderByField: sort.fieldName || 'created_at',
|
||||
orderBy: sort.order || 'desc',
|
||||
page,
|
||||
}
|
||||
|
||||
isFetchingInitialData.value = true
|
||||
|
||||
const response = await adminStore.fetchUsers(params)
|
||||
|
||||
isFetchingInitialData.value = false
|
||||
|
||||
return {
|
||||
data: response.data,
|
||||
pagination: {
|
||||
totalPages: response.meta.last_page,
|
||||
currentPage: page,
|
||||
totalCount: response.meta.total,
|
||||
limit: 10,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getRoleBadgeClass(role: string | null): string {
|
||||
switch (role) {
|
||||
case 'super admin':
|
||||
return 'bg-purple-100 text-purple-800'
|
||||
case 'admin':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
default:
|
||||
return 'bg-surface-tertiary text-heading'
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilter(): void {
|
||||
filters.display_name = ''
|
||||
filters.email = ''
|
||||
filters.phone = ''
|
||||
}
|
||||
|
||||
function toggleFilter(): void {
|
||||
if (showFilters.value) {
|
||||
clearFilter()
|
||||
}
|
||||
showFilters.value = !showFilters.value
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user