diff --git a/resources/scripts-v2/features/company/expenses/components/ExpenseCategoryDropdown.vue b/resources/scripts-v2/features/company/expenses/components/ExpenseCategoryDropdown.vue new file mode 100644 index 00000000..b2bfe0eb --- /dev/null +++ b/resources/scripts-v2/features/company/expenses/components/ExpenseCategoryDropdown.vue @@ -0,0 +1,82 @@ + + + diff --git a/resources/scripts-v2/features/company/expenses/components/ExpenseDropdown.vue b/resources/scripts-v2/features/company/expenses/components/ExpenseDropdown.vue new file mode 100644 index 00000000..ba19313a --- /dev/null +++ b/resources/scripts-v2/features/company/expenses/components/ExpenseDropdown.vue @@ -0,0 +1,65 @@ + + + diff --git a/resources/scripts-v2/features/company/expenses/index.ts b/resources/scripts-v2/features/company/expenses/index.ts new file mode 100644 index 00000000..11292986 --- /dev/null +++ b/resources/scripts-v2/features/company/expenses/index.ts @@ -0,0 +1,11 @@ +export { useExpenseStore } from './store' +export type { ExpenseStore, ExpenseFormData, ExpenseState } from './store' +export { expenseRoutes } from './routes' + +// Views +export { default as ExpenseIndexView } from './views/ExpenseIndexView.vue' +export { default as ExpenseCreateView } from './views/ExpenseCreateView.vue' + +// Components +export { default as ExpenseDropdown } from './components/ExpenseDropdown.vue' +export { default as ExpenseCategoryDropdown } from './components/ExpenseCategoryDropdown.vue' diff --git a/resources/scripts-v2/features/company/expenses/routes.ts b/resources/scripts-v2/features/company/expenses/routes.ts new file mode 100644 index 00000000..5be9165b --- /dev/null +++ b/resources/scripts-v2/features/company/expenses/routes.ts @@ -0,0 +1,34 @@ +import type { RouteRecordRaw } from 'vue-router' + +const ExpenseIndexView = () => import('./views/ExpenseIndexView.vue') +const ExpenseCreateView = () => import('./views/ExpenseCreateView.vue') + +export const expenseRoutes: RouteRecordRaw[] = [ + { + path: 'expenses', + name: 'expenses.index', + component: ExpenseIndexView, + meta: { + ability: 'view-expense', + title: 'expenses.title', + }, + }, + { + path: 'expenses/create', + name: 'expenses.create', + component: ExpenseCreateView, + meta: { + ability: 'create-expense', + title: 'expenses.new_expense', + }, + }, + { + path: 'expenses/:id/edit', + name: 'expenses.edit', + component: ExpenseCreateView, + meta: { + ability: 'edit-expense', + title: 'expenses.edit_expense', + }, + }, +] diff --git a/resources/scripts-v2/features/company/expenses/store.ts b/resources/scripts-v2/features/company/expenses/store.ts new file mode 100644 index 00000000..8981b1d4 --- /dev/null +++ b/resources/scripts-v2/features/company/expenses/store.ts @@ -0,0 +1,253 @@ +import { defineStore } from 'pinia' +import { expenseService } from '../../../api/services/expense.service' +import type { + ExpenseListParams, + ExpenseListResponse, +} from '../../../api/services/expense.service' +import type { + Expense, + ExpenseCategory, + CreateExpensePayload, +} from '../../../types/domain/expense' +import type { PaymentMethod } from '../../../types/domain/payment' +import type { Currency } from '../../../types/domain/currency' +import type { CustomFieldValue } from '../../../types/domain/custom-field' + +// ---------------------------------------------------------------- +// Stub factories +// ---------------------------------------------------------------- + +export interface ReceiptFile { + image?: string + type?: string + name?: string +} + +export interface ExpenseFormData { + id: number | null + expense_date: string + expense_number: string + amount: number + notes: string | null + customer_id: number | null + expense_category_id: number | null + payment_method_id: number | null + currency_id: number | null + exchange_rate: number | null + selectedCurrency: Currency | null + attachment_receipt: File | null + attachment_receipt_url: string | null + receiptFiles: ReceiptFile[] + customFields: CustomFieldValue[] + fields: CustomFieldValue[] +} + +function createExpenseStub(): ExpenseFormData { + return { + id: null, + expense_date: '', + expense_number: '', + amount: 0, + notes: '', + customer_id: null, + expense_category_id: null, + payment_method_id: null, + currency_id: null, + exchange_rate: null, + selectedCurrency: null, + attachment_receipt: null, + attachment_receipt_url: null, + receiptFiles: [], + customFields: [], + fields: [], + } +} + +// ---------------------------------------------------------------- +// Store +// ---------------------------------------------------------------- + +export interface ExpenseState { + expenses: Expense[] + totalExpenses: number + selectAllField: boolean + selectedExpenses: number[] + paymentModes: PaymentMethod[] + showExchangeRate: boolean + currentExpense: ExpenseFormData +} + +export const useExpenseStore = defineStore('expense', { + state: (): ExpenseState => ({ + expenses: [], + totalExpenses: 0, + selectAllField: false, + selectedExpenses: [], + paymentModes: [], + showExchangeRate: false, + currentExpense: createExpenseStub(), + }), + + getters: { + getCurrentExpense: (state): ExpenseFormData => state.currentExpense, + getSelectedExpenses: (state): number[] => state.selectedExpenses, + }, + + actions: { + resetCurrentExpenseData(): void { + this.currentExpense = createExpenseStub() + }, + + async fetchExpenses( + params: ExpenseListParams, + ): Promise<{ data: ExpenseListResponse }> { + const response = await expenseService.list(params) + this.expenses = response.data + this.totalExpenses = response.meta.expense_total_count + return { data: response } + }, + + async fetchExpense(id: number): Promise<{ data: { data: Expense } }> { + const response = await expenseService.get(id) + const data = response.data + + Object.assign(this.currentExpense, data) + this.currentExpense.selectedCurrency = data.currency ?? null + this.currentExpense.attachment_receipt = null + + if (data.attachment_receipt_url) { + if ( + data.attachment_receipt_meta?.mime_type?.startsWith('image/') + ) { + this.currentExpense.receiptFiles = [ + { + image: `/reports/expenses/${id}/receipt?${data.attachment_receipt_meta.uuid}`, + }, + ] + } else if (data.attachment_receipt_meta) { + this.currentExpense.receiptFiles = [ + { + type: 'document', + name: data.attachment_receipt_meta.file_name, + }, + ] + } + } else { + this.currentExpense.receiptFiles = [] + } + + return { data: response } + }, + + async addExpense( + data: Record, + ): Promise<{ data: { data: Expense } }> { + const formData = toFormData(data) + const response = await expenseService.create(formData) + this.expenses.push(response.data) + return { data: response } + }, + + async updateExpense(params: { + id: number + data: Record + isAttachmentReceiptRemoved: boolean + }): Promise<{ data: { data: Expense } }> { + const formData = toFormData(params.data) + formData.append('_method', 'PUT') + formData.append( + 'is_attachment_receipt_removed', + String(params.isAttachmentReceiptRemoved), + ) + + const response = await expenseService.update(params.id, formData) + const pos = this.expenses.findIndex((e) => e.id === response.data.id) + if (pos !== -1) { + this.expenses[pos] = response.data + } + return { data: response } + }, + + async deleteExpense( + payload: { ids: number[] }, + ): Promise<{ data: { success: boolean } }> { + const response = await expenseService.delete(payload) + const id = payload.ids[0] + const index = this.expenses.findIndex((e) => e.id === id) + if (index !== -1) { + this.expenses.splice(index, 1) + } + return { data: response } + }, + + async deleteMultipleExpenses(): Promise<{ data: { success: boolean } }> { + const response = await expenseService.delete({ + ids: this.selectedExpenses, + }) + this.selectedExpenses.forEach((expenseId) => { + const index = this.expenses.findIndex((e) => e.id === expenseId) + if (index !== -1) { + this.expenses.splice(index, 1) + } + }) + this.selectedExpenses = [] + return { data: response } + }, + + async fetchPaymentModes( + params?: Record, + ): Promise<{ data: { data: PaymentMethod[] } }> { + const { paymentService } = await import( + '../../../api/services/payment.service' + ) + const response = await paymentService.listMethods(params as never) + this.paymentModes = response.data + return { data: response } + }, + + setSelectAllState(data: boolean): void { + this.selectAllField = data + }, + + selectExpense(data: number[]): void { + this.selectedExpenses = data + this.selectAllField = + this.selectedExpenses.length === this.expenses.length + }, + + selectAllExpenses(): void { + if (this.selectedExpenses.length === this.expenses.length) { + this.selectedExpenses = [] + this.selectAllField = false + } else { + this.selectedExpenses = this.expenses.map((e) => e.id) + this.selectAllField = true + } + }, + }, +}) + +/** + * Convert an object to FormData, handling nested properties and files. + */ +function toFormData(obj: Record): FormData { + const formData = new FormData() + + for (const key of Object.keys(obj)) { + const value = obj[key] + if (value === null || value === undefined) { + continue + } + if (value instanceof File) { + formData.append(key, value) + } else if (typeof value === 'object' && !(value instanceof Blob)) { + formData.append(key, JSON.stringify(value)) + } else { + formData.append(key, String(value)) + } + } + + return formData +} + +export type ExpenseStore = ReturnType diff --git a/resources/scripts-v2/features/company/expenses/views/ExpenseCreateView.vue b/resources/scripts-v2/features/company/expenses/views/ExpenseCreateView.vue new file mode 100644 index 00000000..0463e391 --- /dev/null +++ b/resources/scripts-v2/features/company/expenses/views/ExpenseCreateView.vue @@ -0,0 +1,368 @@ + + + diff --git a/resources/scripts-v2/features/company/expenses/views/ExpenseIndexView.vue b/resources/scripts-v2/features/company/expenses/views/ExpenseIndexView.vue new file mode 100644 index 00000000..0928f1c9 --- /dev/null +++ b/resources/scripts-v2/features/company/expenses/views/ExpenseIndexView.vue @@ -0,0 +1,414 @@ + + + diff --git a/resources/scripts-v2/features/company/members/components/InviteMemberModal.vue b/resources/scripts-v2/features/company/members/components/InviteMemberModal.vue new file mode 100644 index 00000000..4b8e5bad --- /dev/null +++ b/resources/scripts-v2/features/company/members/components/InviteMemberModal.vue @@ -0,0 +1,143 @@ + + + diff --git a/resources/scripts-v2/features/company/members/components/MemberDropdown.vue b/resources/scripts-v2/features/company/members/components/MemberDropdown.vue new file mode 100644 index 00000000..02e4b094 --- /dev/null +++ b/resources/scripts-v2/features/company/members/components/MemberDropdown.vue @@ -0,0 +1,82 @@ + + + diff --git a/resources/scripts-v2/features/company/members/index.ts b/resources/scripts-v2/features/company/members/index.ts new file mode 100644 index 00000000..2e83ca3e --- /dev/null +++ b/resources/scripts-v2/features/company/members/index.ts @@ -0,0 +1,3 @@ +export { useMemberStore } from './store' +export type { MemberForm } from './store' +export { default as memberRoutes } from './routes' diff --git a/resources/scripts-v2/features/company/members/routes.ts b/resources/scripts-v2/features/company/members/routes.ts new file mode 100644 index 00000000..05770aa6 --- /dev/null +++ b/resources/scripts-v2/features/company/members/routes.ts @@ -0,0 +1,14 @@ +import type { RouteRecordRaw } from 'vue-router' + +const memberRoutes: RouteRecordRaw[] = [ + { + path: 'members', + name: 'members.index', + component: () => import('./views/MemberIndexView.vue'), + meta: { + ability: 'view-member', + }, + }, +] + +export default memberRoutes diff --git a/resources/scripts-v2/features/company/members/store.ts b/resources/scripts-v2/features/company/members/store.ts new file mode 100644 index 00000000..95b70c3b --- /dev/null +++ b/resources/scripts-v2/features/company/members/store.ts @@ -0,0 +1,284 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { memberService } from '../../../api/services/member.service' +import { roleService } from '../../../api/services/role.service' +import type { + MemberListParams, + MemberListResponse, + UpdateMemberPayload, + InviteMemberPayload, + DeleteMembersPayload, +} from '../../../api/services/member.service' +import { useNotificationStore } from '../../../stores/notification.store' +import { handleApiError } from '../../../utils/error-handling' +import type { User } from '../../../types/domain/user' +import type { Role } from '../../../types/domain/role' +import type { CompanyInvitation } from '../../../types/domain/company' + +export interface MemberForm { + id?: number + name: string + email: string + password: string | null + phone: string | null + role: string | null + companies: Array<{ id: number; role?: string }> +} + +function createMemberStub(): MemberForm { + return { + name: '', + email: '', + password: null, + phone: null, + role: null, + companies: [], + } +} + +export const useMemberStore = defineStore('members', () => { + // State + const users = ref([]) + const totalUsers = ref(0) + const roles = ref([]) + const pendingInvitations = ref([]) + const currentMember = ref(createMemberStub()) + const selectAllField = ref(false) + const selectedUsers = ref([]) + + // Getters + const isEdit = computed(() => !!currentMember.value.id) + + // Actions + function resetCurrentMember(): void { + currentMember.value = createMemberStub() + } + + async function fetchUsers(params?: MemberListParams): Promise { + try { + const response = await memberService.list(params) + users.value = response.data + totalUsers.value = response.meta.total + return response + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function fetchUser(id: number): Promise { + try { + const response = await memberService.get(id) + Object.assign(currentMember.value, response.data) + + if (response.data.companies?.length) { + response.data.companies.forEach((c, i) => { + response.data.roles?.forEach((r) => { + if (r.scope === c.id) { + currentMember.value.companies[i] = { + ...currentMember.value.companies[i], + role: r.name, + } + } + }) + }) + } + + return response.data + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function fetchRoles(): Promise { + try { + const response = await roleService.list() + roles.value = response.data as unknown as Role[] + return roles.value + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function addUser(data: UpdateMemberPayload): Promise { + try { + const response = await memberService.create(data) + + const notificationStore = useNotificationStore() + notificationStore.showNotification({ + type: 'success', + message: 'members.created_message', + }) + + return response.data + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function updateUser(data: UpdateMemberPayload & { id: number }): Promise { + try { + const response = await memberService.update(data.id, data) + + if (response.data) { + const pos = users.value.findIndex((user) => user.id === response.data.id) + if (pos !== -1) { + users.value[pos] = response.data + } + } + + const notificationStore = useNotificationStore() + notificationStore.showNotification({ + type: 'success', + message: 'members.updated_message', + }) + + return response.data + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function deleteUser(payload: DeleteMembersPayload): Promise { + try { + const response = await memberService.delete(payload) + + payload.users.forEach((userId) => { + const index = users.value.findIndex((user) => user.id === userId) + if (index !== -1) { + users.value.splice(index, 1) + } + }) + + const notificationStore = useNotificationStore() + notificationStore.showNotification({ + type: 'success', + message: 'members.deleted_message', + }) + + return response.success + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function deleteMultipleUsers(): Promise { + try { + const response = await memberService.delete({ users: selectedUsers.value }) + + selectedUsers.value.forEach((userId) => { + const index = users.value.findIndex((_user) => _user.id === userId) + if (index !== -1) { + users.value.splice(index, 1) + } + }) + + const notificationStore = useNotificationStore() + notificationStore.showNotification({ + type: 'success', + message: 'members.deleted_message', + }) + + return response.success + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function fetchPendingInvitations(): Promise { + try { + const response = await memberService.fetchPendingInvitations() + pendingInvitations.value = response.invitations + return response.invitations + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function inviteMember(data: InviteMemberPayload): Promise { + try { + await memberService.invite(data) + + const notificationStore = useNotificationStore() + notificationStore.showNotification({ + type: 'success', + message: 'members.invited_message', + }) + + await fetchPendingInvitations() + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function cancelInvitation(id: number): Promise { + try { + await memberService.cancelInvitation(id) + + const notificationStore = useNotificationStore() + notificationStore.showNotification({ + type: 'success', + message: 'members.invitation_cancelled', + }) + + pendingInvitations.value = pendingInvitations.value.filter( + (inv) => inv.id !== id + ) + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + function setSelectAllState(data: boolean): void { + selectAllField.value = data + } + + function selectUser(data: number[]): void { + selectedUsers.value = data + selectAllField.value = selectedUsers.value.length === users.value.length + } + + function selectAllUsers(): void { + if (selectedUsers.value.length === users.value.length) { + selectedUsers.value = [] + selectAllField.value = false + } else { + selectedUsers.value = users.value.map((user) => user.id) + selectAllField.value = true + } + } + + return { + users, + totalUsers, + roles, + pendingInvitations, + currentMember, + selectAllField, + selectedUsers, + isEdit, + resetCurrentMember, + fetchUsers, + fetchUser, + fetchRoles, + addUser, + updateUser, + deleteUser, + deleteMultipleUsers, + fetchPendingInvitations, + inviteMember, + cancelInvitation, + setSelectAllState, + selectUser, + selectAllUsers, + } +}) diff --git a/resources/scripts-v2/features/company/members/views/MemberIndexView.vue b/resources/scripts-v2/features/company/members/views/MemberIndexView.vue new file mode 100644 index 00000000..2fa441d1 --- /dev/null +++ b/resources/scripts-v2/features/company/members/views/MemberIndexView.vue @@ -0,0 +1,403 @@ + + + diff --git a/resources/scripts-v2/features/company/modules/components/ModuleCard.vue b/resources/scripts-v2/features/company/modules/components/ModuleCard.vue new file mode 100644 index 00000000..022fccfd --- /dev/null +++ b/resources/scripts-v2/features/company/modules/components/ModuleCard.vue @@ -0,0 +1,83 @@ + + + diff --git a/resources/scripts-v2/features/company/modules/index.ts b/resources/scripts-v2/features/company/modules/index.ts new file mode 100644 index 00000000..374efe5a --- /dev/null +++ b/resources/scripts-v2/features/company/modules/index.ts @@ -0,0 +1,17 @@ +export { moduleRoutes } from './routes' + +export { useModuleStore } from './store' +export type { + ModuleState, + ModuleStore, + ModuleDetailResponse, + ModuleDetailMeta, + InstallationStep, +} from './store' + +// Views +export { default as ModuleIndexView } from './views/ModuleIndexView.vue' +export { default as ModuleDetailView } from './views/ModuleDetailView.vue' + +// Components +export { default as ModuleCard } from './components/ModuleCard.vue' diff --git a/resources/scripts-v2/features/company/modules/routes.ts b/resources/scripts-v2/features/company/modules/routes.ts new file mode 100644 index 00000000..079a543d --- /dev/null +++ b/resources/scripts-v2/features/company/modules/routes.ts @@ -0,0 +1,25 @@ +import type { RouteRecordRaw } from 'vue-router' + +const ModuleIndexView = () => import('./views/ModuleIndexView.vue') +const ModuleDetailView = () => import('./views/ModuleDetailView.vue') + +export const moduleRoutes: RouteRecordRaw[] = [ + { + path: 'modules', + name: 'modules.index', + component: ModuleIndexView, + meta: { + ability: 'manage-module', + title: 'modules.title', + }, + }, + { + path: 'modules/:slug', + name: 'modules.view', + component: ModuleDetailView, + meta: { + ability: 'manage-module', + title: 'modules.title', + }, + }, +] diff --git a/resources/scripts-v2/features/company/modules/store.ts b/resources/scripts-v2/features/company/modules/store.ts new file mode 100644 index 00000000..1c8e152e --- /dev/null +++ b/resources/scripts-v2/features/company/modules/store.ts @@ -0,0 +1,180 @@ +import { defineStore } from 'pinia' +import { moduleService } from '../../../api/services/module.service' +import type { + Module, + ModuleReview, + ModuleFaq, + ModuleLink, + ModuleScreenshot, +} from '../../../types/domain/module' + +// ---------------------------------------------------------------- +// Types +// ---------------------------------------------------------------- + +export interface ModuleDetailMeta { + modules: Module[] +} + +export interface ModuleDetailResponse { + data: Module + meta: ModuleDetailMeta +} + +export interface InstallationStep { + translationKey: string + stepUrl: string + time: string | null + started: boolean + completed: boolean +} + +// ---------------------------------------------------------------- +// Store +// ---------------------------------------------------------------- + +export interface ModuleState { + currentModule: ModuleDetailResponse | null + modules: Module[] + apiToken: string | null + currentUser: { + api_token: string | null + } + enableModules: string[] +} + +export const useModuleStore = defineStore('modules', { + state: (): ModuleState => ({ + currentModule: null, + modules: [], + apiToken: null, + currentUser: { + api_token: null, + }, + enableModules: [], + }), + + getters: { + salesTaxUSEnabled: (state): boolean => + state.enableModules.includes('SalesTaxUS'), + + installedModules: (state): Module[] => + state.modules.filter((m) => m.installed), + }, + + actions: { + async fetchModules(): Promise { + const response = await moduleService.list() + this.modules = response.data + }, + + async fetchModule(slug: string): Promise { + const response = await moduleService.get(slug) + const data = response as unknown as ModuleDetailResponse + + if ((data as Record).error === 'invalid_token') { + this.currentModule = null + this.modules = [] + this.apiToken = null + this.currentUser.api_token = null + return data + } + + this.currentModule = data + return data + }, + + async checkApiToken(token: string): Promise<{ success: boolean; error?: string }> { + const response = await moduleService.checkToken(token) + return { + success: response.success ?? false, + error: response.error, + } + }, + + async disableModule(moduleName: string): Promise<{ success: boolean }> { + return moduleService.disable(moduleName) + }, + + async enableModule(moduleName: string): Promise<{ success: boolean }> { + return moduleService.enable(moduleName) + }, + + async installModule( + moduleName: string, + version: string, + onStepUpdate?: (step: InstallationStep) => void, + ): Promise { + const steps: InstallationStep[] = [ + { + translationKey: 'modules.download_zip_file', + stepUrl: '/api/v1/modules/download', + time: null, + started: false, + completed: false, + }, + { + translationKey: 'modules.unzipping_package', + stepUrl: '/api/v1/modules/unzip', + time: null, + started: false, + completed: false, + }, + { + translationKey: 'modules.copying_files', + stepUrl: '/api/v1/modules/copy', + time: null, + started: false, + completed: false, + }, + { + translationKey: 'modules.completing_installation', + stepUrl: '/api/v1/modules/complete', + time: null, + started: false, + completed: false, + }, + ] + + let path: string | null = null + + for (const step of steps) { + step.started = true + onStepUpdate?.(step) + + try { + const stepFns: Record Promise>> = { + '/api/v1/modules/download': () => + moduleService.download({ module: moduleName, version, path: path ?? undefined } as never) as Promise>, + '/api/v1/modules/unzip': () => + moduleService.unzip({ module: moduleName, version, path: path ?? undefined } as never) as Promise>, + '/api/v1/modules/copy': () => + moduleService.copy({ module: moduleName, version, path: path ?? undefined } as never) as Promise>, + '/api/v1/modules/complete': () => + moduleService.complete({ module: moduleName, version, path: path ?? undefined } as never) as Promise>, + } + + const result = await stepFns[step.stepUrl]() + step.completed = true + onStepUpdate?.(step) + + if ((result as Record).path) { + path = (result as Record).path as string + } + + if (!(result as Record).success) { + return false + } + } catch { + step.completed = true + onStepUpdate?.(step) + return false + } + } + + return true + }, + }, +}) + +export type ModuleStore = ReturnType diff --git a/resources/scripts-v2/features/company/modules/views/ModuleDetailView.vue b/resources/scripts-v2/features/company/modules/views/ModuleDetailView.vue new file mode 100644 index 00000000..62b1ce12 --- /dev/null +++ b/resources/scripts-v2/features/company/modules/views/ModuleDetailView.vue @@ -0,0 +1,516 @@ +