diff --git a/resources/scripts-v2/features/admin/components/AdminCompanyDropdown.vue b/resources/scripts-v2/features/admin/components/AdminCompanyDropdown.vue new file mode 100644 index 00000000..b1b0368a --- /dev/null +++ b/resources/scripts-v2/features/admin/components/AdminCompanyDropdown.vue @@ -0,0 +1,29 @@ + + + diff --git a/resources/scripts-v2/features/admin/components/AdminUserDropdown.vue b/resources/scripts-v2/features/admin/components/AdminUserDropdown.vue new file mode 100644 index 00000000..9855de8c --- /dev/null +++ b/resources/scripts-v2/features/admin/components/AdminUserDropdown.vue @@ -0,0 +1,71 @@ + + + diff --git a/resources/scripts-v2/features/admin/index.ts b/resources/scripts-v2/features/admin/index.ts new file mode 100644 index 00000000..64e64cc5 --- /dev/null +++ b/resources/scripts-v2/features/admin/index.ts @@ -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' diff --git a/resources/scripts-v2/features/admin/routes.ts b/resources/scripts-v2/features/admin/routes.ts new file mode 100644 index 00000000..1531f32e --- /dev/null +++ b/resources/scripts-v2/features/admin/routes.ts @@ -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: '' }, + }, + { + path: 'pdf-generation', + name: 'admin.settings.pdf', + meta: { + isSuperAdmin: true, + }, + component: { template: '' }, + }, + { + path: 'backup', + name: 'admin.settings.backup', + meta: { + isSuperAdmin: true, + }, + component: { template: '' }, + }, + { + path: 'file-disk', + name: 'admin.settings.disk', + meta: { + isSuperAdmin: true, + }, + component: { template: '' }, + }, + { + path: 'update-app', + name: 'admin.settings.update', + meta: { + isSuperAdmin: true, + }, + component: { template: '' }, + }, + ], + }, + ], + }, +] diff --git a/resources/scripts-v2/features/admin/stores/admin.store.ts b/resources/scripts-v2/features/admin/stores/admin.store.ts new file mode 100644 index 00000000..0d4a45e7 --- /dev/null +++ b/resources/scripts-v2/features/admin/stores/admin.store.ts @@ -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([]) + const totalCompanies = ref(0) + const selectedCompany = ref(null) + + const users = ref([]) + const totalUsers = ref(0) + + const dashboardData = ref(null) + + // Actions + async function fetchDashboard(): Promise { + 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> { + 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 { + 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> { + 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 { + 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 { + 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 { + 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, + } +}) diff --git a/resources/scripts-v2/features/admin/views/AdminCompaniesView.vue b/resources/scripts-v2/features/admin/views/AdminCompaniesView.vue new file mode 100644 index 00000000..1392d349 --- /dev/null +++ b/resources/scripts-v2/features/admin/views/AdminCompaniesView.vue @@ -0,0 +1,210 @@ + + + diff --git a/resources/scripts-v2/features/admin/views/AdminCompanyEditView.vue b/resources/scripts-v2/features/admin/views/AdminCompanyEditView.vue new file mode 100644 index 00000000..f49325e5 --- /dev/null +++ b/resources/scripts-v2/features/admin/views/AdminCompanyEditView.vue @@ -0,0 +1,274 @@ + + + diff --git a/resources/scripts-v2/features/admin/views/AdminDashboardView.vue b/resources/scripts-v2/features/admin/views/AdminDashboardView.vue new file mode 100644 index 00000000..ac851da6 --- /dev/null +++ b/resources/scripts-v2/features/admin/views/AdminDashboardView.vue @@ -0,0 +1,105 @@ + + + diff --git a/resources/scripts-v2/features/admin/views/AdminSettingsView.vue b/resources/scripts-v2/features/admin/views/AdminSettingsView.vue new file mode 100644 index 00000000..c065e254 --- /dev/null +++ b/resources/scripts-v2/features/admin/views/AdminSettingsView.vue @@ -0,0 +1,117 @@ + + + diff --git a/resources/scripts-v2/features/admin/views/AdminUserEditView.vue b/resources/scripts-v2/features/admin/views/AdminUserEditView.vue new file mode 100644 index 00000000..94083e33 --- /dev/null +++ b/resources/scripts-v2/features/admin/views/AdminUserEditView.vue @@ -0,0 +1,187 @@ + + + diff --git a/resources/scripts-v2/features/admin/views/AdminUsersView.vue b/resources/scripts-v2/features/admin/views/AdminUsersView.vue new file mode 100644 index 00000000..2a5277f8 --- /dev/null +++ b/resources/scripts-v2/features/admin/views/AdminUsersView.vue @@ -0,0 +1,271 @@ + + + diff --git a/resources/scripts-v2/features/auth/index.ts b/resources/scripts-v2/features/auth/index.ts new file mode 100644 index 00000000..86ae6c13 --- /dev/null +++ b/resources/scripts-v2/features/auth/index.ts @@ -0,0 +1,6 @@ +export { authRoutes } from './routes' + +export { default as LoginView } from './views/LoginView.vue' +export { default as ForgotPasswordView } from './views/ForgotPasswordView.vue' +export { default as ResetPasswordView } from './views/ResetPasswordView.vue' +export { default as RegisterWithInvitationView } from './views/RegisterWithInvitationView.vue' diff --git a/resources/scripts-v2/features/auth/routes.ts b/resources/scripts-v2/features/auth/routes.ts new file mode 100644 index 00000000..ac62748f --- /dev/null +++ b/resources/scripts-v2/features/auth/routes.ts @@ -0,0 +1,52 @@ +import type { RouteRecordRaw } from 'vue-router' + +const AuthLayout = () => import('../../layouts/AuthLayout.vue') +const LoginView = () => import('./views/LoginView.vue') +const ForgotPasswordView = () => import('./views/ForgotPasswordView.vue') +const ResetPasswordView = () => import('./views/ResetPasswordView.vue') +const RegisterWithInvitationView = () => import('./views/RegisterWithInvitationView.vue') + +export const authRoutes: RouteRecordRaw[] = [ + { + path: '/login', + component: AuthLayout, + children: [ + { + path: '', + name: 'login', + component: LoginView, + meta: { + requiresAuth: false, + title: 'Login', + }, + }, + { + path: '/forgot-password', + name: 'forgot-password', + component: ForgotPasswordView, + meta: { + requiresAuth: false, + title: 'Forgot Password', + }, + }, + { + path: '/reset-password/:token', + name: 'reset-password', + component: ResetPasswordView, + meta: { + requiresAuth: false, + title: 'Reset Password', + }, + }, + ], + }, + { + path: '/register', + name: 'register-with-invitation', + component: RegisterWithInvitationView, + meta: { + requiresAuth: false, + title: 'Register', + }, + }, +] diff --git a/resources/scripts-v2/features/auth/views/ForgotPasswordView.vue b/resources/scripts-v2/features/auth/views/ForgotPasswordView.vue new file mode 100644 index 00000000..6914c320 --- /dev/null +++ b/resources/scripts-v2/features/auth/views/ForgotPasswordView.vue @@ -0,0 +1,101 @@ + + + diff --git a/resources/scripts-v2/features/auth/views/LoginView.vue b/resources/scripts-v2/features/auth/views/LoginView.vue new file mode 100644 index 00000000..9c35e079 --- /dev/null +++ b/resources/scripts-v2/features/auth/views/LoginView.vue @@ -0,0 +1,129 @@ + + + diff --git a/resources/scripts-v2/features/auth/views/RegisterWithInvitationView.vue b/resources/scripts-v2/features/auth/views/RegisterWithInvitationView.vue new file mode 100644 index 00000000..da2d1c6e --- /dev/null +++ b/resources/scripts-v2/features/auth/views/RegisterWithInvitationView.vue @@ -0,0 +1,235 @@ + + + diff --git a/resources/scripts-v2/features/auth/views/ResetPasswordView.vue b/resources/scripts-v2/features/auth/views/ResetPasswordView.vue new file mode 100644 index 00000000..ff192d80 --- /dev/null +++ b/resources/scripts-v2/features/auth/views/ResetPasswordView.vue @@ -0,0 +1,165 @@ + + + diff --git a/resources/scripts-v2/features/company/customers/components/CustomerDropdown.vue b/resources/scripts-v2/features/company/customers/components/CustomerDropdown.vue new file mode 100644 index 00000000..bca01019 --- /dev/null +++ b/resources/scripts-v2/features/company/customers/components/CustomerDropdown.vue @@ -0,0 +1,121 @@ + + + diff --git a/resources/scripts-v2/features/company/customers/components/CustomerModal.vue b/resources/scripts-v2/features/company/customers/components/CustomerModal.vue new file mode 100644 index 00000000..2d6341cc --- /dev/null +++ b/resources/scripts-v2/features/company/customers/components/CustomerModal.vue @@ -0,0 +1,650 @@ + + + diff --git a/resources/scripts-v2/features/company/customers/index.ts b/resources/scripts-v2/features/company/customers/index.ts new file mode 100644 index 00000000..be9a5c30 --- /dev/null +++ b/resources/scripts-v2/features/company/customers/index.ts @@ -0,0 +1,7 @@ +export { useCustomerStore } from './store' +export type { + CustomerForm, + CustomerFormAddress, + CustomerViewData, +} from './store' +export { default as customerRoutes } from './routes' diff --git a/resources/scripts-v2/features/company/customers/routes.ts b/resources/scripts-v2/features/company/customers/routes.ts new file mode 100644 index 00000000..540af89a --- /dev/null +++ b/resources/scripts-v2/features/company/customers/routes.ts @@ -0,0 +1,38 @@ +import type { RouteRecordRaw } from 'vue-router' + +const customerRoutes: RouteRecordRaw[] = [ + { + path: 'customers', + name: 'customers.index', + component: () => import('./views/CustomerIndexView.vue'), + meta: { + ability: 'view-customer', + }, + }, + { + path: 'customers/create', + name: 'customers.create', + component: () => import('./views/CustomerCreateView.vue'), + meta: { + ability: 'create-customer', + }, + }, + { + path: 'customers/:id/edit', + name: 'customers.edit', + component: () => import('./views/CustomerCreateView.vue'), + meta: { + ability: 'edit-customer', + }, + }, + { + path: 'customers/:id/view', + name: 'customers.view', + component: () => import('./views/CustomerDetailView.vue'), + meta: { + ability: 'view-customer', + }, + }, +] + +export default customerRoutes diff --git a/resources/scripts-v2/features/company/customers/store.ts b/resources/scripts-v2/features/company/customers/store.ts new file mode 100644 index 00000000..facd0068 --- /dev/null +++ b/resources/scripts-v2/features/company/customers/store.ts @@ -0,0 +1,335 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { useRoute } from 'vue-router' +import { customerService } from '../../../api/services/customer.service' +import type { + CustomerListParams, + CustomerListResponse, + CustomerStatsData, +} from '../../../api/services/customer.service' +import { useNotificationStore } from '../../../stores/notification.store' +import { useGlobalStore } from '../../../stores/global.store' +import { useCompanyStore } from '../../../stores/company.store' +import { handleApiError } from '../../../utils/error-handling' +import type { Customer, CreateCustomerPayload } from '../../../types/domain/customer' +import type { Address } from '../../../types/domain/user' +import type { ApiResponse, DeletePayload } from '../../../types/api' + +export interface CustomerFormAddress { + name: string | null + phone: string | null + address_street_1: string | null + address_street_2: string | null + city: string | null + state: string | null + country_id: number | null + zip: string | null + type: string | null +} + +export interface CustomerForm { + id?: number + name: string + contact_name: string + email: string + phone: string | null + password: string + confirm_password: string + currency_id: number | null + website: string | null + prefix?: string | null + tax_id?: string | null + billing: CustomerFormAddress + shipping: CustomerFormAddress + customFields: unknown[] + fields: unknown[] + enable_portal: boolean + password_added?: boolean +} + +export interface CustomerViewData { + customer?: Customer + [key: string]: unknown +} + +function createAddressStub(): CustomerFormAddress { + return { + name: null, + phone: null, + address_street_1: null, + address_street_2: null, + city: null, + state: null, + country_id: null, + zip: null, + type: null, + } +} + +function createCustomerStub(): CustomerForm { + return { + name: '', + contact_name: '', + email: '', + phone: null, + password: '', + confirm_password: '', + currency_id: null, + website: null, + billing: createAddressStub(), + shipping: createAddressStub(), + customFields: [], + fields: [], + enable_portal: false, + } +} + +export const useCustomerStore = defineStore('customer', () => { + // State + const customers = ref([]) + const totalCustomers = ref(0) + const selectAllField = ref(false) + const selectedCustomers = ref([]) + const selectedViewCustomer = ref({}) + const isFetchingInitialSettings = ref(false) + const isFetchingViewData = ref(false) + const currentCustomer = ref(createCustomerStub()) + const editCustomer = ref(null) + + // Getters + const isEdit = computed(() => !!currentCustomer.value.id) + + // Actions + function resetCurrentCustomer(): void { + currentCustomer.value = createCustomerStub() + } + + function copyAddress(fromBillingToShipping = true): void { + if (fromBillingToShipping) { + currentCustomer.value.shipping = { + ...currentCustomer.value.billing, + type: 'shipping', + } + } + } + + async function fetchCustomerInitialSettings(isEditMode: boolean): Promise { + const route = useRoute() + const globalStore = useGlobalStore() + const companyStore = useCompanyStore() + + isFetchingInitialSettings.value = true + + const editActions: Promise[] = [] + if (isEditMode) { + editActions.push(fetchCustomer(Number(route.params.id))) + } else { + currentCustomer.value.currency_id = + companyStore.selectedCompanyCurrency?.id ?? null + } + + try { + await Promise.all([ + globalStore.fetchCurrencies(), + globalStore.fetchCountries(), + ...editActions, + ]) + isFetchingInitialSettings.value = false + } catch (err: unknown) { + isFetchingInitialSettings.value = false + handleApiError(err) + } + } + + async function fetchCustomers(params?: CustomerListParams): Promise { + try { + const response = await customerService.list(params) + customers.value = response.data + totalCustomers.value = response.meta.customer_total_count + return response + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function fetchViewCustomer(params: { id: number }): Promise> { + isFetchingViewData.value = true + try { + const response = await customerService.getStats(params.id, params as Record) + selectedViewCustomer.value = {} + Object.assign(selectedViewCustomer.value, response.data) + setAddressStub(response.data as unknown as Record) + isFetchingViewData.value = false + return response + } catch (err: unknown) { + isFetchingViewData.value = false + handleApiError(err) + throw err + } + } + + async function fetchCustomer(id: number): Promise> { + try { + const response = await customerService.get(id) + Object.assign(currentCustomer.value, response.data) + setAddressStub(response.data as unknown as Record) + return response + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function addCustomer(data: CustomerForm): Promise> { + try { + const response = await customerService.create(data as unknown as CreateCustomerPayload) + customers.value.push(response.data) + + const notificationStore = useNotificationStore() + notificationStore.showNotification({ + type: 'success', + message: 'customers.created_message', + }) + + return response + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function updateCustomer(data: CustomerForm): Promise> { + try { + const response = await customerService.update( + data.id!, + data as unknown as Partial + ) + + if (response.data) { + const pos = customers.value.findIndex( + (customer) => customer.id === response.data.id + ) + if (pos !== -1) { + customers.value[pos] = response.data + } + + const notificationStore = useNotificationStore() + notificationStore.showNotification({ + type: 'success', + message: 'customers.updated_message', + }) + } + + return response + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function deleteCustomer(payload: DeletePayload): Promise<{ success: boolean }> { + try { + const response = await customerService.delete(payload) + + const index = customers.value.findIndex( + (customer) => customer.id === payload.ids[0] + ) + if (index !== -1) { + customers.value.splice(index, 1) + } + + const notificationStore = useNotificationStore() + notificationStore.showNotification({ + type: 'success', + message: 'customers.deleted_message', + }) + + return response + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + async function deleteMultipleCustomers(): Promise<{ success: boolean }> { + try { + const response = await customerService.delete({ ids: selectedCustomers.value }) + + selectedCustomers.value.forEach((customerId) => { + const index = customers.value.findIndex( + (_customer) => _customer.id === customerId + ) + if (index !== -1) { + customers.value.splice(index, 1) + } + }) + + const notificationStore = useNotificationStore() + notificationStore.showNotification({ + type: 'success', + message: 'customers.deleted_message', + }) + + return response + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + function setSelectAllState(data: boolean): void { + selectAllField.value = data + } + + function selectCustomer(data: number[]): void { + selectedCustomers.value = data + selectAllField.value = selectedCustomers.value.length === customers.value.length + } + + function selectAllCustomers(): void { + if (selectedCustomers.value.length === customers.value.length) { + selectedCustomers.value = [] + selectAllField.value = false + } else { + selectedCustomers.value = customers.value.map((customer) => customer.id) + selectAllField.value = true + } + } + + function setAddressStub(data: Record): void { + if (!data.billing) { + currentCustomer.value.billing = createAddressStub() + } + if (!data.shipping) { + currentCustomer.value.shipping = createAddressStub() + } + } + + return { + customers, + totalCustomers, + selectAllField, + selectedCustomers, + selectedViewCustomer, + isFetchingInitialSettings, + isFetchingViewData, + currentCustomer, + editCustomer, + isEdit, + resetCurrentCustomer, + copyAddress, + fetchCustomerInitialSettings, + fetchCustomers, + fetchViewCustomer, + fetchCustomer, + addCustomer, + updateCustomer, + deleteCustomer, + deleteMultipleCustomers, + setSelectAllState, + selectCustomer, + selectAllCustomers, + setAddressStub, + } +}) diff --git a/resources/scripts-v2/features/company/customers/views/CustomerCreateView.vue b/resources/scripts-v2/features/company/customers/views/CustomerCreateView.vue new file mode 100644 index 00000000..e759a5ad --- /dev/null +++ b/resources/scripts-v2/features/company/customers/views/CustomerCreateView.vue @@ -0,0 +1,739 @@ + + + diff --git a/resources/scripts-v2/features/company/customers/views/CustomerDetailView.vue b/resources/scripts-v2/features/company/customers/views/CustomerDetailView.vue new file mode 100644 index 00000000..67c5399d --- /dev/null +++ b/resources/scripts-v2/features/company/customers/views/CustomerDetailView.vue @@ -0,0 +1,146 @@ + + + diff --git a/resources/scripts-v2/features/company/customers/views/CustomerIndexView.vue b/resources/scripts-v2/features/company/customers/views/CustomerIndexView.vue new file mode 100644 index 00000000..65f118f9 --- /dev/null +++ b/resources/scripts-v2/features/company/customers/views/CustomerIndexView.vue @@ -0,0 +1,392 @@ + + + diff --git a/resources/scripts-v2/features/company/dashboard/components/DashboardChart.vue b/resources/scripts-v2/features/company/dashboard/components/DashboardChart.vue new file mode 100644 index 00000000..0258585f --- /dev/null +++ b/resources/scripts-v2/features/company/dashboard/components/DashboardChart.vue @@ -0,0 +1,281 @@ + + + diff --git a/resources/scripts-v2/features/company/dashboard/components/DashboardStats.vue b/resources/scripts-v2/features/company/dashboard/components/DashboardStats.vue new file mode 100644 index 00000000..e06dab6e --- /dev/null +++ b/resources/scripts-v2/features/company/dashboard/components/DashboardStats.vue @@ -0,0 +1,85 @@ + + + diff --git a/resources/scripts-v2/features/company/dashboard/components/DashboardStatsItem.vue b/resources/scripts-v2/features/company/dashboard/components/DashboardStatsItem.vue new file mode 100644 index 00000000..204cee0a --- /dev/null +++ b/resources/scripts-v2/features/company/dashboard/components/DashboardStatsItem.vue @@ -0,0 +1,103 @@ + + + diff --git a/resources/scripts-v2/features/company/dashboard/components/DashboardTable.vue b/resources/scripts-v2/features/company/dashboard/components/DashboardTable.vue new file mode 100644 index 00000000..c58355eb --- /dev/null +++ b/resources/scripts-v2/features/company/dashboard/components/DashboardTable.vue @@ -0,0 +1,198 @@ + + + diff --git a/resources/scripts-v2/features/company/dashboard/index.ts b/resources/scripts-v2/features/company/dashboard/index.ts new file mode 100644 index 00000000..d1b54b36 --- /dev/null +++ b/resources/scripts-v2/features/company/dashboard/index.ts @@ -0,0 +1,8 @@ +export { useDashboardStore } from './store' +export type { + DashboardStats, + DashboardChartData, + DueInvoice, + RecentEstimate, +} from './store' +export { default as dashboardRoutes } from './routes' diff --git a/resources/scripts-v2/features/company/dashboard/routes.ts b/resources/scripts-v2/features/company/dashboard/routes.ts new file mode 100644 index 00000000..1f5db512 --- /dev/null +++ b/resources/scripts-v2/features/company/dashboard/routes.ts @@ -0,0 +1,14 @@ +import type { RouteRecordRaw } from 'vue-router' + +const dashboardRoutes: RouteRecordRaw[] = [ + { + path: 'dashboard', + name: 'dashboard', + component: () => import('./views/DashboardView.vue'), + meta: { + ability: 'dashboard', + }, + }, +] + +export default dashboardRoutes diff --git a/resources/scripts-v2/features/company/dashboard/store.ts b/resources/scripts-v2/features/company/dashboard/store.ts new file mode 100644 index 00000000..5dbe8c10 --- /dev/null +++ b/resources/scripts-v2/features/company/dashboard/store.ts @@ -0,0 +1,137 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { dashboardService } from '../../../api/services/dashboard.service' +import type { DashboardParams, DashboardResponse, ChartData } from '../../../api/services/dashboard.service' +import type { Invoice } from '../../../types/domain/invoice' +import type { Estimate } from '../../../types/domain/estimate' +import { handleApiError } from '../../../utils/error-handling' + +export interface DashboardStats { + totalAmountDue: number + totalCustomerCount: number + totalInvoiceCount: number + totalEstimateCount: number +} + +export interface DashboardChartData { + months: string[] + invoiceTotals: number[] + expenseTotals: number[] + receiptTotals: number[] + netIncomeTotals: number[] +} + +export interface DueInvoice { + id: number + invoice_number: string + due_amount: number + formattedDueDate: string + formatted_due_date: string + customer: { + id: number + name: string + currency?: { + id: number + code: string + symbol: string + } + } +} + +export interface RecentEstimate { + id: number + estimate_number: string + total: number + status: string + formattedEstimateDate: string + formatted_estimate_date: string + customer: { + id: number + name: string + currency?: { + id: number + code: string + symbol: string + } + } +} + +export const useDashboardStore = defineStore('dashboard', () => { + // State + const stats = ref({ + totalAmountDue: 0, + totalCustomerCount: 0, + totalInvoiceCount: 0, + totalEstimateCount: 0, + }) + + const chartData = ref({ + months: [], + invoiceTotals: [], + expenseTotals: [], + receiptTotals: [], + netIncomeTotals: [], + }) + + const totalSales = ref(0) + const totalReceipts = ref(0) + const totalExpenses = ref(0) + const totalNetIncome = ref(0) + + const recentDueInvoices = ref([]) + const recentEstimates = ref([]) + + const isDashboardDataLoaded = ref(false) + + // Actions + async function loadData(params?: DashboardParams): Promise { + try { + const response = await dashboardService.load(params) + + // Stats + stats.value.totalAmountDue = response.total_amount_due + stats.value.totalCustomerCount = response.total_customer_count + stats.value.totalInvoiceCount = response.total_invoice_count + stats.value.totalEstimateCount = response.total_estimate_count + + // Chart Data + if (response.chart_data) { + chartData.value.months = response.chart_data.months + chartData.value.invoiceTotals = response.chart_data.invoice_totals + chartData.value.expenseTotals = response.chart_data.expense_totals + chartData.value.receiptTotals = response.chart_data.receipt_totals + chartData.value.netIncomeTotals = response.chart_data.net_income_totals + } + + // Chart Labels + totalSales.value = Number(response.total_sales) || 0 + totalReceipts.value = Number(response.total_receipts) || 0 + totalExpenses.value = Number(response.total_expenses) || 0 + totalNetIncome.value = Number(response.total_net_income) || 0 + + // Table Data + recentDueInvoices.value = response.recent_due_invoices as unknown as DueInvoice[] + recentEstimates.value = response.recent_estimates as unknown as RecentEstimate[] + + isDashboardDataLoaded.value = true + + return response + } catch (err: unknown) { + handleApiError(err) + throw err + } + } + + return { + stats, + chartData, + totalSales, + totalReceipts, + totalExpenses, + totalNetIncome, + recentDueInvoices, + recentEstimates, + isDashboardDataLoaded, + loadData, + } +}) diff --git a/resources/scripts-v2/features/company/dashboard/views/DashboardView.vue b/resources/scripts-v2/features/company/dashboard/views/DashboardView.vue new file mode 100644 index 00000000..fc00afb7 --- /dev/null +++ b/resources/scripts-v2/features/company/dashboard/views/DashboardView.vue @@ -0,0 +1,30 @@ + + + diff --git a/resources/scripts-v2/features/company/estimates/components/EstimateBasicFields.vue b/resources/scripts-v2/features/company/estimates/components/EstimateBasicFields.vue new file mode 100644 index 00000000..d41e889b --- /dev/null +++ b/resources/scripts-v2/features/company/estimates/components/EstimateBasicFields.vue @@ -0,0 +1,88 @@ + + + diff --git a/resources/scripts-v2/features/company/estimates/components/EstimateDropdown.vue b/resources/scripts-v2/features/company/estimates/components/EstimateDropdown.vue new file mode 100644 index 00000000..8780b0a0 --- /dev/null +++ b/resources/scripts-v2/features/company/estimates/components/EstimateDropdown.vue @@ -0,0 +1,263 @@ + + + diff --git a/resources/scripts-v2/features/company/estimates/index.ts b/resources/scripts-v2/features/company/estimates/index.ts new file mode 100644 index 00000000..54e5c6db --- /dev/null +++ b/resources/scripts-v2/features/company/estimates/index.ts @@ -0,0 +1,12 @@ +export { useEstimateStore } from './store' +export type { EstimateStore, EstimateFormData, EstimateState } from './store' +export { estimateRoutes } from './routes' + +// Views +export { default as EstimateIndexView } from './views/EstimateIndexView.vue' +export { default as EstimateCreateView } from './views/EstimateCreateView.vue' +export { default as EstimateDetailView } from './views/EstimateDetailView.vue' + +// Components +export { default as EstimateBasicFields } from './components/EstimateBasicFields.vue' +export { default as EstimateDropdown } from './components/EstimateDropdown.vue' diff --git a/resources/scripts-v2/features/company/estimates/routes.ts b/resources/scripts-v2/features/company/estimates/routes.ts new file mode 100644 index 00000000..e1005719 --- /dev/null +++ b/resources/scripts-v2/features/company/estimates/routes.ts @@ -0,0 +1,44 @@ +import type { RouteRecordRaw } from 'vue-router' + +const EstimateIndexView = () => import('./views/EstimateIndexView.vue') +const EstimateCreateView = () => import('./views/EstimateCreateView.vue') +const EstimateDetailView = () => import('./views/EstimateDetailView.vue') + +export const estimateRoutes: RouteRecordRaw[] = [ + { + path: 'estimates', + name: 'estimates.index', + component: EstimateIndexView, + meta: { + ability: 'view-estimate', + title: 'estimates.title', + }, + }, + { + path: 'estimates/create', + name: 'estimates.create', + component: EstimateCreateView, + meta: { + ability: 'create-estimate', + title: 'estimates.new_estimate', + }, + }, + { + path: 'estimates/:id/edit', + name: 'estimates.edit', + component: EstimateCreateView, + meta: { + ability: 'edit-estimate', + title: 'estimates.edit_estimate', + }, + }, + { + path: 'estimates/:id/view', + name: 'estimates.view', + component: EstimateDetailView, + meta: { + ability: 'view-estimate', + title: 'estimates.title', + }, + }, +] diff --git a/resources/scripts-v2/features/company/estimates/store.ts b/resources/scripts-v2/features/company/estimates/store.ts new file mode 100644 index 00000000..192b71f5 --- /dev/null +++ b/resources/scripts-v2/features/company/estimates/store.ts @@ -0,0 +1,548 @@ +import { defineStore } from 'pinia' +import { estimateService } from '../../../api/services/estimate.service' +import type { + EstimateListParams, + EstimateListResponse, + SendEstimatePayload, + EstimateStatusPayload, + EstimateTemplate, +} from '../../../api/services/estimate.service' +import type { Estimate, EstimateItem, DiscountType } from '../../../types/domain/estimate' +import type { Invoice } from '../../../types/domain/invoice' +import type { Tax, TaxType } from '../../../types/domain/tax' +import type { Currency } from '../../../types/domain/currency' +import type { Customer } from '../../../types/domain/customer' +import type { Note } from '../../../types/domain/note' +import type { CustomFieldValue } from '../../../types/domain/custom-field' +import type { DocumentTax, DocumentItem } from '../../shared/document-form/use-document-calculations' + +// ---------------------------------------------------------------- +// Stub factories +// ---------------------------------------------------------------- + +function createTaxStub(): DocumentTax { + return { + id: crypto.randomUUID(), + name: '', + tax_type_id: 0, + type: 'GENERAL', + amount: 0, + percent: null, + compound_tax: false, + calculation_type: null, + fixed_amount: 0, + } +} + +function createEstimateItemStub(): DocumentItem { + return { + id: crypto.randomUUID(), + estimate_id: null, + item_id: null, + name: '', + description: null, + quantity: 1, + price: 0, + discount_type: 'fixed', + discount_val: 0, + discount: 0, + total: 0, + sub_total: 0, + totalTax: 0, + totalSimpleTax: 0, + totalCompoundTax: 0, + tax: 0, + taxes: [createTaxStub()], + unit_name: null, + } +} + +export interface EstimateFormData { + id: number | null + customer: Customer | null + template_name: string | null + tax_per_item: string | null + tax_included: boolean + sales_tax_type: string | null + sales_tax_address_type: string | null + discount_per_item: string | null + estimate_date: string + expiry_date: string + estimate_number: string + customer_id: number | null + sub_total: number + total: number + tax: number + notes: string | null + discount_type: DiscountType + discount_val: number + reference_number: string | null + discount: number + items: DocumentItem[] + taxes: DocumentTax[] + customFields: CustomFieldValue[] + fields: CustomFieldValue[] + selectedNote: Note | null + selectedCurrency: Currency | Record | string + unique_hash?: string + exchange_rate?: number | null + currency_id?: number +} + +function createEstimateStub(): EstimateFormData { + return { + id: null, + customer: null, + template_name: '', + tax_per_item: null, + tax_included: false, + sales_tax_type: null, + sales_tax_address_type: null, + discount_per_item: null, + estimate_date: '', + expiry_date: '', + estimate_number: '', + customer_id: null, + sub_total: 0, + total: 0, + tax: 0, + notes: '', + discount_type: 'fixed', + discount_val: 0, + reference_number: null, + discount: 0, + items: [createEstimateItemStub()], + taxes: [], + customFields: [], + fields: [], + selectedNote: null, + selectedCurrency: '', + } +} + +// ---------------------------------------------------------------- +// Store +// ---------------------------------------------------------------- + +export interface EstimateState { + templates: EstimateTemplate[] + estimates: Estimate[] + selectAllField: boolean + selectedEstimates: number[] + totalEstimateCount: number + isFetchingInitialSettings: boolean + showExchangeRate: boolean + newEstimate: EstimateFormData +} + +export const useEstimateStore = defineStore('estimate', { + state: (): EstimateState => ({ + templates: [], + estimates: [], + selectAllField: false, + selectedEstimates: [], + totalEstimateCount: 0, + isFetchingInitialSettings: false, + showExchangeRate: false, + newEstimate: createEstimateStub(), + }), + + getters: { + getSubTotal(state): number { + return state.newEstimate.items.reduce( + (sum: number, item: DocumentItem) => sum + (item.total ?? 0), + 0, + ) + }, + + getNetTotal(): number { + return this.getSubtotalWithDiscount - this.getTotalTax + }, + + getTotalSimpleTax(state): number { + return state.newEstimate.taxes.reduce( + (sum: number, tax: DocumentTax) => { + if (!tax.compound_tax) return sum + (tax.amount ?? 0) + return sum + }, + 0, + ) + }, + + getTotalCompoundTax(state): number { + return state.newEstimate.taxes.reduce( + (sum: number, tax: DocumentTax) => { + if (tax.compound_tax) return sum + (tax.amount ?? 0) + return sum + }, + 0, + ) + }, + + getTotalTax(): number { + if ( + this.newEstimate.tax_per_item === 'NO' || + this.newEstimate.tax_per_item === null + ) { + return this.getTotalSimpleTax + this.getTotalCompoundTax + } + return this.newEstimate.items.reduce( + (sum: number, item: DocumentItem) => sum + (item.tax ?? 0), + 0, + ) + }, + + getSubtotalWithDiscount(): number { + return this.getSubTotal - this.newEstimate.discount_val + }, + + getTotal(): number { + if (this.newEstimate.tax_included) { + return this.getSubtotalWithDiscount + } + return this.getSubtotalWithDiscount + this.getTotalTax + }, + + isEdit(state): boolean { + return !!state.newEstimate.id + }, + }, + + actions: { + resetCurrentEstimate(): void { + this.newEstimate = createEstimateStub() + }, + + async previewEstimate(params: { id: number }): Promise { + return estimateService.sendPreview(params.id, params) + }, + + async fetchEstimates( + params: EstimateListParams & { estimate_number?: string }, + ): Promise<{ data: EstimateListResponse }> { + const response = await estimateService.list(params) + this.estimates = response.data + this.totalEstimateCount = response.meta.estimate_total_count + return { data: response } + }, + + async getNextNumber( + params?: Record, + setState = false, + ): Promise<{ data: { nextNumber: string } }> { + const response = await estimateService.getNextNumber(params as never) + if (setState) { + this.newEstimate.estimate_number = response.nextNumber + } + return { data: response } + }, + + async fetchEstimate(id: number): Promise<{ data: { data: Estimate } }> { + const response = await estimateService.get(id) + this.setEstimateData(response.data) + this.setCustomerAddresses(this.newEstimate.customer) + return { data: response } + }, + + setEstimateData(estimate: Estimate): void { + Object.assign(this.newEstimate, estimate) + + if (this.newEstimate.tax_per_item === 'YES') { + this.newEstimate.items.forEach((item) => { + if (item.taxes && !item.taxes.length) { + item.taxes.push(createTaxStub()) + } + }) + } + + if (this.newEstimate.discount_per_item === 'YES') { + this.newEstimate.items.forEach((item, index) => { + if (item.discount_type === 'fixed') { + this.newEstimate.items[index].discount = item.discount / 100 + } + }) + } else { + if (this.newEstimate.discount_type === 'fixed') { + this.newEstimate.discount = this.newEstimate.discount / 100 + } + } + }, + + setCustomerAddresses(customer: Customer | null): void { + if (!customer) return + const business = (customer as Record).customer_business as + | Record + | undefined + + if (business?.billing_address) { + ;(this.newEstimate.customer as Record).billing_address = + business.billing_address + } + if (business?.shipping_address) { + ;(this.newEstimate.customer as Record).shipping_address = + business.shipping_address + } + }, + + addSalesTaxUs(taxTypes: TaxType[]): void { + const salesTax = createTaxStub() + const found = this.newEstimate.taxes.find( + (t) => t.name === 'Sales Tax' && t.type === 'MODULE', + ) + if (found) { + for (const key in found) { + if (Object.prototype.hasOwnProperty.call(salesTax, key)) { + ;(salesTax as Record)[key] = ( + found as Record + )[key] + } + } + salesTax.id = found.tax_type_id + taxTypes.push(salesTax as unknown as TaxType) + } + }, + + async sendEstimate(data: SendEstimatePayload): Promise { + return estimateService.send(data) + }, + + async addEstimate(data: Record): Promise<{ data: { data: Estimate } }> { + const response = await estimateService.create(data as never) + this.estimates = [...this.estimates, response.data] + return { data: response } + }, + + async deleteEstimate(payload: { ids: number[] }): Promise<{ data: { success: boolean } }> { + const response = await estimateService.delete(payload) + const id = payload.ids[0] + const index = this.estimates.findIndex((est) => est.id === id) + if (index !== -1) { + this.estimates.splice(index, 1) + } + return { data: response } + }, + + async deleteMultipleEstimates(): Promise<{ data: { success: boolean } }> { + const response = await estimateService.delete({ + ids: this.selectedEstimates, + }) + this.selectedEstimates.forEach((estId) => { + const index = this.estimates.findIndex((est) => est.id === estId) + if (index !== -1) { + this.estimates.splice(index, 1) + } + }) + this.selectedEstimates = [] + return { data: response } + }, + + async updateEstimate(data: Record): Promise<{ data: { data: Estimate } }> { + const response = await estimateService.update(data.id as number, data as never) + const pos = this.estimates.findIndex((est) => est.id === response.data.id) + if (pos !== -1) { + this.estimates[pos] = response.data + } + return { data: response } + }, + + async cloneEstimate(data: { id: number }): Promise<{ data: { data: Estimate } }> { + const response = await estimateService.clone(data.id) + return { data: response } + }, + + async markAsAccepted(data: EstimateStatusPayload): Promise { + const response = await estimateService.changeStatus({ + ...data, + status: 'ACCEPTED', + }) + const pos = this.estimates.findIndex((est) => est.id === data.id) + if (pos !== -1 && this.estimates[pos]) { + this.estimates[pos].status = 'ACCEPTED' as Estimate['status'] + } + return response + }, + + async markAsRejected(data: EstimateStatusPayload): Promise { + const response = await estimateService.changeStatus({ + ...data, + status: 'REJECTED', + }) + return response + }, + + async markAsSent(data: EstimateStatusPayload): Promise { + const response = await estimateService.changeStatus(data) + const pos = this.estimates.findIndex((est) => est.id === data.id) + if (pos !== -1 && this.estimates[pos]) { + this.estimates[pos].status = 'SENT' as Estimate['status'] + } + return response + }, + + async convertToInvoice(id: number): Promise<{ data: { data: Invoice } }> { + const response = await estimateService.convertToInvoice(id) + return { data: response } + }, + + async searchEstimate(queryString: string): Promise { + return estimateService.list( + Object.fromEntries(new URLSearchParams(queryString)) as never, + ) + }, + + selectEstimate(data: number[]): void { + this.selectedEstimates = data + this.selectAllField = + this.selectedEstimates.length === this.estimates.length + }, + + selectAllEstimates(): void { + if (this.selectedEstimates.length === this.estimates.length) { + this.selectedEstimates = [] + this.selectAllField = false + } else { + this.selectedEstimates = this.estimates.map((est) => est.id) + this.selectAllField = true + } + }, + + async selectCustomer(id: number): Promise { + const { customerService } = await import( + '../../../api/services/customer.service' + ) + const response = await customerService.get(id) + this.newEstimate.customer = response.data as unknown as Customer + this.newEstimate.customer_id = response.data.id + return response + }, + + async fetchEstimateTemplates(): Promise<{ + data: { estimateTemplates: EstimateTemplate[] } + }> { + const response = await estimateService.getTemplates() + this.templates = response.estimateTemplates + return { data: response } + }, + + setTemplate(name: string): void { + this.newEstimate.template_name = name + }, + + resetSelectedCustomer(): void { + this.newEstimate.customer = null + this.newEstimate.customer_id = null + }, + + selectNote(data: Note): void { + this.newEstimate.selectedNote = null + this.newEstimate.selectedNote = data + }, + + resetSelectedNote(): void { + this.newEstimate.selectedNote = null + }, + + addItem(): void { + this.newEstimate.items.push(createEstimateItemStub()) + }, + + updateItem(data: DocumentItem & { index: number }): void { + Object.assign(this.newEstimate.items[data.index], { ...data }) + }, + + removeItem(index: number): void { + this.newEstimate.items.splice(index, 1) + }, + + deselectItem(index: number): void { + this.newEstimate.items[index] = createEstimateItemStub() + }, + + async fetchEstimateInitialSettings( + isEdit: boolean, + routeParams?: { id?: string; query?: Record }, + companySettings?: Record, + companyCurrency?: Currency, + userSettings?: Record, + ): Promise { + this.isFetchingInitialSettings = true + + if (companyCurrency) { + this.newEstimate.selectedCurrency = companyCurrency + } + + // If customer is specified in route query + if (routeParams?.query?.customer) { + try { + await this.selectCustomer(Number(routeParams.query.customer)) + } catch { + // Silently fail + } + } + + const editActions: Promise[] = [] + + if (!isEdit && companySettings) { + this.newEstimate.tax_per_item = companySettings.tax_per_item ?? null + this.newEstimate.sales_tax_type = companySettings.sales_tax_type ?? null + this.newEstimate.sales_tax_address_type = + companySettings.sales_tax_address_type ?? null + this.newEstimate.discount_per_item = + companySettings.discount_per_item ?? null + + const now = new Date() + this.newEstimate.estimate_date = formatDate(now, 'YYYY-MM-DD') + + if (companySettings.estimate_set_expiry_date_automatically === 'YES') { + const expiryDate = new Date(now) + expiryDate.setDate( + expiryDate.getDate() + + Number(companySettings.estimate_expiry_date_days ?? 7), + ) + this.newEstimate.expiry_date = formatDate(expiryDate, 'YYYY-MM-DD') + } + } else if (isEdit && routeParams?.id) { + editActions.push(this.fetchEstimate(Number(routeParams.id))) + } + + try { + const [, , templatesRes, nextNumRes] = await Promise.all([ + Promise.resolve(), // placeholder for items fetch + this.resetSelectedNote(), + this.fetchEstimateTemplates(), + this.getNextNumber(), + Promise.resolve(), // placeholder for tax types fetch + ...editActions, + ]) + + if (!isEdit) { + if (nextNumRes?.data?.nextNumber) { + this.newEstimate.estimate_number = nextNumRes.data.nextNumber + } + + if (this.templates.length) { + this.setTemplate(this.templates[0].name) + if (userSettings?.default_estimate_template) { + this.newEstimate.template_name = + userSettings.default_estimate_template + } + } + } + } catch { + // Error handling + } finally { + this.isFetchingInitialSettings = false + } + }, + }, +}) + +/** Simple date formatter without moment dependency */ +function formatDate(date: Date, _format: string): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +export type EstimateStore = ReturnType diff --git a/resources/scripts-v2/features/company/estimates/views/EstimateCreateView.vue b/resources/scripts-v2/features/company/estimates/views/EstimateCreateView.vue new file mode 100644 index 00000000..0aa829dd --- /dev/null +++ b/resources/scripts-v2/features/company/estimates/views/EstimateCreateView.vue @@ -0,0 +1,241 @@ + + + diff --git a/resources/scripts-v2/features/company/estimates/views/EstimateDetailView.vue b/resources/scripts-v2/features/company/estimates/views/EstimateDetailView.vue new file mode 100644 index 00000000..a72f2e79 --- /dev/null +++ b/resources/scripts-v2/features/company/estimates/views/EstimateDetailView.vue @@ -0,0 +1,416 @@ +