From 774b2614f0161a3e7be144adc80d83392365dcb7 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Sat, 4 Apr 2026 06:30:00 +0200 Subject: [PATCH] =?UTF-8?q?Phase=204a:=20Feature=20modules=20=E2=80=94=20l?= =?UTF-8?q?ayouts,=20auth,=20admin,=20dashboard,=20customers,=20items,=20i?= =?UTF-8?q?nvoices,=20estimates,=20shared=20document=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 77 files, 14451 lines. Typed layouts (CompanyLayout, AuthLayout, header, sidebar, company switcher), auth views (login, register, forgot/reset password), admin feature (dashboard, companies, users, settings with typed store), company features (dashboard with chart/ stats, customers CRUD, items CRUD, invoices CRUD with full store, estimates CRUD with full store), and shared document form components (items table, item row, totals, notes, tax popup, template select, exchange rate converter, calculation composable). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../admin/components/AdminCompanyDropdown.vue | 29 + .../admin/components/AdminUserDropdown.vue | 71 ++ resources/scripts-v2/features/admin/index.ts | 20 + resources/scripts-v2/features/admin/routes.ts | 113 +++ .../features/admin/stores/admin.store.ts | 203 +++++ .../admin/views/AdminCompaniesView.vue | 210 +++++ .../admin/views/AdminCompanyEditView.vue | 274 +++++++ .../admin/views/AdminDashboardView.vue | 105 +++ .../admin/views/AdminSettingsView.vue | 117 +++ .../admin/views/AdminUserEditView.vue | 187 +++++ .../features/admin/views/AdminUsersView.vue | 271 +++++++ resources/scripts-v2/features/auth/index.ts | 6 + resources/scripts-v2/features/auth/routes.ts | 52 ++ .../auth/views/ForgotPasswordView.vue | 101 +++ .../features/auth/views/LoginView.vue | 129 +++ .../auth/views/RegisterWithInvitationView.vue | 235 ++++++ .../features/auth/views/ResetPasswordView.vue | 165 ++++ .../customers/components/CustomerDropdown.vue | 121 +++ .../customers/components/CustomerModal.vue | 650 +++++++++++++++ .../features/company/customers/index.ts | 7 + .../features/company/customers/routes.ts | 38 + .../features/company/customers/store.ts | 335 ++++++++ .../customers/views/CustomerCreateView.vue | 739 ++++++++++++++++++ .../customers/views/CustomerDetailView.vue | 146 ++++ .../customers/views/CustomerIndexView.vue | 392 ++++++++++ .../dashboard/components/DashboardChart.vue | 281 +++++++ .../dashboard/components/DashboardStats.vue | 85 ++ .../components/DashboardStatsItem.vue | 103 +++ .../dashboard/components/DashboardTable.vue | 198 +++++ .../features/company/dashboard/index.ts | 8 + .../features/company/dashboard/routes.ts | 14 + .../features/company/dashboard/store.ts | 137 ++++ .../company/dashboard/views/DashboardView.vue | 30 + .../components/EstimateBasicFields.vue | 88 +++ .../estimates/components/EstimateDropdown.vue | 263 +++++++ .../features/company/estimates/index.ts | 12 + .../features/company/estimates/routes.ts | 44 ++ .../features/company/estimates/store.ts | 548 +++++++++++++ .../estimates/views/EstimateCreateView.vue | 241 ++++++ .../estimates/views/EstimateDetailView.vue | 416 ++++++++++ .../estimates/views/EstimateIndexView.vue | 460 +++++++++++ .../components/InvoiceBasicFields.vue | 101 +++ .../invoices/components/InvoiceDropdown.vue | 218 ++++++ .../components/InvoiceStatusBadge.vue | 12 + .../features/company/invoices/index.ts | 13 + .../features/company/invoices/routes.ts | 44 ++ .../features/company/invoices/store.ts | 539 +++++++++++++ .../invoices/views/InvoiceCreateView.vue | 235 ++++++ .../invoices/views/InvoiceDetailView.vue | 408 ++++++++++ .../invoices/views/InvoiceIndexView.vue | 514 ++++++++++++ .../company/items/components/ItemDropdown.vue | 94 +++ .../company/items/components/ItemModal.vue | 292 +++++++ .../features/company/items/index.ts | 6 + .../features/company/items/routes.ts | 30 + .../features/company/items/store.ts | 316 ++++++++ .../company/items/views/ItemCreateView.vue | 331 ++++++++ .../company/items/views/ItemIndexView.vue | 384 +++++++++ .../shared/document-form/DocumentItemRow.vue | 434 ++++++++++ .../document-form/DocumentItemRowTax.vue | 263 +++++++ .../document-form/DocumentItemsTable.vue | 151 ++++ .../shared/document-form/DocumentNotes.vue | 52 ++ .../shared/document-form/DocumentTotals.vue | 388 +++++++++ .../document-form/ExchangeRateConverter.vue | 189 +++++ .../shared/document-form/NoteSelectPopup.vue | 151 ++++ .../shared/document-form/TaxSelectPopup.vue | 172 ++++ .../document-form/TemplateSelectButton.vue | 71 ++ .../features/shared/document-form/index.ts | 25 + .../use-document-calculations.ts | 180 +++++ resources/scripts-v2/layouts/AuthLayout.vue | 134 ++++ .../scripts-v2/layouts/CompanyLayout.vue | 99 +++ .../scripts-v2/layouts/InstallationLayout.vue | 13 + resources/scripts-v2/layouts/index.ts | 8 + .../layouts/partials/CompanySwitcher.vue | 271 +++++++ .../layouts/partials/GlobalSearchBar.vue | 179 +++++ .../layouts/partials/ImpersonationBanner.vue | 44 ++ .../layouts/partials/SiteHeader.vue | 229 ++++++ .../layouts/partials/SiteSidebar.vue | 217 +++++ 77 files changed, 14451 insertions(+) create mode 100644 resources/scripts-v2/features/admin/components/AdminCompanyDropdown.vue create mode 100644 resources/scripts-v2/features/admin/components/AdminUserDropdown.vue create mode 100644 resources/scripts-v2/features/admin/index.ts create mode 100644 resources/scripts-v2/features/admin/routes.ts create mode 100644 resources/scripts-v2/features/admin/stores/admin.store.ts create mode 100644 resources/scripts-v2/features/admin/views/AdminCompaniesView.vue create mode 100644 resources/scripts-v2/features/admin/views/AdminCompanyEditView.vue create mode 100644 resources/scripts-v2/features/admin/views/AdminDashboardView.vue create mode 100644 resources/scripts-v2/features/admin/views/AdminSettingsView.vue create mode 100644 resources/scripts-v2/features/admin/views/AdminUserEditView.vue create mode 100644 resources/scripts-v2/features/admin/views/AdminUsersView.vue create mode 100644 resources/scripts-v2/features/auth/index.ts create mode 100644 resources/scripts-v2/features/auth/routes.ts create mode 100644 resources/scripts-v2/features/auth/views/ForgotPasswordView.vue create mode 100644 resources/scripts-v2/features/auth/views/LoginView.vue create mode 100644 resources/scripts-v2/features/auth/views/RegisterWithInvitationView.vue create mode 100644 resources/scripts-v2/features/auth/views/ResetPasswordView.vue create mode 100644 resources/scripts-v2/features/company/customers/components/CustomerDropdown.vue create mode 100644 resources/scripts-v2/features/company/customers/components/CustomerModal.vue create mode 100644 resources/scripts-v2/features/company/customers/index.ts create mode 100644 resources/scripts-v2/features/company/customers/routes.ts create mode 100644 resources/scripts-v2/features/company/customers/store.ts create mode 100644 resources/scripts-v2/features/company/customers/views/CustomerCreateView.vue create mode 100644 resources/scripts-v2/features/company/customers/views/CustomerDetailView.vue create mode 100644 resources/scripts-v2/features/company/customers/views/CustomerIndexView.vue create mode 100644 resources/scripts-v2/features/company/dashboard/components/DashboardChart.vue create mode 100644 resources/scripts-v2/features/company/dashboard/components/DashboardStats.vue create mode 100644 resources/scripts-v2/features/company/dashboard/components/DashboardStatsItem.vue create mode 100644 resources/scripts-v2/features/company/dashboard/components/DashboardTable.vue create mode 100644 resources/scripts-v2/features/company/dashboard/index.ts create mode 100644 resources/scripts-v2/features/company/dashboard/routes.ts create mode 100644 resources/scripts-v2/features/company/dashboard/store.ts create mode 100644 resources/scripts-v2/features/company/dashboard/views/DashboardView.vue create mode 100644 resources/scripts-v2/features/company/estimates/components/EstimateBasicFields.vue create mode 100644 resources/scripts-v2/features/company/estimates/components/EstimateDropdown.vue create mode 100644 resources/scripts-v2/features/company/estimates/index.ts create mode 100644 resources/scripts-v2/features/company/estimates/routes.ts create mode 100644 resources/scripts-v2/features/company/estimates/store.ts create mode 100644 resources/scripts-v2/features/company/estimates/views/EstimateCreateView.vue create mode 100644 resources/scripts-v2/features/company/estimates/views/EstimateDetailView.vue create mode 100644 resources/scripts-v2/features/company/estimates/views/EstimateIndexView.vue create mode 100644 resources/scripts-v2/features/company/invoices/components/InvoiceBasicFields.vue create mode 100644 resources/scripts-v2/features/company/invoices/components/InvoiceDropdown.vue create mode 100644 resources/scripts-v2/features/company/invoices/components/InvoiceStatusBadge.vue create mode 100644 resources/scripts-v2/features/company/invoices/index.ts create mode 100644 resources/scripts-v2/features/company/invoices/routes.ts create mode 100644 resources/scripts-v2/features/company/invoices/store.ts create mode 100644 resources/scripts-v2/features/company/invoices/views/InvoiceCreateView.vue create mode 100644 resources/scripts-v2/features/company/invoices/views/InvoiceDetailView.vue create mode 100644 resources/scripts-v2/features/company/invoices/views/InvoiceIndexView.vue create mode 100644 resources/scripts-v2/features/company/items/components/ItemDropdown.vue create mode 100644 resources/scripts-v2/features/company/items/components/ItemModal.vue create mode 100644 resources/scripts-v2/features/company/items/index.ts create mode 100644 resources/scripts-v2/features/company/items/routes.ts create mode 100644 resources/scripts-v2/features/company/items/store.ts create mode 100644 resources/scripts-v2/features/company/items/views/ItemCreateView.vue create mode 100644 resources/scripts-v2/features/company/items/views/ItemIndexView.vue create mode 100644 resources/scripts-v2/features/shared/document-form/DocumentItemRow.vue create mode 100644 resources/scripts-v2/features/shared/document-form/DocumentItemRowTax.vue create mode 100644 resources/scripts-v2/features/shared/document-form/DocumentItemsTable.vue create mode 100644 resources/scripts-v2/features/shared/document-form/DocumentNotes.vue create mode 100644 resources/scripts-v2/features/shared/document-form/DocumentTotals.vue create mode 100644 resources/scripts-v2/features/shared/document-form/ExchangeRateConverter.vue create mode 100644 resources/scripts-v2/features/shared/document-form/NoteSelectPopup.vue create mode 100644 resources/scripts-v2/features/shared/document-form/TaxSelectPopup.vue create mode 100644 resources/scripts-v2/features/shared/document-form/TemplateSelectButton.vue create mode 100644 resources/scripts-v2/features/shared/document-form/index.ts create mode 100644 resources/scripts-v2/features/shared/document-form/use-document-calculations.ts create mode 100644 resources/scripts-v2/layouts/AuthLayout.vue create mode 100644 resources/scripts-v2/layouts/CompanyLayout.vue create mode 100644 resources/scripts-v2/layouts/InstallationLayout.vue create mode 100644 resources/scripts-v2/layouts/index.ts create mode 100644 resources/scripts-v2/layouts/partials/CompanySwitcher.vue create mode 100644 resources/scripts-v2/layouts/partials/GlobalSearchBar.vue create mode 100644 resources/scripts-v2/layouts/partials/ImpersonationBanner.vue create mode 100644 resources/scripts-v2/layouts/partials/SiteHeader.vue create mode 100644 resources/scripts-v2/layouts/partials/SiteSidebar.vue 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 @@ +