diff --git a/resources/scripts-v2/stores/auth.store.ts b/resources/scripts-v2/stores/auth.store.ts new file mode 100644 index 00000000..b3aad208 --- /dev/null +++ b/resources/scripts-v2/stores/auth.store.ts @@ -0,0 +1,111 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { authService } from '../api/services/auth.service' +import type { LoginPayload, ForgotPasswordPayload, ResetPasswordPayload } from '../api/services/auth.service' +import { useNotificationStore } from './notification.store' +import { handleApiError } from '../utils/error-handling' +import * as localStore from '../utils/local-storage' + +export interface LoginData { + email: string + password: string + remember: boolean +} + +export interface ForgotPasswordData { + email: string +} + +export interface ResetPasswordData { + email: string + password: string + password_confirmation: string + token: string +} + +export const useAuthStore = defineStore('auth', () => { + // State + const loginData = ref({ + email: '', + password: '', + remember: false, + }) + + const forgotPasswordData = ref({ + email: '', + }) + + const resetPasswordData = ref({ + email: '', + password: '', + password_confirmation: '', + token: '', + }) + + // Actions + async function login(data: LoginPayload): Promise { + try { + await authService.login(data) + + setTimeout(() => { + loginData.value.email = '' + loginData.value.password = '' + }, 1000) + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function logout(): Promise { + const notificationStore = useNotificationStore() + + try { + await authService.logout() + + notificationStore.showNotification({ + type: 'success', + message: 'Logged out successfully.', + }) + + localStore.remove('auth.token') + localStore.remove('selectedCompany') + + await authService.refreshCsrfCookie().catch(() => {}) + } catch (err: unknown) { + handleApiError(err) + localStore.remove('auth.token') + localStore.remove('selectedCompany') + await authService.refreshCsrfCookie().catch(() => {}) + throw err + } + } + + async function forgotPassword(data: ForgotPasswordPayload): Promise { + try { + await authService.forgotPassword(data) + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function resetPassword(data: ResetPasswordPayload): Promise { + try { + await authService.resetPassword(data) + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + return { + loginData, + forgotPasswordData, + resetPasswordData, + login, + logout, + forgotPassword, + resetPassword, + } +}) diff --git a/resources/scripts-v2/stores/company.store.ts b/resources/scripts-v2/stores/company.store.ts new file mode 100644 index 00000000..a0b3eabf --- /dev/null +++ b/resources/scripts-v2/stores/company.store.ts @@ -0,0 +1,194 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { companyService } from '../api/services/company.service' +import type { + UpdateCompanyPayload, + CompanySettingsPayload, + CreateCompanyPayload, +} from '../api/services/company.service' +import { useNotificationStore } from './notification.store' +import { handleApiError } from '../utils/error-handling' +import * as localStore from '../utils/local-storage' +import type { Company } from '../types/domain/company' +import type { Currency } from '../types/domain/currency' +import type { ApiResponse } from '../types/api' + +export const useCompanyStore = defineStore('company', () => { + // State + const companies = ref([]) + const selectedCompany = ref(null) + const selectedCompanySettings = ref>({}) + const selectedCompanyCurrency = ref(null) + const isAdminMode = ref(localStore.get('isAdminMode') === 'true') + const defaultCurrency = ref(null) + + // Actions + function setSelectedCompany(data: Company | null): void { + if (data) { + localStore.set('selectedCompany', data.id) + localStore.remove('isAdminMode') + isAdminMode.value = false + } else { + localStore.remove('selectedCompany') + } + selectedCompany.value = data + } + + function setAdminMode(enabled: boolean): void { + isAdminMode.value = enabled + if (enabled) { + localStore.set('isAdminMode', 'true') + localStore.remove('selectedCompany') + selectedCompany.value = null + } else { + localStore.remove('isAdminMode') + } + } + + async function fetchBasicMailConfig(): Promise> { + try { + return await companyService.getMailConfig() + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function updateCompany(data: UpdateCompanyPayload): Promise> { + try { + const response = await companyService.update(data) + + const notificationStore = useNotificationStore() + notificationStore.showNotification({ + type: 'success', + message: 'settings.company_info.updated_message', + }) + + selectedCompany.value = response.data + const companyIndex = companies.value.findIndex( + (company) => company.unique_hash === response.data.unique_hash + ) + if (companyIndex !== -1) { + companies.value[companyIndex] = response.data + } + + return response + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function updateCompanyLogo(data: FormData): Promise> { + try { + return await companyService.uploadLogo(data) + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function addNewCompany(data: CreateCompanyPayload): Promise> { + try { + const response = await companyService.create(data) + + const notificationStore = useNotificationStore() + notificationStore.showNotification({ + type: 'success', + message: 'company_switcher.created_message', + }) + + return response + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function fetchCompany(): Promise { + try { + const response = await companyService.listUserCompanies() + return response.data[0] + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function fetchUserCompanies(): Promise> { + try { + return await companyService.listUserCompanies() + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function fetchCompanySettings(settings?: string[]): Promise> { + try { + return await companyService.getSettings(settings) + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function updateCompanySettings(params: { + data: CompanySettingsPayload + message?: string + }): Promise { + try { + await companyService.updateSettings(params.data) + + Object.assign( + selectedCompanySettings.value, + params.data.settings + ) + + if (params.message) { + const notificationStore = useNotificationStore() + notificationStore.showNotification({ + type: 'success', + message: params.message, + }) + } + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function deleteCompany(data: { id: number }): Promise { + try { + await companyService.delete(data) + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + function setDefaultCurrency(data: { currency: Currency }): void { + defaultCurrency.value = data.currency + } + + return { + companies, + selectedCompany, + selectedCompanySettings, + selectedCompanyCurrency, + isAdminMode, + defaultCurrency, + setSelectedCompany, + setAdminMode, + fetchBasicMailConfig, + updateCompany, + updateCompanyLogo, + addNewCompany, + fetchCompany, + fetchUserCompanies, + fetchCompanySettings, + updateCompanySettings, + deleteCompany, + setDefaultCurrency, + } +}) diff --git a/resources/scripts-v2/stores/dialog.store.ts b/resources/scripts-v2/stores/dialog.store.ts new file mode 100644 index 00000000..540a4da8 --- /dev/null +++ b/resources/scripts-v2/stores/dialog.store.ts @@ -0,0 +1,90 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export type DialogVariant = 'primary' | 'danger' + +export type DialogSize = 'sm' | 'md' | 'lg' + +export interface OpenDialogPayload { + title: string + message: string + size?: DialogSize + data?: unknown + variant?: DialogVariant + yesLabel?: string + noLabel?: string + hideNoButton?: boolean +} + +export const useDialogStore = defineStore('dialog', () => { + // State + const active = ref(false) + const title = ref('') + const message = ref('') + const size = ref('md') + const data = ref(null) + const variant = ref('danger') + const yesLabel = ref('Yes') + const noLabel = ref('No') + const hideNoButton = ref(false) + const resolve = ref<((value: boolean) => void) | null>(null) + + // Actions + function openDialog(payload: OpenDialogPayload): Promise { + active.value = true + title.value = payload.title + message.value = payload.message + size.value = payload.size ?? 'md' + data.value = payload.data ?? null + variant.value = payload.variant ?? 'danger' + yesLabel.value = payload.yesLabel ?? 'Yes' + noLabel.value = payload.noLabel ?? 'No' + hideNoButton.value = payload.hideNoButton ?? false + + return new Promise((res) => { + resolve.value = res + }) + } + + function confirm(): void { + if (resolve.value) { + resolve.value(true) + } + closeDialog() + } + + function cancel(): void { + if (resolve.value) { + resolve.value(false) + } + closeDialog() + } + + function closeDialog(): void { + active.value = false + + setTimeout(() => { + title.value = '' + message.value = '' + data.value = null + resolve.value = null + }, 300) + } + + return { + active, + title, + message, + size, + data, + variant, + yesLabel, + noLabel, + hideNoButton, + resolve, + openDialog, + confirm, + cancel, + closeDialog, + } +}) diff --git a/resources/scripts-v2/stores/global.store.ts b/resources/scripts-v2/stores/global.store.ts new file mode 100644 index 00000000..3e7f3962 --- /dev/null +++ b/resources/scripts-v2/stores/global.store.ts @@ -0,0 +1,286 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import groupBy from 'lodash/groupBy' +import { bootstrapService } from '../api/services/bootstrap.service' +import type { MenuItem, BootstrapResponse } from '../api/services/bootstrap.service' +import { settingService } from '../api/services/setting.service' +import type { + DateFormat, + TimeFormat, + ConfigResponse, + GlobalSettingsPayload, + NumberPlaceholdersParams, + NumberPlaceholder, +} from '../api/services/setting.service' +import { useCompanyStore } from './company.store' +import { useUserStore } from './user.store' +import { useNotificationStore } from './notification.store' +import { handleApiError } from '../utils/error-handling' +import * as localStore from '../utils/local-storage' +import type { Currency } from '../types/domain/currency' +import type { Country } from '../types/domain/customer' + +export const useGlobalStore = defineStore('global', () => { + // State + const config = ref | null>(null) + const globalSettings = ref | null>(null) + + const timeZones = ref([]) + const dateFormats = ref([]) + const timeFormats = ref([]) + const currencies = ref([]) + const countries = ref([]) + const languages = ref>([]) + const fiscalYears = ref>([]) + + const mainMenu = ref([]) + const settingMenu = ref([]) + + const isAppLoaded = ref(false) + const isSidebarOpen = ref(false) + const isSidebarCollapsed = ref( + localStore.get('sidebarCollapsed') === 'true' + ) + const areCurrenciesLoading = ref(false) + + const downloadReport = ref(null) + + // Getters + const menuGroups = computed(() => { + return Object.values(groupBy(mainMenu.value, 'group')) + }) + + // Actions + async function bootstrap(): Promise { + const companyStore = useCompanyStore() + const userStore = useUserStore() + + try { + const response = await bootstrapService.bootstrap(companyStore.isAdminMode) + + mainMenu.value = response.main_menu + settingMenu.value = response.setting_menu + + config.value = response.config + globalSettings.value = response.global_settings + + // user store + userStore.currentUser = response.current_user + userStore.currentUserSettings = response.current_user_settings + userStore.currentAbilities = response.current_user_abilities + + // company store + companyStore.companies = response.companies + + if (response.current_company) { + companyStore.setSelectedCompany(response.current_company) + companyStore.selectedCompanySettings = response.current_company_settings + companyStore.selectedCompanyCurrency = response.current_company_currency + } else { + companyStore.setSelectedCompany(null) + companyStore.selectedCompanySettings = {} + companyStore.selectedCompanyCurrency = null + } + + isAppLoaded.value = true + return response + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function fetchCurrencies(): Promise { + if (currencies.value.length || areCurrenciesLoading.value) { + return currencies.value + } + + areCurrenciesLoading.value = true + + try { + const response = await settingService.getCurrencies() + currencies.value = response.data.map((currency) => ({ + ...currency, + name: `${currency.code} - ${currency.name}`, + })) + areCurrenciesLoading.value = false + return currencies.value + } catch (err: unknown) { + areCurrenciesLoading.value = false + handleApiError(err) + throw err + } + } + + async function fetchConfig(params?: Record): Promise { + try { + const response = await settingService.getConfig(params) + + if ((response as Record).languages) { + languages.value = (response as Record).languages as Array<{ + code: string + name: string + }> + } else if ((response as Record).fiscal_years) { + fiscalYears.value = (response as Record).fiscal_years as Array<{ + key: string + value: string + }> + } + + return response + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function fetchDateFormats(): Promise { + if (dateFormats.value.length) { + return dateFormats.value + } + + try { + const response = await settingService.getDateFormats() + dateFormats.value = response.date_formats + return dateFormats.value + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function fetchTimeFormats(): Promise { + if (timeFormats.value.length) { + return timeFormats.value + } + + try { + const response = await settingService.getTimeFormats() + timeFormats.value = response.time_formats + return timeFormats.value + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function fetchTimeZones(): Promise { + if (timeZones.value.length) { + return timeZones.value + } + + try { + const response = await settingService.getTimezones() + timeZones.value = response.time_zones + return timeZones.value + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function fetchCountries(): Promise { + if (countries.value.length) { + return countries.value + } + + try { + const response = await settingService.getCountries() + countries.value = response.data + return countries.value + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function fetchPlaceholders( + params: NumberPlaceholdersParams + ): Promise<{ placeholders: NumberPlaceholder[] }> { + try { + return await settingService.getNumberPlaceholders(params) + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + function setSidebarVisibility(val: boolean): void { + isSidebarOpen.value = val + } + + function toggleSidebarCollapse(): void { + isSidebarCollapsed.value = !isSidebarCollapsed.value + localStore.set( + 'sidebarCollapsed', + isSidebarCollapsed.value ? 'true' : 'false' + ) + } + + function setIsAppLoaded(loaded: boolean): void { + isAppLoaded.value = loaded + } + + async function updateGlobalSettings(params: { + data: GlobalSettingsPayload + message?: string + }): Promise { + try { + await settingService.updateGlobalSettings(params.data) + + if (globalSettings.value) { + Object.assign( + globalSettings.value, + params.data.settings + ) + } + + if (params.message) { + const notificationStore = useNotificationStore() + notificationStore.showNotification({ + type: 'success', + message: params.message, + }) + } + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + return { + // State + config, + globalSettings, + timeZones, + dateFormats, + timeFormats, + currencies, + countries, + languages, + fiscalYears, + mainMenu, + settingMenu, + isAppLoaded, + isSidebarOpen, + isSidebarCollapsed, + areCurrenciesLoading, + downloadReport, + // Getters + menuGroups, + // Actions + bootstrap, + fetchCurrencies, + fetchConfig, + fetchDateFormats, + fetchTimeFormats, + fetchTimeZones, + fetchCountries, + fetchPlaceholders, + setSidebarVisibility, + toggleSidebarCollapse, + setIsAppLoaded, + updateGlobalSettings, + } +}) diff --git a/resources/scripts-v2/stores/index.ts b/resources/scripts-v2/stores/index.ts new file mode 100644 index 00000000..e0264037 --- /dev/null +++ b/resources/scripts-v2/stores/index.ts @@ -0,0 +1,18 @@ +export { useAuthStore } from './auth.store' +export type { LoginData, ForgotPasswordData, ResetPasswordData } from './auth.store' + +export { useGlobalStore } from './global.store' + +export { useCompanyStore } from './company.store' + +export { useUserStore } from './user.store' +export type { UserForm } from './user.store' + +export { useNotificationStore } from './notification.store' +export type { NotificationType, Notification, ShowNotificationPayload } from './notification.store' + +export { useDialogStore } from './dialog.store' +export type { DialogVariant, DialogSize, OpenDialogPayload } from './dialog.store' + +export { useModalStore } from './modal.store' +export type { ModalSize, OpenModalPayload } from './modal.store' diff --git a/resources/scripts-v2/stores/modal.store.ts b/resources/scripts-v2/stores/modal.store.ts new file mode 100644 index 00000000..fc690764 --- /dev/null +++ b/resources/scripts-v2/stores/modal.store.ts @@ -0,0 +1,98 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' + +export interface OpenModalPayload { + componentName: string + title: string + id?: string | number + content?: string + data?: unknown + refreshData?: (() => void) | null + variant?: string + size?: ModalSize +} + +export const useModalStore = defineStore('modal', () => { + // State + const active = ref(false) + const content = ref('') + const title = ref('') + const componentName = ref('') + const id = ref('') + const size = ref('md') + const data = ref(null) + const refreshData = ref<(() => void) | null>(null) + const variant = ref('') + + // Getters + const isEdit = computed(() => { + return id.value !== '' && id.value !== 0 + }) + + // Actions + function openModal(payload: OpenModalPayload): void { + componentName.value = payload.componentName + active.value = true + + if (payload.id) { + id.value = payload.id + } + + title.value = payload.title + + if (payload.content) { + content.value = payload.content + } + + if (payload.data) { + data.value = payload.data + } + + if (payload.refreshData) { + refreshData.value = payload.refreshData + } + + if (payload.variant) { + variant.value = payload.variant + } + + if (payload.size) { + size.value = payload.size + } + } + + function resetModalData(): void { + content.value = '' + title.value = '' + componentName.value = '' + id.value = '' + data.value = null + refreshData.value = null + } + + function closeModal(): void { + active.value = false + + setTimeout(() => { + resetModalData() + }, 300) + } + + return { + active, + content, + title, + componentName, + id, + size, + data, + refreshData, + variant, + isEdit, + openModal, + resetModalData, + closeModal, + } +}) diff --git a/resources/scripts-v2/stores/notification.store.ts b/resources/scripts-v2/stores/notification.store.ts new file mode 100644 index 00000000..694c9a63 --- /dev/null +++ b/resources/scripts-v2/stores/notification.store.ts @@ -0,0 +1,44 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export type NotificationType = 'success' | 'error' | 'warning' | 'info' + +export interface Notification { + id: string + type: NotificationType + message: string +} + +export interface ShowNotificationPayload { + type: NotificationType + message: string +} + +export const useNotificationStore = defineStore('notification', () => { + // State + const active = ref(false) + const autoHide = ref(true) + const notifications = ref([]) + + // Actions + function showNotification(notification: ShowNotificationPayload): void { + notifications.value.push({ + ...notification, + id: (Math.random().toString(36) + Date.now().toString(36)).substring(2), + }) + } + + function hideNotification(data: { id: string }): void { + notifications.value = notifications.value.filter( + (notification) => notification.id !== data.id + ) + } + + return { + active, + autoHide, + notifications, + showNotification, + hideNotification, + } +}) diff --git a/resources/scripts-v2/stores/user.store.ts b/resources/scripts-v2/stores/user.store.ts new file mode 100644 index 00000000..728e60a0 --- /dev/null +++ b/resources/scripts-v2/stores/user.store.ts @@ -0,0 +1,164 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { userService } from '../api/services/user.service' +import type { UpdateProfilePayload, UserSettingsPayload } from '../api/services/user.service' +import { useNotificationStore } from './notification.store' +import { handleApiError } from '../utils/error-handling' +import type { User } from '../types/domain/user' +import type { Ability } from '../types/domain/role' +import type { ApiResponse } from '../types/api' + +export interface UserForm { + name: string + email: string + password: string + confirm_password: string + language: string +} + +export const useUserStore = defineStore('user', () => { + // State + const currentUser = ref(null) + const currentAbilities = ref([]) + const currentUserSettings = ref>({}) + + const userForm = ref({ + name: '', + email: '', + password: '', + confirm_password: '', + language: '', + }) + + // Getters + const currentAbilitiesCount = computed(() => currentAbilities.value.length) + + const isOwner = computed(() => currentUser.value?.is_owner ?? false) + + // Actions + async function fetchCurrentUser(): Promise> { + try { + const response = await userService.getProfile() + currentUser.value = response.data + userForm.value = { + name: response.data.name, + email: response.data.email, + password: '', + confirm_password: '', + language: '', + } + return response + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function updateCurrentUser(data: UpdateProfilePayload): Promise> { + try { + const response = await userService.updateProfile(data) + currentUser.value = response.data + userForm.value = { + name: response.data.name, + email: response.data.email, + password: '', + confirm_password: '', + language: '', + } + + const notificationStore = useNotificationStore() + notificationStore.showNotification({ + type: 'success', + message: 'settings.account_settings.updated_message', + }) + + return response + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function uploadAvatar(data: FormData): Promise> { + try { + const response = await userService.uploadAvatar(data) + if (currentUser.value) { + currentUser.value.avatar = response.data.avatar + } + return response + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function fetchUserSettings(settings?: string[]): Promise> { + try { + const response = await userService.getSettings(settings) + return response + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function updateUserSettings(data: UserSettingsPayload): Promise { + try { + await userService.updateSettings(data) + + const settings = data.settings as Record + + if (settings.language && typeof settings.language === 'string') { + currentUserSettings.value.language = settings.language + } + + if (settings.default_estimate_template && typeof settings.default_estimate_template === 'string') { + currentUserSettings.value.default_estimate_template = settings.default_estimate_template + } + + if (settings.default_invoice_template && typeof settings.default_invoice_template === 'string') { + currentUserSettings.value.default_invoice_template = settings.default_invoice_template + } + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + function hasAbilities(abilities: string | string[]): boolean { + return !!currentAbilities.value.find((ab) => { + if (ab.name === '*') return true + if (typeof abilities === 'string') { + return ab.name === abilities + } + return !!abilities.find((p) => ab.name === p) + }) + } + + function hasAllAbilities(abilities: string[]): boolean { + let isAvailable = true + currentAbilities.value.filter((ab) => { + const hasContain = !!abilities.find((p) => ab.name === p) + if (!hasContain) { + isAvailable = false + } + }) + return isAvailable + } + + return { + currentUser, + currentAbilities, + currentUserSettings, + userForm, + currentAbilitiesCount, + isOwner, + fetchCurrentUser, + updateCurrentUser, + uploadAvatar, + fetchUserSettings, + updateUserSettings, + hasAbilities, + hasAllAbilities, + } +})