Complete scripts-v2 TypeScript migration — all imports resolved,

build passes

Create all missing components (modals, dropdowns, icons, tabs, mail
drivers, customer partials), fix all @/scripts/ imports to @v2/,
wire up vite entry point and blade template. 382 files, 48883 lines.

- 27 settings components: modals (tax, payment, custom field, note,
  category, role, exchange rate, unit, mail test), dropdowns (6),
  customization tabs (4), mail driver forms (4)
- 22 icon components: 5 utility icons, 4 dashboard icons, 13 editor
  toolbar icons with typed barrel export
- 3 customer components: info, chart placeholder, custom fields single
- Fixed usePopper composable, client/format-money import patterns
- Zero remaining @/scripts/ imports in scripts-v2/

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Darko Gjorgjijoski
2026-04-04 09:30:00 +02:00
parent 812956abcc
commit a46cca5cd8
156 changed files with 6246 additions and 213 deletions

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useNotificationStore } from '@v2/stores/notification.store'
const notificationStore = useNotificationStore()
const { t } = useI18n()
const publicUrl = ref<HTMLElement | null>(null)
const props = defineProps<{
token: string
}>()
function selectText(element: HTMLElement): void {
if (window.getSelection) {
const range = document.createRange()
range.selectNode(element)
window.getSelection()?.removeAllRanges()
window.getSelection()?.addRange(range)
}
}
function copyUrl(): void {
if (publicUrl.value) {
selectText(publicUrl.value)
document.execCommand('copy')
notificationStore.showNotification({
type: 'success',
message: t('general.copied_url_clipboard'),
})
}
}
</script>
<template>
<div
class="relative flex px-4 py-2 rounded-lg bg-surface-muted/40 whitespace-nowrap flex-col mt-1"
>
<span
ref="publicUrl"
class="pr-10 text-sm font-medium text-heading truncate select-all select-color"
>
{{ token }}
</span>
<svg
v-tooltip="{ content: $t('general.copy_to_clipboard') }"
class="absolute right-0 h-full inset-y-0 cursor-pointer focus:outline-hidden text-primary-500"
width="37"
viewBox="0 0 37 37"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@click="copyUrl"
>
<rect width="37" height="37" rx="10" fill="currentColor" />
<path
d="M16 10C15.7348 10 15.4804 10.1054 15.2929 10.2929C15.1054 10.4804 15 10.7348 15 11C15 11.2652 15.1054 11.5196 15.2929 11.7071C15.4804 11.8946 15.7348 12 16 12H18C18.2652 12 18.5196 11.8946 18.7071 11.7071C18.8946 11.5196 19 11.2652 19 11C19 10.7348 18.8946 10.4804 18.7071 10.2929C18.5196 10.1054 18.2652 10 18 10H16Z"
fill="white"
/>
<path
d="M11 13C11 12.4696 11.2107 11.9609 11.5858 11.5858C11.9609 11.2107 12.4696 11 13 11C13 11.7956 13.3161 12.5587 13.8787 13.1213C14.4413 13.6839 15.2044 14 16 14H18C18.7956 14 19.5587 13.6839 20.1213 13.1213C20.6839 12.5587 21 11.7956 21 11C21.5304 11 22.0391 11.2107 22.4142 11.5858C22.7893 11.9609 23 12.4696 23 13V19H18.414L19.707 17.707C19.8892 17.5184 19.99 17.2658 19.9877 17.0036C19.9854 16.7414 19.8802 16.4906 19.6948 16.3052C19.5094 16.1198 19.2586 16.0146 18.9964 16.0123C18.7342 16.01 18.4816 16.1108 18.293 16.293L15.293 19.293C15.1055 19.4805 15.0002 19.7348 15.0002 20C15.0002 20.2652 15.1055 20.5195 15.293 20.707L18.293 23.707C18.4816 23.8892 18.7342 23.99 18.9964 23.9877C19.2586 23.9854 19.5094 23.8802 19.6948 23.6948C19.8802 23.5094 19.9854 23.2586 19.9877 22.9964C19.99 22.7342 19.8892 22.4816 19.707 22.293L18.414 21H23V24C23 24.5304 22.7893 25.0391 22.4142 25.4142C22.0391 25.7893 21.5304 26 21 26H13C12.4696 26 11.9609 25.7893 11.5858 25.4142C11.2107 25.0391 11 24.5304 11 24V13ZM23 19H25C25.2652 19 25.5196 19.1054 25.7071 19.2929C25.8946 19.4804 26 19.7348 26 20C26 20.2652 25.8946 20.5196 25.7071 20.7071C25.5196 20.8946 25.2652 21 25 21H23V19Z"
fill="white"
/>
</svg>
</div>
</template>

View File

@@ -0,0 +1,138 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import lodash from 'lodash'
import moment from 'moment'
import { customFieldService } from '@v2/api/services/custom-field.service'
import SingleField from './CreateCustomFieldsSingle.vue'
interface CustomFieldItem {
id: number
value: string | boolean | number | null
default_answer: string | boolean | number | null
label: string
options: string[] | null
is_required: boolean
placeholder: string | null
order: number | null
type: string
custom_field_id?: number
custom_field?: {
label: string
options: string[] | null
is_required: boolean
placeholder: string | null
order: number | null
type: string
}
}
interface StoreWithProp {
[key: string]: {
customFields: CustomFieldItem[]
fields: CustomFieldItem[]
}
}
const props = withDefaults(
defineProps<{
store: StoreWithProp
storeProp: string
isEdit?: boolean
type?: string | null
gridLayout?: string
isLoading?: boolean | null
customFieldScope: string
}>(),
{
isEdit: false,
type: null,
gridLayout: 'two-column',
isLoading: null,
}
)
const customFields = ref<CustomFieldItem[]>([])
getInitialCustomFields()
function mergeExistingValues(): void {
if (props.isEdit && props.store[props.storeProp]) {
props.store[props.storeProp].fields.forEach((field) => {
const existingIndex = props.store[
props.storeProp
].customFields.findIndex((f) => f.id === field.custom_field_id)
if (existingIndex > -1) {
let value: string | boolean | number | null = field.default_answer
if (value && field.custom_field?.type === 'DateTime') {
value = moment(
String(field.default_answer),
'YYYY-MM-DD HH:mm:ss'
).format('YYYY-MM-DD HH:mm')
}
props.store[props.storeProp].customFields[existingIndex] = {
...field,
id: field.custom_field_id ?? field.id,
value,
label: field.custom_field?.label ?? '',
options: field.custom_field?.options ?? null,
is_required: field.custom_field?.is_required ?? false,
placeholder: field.custom_field?.placeholder ?? null,
order: field.custom_field?.order ?? null,
type: field.custom_field?.type ?? field.type,
}
}
})
}
}
async function getInitialCustomFields(): Promise<void> {
const res = await customFieldService.list({
type: props.type ?? undefined,
limit: 'all',
})
const data = (res as Record<string, unknown>).data as CustomFieldItem[]
data.forEach((d) => {
d.value = d.default_answer
})
props.store[props.storeProp].customFields = lodash.sortBy(
data,
(_cf: CustomFieldItem) => _cf.order
)
mergeExistingValues()
}
watch(
() => props.store[props.storeProp]?.fields,
() => {
mergeExistingValues()
}
)
</script>
<template>
<div
v-if="
store[storeProp] &&
store[storeProp].customFields.length > 0 &&
!isLoading
"
>
<BaseInputGrid :layout="gridLayout">
<SingleField
v-for="(field, index) in store[storeProp].customFields"
:key="field.id"
:custom-field-scope="customFieldScope"
:store="store"
:store-prop="storeProp"
:index="index"
:field="field"
/>
</BaseInputGrid>
</div>
</template>

View File

@@ -0,0 +1,57 @@
<template>
<BaseInputGroup
:label="field.label"
:required="field.is_required ? true : false"
:error="v$.value.$error && v$.value.$errors[0].$message"
>
<component
:is="getTypeComponent"
v-model="field.value"
:options="field.options"
:invalid="v$.value.$error"
:placeholder="field.placeholder"
/>
</BaseInputGroup>
</template>
<script setup lang="ts">
import { defineAsyncComponent, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { helpers, requiredIf } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
const props = defineProps<{
field: Record<string, any>
customFieldScope: string
index: number
store: Record<string, any>
storeProp: string
}>()
const { t } = useI18n()
const rules = {
value: {
required: helpers.withMessage(
t('validation.required'),
requiredIf(props.field.is_required)
),
},
}
const v$ = useVuelidate(
rules,
computed(() => props.field),
{ $scope: props.customFieldScope }
)
const getTypeComponent = computed(() => {
if (props.field.type) {
return defineAsyncComponent(() =>
import(`./types/${props.field.type}Type.vue`)
)
}
return false
})
</script>

View File

@@ -0,0 +1,208 @@
<script setup lang="ts">
import { ref, computed, watch, reactive } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useCustomerStore } from '../store'
import { useCompanyStore } from '@v2/stores/company.store'
import { customerService } from '@v2/api/services/customer.service'
import LineChart from '@v2/components/charts/LineChart.vue'
import ChartPlaceholder from './CustomerChartPlaceholder.vue'
import CustomerInfo from './CustomerInfo.vue'
interface ChartData {
salesTotal: number
totalReceipts: number
totalExpenses: number
netProfit: number
expenseTotals: number[]
netProfits: number[]
months: string[]
receiptTotals: number[]
invoiceTotals: number[]
}
interface YearOption {
label: string
value: string
}
const companyStore = useCompanyStore()
const customerStore = useCustomerStore()
const { t } = useI18n()
const route = useRoute()
const isLoading = ref<boolean>(false)
const chartData = reactive<Partial<ChartData>>({})
const years = reactive<YearOption[]>([
{ label: t('dateRange.this_year'), value: 'This year' },
{ label: t('dateRange.previous_year'), value: 'Previous year' },
])
const selectedYear = ref<string>('This year')
const getChartExpenses = computed<number[]>(() => chartData.expenseTotals ?? [])
const getNetProfits = computed<number[]>(() => chartData.netProfits ?? [])
const getChartMonths = computed<string[]>(() => chartData.months ?? [])
const getReceiptTotals = computed<number[]>(() => chartData.receiptTotals ?? [])
const getChartInvoices = computed<number[]>(() => chartData.invoiceTotals ?? [])
watch(
route,
() => {
if (route.params.id) {
loadCustomer()
}
selectedYear.value = 'This year'
},
{ immediate: true }
)
async function loadCustomer(): Promise<void> {
isLoading.value = false
const response = await customerService.getStats(Number(route.params.id))
if (response.data) {
const meta = (response as Record<string, unknown>).meta as Record<string, unknown> | undefined
if (meta?.chartData) {
Object.assign(chartData, meta.chartData)
}
}
isLoading.value = true
}
async function onChangeYear(data: string): Promise<boolean> {
const params: Record<string, unknown> = {
id: route.params.id,
}
if (data === 'Previous year') {
params.previous_year = true
} else {
params.this_year = true
}
const response = await customerService.getStats(
Number(route.params.id),
params
)
const meta = (response as Record<string, unknown>).meta as Record<string, unknown> | undefined
if (meta?.chartData) {
Object.assign(chartData, meta.chartData)
}
return true
}
</script>
<template>
<BaseCard class="flex flex-col mt-6">
<ChartPlaceholder v-if="!isLoading" />
<div v-else class="grid grid-cols-12">
<div class="col-span-12 xl:col-span-9 xxl:col-span-10">
<div class="flex justify-between mt-1 mb-6">
<h6 class="flex items-center">
<BaseIcon name="ChartBarSquareIcon" class="h-5 text-primary-400" />
{{ $t('dashboard.monthly_chart.title') }}
</h6>
<div class="w-40 h-10">
<BaseMultiselect
v-model="selectedYear"
:options="years"
:allow-empty="false"
:show-labels="false"
:placeholder="$t('dashboard.select_year')"
:can-deselect="false"
@select="onChangeYear"
/>
</div>
</div>
<LineChart
v-if="isLoading"
:invoices="getChartInvoices"
:expenses="getChartExpenses"
:receipts="getReceiptTotals"
:income="getNetProfits"
:labels="getChartMonths"
class="sm:w-full"
/>
</div>
<div
class="grid col-span-12 mt-6 text-center xl:mt-0 sm:grid-cols-4 xl:text-right xl:col-span-3 xl:grid-cols-1 xxl:col-span-2"
>
<div class="px-6 py-2">
<span class="text-xs leading-5 lg:text-sm">
{{ $t('dashboard.chart_info.total_sales') }}
</span>
<br />
<span
v-if="isLoading"
class="block mt-1 text-xl font-semibold leading-8"
>
<BaseFormatMoney
:amount="chartData.salesTotal"
:currency="companyStore.selectedCompanyCurrency"
/>
</span>
</div>
<div class="px-6 py-2">
<span class="text-xs leading-5 lg:text-sm">
{{ $t('dashboard.chart_info.total_receipts') }}
</span>
<br />
<span
v-if="isLoading"
class="block mt-1 text-xl font-semibold leading-8"
style="color: #00c99c"
>
<BaseFormatMoney
:amount="chartData.totalReceipts"
:currency="companyStore.selectedCompanyCurrency"
/>
</span>
</div>
<div class="px-6 py-2">
<span class="text-xs leading-5 lg:text-sm">
{{ $t('dashboard.chart_info.total_expense') }}
</span>
<br />
<span
v-if="isLoading"
class="block mt-1 text-xl font-semibold leading-8"
style="color: #fb7178"
>
<BaseFormatMoney
:amount="chartData.totalExpenses"
:currency="companyStore.selectedCompanyCurrency"
/>
</span>
</div>
<div class="px-6 py-2">
<span class="text-xs leading-5 lg:text-sm">
{{ $t('dashboard.chart_info.net_income') }}
</span>
<br />
<span
v-if="isLoading"
class="block mt-1 text-xl font-semibold leading-8"
style="color: #5851d8"
>
<BaseFormatMoney
:amount="chartData.netProfit"
:currency="companyStore.selectedCompanyCurrency"
/>
</span>
</div>
</div>
</div>
<CustomerInfo />
</BaseCard>
</template>

View File

@@ -0,0 +1,79 @@
<template>
<BaseContentPlaceholders class="grid grid-cols-12">
<div class="col-span-12 xl:col-span-9 xxl:col-span-10">
<div class="flex justify-between mt-1 mb-6">
<BaseContentPlaceholdersText class="h-10 w-36" :lines="1" />
<BaseContentPlaceholdersText class="h-10 w-40 !mt-0" :lines="1" />
</div>
<BaseContentPlaceholdersBox class="h-80 xl:h-72 sm:w-full" />
</div>
<div
class="
grid
col-span-12
mt-6
text-center
xl:mt-0
sm:grid-cols-4
xl:text-right xl:col-span-3 xl:grid-cols-1
xxl:col-span-2
"
>
<div
class="
flex flex-col
items-center
justify-center
px-6
py-2
lg:justify-end lg:items-end
"
>
<BaseContentPlaceholdersText class="h-3 w-14 xl:h-4" :lines="1" />
<BaseContentPlaceholdersText class="w-20 h-5 xl:h-6" :lines="1" />
</div>
<div
class="
flex flex-col
items-center
justify-center
px-6
py-2
lg:justify-end lg:items-end
"
>
<BaseContentPlaceholdersText class="h-3 w-14 xl:h-4" :lines="1" />
<BaseContentPlaceholdersText class="w-20 h-5 xl:h-6" :lines="1" />
</div>
<div
class="
flex flex-col
items-center
justify-center
px-6
py-2
lg:justify-end lg:items-end
"
>
<BaseContentPlaceholdersText class="h-3 w-14 xl:h-4" :lines="1" />
<BaseContentPlaceholdersText class="w-20 h-5 xl:h-6" :lines="1" />
</div>
<div
class="
flex flex-col
items-center
justify-center
px-6
py-2
lg:justify-end lg:items-end
"
>
<BaseContentPlaceholdersText class="h-3 w-14 xl:h-4" :lines="1" />
<BaseContentPlaceholdersText class="w-20 h-5 xl:h-6" :lines="1" />
</div>
</div>
</BaseContentPlaceholders>
</template>

View File

@@ -0,0 +1,124 @@
<template>
<div class="pt-6 mt-5 border-t border-solid lg:pt-8 md:pt-4 border-line-default">
<!-- Basic Info -->
<BaseHeading>
{{ $t('customers.basic_info') }}
</BaseHeading>
<BaseDescriptionList>
<BaseDescriptionListItem
v-if="selectedViewCustomer.name"
:content-loading="contentLoading"
:label="$t('customers.display_name')"
:value="selectedViewCustomer?.name"
/>
<BaseDescriptionListItem
v-if="selectedViewCustomer.contact_name"
:content-loading="contentLoading"
:label="$t('customers.primary_contact_name')"
:value="selectedViewCustomer?.contact_name"
/>
<BaseDescriptionListItem
v-if="selectedViewCustomer.email"
:content-loading="contentLoading"
:label="$t('customers.email')"
:value="selectedViewCustomer?.email"
/>
</BaseDescriptionList>
<BaseDescriptionList class="mt-5">
<BaseDescriptionListItem
:content-loading="contentLoading"
:label="$t('wizard.currency')"
:value="
selectedViewCustomer?.currency
? `${selectedViewCustomer?.currency?.code} (${selectedViewCustomer?.currency?.symbol})`
: ''
"
/>
<BaseDescriptionListItem
v-if="selectedViewCustomer.phone"
:content-loading="contentLoading"
:label="$t('customers.phone_number')"
:value="selectedViewCustomer?.phone"
/>
<BaseDescriptionListItem
v-if="selectedViewCustomer.website"
:content-loading="contentLoading"
:label="$t('customers.website')"
:value="selectedViewCustomer?.website"
/>
</BaseDescriptionList>
<!-- Address -->
<BaseHeading
v-if="selectedViewCustomer.billing || selectedViewCustomer.shipping"
class="mt-8"
>
{{ $t('customers.address') }}
</BaseHeading>
<BaseDescriptionList class="mt-5">
<BaseDescriptionListItem
v-if="selectedViewCustomer.billing"
:content-loading="contentLoading"
:label="$t('customers.billing_address')"
>
<BaseCustomerAddressDisplay :address="selectedViewCustomer.billing" />
</BaseDescriptionListItem>
<BaseDescriptionListItem
v-if="selectedViewCustomer.shipping"
:content-loading="contentLoading"
:label="$t('customers.shipping_address')"
>
<BaseCustomerAddressDisplay :address="selectedViewCustomer.shipping" />
</BaseDescriptionListItem>
</BaseDescriptionList>
<!-- Custom Fields -->
<BaseHeading v-if="customerCustomFields.length > 0" class="mt-8">
{{ $t('settings.custom_fields.title') }}
</BaseHeading>
<BaseDescriptionList class="mt-5">
<BaseDescriptionListItem
v-for="(field, index) in customerCustomFields"
:key="index"
:content-loading="contentLoading"
:label="field.custom_field.label"
>
<p
v-if="field.type === 'Switch'"
class="text-sm font-bold leading-5 text-heading non-italic"
>
<span v-if="field.default_answer === 1"> {{ $t('general.yes') }} </span>
<span v-else> {{ $t('general.no') }} </span>
</p>
<p v-else class="text-sm font-bold leading-5 text-heading non-italic">
{{ field.default_answer }}
</p>
</BaseDescriptionListItem>
</BaseDescriptionList>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useCustomerStore } from '../store'
const customerStore = useCustomerStore()
const selectedViewCustomer = computed(() => customerStore.selectedViewCustomer)
const contentLoading = computed(() => customerStore.isFetchingViewData)
const customerCustomFields = computed(() => {
if (selectedViewCustomer?.value?.fields) {
return selectedViewCustomer?.value?.fields
}
return []
})
</script>

View File

@@ -18,12 +18,12 @@ import { useCustomerStore } from '../store'
import { useCompanyStore } from '../../../../stores/company.store'
import { useGlobalStore } from '../../../../stores/global.store'
import { useNotificationStore } from '../../../../stores/notification.store'
import CopyInputField from '@/scripts/admin/components/CopyInputField.vue'
import CopyInputField from '@v2/features/company/customers/components/CopyInputField.vue'
// These stores are needed for auto-selecting customer after creation
import { useEstimateStore } from '@/scripts/admin/stores/estimate'
import { useInvoiceStore } from '@/scripts/admin/stores/invoice'
import { useRecurringInvoiceStore } from '@/scripts/admin/stores/recurring-invoice'
import { useEstimateStore } from '@v2/features/company/estimates/store'
import { useInvoiceStore } from '@v2/features/company/invoices/store'
import { useRecurringInvoiceStore } from '@v2/features/company/recurring-invoices/store'
const recurringInvoiceStore = useRecurringInvoiceStore()
const modalStore = useModalStore()

View File

@@ -0,0 +1,272 @@
<script setup lang="ts">
import { computed, ref, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useCustomerStore } from '../store'
import { useDebounceFn } from '@vueuse/core'
import LoadingIcon from '@v2/components/icons/LoadingIcon.vue'
interface SearchData {
orderBy: string | null
orderByField: string | null
searchText: string | null
}
interface CustomerListItem {
id: number
name: string
contact_name: string | null
due_amount: number | null
currency: Record<string, unknown> | null
}
const customerStore = useCustomerStore()
const route = useRoute()
const { t } = useI18n()
const isFetching = ref<boolean>(false)
const searchData = reactive<SearchData>({
orderBy: null,
orderByField: null,
searchText: null,
})
const customerList = ref<CustomerListItem[] | null>(null)
const currentPageNumber = ref<number>(1)
const lastPageNumber = ref<number>(1)
const customerListSection = ref<HTMLElement | null>(null)
const onSearch = useDebounceFn(async () => {
customerList.value = []
loadCustomers()
}, 500)
const getOrderBy = computed<boolean>(() => {
return searchData.orderBy === 'asc' || searchData.orderBy === null
})
function hasActiveUrl(id: number): boolean {
return Number(route.params.id) === id
}
async function loadCustomers(
pageNumber?: number,
fromScrollListener = false
): Promise<void> {
if (isFetching.value) return
const params: Record<string, unknown> = {}
if (searchData.searchText) {
params.display_name = searchData.searchText
}
if (searchData.orderBy) {
params.orderBy = searchData.orderBy
}
if (searchData.orderByField) {
params.orderByField = searchData.orderByField
}
isFetching.value = true
const response = await customerStore.fetchCustomers({
page: pageNumber ?? 1,
...params,
limit: 15,
})
isFetching.value = false
if (!customerList.value) customerList.value = []
customerList.value = [...customerList.value, ...response.data]
currentPageNumber.value = pageNumber ?? 1
lastPageNumber.value = response.meta.last_page
const customerFound = customerList.value.find(
(cust) => cust.id === Number(route.params.id)
)
if (
!fromScrollListener &&
!customerFound &&
currentPageNumber.value < lastPageNumber.value &&
Object.keys(params).length === 0
) {
loadCustomers(++currentPageNumber.value)
}
if (customerFound) {
setTimeout(() => {
if (!fromScrollListener) {
scrollToCustomer()
}
}, 500)
}
}
function scrollToCustomer(): void {
const el = document.getElementById(`customer-${route.params.id}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth' })
el.classList.add('shake')
addScrollListener()
}
}
function addScrollListener(): void {
customerListSection.value?.addEventListener('scroll', (ev: Event) => {
const target = ev.target as HTMLElement
if (
target.scrollTop > 0 &&
target.scrollTop + target.clientHeight > target.scrollHeight - 200
) {
if (currentPageNumber.value < lastPageNumber.value) {
loadCustomers(++currentPageNumber.value, true)
}
}
})
}
function sortData(): void {
if (searchData.orderBy === 'asc') {
searchData.orderBy = 'desc'
} else {
searchData.orderBy = 'asc'
}
onSearch()
}
loadCustomers()
</script>
<template>
<div
class="fixed top-0 left-0 hidden h-full pt-16 pb-[6.6rem] ml-56 bg-surface xl:ml-64 w-88 xl:block"
>
<div
class="flex items-center justify-between px-4 pt-8 pb-2 border border-line-default border-solid height-full"
>
<BaseInput
v-model="searchData.searchText"
:placeholder="$t('general.search')"
container-class="mb-6"
type="text"
variant="gray"
@input="onSearch()"
>
<BaseIcon name="MagnifyingGlassIcon" class="text-muted" />
</BaseInput>
<div class="flex mb-6 ml-3" role="group" aria-label="First group">
<BaseDropdown
:close-on-select="false"
position="bottom-start"
width-class="w-40"
position-class="left-0"
>
<template #activator>
<BaseButton variant="gray">
<BaseIcon name="FunnelIcon" />
</BaseButton>
</template>
<div
class="px-4 py-3 pb-2 mb-2 text-sm border-b border-line-default border-solid"
>
{{ $t('general.sort_by') }}
</div>
<div class="px-2">
<BaseDropdownItem
class="flex px-1 py-2 mt-1 cursor-pointer hover:rounded-md"
>
<BaseInputGroup class="pt-2 -mt-4">
<BaseRadio
id="filter_create_date"
v-model="searchData.orderByField"
:label="$t('customers.create_date')"
size="sm"
name="filter"
value="invoices.created_at"
@update:model-value="onSearch"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
<div class="px-2">
<BaseDropdownItem
class="flex px-1 cursor-pointer hover:rounded-md"
>
<BaseInputGroup class="pt-2 -mt-4">
<BaseRadio
id="filter_display_name"
v-model="searchData.orderByField"
:label="$t('customers.display_name')"
size="sm"
name="filter"
value="name"
@update:model-value="onSearch"
/>
</BaseInputGroup>
</BaseDropdownItem>
</div>
</BaseDropdown>
<BaseButton class="ml-1" size="md" variant="gray" @click="sortData">
<BaseIcon v-if="getOrderBy" name="SortAscendingIcon" />
<BaseIcon v-else name="SortDescendingIcon" />
</BaseButton>
</div>
</div>
<div
ref="customerListSection"
class="h-full overflow-y-scroll border-l border-line-default border-solid sidebar base-scroll"
>
<div v-for="(customer, index) in customerList" :key="index">
<router-link
v-if="customer"
:id="'customer-' + customer.id"
:to="`/admin/customers/${customer.id}/view`"
:class="[
'flex justify-between p-4 items-center cursor-pointer hover:bg-hover-strong border-l-4 border-l-transparent',
{
'bg-surface-tertiary border-l-4 border-l-primary-500 border-solid':
hasActiveUrl(customer.id),
},
]"
style="border-top: 1px solid rgba(185, 193, 209, 0.41)"
>
<div>
<BaseText
:text="customer.name"
class="pr-2 text-sm not-italic font-normal leading-5 text-heading capitalize truncate"
/>
<BaseText
v-if="customer.contact_name"
:text="customer.contact_name"
class="mt-1 text-xs not-italic font-medium leading-5 text-body"
/>
</div>
<div class="flex-1 font-bold text-right whitespace-nowrap">
<BaseFormatMoney
:amount="customer.due_amount !== null ? customer.due_amount : 0"
:currency="customer.currency"
/>
</div>
</router-link>
</div>
<div v-if="isFetching" class="flex justify-center p-4 items-center">
<LoadingIcon class="h-6 m-1 animate-spin text-primary-400" />
</div>
<p
v-if="!customerList?.length && !isFetching"
class="flex justify-center px-4 mt-5 text-sm text-body"
>
{{ $t('customers.no_matching_customers') }}
</p>
</div>
</div>
</template>

View File

@@ -16,14 +16,14 @@ import useVuelidate from '@vuelidate/core'
import { useCustomerStore } from '../store'
import { useGlobalStore } from '../../../../stores/global.store'
import { useCompanyStore } from '../../../../stores/company.store'
import CustomerCustomFields from '@/scripts/admin/components/custom-fields/CreateCustomFields.vue'
import CopyInputField from '@/scripts/admin/components/CopyInputField.vue'
import CustomerCustomFields from '@v2/features/company/customers/components/CreateCustomFields.vue'
import CopyInputField from '@v2/features/company/customers/components/CopyInputField.vue'
// Custom field store - imported from original location
import { useCustomFieldStore } from '@/scripts/admin/stores/custom-field'
import { customFieldService } from '@v2/api/services/custom-field.service'
const customerStore = useCustomerStore()
const customFieldStore = useCustomFieldStore()
// Custom fields fetched via service
const globalStore = useGlobalStore()
const companyStore = useCompanyStore()

View File

@@ -5,8 +5,8 @@ import { useI18n } from 'vue-i18n'
import { useCustomerStore } from '../store'
import { useUserStore } from '../../../../stores/user.store'
import CustomerDropdown from '../components/CustomerDropdown.vue'
import CustomerViewSidebar from '@/scripts/admin/views/customers/partials/CustomerViewSidebar.vue'
import CustomerChart from '@/scripts/admin/views/customers/partials/CustomerChart.vue'
import CustomerViewSidebar from '@v2/features/company/customers/components/CustomerViewSidebar.vue'
import CustomerChart from '@v2/features/company/customers/components/CustomerChart.vue'
const ABILITIES = {
EDIT_CUSTOMER: 'edit-customer',

View File

@@ -7,7 +7,7 @@ import { useDialogStore } from '../../../../stores/dialog.store'
import { useCompanyStore } from '../../../../stores/company.store'
import { useUserStore } from '../../../../stores/user.store'
import CustomerDropdown from '../components/CustomerDropdown.vue'
import AstronautIcon from '@/scripts/components/icons/empty/AstronautIcon.vue'
import AstronautIcon from '@v2/components/icons/AstronautIcon.vue'
interface TableColumn {
key: string

View File

@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import { useDashboardStore } from '../store'
import { useCompanyStore } from '../../../../stores/company.store'
import { useUserStore } from '../../../../stores/user.store'
import LineChart from '@/scripts/admin/components/charts/LineChart.vue'
import LineChart from '@v2/components/charts/LineChart.vue'
import type { DashboardParams } from '../../../../api/services/dashboard.service'
interface YearOption {

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import DollarIcon from '@/scripts/components/icons/dashboard/DollarIcon.vue'
import CustomerIcon from '@/scripts/components/icons/dashboard/CustomerIcon.vue'
import InvoiceIcon from '@/scripts/components/icons/dashboard/InvoiceIcon.vue'
import EstimateIcon from '@/scripts/components/icons/dashboard/EstimateIcon.vue'
import DollarIcon from '@v2/components/icons/dashboard/DollarIcon.vue'
import CustomerIcon from '@v2/components/icons/dashboard/CustomerIcon.vue'
import InvoiceIcon from '@v2/components/icons/dashboard/InvoiceIcon.vue'
import EstimateIcon from '@v2/components/icons/dashboard/EstimateIcon.vue'
import DashboardStatsItem from './DashboardStatsItem.vue'
import { useDashboardStore } from '../store'

View File

@@ -3,8 +3,8 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDashboardStore } from '../store'
import { useUserStore } from '../../../../stores/user.store'
import InvoiceDropdown from '@/scripts/admin/components/dropdowns/InvoiceIndexDropdown.vue'
import EstimateDropdown from '@/scripts/admin/components/dropdowns/EstimateIndexDropdown.vue'
import InvoiceDropdown from '@v2/features/company/invoices/components/InvoiceDropdown.vue'
import EstimateDropdown from '@v2/features/company/estimates/components/EstimateDropdown.vue'
interface TableColumn {
key: string

View File

@@ -206,7 +206,7 @@ import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useEstimateStore } from '../store'
import EstimateDropdown from '../components/EstimateDropdown.vue'
import LoadingIcon from '@/scripts/components/icons/LoadingIcon.vue'
import LoadingIcon from '@v2/components/icons/LoadingIcon.vue'
import type { Estimate } from '../../../../types/domain/estimate'
interface Props {

View File

@@ -212,7 +212,7 @@ import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useInvoiceStore } from '../store'
import InvoiceDropdown from '../components/InvoiceDropdown.vue'
import LoadingIcon from '@/scripts/components/icons/LoadingIcon.vue'
import LoadingIcon from '@v2/components/icons/LoadingIcon.vue'
import type { Invoice } from '../../../../types/domain/invoice'
interface Props {

View File

@@ -13,7 +13,7 @@ import { useCompanyStore } from '../../../../stores/company.store'
import { useItemStore } from '../store'
// Tax type store - imported from original location
import { useTaxTypeStore } from '@/scripts/admin/stores/tax-type'
import { taxTypeService } from '@v2/api/services/tax-type.service'
interface TaxOption {
id: number
@@ -33,7 +33,7 @@ const emit = defineEmits<Emits>()
const modalStore = useModalStore()
const itemStore = useItemStore()
const companyStore = useCompanyStore()
const taxTypeStore = useTaxTypeStore()
// Tax types fetched via service
const { t } = useI18n()
const isLoading = ref<boolean>(false)

View File

@@ -13,10 +13,10 @@ import { useItemStore } from '../store'
import { useCompanyStore } from '../../../../stores/company.store'
import { useModalStore } from '../../../../stores/modal.store'
import { useUserStore } from '../../../../stores/user.store'
import ItemUnitModal from '@/scripts/admin/components/modal-components/ItemUnitModal.vue'
import ItemUnitModal from '@v2/features/company/settings/components/ItemUnitModal.vue'
// Tax type store - imported from original location
import { useTaxTypeStore } from '@/scripts/admin/stores/tax-type'
import { taxTypeService } from '@v2/api/services/tax-type.service'
interface TaxOption {
id: number
@@ -33,7 +33,7 @@ const ABILITIES = {
} as const
const itemStore = useItemStore()
const taxTypeStore = useTaxTypeStore()
// Tax types fetched via service
const modalStore = useModalStore()
const companyStore = useCompanyStore()
const userStore = useUserStore()

View File

@@ -7,7 +7,7 @@ import { useDialogStore } from '../../../../stores/dialog.store'
import { useCompanyStore } from '../../../../stores/company.store'
import { useUserStore } from '../../../../stores/user.store'
import ItemDropdown from '../components/ItemDropdown.vue'
import SatelliteIcon from '@/scripts/components/icons/empty/SatelliteIcon.vue'
import SatelliteIcon from '@v2/components/icons/SatelliteIcon.vue'
interface TableColumn {
key: string

View File

@@ -8,7 +8,7 @@ import { useUserStore } from '../../../../stores/user.store'
import { useNotificationStore } from '../../../../stores/notification.store'
import MemberDropdown from '../components/MemberDropdown.vue'
import InviteMemberModal from '../components/InviteMemberModal.vue'
import AstronautIcon from '@/scripts/components/icons/empty/AstronautIcon.vue'
import AstronautIcon from '@v2/components/icons/AstronautIcon.vue'
interface TableColumn {
key: string

View File

@@ -170,7 +170,7 @@ import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { usePaymentStore } from '../store'
import PaymentDropdown from '../components/PaymentDropdown.vue'
import LoadingIcon from '@/scripts/components/icons/LoadingIcon.vue'
import LoadingIcon from '@v2/components/icons/LoadingIcon.vue'
import type { Payment } from '../../../../types/domain/payment'
interface Props {

View File

@@ -91,7 +91,7 @@ import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useRecurringInvoiceStore } from '../store'
import RecurringInvoiceDropdown from '../components/RecurringInvoiceDropdown.vue'
import LoadingIcon from '@/scripts/components/icons/LoadingIcon.vue'
import LoadingIcon from '@v2/components/icons/LoadingIcon.vue'
interface Props {
canEdit?: boolean

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
import { onMounted, computed, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, email, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
interface BasicMailConfig {
mail_driver: string
from_mail: string
from_name: string
}
const props = withDefaults(
defineProps<{
configData?: Record<string, unknown>
isSaving?: boolean
isFetchingInitialData?: boolean
mailDrivers?: string[]
}>(),
{
configData: () => ({}),
isSaving: false,
isFetchingInitialData: false,
mailDrivers: () => [],
}
)
const emit = defineEmits<{
'submit-data': [config: BasicMailConfig]
'on-change-driver': [driver: string]
}>()
const { t } = useI18n()
const basicMailConfig = reactive<BasicMailConfig>({
mail_driver: 'sendmail',
from_mail: '',
from_name: '',
})
const rules = computed(() => ({
mail_driver: {
required: helpers.withMessage(t('validation.required'), required),
},
from_mail: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
from_name: {
required: helpers.withMessage(t('validation.required'), required),
},
}))
const v$ = useVuelidate(rules, basicMailConfig)
onMounted(() => {
for (const key in basicMailConfig) {
if (Object.prototype.hasOwnProperty.call(props.configData, key)) {
;(basicMailConfig as Record<string, unknown>)[key] = props.configData[key]
}
}
})
async function saveEmailConfig(): Promise<void> {
v$.value.$touch()
if (!v$.value.$invalid) {
emit('submit-data', { ...basicMailConfig })
}
}
function onChangeDriver(): void {
v$.value.mail_driver.$touch()
emit('on-change-driver', basicMailConfig.mail_driver)
}
</script>
<template>
<form @submit.prevent="saveEmailConfig">
<BaseInputGrid>
<BaseInputGroup
:label="$t('settings.mail.driver')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_driver.$error && v$.mail_driver.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="basicMailConfig.mail_driver"
:content-loading="isFetchingInitialData"
:options="mailDrivers"
:can-deselect="false"
:invalid="v$.mail_driver.$error"
@update:model-value="onChangeDriver"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_mail')"
:content-loading="isFetchingInitialData"
:error="
v$.from_mail.$error && v$.from_mail.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="basicMailConfig.from_mail"
:content-loading="isFetchingInitialData"
type="text"
name="from_mail"
:invalid="v$.from_mail.$error"
@input="v$.from_mail.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_name')"
:content-loading="isFetchingInitialData"
:error="
v$.from_name.$error && v$.from_name.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="basicMailConfig.from_name"
:content-loading="isFetchingInitialData"
type="text"
name="name"
:invalid="v$.from_name.$error"
@input="v$.from_name.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
<div class="flex mt-8">
<BaseButton
:content-loading="isFetchingInitialData"
:disabled="isSaving"
:loading="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
:class="slotProps.class"
name="ArrowDownOnSquareIcon"
/>
</template>
{{ $t('general.save') }}
</BaseButton>
<slot />
</div>
</form>
</template>

View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, minLength, maxLength, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '@v2/stores/modal.store'
import { expenseService } from '@v2/api/services/expense.service'
interface CategoryForm {
id: number | null
name: string
description: string
}
const modalStore = useModalStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const isEdit = ref<boolean>(false)
const currentCategory = ref<CategoryForm>({
id: null,
name: '',
description: '',
})
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'CategoryModal'
)
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3)
),
},
description: {
maxLength: helpers.withMessage(
t('validation.description_maxlength', { count: 255 }),
maxLength(255)
),
},
}))
const v$ = useVuelidate(rules, currentCategory)
async function setInitialData(): Promise<void> {
if (modalStore.data && typeof modalStore.data === 'number') {
isEdit.value = true
const response = await expenseService.getCategory(modalStore.data)
if (response.data) {
currentCategory.value = {
id: response.data.id,
name: response.data.name,
description: response.data.description ?? '',
}
}
} else {
isEdit.value = false
resetForm()
}
}
async function submitCategoryData(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
try {
if (isEdit.value && currentCategory.value.id) {
await expenseService.updateCategory(currentCategory.value.id, {
name: currentCategory.value.name,
description: currentCategory.value.description || null,
})
} else {
await expenseService.createCategory({
name: currentCategory.value.name,
description: currentCategory.value.description || null,
})
}
isSaving.value = false
if (modalStore.refreshData) {
modalStore.refreshData()
}
closeCategoryModal()
} catch {
isSaving.value = false
}
}
function resetForm(): void {
currentCategory.value = {
id: null,
name: '',
description: '',
}
}
function closeCategoryModal(): void {
modalStore.closeModal()
setTimeout(() => {
resetForm()
v$.value.$reset()
}, 300)
}
</script>
<template>
<BaseModal
:show="modalActive"
@close="closeCategoryModal"
@open="setInitialData"
>
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-muted cursor-pointer"
@click="closeCategoryModal"
/>
</div>
</template>
<form action="" @submit.prevent="submitCategoryData">
<div class="p-8 sm:p-6">
<BaseInputGrid layout="one-column">
<BaseInputGroup
:label="$t('expenses.category')"
:error="v$.name.$error && v$.name.$errors[0].$message"
required
>
<BaseInput
v-model="currentCategory.name"
:invalid="v$.name.$error"
type="text"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('expenses.description')"
:error="
v$.description.$error && v$.description.$errors[0].$message
"
>
<BaseTextarea
v-model="currentCategory.description"
rows="4"
cols="50"
@input="v$.description.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
</div>
<div
class="z-0 flex justify-end p-4 border-t border-line-default border-solid"
>
<BaseButton
type="button"
variant="primary-outline"
class="mr-3 text-sm"
@click="closeCategoryModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{ isEdit ? $t('general.update') : $t('general.save') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useDialogStore } from '@v2/stores/dialog.store'
import { useUserStore } from '@v2/stores/user.store'
import { useModalStore } from '@v2/stores/modal.store'
import { customFieldService } from '@v2/api/services/custom-field.service'
const ABILITIES = {
EDIT_CUSTOM_FIELDS: 'edit-custom-field',
DELETE_CUSTOM_FIELDS: 'delete-custom-field',
} as const
interface CustomFieldRow {
id: number
name: string
[key: string]: unknown
}
const props = defineProps<{
row: CustomFieldRow
table?: { refresh: () => void } | null
loadData?: (() => void) | null
}>()
const dialogStore = useDialogStore()
const { t } = useI18n()
const userStore = useUserStore()
const modalStore = useModalStore()
async function editCustomField(id: number): Promise<void> {
modalStore.openModal({
title: t('settings.custom_fields.edit_custom_field'),
componentName: 'CustomFieldModal',
size: 'sm',
data: id,
refreshData: props.loadData ?? undefined,
})
}
async function removeCustomField(id: number): Promise<void> {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('settings.custom_fields.custom_field_confirm_delete'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res: boolean) => {
if (res) {
await customFieldService.delete(id)
props.loadData?.()
}
})
}
</script>
<template>
<BaseDropdown>
<template #activator>
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.EDIT_CUSTOM_FIELDS)"
@click="editCustomField(row.id)"
>
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.DELETE_CUSTOM_FIELDS)"
@click="removeCustomField(row.id)"
>
<BaseIcon
name="TrashIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>

View File

@@ -0,0 +1,445 @@
<script setup lang="ts">
import { reactive, ref, computed, defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n'
import useVuelidate from '@vuelidate/core'
import { required, numeric, helpers } from '@vuelidate/validators'
import { useModalStore } from '@v2/stores/modal.store'
import { customFieldService } from '@v2/api/services/custom-field.service'
import type { CreateCustomFieldPayload } from '@v2/api/services/custom-field.service'
interface FieldOption {
name: string
}
interface DataType {
label: string
value: string
}
interface CustomFieldForm {
id: number | null
name: string
label: string
model_type: string
type: string
placeholder: string | null
is_required: number
options: FieldOption[]
order: number | null
default_answer: string | boolean | number | null
dateTimeValue: string | null
in_use: boolean
}
const modalStore = useModalStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const isEdit = ref<boolean>(false)
const currentCustomField = ref<CustomFieldForm>({
id: null,
name: '',
label: '',
model_type: 'Customer',
type: 'Input',
placeholder: null,
is_required: 0,
options: [],
order: null,
default_answer: null,
dateTimeValue: null,
in_use: false,
})
const modelTypes = reactive([
{ label: t('settings.custom_fields.model_type.customer'), value: 'Customer' },
{ label: t('settings.custom_fields.model_type.invoice'), value: 'Invoice' },
{ label: t('settings.custom_fields.model_type.estimate'), value: 'Estimate' },
{ label: t('settings.custom_fields.model_type.expense'), value: 'Expense' },
{ label: t('settings.custom_fields.model_type.payment'), value: 'Payment' },
])
const dataTypes = reactive<DataType[]>([
{ label: 'Text', value: 'Input' },
{ label: 'Textarea', value: 'TextArea' },
{ label: 'Phone', value: 'Phone' },
{ label: 'URL', value: 'Url' },
{ label: 'Number', value: 'Number' },
{ label: 'Select Field', value: 'Dropdown' },
{ label: 'Switch Toggle', value: 'Switch' },
{ label: 'Date', value: 'Date' },
{ label: 'Time', value: 'Time' },
{ label: 'Date & Time', value: 'DateTime' },
])
const selectedType = ref<DataType>(dataTypes[0])
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'CustomFieldModal'
)
const isSwitchTypeSelected = computed<boolean>(
() => selectedType.value?.label === 'Switch Toggle'
)
const isDropdownSelected = computed<boolean>(
() => selectedType.value?.label === 'Select Field'
)
const defaultValueComponent = computed(() => {
if (currentCustomField.value.type) {
return defineAsyncComponent(
() =>
import(
`@/scripts/admin/components/custom-fields/types/${currentCustomField.value.type}Type.vue`
)
)
}
return null
})
const isRequiredField = computed<boolean>({
get: () => currentCustomField.value.is_required === 1,
set: (value: boolean) => {
currentCustomField.value.is_required = value ? 1 : 0
},
})
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
},
label: {
required: helpers.withMessage(t('validation.required'), required),
},
model_type: {
required: helpers.withMessage(t('validation.required'), required),
},
order: {
required: helpers.withMessage(t('validation.required'), required),
numeric: helpers.withMessage(t('validation.numbers_only'), numeric),
},
type: {
required: helpers.withMessage(t('validation.required'), required),
},
}))
const v$ = useVuelidate(rules, currentCustomField)
function setData(): void {
if (isEdit.value) {
const found = dataTypes.find(
(type) => type.value === currentCustomField.value.type
)
if (found) selectedType.value = found
} else {
currentCustomField.value.model_type = modelTypes[0].value
currentCustomField.value.type = dataTypes[0].value
selectedType.value = dataTypes[0]
}
}
async function setInitialData(): Promise<void> {
if (modalStore.data && typeof modalStore.data === 'number') {
isEdit.value = true
const response = await customFieldService.get(modalStore.data)
if (response.data) {
const field = response.data
currentCustomField.value = {
id: field.id,
name: field.name,
label: field.label,
model_type: field.model_type,
type: field.type,
placeholder: field.placeholder,
is_required: field.is_required ? 1 : 0,
options: field.options
? field.options.map((o) => ({ name: typeof o === 'string' ? o : o }))
: [],
order: field.order,
default_answer: field.default_answer,
dateTimeValue: null,
in_use: field.in_use,
}
}
} else {
isEdit.value = false
resetForm()
}
setData()
}
async function submitCustomFieldData(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
const payload: CreateCustomFieldPayload = {
name: currentCustomField.value.name,
label: currentCustomField.value.label,
model_type: currentCustomField.value.model_type,
type: currentCustomField.value.type,
placeholder: currentCustomField.value.placeholder,
is_required: currentCustomField.value.is_required === 1,
options: currentCustomField.value.options.length
? currentCustomField.value.options.map((o) => o.name)
: null,
order: currentCustomField.value.order,
}
try {
if (isEdit.value && currentCustomField.value.id) {
await customFieldService.update(currentCustomField.value.id, payload)
} else {
await customFieldService.create(payload)
}
isSaving.value = false
if (modalStore.refreshData) {
modalStore.refreshData()
}
closeCustomFieldModal()
} catch {
isSaving.value = false
}
}
function addNewOption(option: string): void {
currentCustomField.value.options = [
{ name: option },
...currentCustomField.value.options,
]
}
function removeOption(index: number): void {
if (isEdit.value && currentCustomField.value.in_use) {
return
}
const option = currentCustomField.value.options[index]
if (option.name === currentCustomField.value.default_answer) {
currentCustomField.value.default_answer = null
}
currentCustomField.value.options.splice(index, 1)
}
function onSelectedTypeChange(data: DataType): void {
currentCustomField.value.type = data.value
}
function resetForm(): void {
currentCustomField.value = {
id: null,
name: '',
label: '',
model_type: 'Customer',
type: 'Input',
placeholder: null,
is_required: 0,
options: [],
order: null,
default_answer: null,
dateTimeValue: null,
in_use: false,
}
selectedType.value = dataTypes[0]
}
function closeCustomFieldModal(): void {
modalStore.closeModal()
setTimeout(() => {
resetForm()
v$.value.$reset()
}, 300)
}
</script>
<template>
<BaseModal :show="modalActive" @open="setInitialData">
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-muted cursor-pointer"
@click="closeCustomFieldModal"
/>
</div>
</template>
<form action="" @submit.prevent="submitCustomFieldData">
<div class="overflow-y-auto max-h-[550px]">
<div class="px-4 md:px-8 py-8 overflow-y-auto sm:p-6">
<BaseInputGrid layout="one-column">
<BaseInputGroup
:label="$t('settings.custom_fields.name')"
required
:error="v$.name.$error && v$.name.$errors[0].$message"
>
<BaseInput
v-model="currentCustomField.name"
:invalid="v$.name.$error"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.custom_fields.model')"
:error="
v$.model_type.$error && v$.model_type.$errors[0].$message
"
:help-text="
currentCustomField.in_use
? $t('settings.custom_fields.model_in_use')
: ''
"
required
>
<BaseMultiselect
v-model="currentCustomField.model_type"
:options="modelTypes"
value-prop="value"
:can-deselect="false"
:invalid="v$.model_type.$error"
:searchable="true"
:disabled="currentCustomField.in_use"
@input="v$.model_type.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
class="flex items-center space-x-4"
:label="$t('settings.custom_fields.required')"
>
<BaseSwitch v-model="isRequiredField" />
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.custom_fields.type')"
:error="v$.type.$error && v$.type.$errors[0].$message"
:help-text="
currentCustomField.in_use
? $t('settings.custom_fields.type_in_use')
: ''
"
required
>
<BaseMultiselect
v-model="selectedType"
:options="dataTypes"
:invalid="v$.type.$error"
:disabled="currentCustomField.in_use"
:searchable="true"
:can-deselect="false"
object
@update:model-value="onSelectedTypeChange"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.custom_fields.label')"
required
:error="v$.label.$error && v$.label.$errors[0].$message"
>
<BaseInput
v-model="currentCustomField.label"
:invalid="v$.label.$error"
@input="v$.label.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
v-if="isDropdownSelected"
:label="$t('settings.custom_fields.options')"
>
<div
v-for="(option, index) in currentCustomField.options"
:key="index"
class="flex items-center mt-5"
>
<BaseInput v-model="option.name" class="w-64" />
<BaseIcon
name="MinusCircleIcon"
class="ml-1 cursor-pointer"
:class="
currentCustomField.in_use
? 'text-subtle'
: 'text-red-300'
"
@click="removeOption(index)"
/>
</div>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.custom_fields.default_value')"
class="relative"
>
<component
:is="defaultValueComponent"
v-if="defaultValueComponent"
v-model="currentCustomField.default_answer"
:options="currentCustomField.options"
:default-date-time="currentCustomField.dateTimeValue"
/>
</BaseInputGroup>
<BaseInputGroup
v-if="!isSwitchTypeSelected"
:label="$t('settings.custom_fields.placeholder')"
>
<BaseInput v-model="currentCustomField.placeholder" />
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.custom_fields.order')"
:error="v$.order.$error && v$.order.$errors[0].$message"
required
>
<BaseInput
v-model="currentCustomField.order"
type="number"
:invalid="v$.order.$error"
@input="v$.order.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
</div>
</div>
<div
class="z-0 flex justify-end p-4 border-t border-solid border-line-default"
>
<BaseButton
class="mr-3"
type="button"
variant="primary-outline"
@click="closeCustomFieldModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
variant="primary"
:loading="isSaving"
:disabled="isSaving"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
:class="slotProps.class"
name="ArrowDownOnSquareIcon"
/>
</template>
{{ isEdit ? $t('general.update') : $t('general.save') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed, reactive, inject } from 'vue'
import { useCompanyStore } from '@v2/stores/company.store'
import NumberCustomizer from './NumberCustomizer.vue'
interface Utils {
mergeSettings: (target: Record<string, unknown>, source: Record<string, unknown>) => void
}
const utils = inject<Utils>('utils')!
const companyStore = useCompanyStore()
const estimateSettings = reactive<{ estimate_email_attachment: string | null }>({
estimate_email_attachment: null,
})
utils.mergeSettings(
estimateSettings as unknown as Record<string, unknown>,
{ ...companyStore.selectedCompanySettings }
)
const sendAsAttachmentField = computed<boolean>({
get: () => estimateSettings.estimate_email_attachment === 'YES',
set: async (newValue: boolean) => {
const value = newValue ? 'YES' : 'NO'
const data = {
settings: {
estimate_email_attachment: value,
},
}
estimateSettings.estimate_email_attachment = value
await companyStore.updateCompanySettings({
data,
message: 'general.setting_updated',
})
},
})
</script>
<template>
<NumberCustomizer type="estimate" :type-store="companyStore" />
<BaseDivider class="mt-6 mb-2" />
<ul class="divide-y divide-line-default">
<BaseSwitchSection
v-model="sendAsAttachmentField"
:title="$t('settings.customization.estimates.estimate_email_attachment')"
:description="
$t(
'settings.customization.estimates.estimate_email_attachment_setting_description'
)
"
/>
</ul>
</template>

View File

@@ -0,0 +1,431 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import useVuelidate from '@vuelidate/core'
import { required, helpers, requiredIf, url } from '@vuelidate/validators'
import { useModalStore } from '@v2/stores/modal.store'
import { exchangeRateService } from '@v2/api/services/exchange-rate.service'
import { useDebounceFn } from '@vueuse/core'
interface DriverOption {
key: string
value: string
}
interface ServerOption {
value: string
}
interface ExchangeRateForm {
id: number | null
driver: string
key: string | null
active: boolean
currencies: string[]
}
interface CurrencyConverterForm {
type: string
url: string
}
const { t } = useI18n()
const modalStore = useModalStore()
const isSaving = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(false)
const isFetchingCurrencies = ref<boolean>(false)
const isEdit = ref<boolean>(false)
const currenciesAlreadyInUsed = ref<string[]>([])
const supportedCurrencies = ref<string[]>([])
const serverOptions = ref<ServerOption[]>([])
const drivers = ref<DriverOption[]>([])
const currentExchangeRate = ref<ExchangeRateForm>({
id: null,
driver: 'currency_converter',
key: null,
active: true,
currencies: [],
})
const currencyConverter = ref<CurrencyConverterForm>({
type: '',
url: '',
})
const modalActive = computed<boolean>(
() =>
modalStore.active &&
modalStore.componentName === 'ExchangeRateProviderModal'
)
const isCurrencyConverter = computed<boolean>(
() => currentExchangeRate.value.driver === 'currency_converter'
)
const isDedicatedServer = computed<boolean>(
() => currencyConverter.value.type === 'DEDICATED'
)
const driverSite = computed<string>(() => {
switch (currentExchangeRate.value.driver) {
case 'currency_converter':
return 'https://www.currencyconverterapi.com'
case 'currency_freak':
return 'https://currencyfreaks.com'
case 'currency_layer':
return 'https://currencylayer.com'
case 'open_exchange_rate':
return 'https://openexchangerates.org'
default:
return ''
}
})
const driversLists = computed(() =>
drivers.value.map((item) => ({
...item,
key: t(item.key),
}))
)
const rules = computed(() => ({
driver: {
required: helpers.withMessage(t('validation.required'), required),
},
key: {
required: helpers.withMessage(t('validation.required'), required),
},
currencies: {
required: helpers.withMessage(t('validation.required'), required),
},
converterType: {
required: helpers.withMessage(
t('validation.required'),
requiredIf(isCurrencyConverter)
),
},
converterUrl: {
required: helpers.withMessage(
t('validation.required'),
requiredIf(isDedicatedServer)
),
url: helpers.withMessage(t('validation.invalid_url'), url),
},
}))
const v$ = useVuelidate(rules, currentExchangeRate)
watch(isCurrencyConverter, (newVal) => {
if (newVal) {
fetchServers()
}
}, { immediate: true })
const fetchCurrenciesDebounced = useDebounceFn(() => {
fetchCurrencies()
}, 500)
watch(() => currentExchangeRate.value.key, (newVal) => {
if (newVal) fetchCurrenciesDebounced()
})
watch(() => currencyConverter.value.type, (newVal) => {
if (newVal) fetchCurrenciesDebounced()
})
function dismiss(): void {
currenciesAlreadyInUsed.value = []
}
function removeUsedSelectedCurrencies(): void {
const { currencies } = currentExchangeRate.value
currenciesAlreadyInUsed.value.forEach((uc) => {
const idx = currencies.indexOf(uc)
if (idx > -1) currencies.splice(idx, 1)
})
currenciesAlreadyInUsed.value = []
}
function resetCurrency(): void {
currentExchangeRate.value.key = null
currentExchangeRate.value.currencies = []
supportedCurrencies.value = []
}
function resetModalData(): void {
supportedCurrencies.value = []
currentExchangeRate.value = {
id: null,
driver: 'currency_converter',
key: null,
active: true,
currencies: [],
}
currencyConverter.value = { type: '', url: '' }
currenciesAlreadyInUsed.value = []
isEdit.value = false
}
async function fetchInitialData(): Promise<void> {
isFetchingInitialData.value = true
const driversRes = await exchangeRateService.getDrivers()
if (driversRes.exchange_rate_drivers) {
drivers.value = (driversRes.exchange_rate_drivers as unknown as DriverOption[])
}
if (modalStore.data && typeof modalStore.data === 'number') {
isEdit.value = true
const response = await exchangeRateService.getProvider(modalStore.data)
if (response.data) {
const provider = response.data
currentExchangeRate.value = {
id: provider.id,
driver: provider.driver,
key: provider.key,
active: provider.active,
currencies: provider.currencies ?? [],
}
}
} else {
currentExchangeRate.value.driver = 'currency_converter'
}
isFetchingInitialData.value = false
}
async function fetchServers(): Promise<void> {
const res = await exchangeRateService.getCurrencyConverterServers()
serverOptions.value = (res as Record<string, ServerOption[]>).currency_converter_servers ?? []
currencyConverter.value.type = 'FREE'
}
async function fetchCurrencies(): Promise<void> {
const { driver, key } = currentExchangeRate.value
if (!driver || !key) return
if (isCurrencyConverter.value && !currencyConverter.value.type) return
isFetchingCurrencies.value = true
try {
const params: Record<string, string> = { driver, key }
if (currencyConverter.value.type) {
params.type = currencyConverter.value.type
}
const res = await exchangeRateService.getSupportedCurrencies()
supportedCurrencies.value = res.supportedCurrencies ?? []
} finally {
isFetchingCurrencies.value = false
}
}
async function submitExchangeRate(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) return
const data: Record<string, unknown> = {
...currentExchangeRate.value,
}
if (isCurrencyConverter.value) {
data.driver_config = { ...currencyConverter.value }
if (!isDedicatedServer.value) {
(data.driver_config as CurrencyConverterForm).url = ''
}
}
isSaving.value = true
try {
if (isEdit.value && currentExchangeRate.value.id) {
await exchangeRateService.updateProvider(
currentExchangeRate.value.id,
data as Parameters<typeof exchangeRateService.updateProvider>[1]
)
} else {
await exchangeRateService.createProvider(
data as Parameters<typeof exchangeRateService.createProvider>[0]
)
}
if (modalStore.refreshData) {
modalStore.refreshData()
}
closeExchangeRateModal()
} finally {
isSaving.value = false
}
}
function closeExchangeRateModal(): void {
modalStore.closeModal()
setTimeout(() => {
resetModalData()
v$.value.$reset()
}, 300)
}
</script>
<template>
<BaseModal
:show="modalActive"
@close="closeExchangeRateModal"
@open="fetchInitialData"
>
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-muted cursor-pointer"
@click="closeExchangeRateModal"
/>
</div>
</template>
<form @submit.prevent="submitExchangeRate">
<div class="px-4 md:px-8 py-8 overflow-y-auto sm:p-6">
<BaseInputGrid layout="one-column">
<BaseInputGroup
:label="$t('settings.exchange_rate.driver')"
:content-loading="isFetchingInitialData"
required
:error="v$.driver.$error && v$.driver.$errors[0].$message"
:help-text="driverSite"
>
<BaseMultiselect
v-model="currentExchangeRate.driver"
:options="driversLists"
:content-loading="isFetchingInitialData"
value-prop="value"
:can-deselect="true"
label="key"
:searchable="true"
:invalid="v$.driver.$error"
track-by="key"
@update:model-value="resetCurrency"
/>
</BaseInputGroup>
<BaseInputGroup
v-if="isCurrencyConverter"
required
:label="$t('settings.exchange_rate.server')"
:content-loading="isFetchingInitialData"
>
<BaseMultiselect
v-model="currencyConverter.type"
:content-loading="isFetchingInitialData"
value-prop="value"
searchable
:options="serverOptions"
label="value"
track-by="value"
@update:model-value="resetCurrency"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.exchange_rate.key')"
required
:content-loading="isFetchingInitialData"
:error="v$.key.$error && v$.key.$errors[0].$message"
>
<BaseInput
v-model="currentExchangeRate.key"
:content-loading="isFetchingInitialData"
type="text"
name="key"
:loading="isFetchingCurrencies"
loading-position="right"
:invalid="v$.key.$error"
/>
</BaseInputGroup>
<BaseInputGroup
v-if="supportedCurrencies.length"
:label="$t('settings.exchange_rate.currency')"
:content-loading="isFetchingInitialData"
:error="
v$.currencies.$error && v$.currencies.$errors[0].$message
"
:help-text="$t('settings.exchange_rate.currency_help_text')"
>
<BaseMultiselect
v-model="currentExchangeRate.currencies"
:content-loading="isFetchingInitialData"
value-prop="code"
mode="tags"
searchable
:options="supportedCurrencies"
:invalid="v$.currencies.$error"
label="code"
track-by="code"
open-direction="top"
/>
</BaseInputGroup>
<BaseInputGroup
v-if="isDedicatedServer"
:label="$t('settings.exchange_rate.url')"
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model="currencyConverter.url"
:content-loading="isFetchingInitialData"
type="url"
/>
</BaseInputGroup>
<BaseSwitch
v-model="currentExchangeRate.active"
class="flex"
:label-right="$t('settings.exchange_rate.active')"
/>
</BaseInputGrid>
<BaseInfoAlert
v-if="
currenciesAlreadyInUsed.length && currentExchangeRate.active
"
class="mt-5"
:title="$t('settings.exchange_rate.currency_in_used')"
:lists="[currenciesAlreadyInUsed.toString()]"
:actions="['Remove']"
@hide="dismiss"
@Remove="removeUsedSelectedCurrencies"
/>
</div>
<div
class="z-0 flex justify-end p-4 border-t border-line-default border-solid"
>
<BaseButton
class="mr-3"
variant="primary-outline"
type="button"
:disabled="isSaving"
@click="closeExchangeRateModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isSaving"
:disabled="isSaving || isFetchingCurrencies"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{ isEdit ? $t('general.update') : $t('general.save') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useDialogStore } from '@v2/stores/dialog.store'
import { useUserStore } from '@v2/stores/user.store'
import { useModalStore } from '@v2/stores/modal.store'
import { expenseService } from '@v2/api/services/expense.service'
const ABILITIES = {
EDIT_EXPENSE: 'edit-expense',
DELETE_EXPENSE: 'delete-expense',
} as const
interface ExpenseCategoryRow {
id: number
name: string
[key: string]: unknown
}
const props = defineProps<{
row: ExpenseCategoryRow
table?: { refresh: () => void } | null
loadData?: (() => void) | null
}>()
const dialogStore = useDialogStore()
const { t } = useI18n()
const route = useRoute()
const userStore = useUserStore()
const modalStore = useModalStore()
function editExpenseCategory(id: number): void {
modalStore.openModal({
title: t('settings.expense_category.edit_category'),
componentName: 'CategoryModal',
data: id,
refreshData: props.loadData ?? undefined,
size: 'sm',
})
}
function removeExpenseCategory(id: number): void {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('settings.expense_category.confirm_delete'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res: boolean) => {
if (res) {
const response = await expenseService.deleteCategory(id)
if (response.success) {
props.loadData?.()
}
}
})
}
</script>
<template>
<BaseDropdown>
<template #activator>
<BaseButton
v-if="route.name === 'expenseCategorys.view'"
variant="primary"
>
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />
</BaseButton>
<BaseIcon v-else name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.EDIT_EXPENSE)"
@click="editExpenseCategory(row.id)"
>
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.DELETE_EXPENSE)"
@click="removeExpenseCategory(row.id)"
>
<BaseIcon
name="TrashIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed, reactive, inject } from 'vue'
import { useCompanyStore } from '@v2/stores/company.store'
import NumberCustomizer from './NumberCustomizer.vue'
interface Utils {
mergeSettings: (target: Record<string, unknown>, source: Record<string, unknown>) => void
}
const utils = inject<Utils>('utils')!
const companyStore = useCompanyStore()
const invoiceSettings = reactive<{ invoice_email_attachment: string | null }>({
invoice_email_attachment: null,
})
utils.mergeSettings(
invoiceSettings as unknown as Record<string, unknown>,
{ ...companyStore.selectedCompanySettings }
)
const sendAsAttachmentField = computed<boolean>({
get: () => invoiceSettings.invoice_email_attachment === 'YES',
set: async (newValue: boolean) => {
const value = newValue ? 'YES' : 'NO'
const data = {
settings: {
invoice_email_attachment: value,
},
}
invoiceSettings.invoice_email_attachment = value
await companyStore.updateCompanySettings({
data,
message: 'general.setting_updated',
})
},
})
</script>
<template>
<NumberCustomizer type="invoice" :type-store="companyStore" />
<BaseDivider class="mt-6 mb-2" />
<ul class="divide-y divide-line-default">
<BaseSwitchSection
v-model="sendAsAttachmentField"
:title="$t('settings.customization.invoices.invoice_email_attachment')"
:description="
$t(
'settings.customization.invoices.invoice_email_attachment_setting_description'
)
"
/>
</ul>
</template>

View File

@@ -0,0 +1,165 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, minLength, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '@v2/stores/modal.store'
import { itemService } from '@v2/api/services/item.service'
interface ItemUnitForm {
id: number | null
name: string
}
const modalStore = useModalStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const isEdit = ref<boolean>(false)
const currentItemUnit = ref<ItemUnitForm>({
id: null,
name: '',
})
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'ItemUnitModal'
)
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 2 }),
minLength(2)
),
},
}))
const v$ = useVuelidate(rules, currentItemUnit)
async function setInitialData(): Promise<void> {
if (modalStore.data && typeof modalStore.data === 'number') {
isEdit.value = true
const response = await itemService.getUnit(modalStore.data)
if (response.data) {
currentItemUnit.value = {
id: response.data.id,
name: response.data.name,
}
}
} else {
isEdit.value = false
resetForm()
}
}
async function submitItemUnit(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
try {
if (isEdit.value && currentItemUnit.value.id) {
await itemService.updateUnit(currentItemUnit.value.id, {
name: currentItemUnit.value.name,
})
} else {
await itemService.createUnit({
name: currentItemUnit.value.name,
})
}
if (modalStore.refreshData) {
modalStore.refreshData()
}
closeItemUnitModal()
} catch {
// handled
} finally {
isSaving.value = false
}
}
function resetForm(): void {
currentItemUnit.value = { id: null, name: '' }
}
function closeItemUnitModal(): void {
modalStore.closeModal()
setTimeout(() => {
resetForm()
isEdit.value = false
v$.value.$reset()
}, 300)
}
</script>
<template>
<BaseModal
:show="modalActive"
@close="closeItemUnitModal"
@open="setInitialData"
>
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-muted cursor-pointer"
@click="closeItemUnitModal"
/>
</div>
</template>
<form action="" @submit.prevent="submitItemUnit">
<div class="p-8 sm:p-6">
<BaseInputGroup
:label="$t('settings.customization.items.unit_name')"
:error="v$.name.$error && v$.name.$errors[0].$message"
variant="horizontal"
required
>
<BaseInput
v-model="currentItemUnit.name"
:invalid="v$.name.$error"
type="text"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
</div>
<div
class="z-0 flex justify-end p-4 border-t border-line-default border-solid"
>
<BaseButton
type="button"
variant="primary-outline"
class="mr-3 text-sm"
@click="closeItemUnitModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{ isEdit ? $t('general.update') : $t('general.save') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>

View File

@@ -0,0 +1,157 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModalStore } from '@v2/stores/modal.store'
import { useDialogStore } from '@v2/stores/dialog.store'
import { itemService } from '@v2/api/services/item.service'
import ItemUnitModal from './ItemUnitModal.vue'
interface TableColumn {
key: string
label?: string
thClass?: string
tdClass?: string
sortable?: boolean
}
interface FetchParams {
page: number
filter: Record<string, unknown>
sort: { fieldName: string; order: string }
}
interface FetchResult {
data: unknown[]
pagination: {
totalPages: number
currentPage: number
totalCount: number
limit: number
}
}
interface RowData {
data: {
id: number
name: string
}
}
const { t } = useI18n()
const table = ref<{ refresh: () => void } | null>(null)
const modalStore = useModalStore()
const dialogStore = useDialogStore()
const columns = computed<TableColumn[]>(() => [
{
key: 'name',
label: t('settings.customization.items.unit_name'),
thClass: 'extra',
tdClass: 'font-medium text-heading',
},
{
key: 'actions',
label: '',
tdClass: 'text-right text-sm font-medium',
sortable: false,
},
])
async function fetchData({ page, sort }: FetchParams): Promise<FetchResult> {
const data = {
orderByField: sort.fieldName || 'created_at',
orderBy: sort.order || 'desc',
page,
}
const response = await itemService.listUnits(data)
return {
data: (response as Record<string, unknown>).data as unknown[],
pagination: {
totalPages: ((response as Record<string, unknown>).meta as Record<string, number>).last_page,
currentPage: page,
totalCount: ((response as Record<string, unknown>).meta as Record<string, number>).total,
limit: 5,
},
}
}
function addItemUnit(): void {
modalStore.openModal({
title: t('settings.customization.items.add_item_unit'),
componentName: 'ItemUnitModal',
refreshData: table.value?.refresh,
size: 'sm',
})
}
function editItemUnit(row: RowData): void {
modalStore.openModal({
title: t('settings.customization.items.edit_item_unit'),
componentName: 'ItemUnitModal',
data: row.data.id,
refreshData: table.value?.refresh,
})
}
function removeItemUnit(row: RowData): void {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('settings.customization.items.item_unit_confirm_delete'),
yesLabel: t('general.yes'),
noLabel: t('general.no'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res: boolean) => {
if (res) {
await itemService.deleteUnit(row.data.id)
table.value?.refresh()
}
})
}
</script>
<template>
<ItemUnitModal />
<div class="flex flex-wrap justify-end mt-2 lg:flex-nowrap">
<BaseButton variant="primary-outline" @click="addItemUnit">
<template #left="slotProps">
<BaseIcon :class="slotProps.class" name="PlusIcon" />
</template>
{{ $t('settings.customization.items.add_item_unit') }}
</BaseButton>
</div>
<BaseTable ref="table" class="mt-10" :data="fetchData" :columns="columns">
<template #cell-actions="{ row }">
<BaseDropdown>
<template #activator>
<div class="inline-block">
<BaseIcon name="EllipsisHorizontalIcon" class="text-muted" />
</div>
</template>
<BaseDropdownItem @click="editItemUnit(row)">
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
<BaseDropdownItem @click="removeItemUnit(row)">
<BaseIcon
name="TrashIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>
</BaseTable>
</template>

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import { reactive, ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, email, maxLength, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '@v2/stores/modal.store'
import { mailService } from '@v2/api/services/mail.service'
import { companyService } from '@v2/api/services/company.service'
interface MailTestForm {
to: string
subject: string
message: string
}
const props = withDefaults(
defineProps<{
storeType?: string
}>(),
{
storeType: 'global',
}
)
const modalStore = useModalStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const formData = reactive<MailTestForm>({
to: '',
subject: '',
message: '',
})
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'MailTestModal'
)
const rules = {
to: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
subject: {
required: helpers.withMessage(t('validation.required'), required),
maxLength: helpers.withMessage(
t('validation.subject_maxlength'),
maxLength(100)
),
},
message: {
required: helpers.withMessage(t('validation.required'), required),
maxLength: helpers.withMessage(
t('validation.message_maxlength'),
maxLength(255)
),
},
}
const v$ = useVuelidate({ formData: rules }, { formData })
function resetFormData(): void {
formData.to = ''
formData.subject = ''
formData.message = ''
v$.value.$reset()
}
async function onTestMailSend(): Promise<void> {
v$.value.formData.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
try {
await mailService.testEmail({ to: formData.to })
closeTestModal()
} finally {
isSaving.value = false
}
}
function closeTestModal(): void {
modalStore.closeModal()
setTimeout(() => {
resetFormData()
}, 300)
}
</script>
<template>
<BaseModal :show="modalActive" @close="closeTestModal">
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-muted cursor-pointer"
@click="closeTestModal"
/>
</div>
</template>
<form action="" @submit.prevent="onTestMailSend">
<div class="p-4 md:p-8">
<BaseInputGrid layout="one-column">
<BaseInputGroup
:label="$t('general.to')"
:error="
v$.formData.to.$error && v$.formData.to.$errors[0].$message
"
variant="horizontal"
required
>
<BaseInput
v-model="formData.to"
type="text"
:invalid="v$.formData.to.$error"
@input="v$.formData.to.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('general.subject')"
:error="
v$.formData.subject.$error &&
v$.formData.subject.$errors[0].$message
"
variant="horizontal"
required
>
<BaseInput
v-model="formData.subject"
type="text"
:invalid="v$.formData.subject.$error"
@input="v$.formData.subject.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('general.message')"
:error="
v$.formData.message.$error &&
v$.formData.message.$errors[0].$message
"
variant="horizontal"
required
>
<BaseTextarea
v-model="formData.message"
rows="4"
cols="50"
:invalid="v$.formData.message.$error"
@input="v$.formData.message.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
</div>
<div
class="z-0 flex justify-end p-4 border-t border-line-default border-solid"
>
<BaseButton
variant="primary-outline"
type="button"
class="mr-3"
@click="closeTestModal()"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton :loading="isSaving" variant="primary" type="submit">
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="PaperAirplaneIcon"
:class="slotProps.class"
/>
</template>
{{ $t('general.send') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>

View File

@@ -0,0 +1,241 @@
<script setup lang="ts">
import { onMounted, ref, computed, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, email, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
interface MailgunConfig {
mail_driver: string
mail_mailgun_domain: string
mail_mailgun_secret: string
mail_mailgun_endpoint: string
from_mail: string
from_name: string
}
const props = withDefaults(
defineProps<{
configData?: Record<string, unknown>
isSaving?: boolean
isFetchingInitialData?: boolean
mailDrivers?: string[]
}>(),
{
configData: () => ({}),
isSaving: false,
isFetchingInitialData: false,
mailDrivers: () => [],
}
)
const emit = defineEmits<{
'submit-data': [config: MailgunConfig]
'on-change-driver': [driver: string]
}>()
const { t } = useI18n()
const isShowPassword = ref<boolean>(false)
const mailgunConfig = reactive<MailgunConfig>({
mail_driver: 'mailgun',
mail_mailgun_domain: '',
mail_mailgun_secret: '',
mail_mailgun_endpoint: '',
from_mail: '',
from_name: '',
})
const getInputType = computed<string>(() =>
isShowPassword.value ? 'text' : 'password'
)
const rules = computed(() => ({
mail_driver: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_mailgun_domain: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_mailgun_endpoint: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_mailgun_secret: {
required: helpers.withMessage(t('validation.required'), required),
},
from_mail: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
from_name: {
required: helpers.withMessage(t('validation.required'), required),
},
}))
const v$ = useVuelidate(rules, mailgunConfig)
onMounted(() => {
for (const key in mailgunConfig) {
if (Object.prototype.hasOwnProperty.call(props.configData, key)) {
;(mailgunConfig as Record<string, unknown>)[key] = props.configData[key]
}
}
})
async function saveEmailConfig(): Promise<void> {
v$.value.$touch()
if (!v$.value.$invalid) {
emit('submit-data', { ...mailgunConfig })
}
}
function onChangeDriver(): void {
v$.value.mail_driver.$touch()
emit('on-change-driver', mailgunConfig.mail_driver)
}
</script>
<template>
<form @submit.prevent="saveEmailConfig">
<BaseInputGrid>
<BaseInputGroup
:label="$t('settings.mail.driver')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_driver.$error && v$.mail_driver.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="mailgunConfig.mail_driver"
:content-loading="isFetchingInitialData"
:options="mailDrivers"
:can-deselect="false"
:invalid="v$.mail_driver.$error"
@update:model-value="onChangeDriver"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.mailgun_domain')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_mailgun_domain.$error &&
v$.mail_mailgun_domain.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailgunConfig.mail_mailgun_domain"
:content-loading="isFetchingInitialData"
type="text"
name="mailgun_domain"
:invalid="v$.mail_mailgun_domain.$error"
@input="v$.mail_mailgun_domain.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.mailgun_secret')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_mailgun_secret.$error &&
v$.mail_mailgun_secret.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailgunConfig.mail_mailgun_secret"
:content-loading="isFetchingInitialData"
:type="getInputType"
name="mailgun_secret"
autocomplete="off"
:invalid="v$.mail_mailgun_secret.$error"
@input="v$.mail_mailgun_secret.$touch()"
>
<template #right>
<BaseIcon
:name="isShowPassword ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted cursor-pointer"
@click="isShowPassword = !isShowPassword"
/>
</template>
</BaseInput>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.mailgun_endpoint')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_mailgun_endpoint.$error &&
v$.mail_mailgun_endpoint.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailgunConfig.mail_mailgun_endpoint"
:content-loading="isFetchingInitialData"
type="text"
name="mailgun_endpoint"
:invalid="v$.mail_mailgun_endpoint.$error"
@input="v$.mail_mailgun_endpoint.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_mail')"
:content-loading="isFetchingInitialData"
:error="
v$.from_mail.$error && v$.from_mail.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailgunConfig.from_mail"
:content-loading="isFetchingInitialData"
type="text"
name="from_mail"
:invalid="v$.from_mail.$error"
@input="v$.from_mail.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_name')"
:content-loading="isFetchingInitialData"
:error="
v$.from_name.$error && v$.from_name.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="mailgunConfig.from_name"
:content-loading="isFetchingInitialData"
type="text"
name="from_name"
:invalid="v$.from_name.$error"
@input="v$.from_name.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
<div class="flex my-10">
<BaseButton
:disabled="isSaving"
:content-loading="isFetchingInitialData"
:loading="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{ $t('general.save') }}
</BaseButton>
<slot />
</div>
</form>
</template>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useDialogStore } from '@v2/stores/dialog.store'
import { useNotificationStore } from '@v2/stores/notification.store'
import { useUserStore } from '@v2/stores/user.store'
import { useModalStore } from '@v2/stores/modal.store'
import { noteService } from '@v2/api/services/note.service'
const ABILITIES = {
MANAGE_NOTE: 'manage-note',
} as const
interface NoteRow {
id: number
name: string
[key: string]: unknown
}
const props = defineProps<{
row: NoteRow
table?: { refresh: () => void } | null
loadData?: (() => void) | null
}>()
const dialogStore = useDialogStore()
const notificationStore = useNotificationStore()
const { t } = useI18n()
const route = useRoute()
const userStore = useUserStore()
const modalStore = useModalStore()
function editNote(id: number): void {
modalStore.openModal({
title: t('settings.customization.notes.edit_note'),
componentName: 'NoteModal',
size: 'md',
data: id,
refreshData: props.loadData ?? undefined,
})
}
function removeNote(id: number): void {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('settings.customization.notes.note_confirm_delete'),
yesLabel: t('general.yes'),
noLabel: t('general.no'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async () => {
const response = await noteService.delete(id)
if (response.success) {
notificationStore.showNotification({
type: 'success',
message: t('settings.customization.notes.deleted_message'),
})
} else {
notificationStore.showNotification({
type: 'error',
message: t('settings.customization.notes.already_in_use'),
})
}
props.loadData?.()
})
}
</script>
<template>
<BaseDropdown>
<template #activator>
<BaseButton v-if="route.name === 'notes.view'" variant="primary">
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />
</BaseButton>
<BaseIcon v-else name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.MANAGE_NOTE)"
@click="editNote(row.id)"
>
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.MANAGE_NOTE)"
@click="removeNote(row.id)"
>
<BaseIcon
name="TrashIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>

View File

@@ -0,0 +1,287 @@
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { required, minLength, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '@v2/stores/modal.store'
import { useNotificationStore } from '@v2/stores/notification.store'
import { noteService } from '@v2/api/services/note.service'
import type { CreateNotePayload } from '@v2/api/services/note.service'
interface NoteForm {
id: number | null
name: string
type: string
notes: string
is_default: boolean
}
const modalStore = useModalStore()
const notificationStore = useNotificationStore()
const route = useRoute()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const isEdit = ref<boolean>(false)
const currentNote = ref<NoteForm>({
id: null,
name: '',
type: 'Invoice',
notes: '',
is_default: false,
})
const types = reactive([
{ label: t('settings.customization.notes.types.invoice'), value: 'Invoice' },
{ label: t('settings.customization.notes.types.estimate'), value: 'Estimate' },
{ label: t('settings.customization.notes.types.payment'), value: 'Payment' },
])
const fields = ref<string[]>(['customer', 'customerCustom'])
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'NoteModal'
)
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3)
),
},
notes: {
required: helpers.withMessage(t('validation.required'), required),
},
type: {
required: helpers.withMessage(t('validation.required'), required),
},
}))
const v$ = useVuelidate(rules, currentNote)
watch(
() => currentNote.value.type,
() => {
setFields()
}
)
onMounted(() => {
if (route.name === 'estimates.create') {
currentNote.value.type = 'Estimate'
} else if (
route.name === 'invoices.create' ||
route.name === 'recurring-invoices.create'
) {
currentNote.value.type = 'Invoice'
} else {
currentNote.value.type = 'Payment'
}
})
function setFields(): void {
fields.value = ['customer', 'customerCustom']
if (currentNote.value.type === 'Invoice') {
fields.value.push('invoice', 'invoiceCustom')
}
if (currentNote.value.type === 'Estimate') {
fields.value.push('estimate', 'estimateCustom')
}
if (currentNote.value.type === 'Payment') {
fields.value.push('payment', 'paymentCustom')
}
}
async function setInitialData(): Promise<void> {
if (modalStore.data && typeof modalStore.data === 'number') {
isEdit.value = true
const response = await noteService.get(modalStore.data)
if (response.data) {
const note = response.data
currentNote.value = {
id: note.id,
name: note.name,
type: note.type,
notes: note.notes,
is_default: note.is_default ?? false,
}
}
} else {
isEdit.value = false
resetForm()
}
setFields()
}
async function submitNote(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
const payload: CreateNotePayload = {
name: currentNote.value.name,
type: currentNote.value.type,
notes: currentNote.value.notes,
is_default: currentNote.value.is_default,
}
try {
if (isEdit.value && currentNote.value.id) {
const res = await noteService.update(currentNote.value.id, payload)
if (res.data) {
notificationStore.showNotification({
type: 'success',
message: t('settings.customization.notes.note_updated'),
})
}
} else {
const res = await noteService.create(payload)
if (res) {
notificationStore.showNotification({
type: 'success',
message: t('settings.customization.notes.note_added'),
})
}
}
isSaving.value = false
if (modalStore.refreshData) {
modalStore.refreshData()
}
closeNoteModal()
} catch {
isSaving.value = false
}
}
function resetForm(): void {
currentNote.value = {
id: null,
name: '',
type: 'Invoice',
notes: '',
is_default: false,
}
}
function closeNoteModal(): void {
modalStore.closeModal()
setTimeout(() => {
resetForm()
v$.value.$reset()
}, 300)
}
</script>
<template>
<BaseModal
:show="modalActive"
@close="closeNoteModal"
@open="setInitialData"
>
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="h-6 w-6 text-muted cursor-pointer"
@click="closeNoteModal"
/>
</div>
</template>
<form action="" @submit.prevent="submitNote">
<div class="px-8 py-8 sm:p-6">
<BaseInputGrid layout="one-column">
<BaseInputGroup
:label="$t('settings.customization.notes.name')"
variant="vertical"
:error="v$.name.$error && v$.name.$errors[0].$message"
required
>
<BaseInput
v-model="currentNote.name"
:invalid="v$.name.$error"
type="text"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.customization.notes.type')"
:error="v$.type.$error && v$.type.$errors[0].$message"
required
>
<BaseMultiselect
v-model="currentNote.type"
:options="types"
value-prop="value"
class="mt-2"
/>
</BaseInputGroup>
<BaseSwitchSection
v-model="currentNote.is_default"
:title="$t('settings.customization.notes.is_default')"
:description="
$t('settings.customization.notes.is_default_description')
"
/>
<BaseInputGroup
:label="$t('settings.customization.notes.notes')"
:error="v$.notes.$error && v$.notes.$errors[0].$message"
required
>
<BaseCustomInput
v-model="currentNote.notes"
:invalid="v$.notes.$error"
:fields="fields"
@input="v$.notes.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
</div>
<div
class="z-0 flex justify-end px-4 py-4 border-t border-solid border-line-default"
>
<BaseButton
class="mr-2"
variant="primary-outline"
type="button"
@click="closeNoteModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
</template>
{{ isEdit ? $t('general.update') : $t('general.save') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>
<style>
.note-modal {
.header-editior .editor-menu-bar {
margin-left: 0.5px;
margin-right: 0px;
}
}
</style>

View File

@@ -6,7 +6,7 @@ import draggable from 'vuedraggable'
import Guid from 'guid'
import { useCompanyStore } from '../../../../stores/company.store'
import { useGlobalStore } from '../../../../stores/global.store'
import DragIcon from '@/scripts/components/icons/DragIcon.vue'
import DragIcon from '@v2/components/icons/DragIcon.vue'
interface NumberField {
id: string

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useDialogStore } from '@v2/stores/dialog.store'
import { useModalStore } from '@v2/stores/modal.store'
import { paymentService } from '@v2/api/services/payment.service'
interface PaymentModeRow {
id: number
name: string
[key: string]: unknown
}
const props = defineProps<{
row: PaymentModeRow
table?: { refresh: () => void } | null
loadData?: (() => void) | null
}>()
const dialogStore = useDialogStore()
const { t } = useI18n()
const route = useRoute()
const modalStore = useModalStore()
function editPaymentMode(id: number): void {
modalStore.openModal({
title: t('settings.payment_modes.edit_payment_mode'),
componentName: 'PaymentModeModal',
data: id,
refreshData: props.loadData ?? undefined,
size: 'sm',
})
}
function removePaymentMode(id: number): void {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('settings.payment_modes.payment_mode_confirm_delete'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res: boolean) => {
if (res) {
await paymentService.deleteMethod(id)
props.loadData?.()
}
})
}
</script>
<template>
<BaseDropdown>
<template #activator>
<BaseButton v-if="route.name === 'paymentModes.view'" variant="primary">
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />
</BaseButton>
<BaseIcon v-else name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<BaseDropdownItem @click="editPaymentMode(row.id)">
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
<BaseDropdownItem @click="removePaymentMode(row.id)">
<BaseIcon
name="TrashIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>

View File

@@ -0,0 +1,160 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, minLength, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '@v2/stores/modal.store'
import { paymentService } from '@v2/api/services/payment.service'
interface PaymentModeForm {
id: number | null
name: string
}
const modalStore = useModalStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const currentPaymentMode = ref<PaymentModeForm>({
id: null,
name: '',
})
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'PaymentModeModal'
)
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3)
),
},
}))
const v$ = useVuelidate(rules, currentPaymentMode)
async function setInitialData(): Promise<void> {
if (modalStore.data && typeof modalStore.data === 'number') {
const response = await paymentService.getMethod(modalStore.data)
if (response.data) {
currentPaymentMode.value = {
id: response.data.id,
name: response.data.name,
}
}
} else {
resetForm()
}
}
async function submitPaymentMode(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
try {
if (currentPaymentMode.value.id) {
await paymentService.updateMethod(currentPaymentMode.value.id, {
name: currentPaymentMode.value.name,
})
} else {
await paymentService.createMethod({
name: currentPaymentMode.value.name,
})
}
isSaving.value = false
if (modalStore.refreshData) {
modalStore.refreshData()
}
closePaymentModeModal()
} catch {
isSaving.value = false
}
}
function resetForm(): void {
currentPaymentMode.value = {
id: null,
name: '',
}
}
function closePaymentModeModal(): void {
modalStore.closeModal()
setTimeout(() => {
resetForm()
v$.value.$reset()
}, 300)
}
</script>
<template>
<BaseModal
:show="modalActive"
@close="closePaymentModeModal"
@open="setInitialData"
>
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-muted cursor-pointer"
@click="closePaymentModeModal"
/>
</div>
</template>
<form action="" @submit.prevent="submitPaymentMode">
<div class="p-4 sm:p-6">
<BaseInputGroup
:label="$t('settings.payment_modes.mode_name')"
:error="v$.name.$error && v$.name.$errors[0].$message"
required
>
<BaseInput
v-model="currentPaymentMode.name"
:invalid="v$.name.$error"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
</div>
<div
class="z-0 flex justify-end p-4 border-t border-line-default border-solid"
>
<BaseButton
variant="primary-outline"
class="mr-3"
type="button"
@click="closePaymentModeModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
</template>
{{
currentPaymentMode.id
? $t('general.update')
: $t('general.save')
}}
</BaseButton>
</div>
</form>
</BaseModal>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed, reactive, inject } from 'vue'
import { useCompanyStore } from '@v2/stores/company.store'
import NumberCustomizer from './NumberCustomizer.vue'
interface Utils {
mergeSettings: (target: Record<string, unknown>, source: Record<string, unknown>) => void
}
const utils = inject<Utils>('utils')!
const companyStore = useCompanyStore()
const paymentSettings = reactive<{ payment_email_attachment: string | null }>({
payment_email_attachment: null,
})
utils.mergeSettings(
paymentSettings as unknown as Record<string, unknown>,
{ ...companyStore.selectedCompanySettings }
)
const sendAsAttachmentField = computed<boolean>({
get: () => paymentSettings.payment_email_attachment === 'YES',
set: async (newValue: boolean) => {
const value = newValue ? 'YES' : 'NO'
const data = {
settings: {
payment_email_attachment: value,
},
}
paymentSettings.payment_email_attachment = value
await companyStore.updateCompanySettings({
data,
message: 'general.setting_updated',
})
},
})
</script>
<template>
<NumberCustomizer type="payment" :type-store="companyStore" />
<BaseDivider class="mt-6 mb-2" />
<ul class="divide-y divide-line-default">
<BaseSwitchSection
v-model="sendAsAttachmentField"
:title="$t('settings.customization.payments.payment_email_attachment')"
:description="
$t(
'settings.customization.payments.payment_email_attachment_setting_description'
)
"
/>
</ul>
</template>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useDialogStore } from '@v2/stores/dialog.store'
import { useUserStore } from '@v2/stores/user.store'
import { useModalStore } from '@v2/stores/modal.store'
import { roleService } from '@v2/api/services/role.service'
interface RoleRow {
id: number
name: string
[key: string]: unknown
}
const props = defineProps<{
row: RoleRow
table?: { refresh: () => void } | null
loadData?: (() => void) | null
}>()
const dialogStore = useDialogStore()
const { t } = useI18n()
const route = useRoute()
const userStore = useUserStore()
const modalStore = useModalStore()
async function editRole(id: number): Promise<void> {
modalStore.openModal({
title: t('settings.roles.edit_role'),
componentName: 'RolesModal',
size: 'lg',
data: id,
refreshData: props.loadData ?? undefined,
})
}
async function removeRole(id: number): Promise<void> {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('settings.roles.confirm_delete'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res: boolean) => {
if (res) {
await roleService.delete(id)
props.loadData?.()
}
})
}
</script>
<template>
<BaseDropdown>
<template #activator>
<BaseButton v-if="route.name === 'roles.view'" variant="primary">
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />
</BaseButton>
<BaseIcon v-else name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<BaseDropdownItem
v-if="userStore.currentUser?.is_owner"
@click="editRole(row.id)"
>
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
<BaseDropdownItem
v-if="userStore.currentUser?.is_owner"
@click="removeRole(row.id)"
>
<BaseIcon
name="TrashIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>

View File

@@ -0,0 +1,347 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, minLength, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '@v2/stores/modal.store'
import { roleService } from '@v2/api/services/role.service'
import type { CreateRolePayload } from '@v2/api/services/role.service'
import type { Ability } from '@v2/types/domain/role'
interface AbilityItem extends Ability {
ability: string
disabled: boolean
depends_on?: string[]
}
interface AbilitiesList {
[group: string]: AbilityItem[]
}
interface RoleForm {
id: number | null
name: string
abilities: AbilityItem[]
}
const modalStore = useModalStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const isFetchingInitialData = ref<boolean>(false)
const isEdit = ref<boolean>(false)
const currentRole = ref<RoleForm>({
id: null,
name: '',
abilities: [],
})
const abilitiesList = ref<AbilitiesList>({})
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'RolesModal'
)
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3)
),
},
abilities: {
required: helpers.withMessage(
t('validation.at_least_one_ability'),
required
),
},
}))
const v$ = useVuelidate(rules, currentRole)
async function setInitialData(): Promise<void> {
isFetchingInitialData.value = true
const abilitiesRes = await roleService.getAbilities()
if (abilitiesRes.abilities) {
const grouped: AbilitiesList = {}
abilitiesRes.abilities.forEach((a) => {
const group = a.title || 'General'
if (!grouped[group]) grouped[group] = []
grouped[group].push({
...a,
ability: a.name,
disabled: false,
depends_on: [],
})
})
abilitiesList.value = grouped
}
if (modalStore.data && typeof modalStore.data === 'number') {
isEdit.value = true
const response = await roleService.get(modalStore.data)
if (response.data) {
currentRole.value = {
id: response.data.id,
name: response.data.name,
abilities: response.data.abilities.map((a) => ({
...a,
ability: a.name,
disabled: false,
depends_on: [],
})),
}
}
} else {
isEdit.value = false
currentRole.value = { id: null, name: '', abilities: [] }
}
isFetchingInitialData.value = false
}
async function submitRoleData(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
try {
const payload: CreateRolePayload = {
name: currentRole.value.name,
abilities: currentRole.value.abilities.map((a) => ({
ability: a.ability,
})),
}
if (isEdit.value && currentRole.value.id) {
await roleService.update(currentRole.value.id, payload)
} else {
await roleService.create(payload)
}
isSaving.value = false
if (modalStore.refreshData) {
modalStore.refreshData()
}
closeRolesModal()
} catch {
isSaving.value = false
}
}
function onUpdateAbility(currentAbility: AbilityItem): void {
const fd = currentRole.value.abilities.find(
(_abl) => _abl.ability === currentAbility.ability
)
if (!fd && currentAbility.depends_on?.length) {
enableAbilities(currentAbility)
return
}
currentAbility.depends_on?.forEach((_d) => {
Object.keys(abilitiesList.value).forEach((group) => {
abilitiesList.value[group].forEach((_a) => {
if (_d === _a.ability) {
_a.disabled = true
const found = currentRole.value.abilities.find(
(_af) => _af.ability === _d
)
if (!found) {
currentRole.value.abilities.push(_a)
}
}
})
})
})
}
function setSelectAll(checked: boolean): void {
const dependList: string[] = []
Object.keys(abilitiesList.value).forEach((group) => {
abilitiesList.value[group].forEach((_a) => {
if (_a.depends_on) {
dependList.push(..._a.depends_on)
}
})
})
Object.keys(abilitiesList.value).forEach((group) => {
abilitiesList.value[group].forEach((_a) => {
if (dependList.includes(_a.ability)) {
_a.disabled = checked
}
currentRole.value.abilities.push(_a)
})
})
if (!checked) {
currentRole.value.abilities = []
}
}
function enableAbilities(ability: AbilityItem): void {
ability.depends_on?.forEach((_d) => {
Object.keys(abilitiesList.value).forEach((group) => {
abilitiesList.value[group].forEach((_a) => {
const found = currentRole.value.abilities.find((_r) =>
_r.depends_on?.includes(_a.ability)
)
if (_d === _a.ability && !found) {
_a.disabled = false
}
})
})
})
}
function closeRolesModal(): void {
modalStore.closeModal()
setTimeout(() => {
currentRole.value = { id: null, name: '', abilities: [] }
isEdit.value = false
Object.keys(abilitiesList.value).forEach((group) => {
abilitiesList.value[group].forEach((_a) => {
_a.disabled = false
})
})
v$.value.$reset()
}, 300)
}
</script>
<template>
<BaseModal
:show="modalActive"
@close="closeRolesModal"
@open="setInitialData"
>
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-muted cursor-pointer"
@click="closeRolesModal"
/>
</div>
</template>
<form @submit.prevent="submitRoleData">
<div class="px-4 md:px-8 py-4 md:py-6">
<BaseInputGroup
:label="$t('settings.roles.name')"
class="mt-3"
:error="v$.name.$error && v$.name.$errors[0].$message"
required
:content-loading="isFetchingInitialData"
>
<BaseInput
v-model="currentRole.name"
:invalid="v$.name.$error"
type="text"
:content-loading="isFetchingInitialData"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
</div>
<div class="flex justify-between">
<h6
class="text-sm not-italic font-medium text-heading px-4 md:px-8 py-1.5"
>
{{ $t('settings.roles.permission', 2) }}
<span class="text-sm text-red-500"> *</span>
</h6>
<div
class="text-sm not-italic font-medium text-subtle px-4 md:px-8 py-1.5"
>
<a
class="cursor-pointer text-primary-400"
@click="setSelectAll(true)"
>
{{ $t('settings.roles.select_all') }}
</a>
/
<a
class="cursor-pointer text-primary-400"
@click="setSelectAll(false)"
>
{{ $t('settings.roles.none') }}
</a>
</div>
</div>
<div class="border-t border-line-default py-3">
<div
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 px-8 sm:px-8"
>
<div
v-for="(abilityGroup, gIndex) in abilitiesList"
:key="gIndex"
class="flex flex-col space-y-1"
>
<p
class="text-sm text-muted border-b border-line-default pb-1 mb-2"
>
{{ gIndex }}
</p>
<div
v-for="(ability, index) in abilityGroup"
:key="index"
class="flex"
>
<BaseCheckbox
v-model="currentRole.abilities"
:set-initial-value="true"
variant="primary"
:disabled="ability.disabled"
:label="ability.name"
:value="ability"
@update:model-value="onUpdateAbility(ability)"
/>
</div>
</div>
<span
v-if="v$.abilities.$error"
class="block mt-0.5 text-sm text-red-500"
>
{{ v$.abilities.$errors[0].$message }}
</span>
</div>
</div>
<div
class="z-0 flex justify-end p-4 border-t border-solid border-line-default"
>
<BaseButton
class="mr-3 text-sm"
variant="primary-outline"
type="button"
@click="closeRolesModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
</template>
{{ isEdit ? $t('general.update') : $t('general.save') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>

View File

@@ -0,0 +1,313 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, email, numeric, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
interface SesConfig {
mail_driver: string
mail_host: string
mail_port: string
mail_encryption: string
mail_ses_key: string
mail_ses_secret: string
mail_ses_region: string
from_mail: string
from_name: string
}
const props = withDefaults(
defineProps<{
configData?: Record<string, unknown>
isSaving?: boolean
isFetchingInitialData?: boolean
mailDrivers?: string[]
}>(),
{
configData: () => ({}),
isSaving: false,
isFetchingInitialData: false,
mailDrivers: () => [],
}
)
const emit = defineEmits<{
'submit-data': [config: SesConfig]
'on-change-driver': [driver: string]
}>()
const { t } = useI18n()
const isShowPassword = ref<boolean>(false)
const encryptions = reactive<string[]>(['tls', 'ssl', 'starttls'])
const sesConfig = reactive<SesConfig>({
mail_driver: 'ses',
mail_host: '',
mail_port: '',
mail_encryption: '',
mail_ses_key: '',
mail_ses_secret: '',
mail_ses_region: '',
from_mail: '',
from_name: '',
})
const getInputType = computed<string>(() =>
isShowPassword.value ? 'text' : 'password'
)
const rules = computed(() => ({
mail_driver: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_host: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_port: {
required: helpers.withMessage(t('validation.required'), required),
numeric: helpers.withMessage(t('validation.numbers_only'), numeric),
},
mail_ses_key: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_ses_secret: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_ses_region: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_encryption: {
required: helpers.withMessage(t('validation.required'), required),
},
from_mail: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
from_name: {
required: helpers.withMessage(t('validation.required'), required),
},
}))
const v$ = useVuelidate(rules, sesConfig)
onMounted(() => {
for (const key in sesConfig) {
if (Object.prototype.hasOwnProperty.call(props.configData, key)) {
;(sesConfig as Record<string, unknown>)[key] = props.configData[key]
}
}
})
async function saveEmailConfig(): Promise<void> {
v$.value.$touch()
if (!v$.value.$invalid) {
emit('submit-data', { ...sesConfig })
}
}
function onChangeDriver(): void {
v$.value.mail_driver.$touch()
emit('on-change-driver', sesConfig.mail_driver)
}
</script>
<template>
<form @submit.prevent="saveEmailConfig">
<BaseInputGrid>
<BaseInputGroup
:label="$t('settings.mail.driver')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_driver.$error && v$.mail_driver.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="sesConfig.mail_driver"
:content-loading="isFetchingInitialData"
:options="mailDrivers"
:can-deselect="false"
:invalid="v$.mail_driver.$error"
@update:model-value="onChangeDriver"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.host')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_host.$error && v$.mail_host.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="sesConfig.mail_host"
:content-loading="isFetchingInitialData"
type="text"
name="mail_host"
:invalid="v$.mail_host.$error"
@input="v$.mail_host.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.port')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_port.$error && v$.mail_port.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="sesConfig.mail_port"
:content-loading="isFetchingInitialData"
type="text"
name="mail_port"
:invalid="v$.mail_port.$error"
@input="v$.mail_port.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.encryption')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_encryption.$error &&
v$.mail_encryption.$errors[0].$message
"
required
>
<BaseMultiselect
v-model.trim="sesConfig.mail_encryption"
:content-loading="isFetchingInitialData"
:options="encryptions"
:invalid="v$.mail_encryption.$error"
placeholder="Select option"
@input="v$.mail_encryption.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_mail')"
:content-loading="isFetchingInitialData"
:error="
v$.from_mail.$error && v$.from_mail.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="sesConfig.from_mail"
:content-loading="isFetchingInitialData"
type="text"
name="from_mail"
:invalid="v$.from_mail.$error"
@input="v$.from_mail.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_name')"
:content-loading="isFetchingInitialData"
:error="
v$.from_name.$error && v$.from_name.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="sesConfig.from_name"
:content-loading="isFetchingInitialData"
type="text"
name="name"
:invalid="v$.from_name.$error"
@input="v$.from_name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.ses_key')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_ses_key.$error && v$.mail_ses_key.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="sesConfig.mail_ses_key"
:content-loading="isFetchingInitialData"
type="text"
name="mail_ses_key"
:invalid="v$.mail_ses_key.$error"
@input="v$.mail_ses_key.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.ses_secret')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_ses_secret.$error &&
v$.mail_ses_secret.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="sesConfig.mail_ses_secret"
:content-loading="isFetchingInitialData"
:type="getInputType"
name="mail_ses_secret"
autocomplete="off"
:invalid="v$.mail_ses_secret.$error"
@input="v$.mail_ses_secret.$touch()"
>
<template #right>
<BaseIcon
:name="isShowPassword ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted cursor-pointer"
@click="isShowPassword = !isShowPassword"
/>
</template>
</BaseInput>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.ses_region')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_ses_region.$error &&
v$.mail_ses_region.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="sesConfig.mail_ses_region"
:content-loading="isFetchingInitialData"
type="text"
name="mail_ses_region"
:invalid="v$.mail_ses_region.$error"
@input="v$.mail_ses_region.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
<div class="flex my-10">
<BaseButton
:disabled="isSaving"
:content-loading="isFetchingInitialData"
:loading="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{ $t('general.save') }}
</BaseButton>
<slot />
</div>
</form>
</template>

View File

@@ -0,0 +1,261 @@
<script setup lang="ts">
import { reactive, onMounted, ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { required, email, numeric, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
interface SmtpConfig {
mail_driver: string
mail_host: string
mail_port: string
mail_username: string
mail_password: string
mail_encryption: string
from_mail: string
from_name: string
}
const props = withDefaults(
defineProps<{
configData?: Record<string, unknown>
isSaving?: boolean
isFetchingInitialData?: boolean
mailDrivers?: string[]
}>(),
{
configData: () => ({}),
isSaving: false,
isFetchingInitialData: false,
mailDrivers: () => [],
}
)
const emit = defineEmits<{
'submit-data': [config: SmtpConfig]
'on-change-driver': [driver: string]
}>()
const { t } = useI18n()
const isShowPassword = ref<boolean>(false)
const schemes = reactive<string[]>(['smtp', 'smtps', 'none'])
const smtpConfig = reactive<SmtpConfig>({
mail_driver: 'smtp',
mail_host: '',
mail_port: '',
mail_username: '',
mail_password: '',
mail_encryption: '',
from_mail: '',
from_name: '',
})
const getInputType = computed<string>(() =>
isShowPassword.value ? 'text' : 'password'
)
const rules = computed(() => ({
mail_driver: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_host: {
required: helpers.withMessage(t('validation.required'), required),
},
mail_port: {
required: helpers.withMessage(t('validation.required'), required),
numeric: helpers.withMessage(t('validation.numbers_only'), numeric),
},
from_mail: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
from_name: {
required: helpers.withMessage(t('validation.required'), required),
},
}))
const v$ = useVuelidate(rules, smtpConfig)
onMounted(() => {
for (const key in smtpConfig) {
if (Object.prototype.hasOwnProperty.call(props.configData, key)) {
;(smtpConfig as Record<string, unknown>)[key] = props.configData[key]
}
}
})
async function saveEmailConfig(): Promise<void> {
v$.value.$touch()
if (!v$.value.$invalid) {
emit('submit-data', { ...smtpConfig })
}
}
function onChangeDriver(): void {
v$.value.mail_driver.$touch()
emit('on-change-driver', smtpConfig.mail_driver)
}
</script>
<template>
<form @submit.prevent="saveEmailConfig">
<BaseInputGrid>
<BaseInputGroup
:label="$t('settings.mail.driver')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_driver.$error && v$.mail_driver.$errors[0].$message
"
required
>
<BaseMultiselect
v-model="smtpConfig.mail_driver"
:content-loading="isFetchingInitialData"
:options="mailDrivers"
:can-deselect="false"
:invalid="v$.mail_driver.$error"
@update:model-value="onChangeDriver"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.host')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_host.$error && v$.mail_host.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="smtpConfig.mail_host"
:content-loading="isFetchingInitialData"
type="text"
name="mail_host"
:invalid="v$.mail_host.$error"
@input="v$.mail_host.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('settings.mail.username')"
>
<BaseInput
v-model.trim="smtpConfig.mail_username"
:content-loading="isFetchingInitialData"
type="text"
name="db_name"
/>
</BaseInputGroup>
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('settings.mail.password')"
>
<BaseInput
v-model.trim="smtpConfig.mail_password"
:content-loading="isFetchingInitialData"
:type="getInputType"
name="password"
>
<template #right>
<BaseIcon
:name="isShowPassword ? 'EyeIcon' : 'EyeSlashIcon'"
class="mr-1 text-muted cursor-pointer"
@click="isShowPassword = !isShowPassword"
/>
</template>
</BaseInput>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.port')"
:content-loading="isFetchingInitialData"
:error="
v$.mail_port.$error && v$.mail_port.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="smtpConfig.mail_port"
:content-loading="isFetchingInitialData"
type="text"
name="mail_port"
:invalid="v$.mail_port.$error"
@input="v$.mail_port.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.encryption')"
:content-loading="isFetchingInitialData"
>
<BaseMultiselect
v-model.trim="smtpConfig.mail_encryption"
:content-loading="isFetchingInitialData"
:options="schemes"
:searchable="true"
:show-labels="false"
placeholder="Select option"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_mail')"
:content-loading="isFetchingInitialData"
:error="
v$.from_mail.$error && v$.from_mail.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="smtpConfig.from_mail"
:content-loading="isFetchingInitialData"
type="text"
name="from_mail"
:invalid="v$.from_mail.$error"
@input="v$.from_mail.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.mail.from_name')"
:content-loading="isFetchingInitialData"
:error="
v$.from_name.$error && v$.from_name.$errors[0].$message
"
required
>
<BaseInput
v-model.trim="smtpConfig.from_name"
:content-loading="isFetchingInitialData"
type="text"
name="from_name"
:invalid="v$.from_name.$error"
@input="v$.from_name.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
<div class="flex my-10">
<BaseButton
:disabled="isSaving"
:content-loading="isFetchingInitialData"
:loading="isSaving"
type="submit"
variant="primary"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{ $t('general.save') }}
</BaseButton>
<slot />
</div>
</form>
</template>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useDialogStore } from '@v2/stores/dialog.store'
import { useUserStore } from '@v2/stores/user.store'
import { useModalStore } from '@v2/stores/modal.store'
import { taxTypeService } from '@v2/api/services/tax-type.service'
const ABILITIES = {
EDIT_TAX_TYPE: 'edit-tax-type',
DELETE_TAX_TYPE: 'delete-tax-type',
} as const
interface TaxTypeRow {
id: number
name: string
[key: string]: unknown
}
const props = defineProps<{
row: TaxTypeRow
table?: { refresh: () => void } | null
loadData?: (() => void) | null
}>()
const dialogStore = useDialogStore()
const { t } = useI18n()
const route = useRoute()
const userStore = useUserStore()
const modalStore = useModalStore()
async function editTaxType(id: number): Promise<void> {
modalStore.openModal({
title: t('settings.tax_types.edit_tax'),
componentName: 'TaxTypeModal',
size: 'sm',
data: id,
refreshData: props.loadData ?? undefined,
})
}
function removeTaxType(id: number): void {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('settings.tax_types.confirm_delete'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then(async (res: boolean) => {
if (res) {
const response = await taxTypeService.delete(id)
if (response.success) {
props.loadData?.()
}
}
})
}
</script>
<template>
<BaseDropdown>
<template #activator>
<BaseButton v-if="route.name === 'tax-types.view'" variant="primary">
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />
</BaseButton>
<BaseIcon v-else name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.EDIT_TAX_TYPE)"
@click="editTaxType(row.id)"
>
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.DELETE_TAX_TYPE)"
@click="removeTaxType(row.id)"
>
<BaseIcon
name="TrashIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.delete') }}
</BaseDropdownItem>
</BaseDropdown>
</template>

View File

@@ -0,0 +1,285 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
required,
minLength,
maxLength,
between,
helpers,
} from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '@v2/stores/modal.store'
import { useCompanyStore } from '@v2/stores/company.store'
import { taxTypeService } from '@v2/api/services/tax-type.service'
import type { CreateTaxTypePayload } from '@v2/api/services/tax-type.service'
interface TaxTypeForm {
id: number | null
name: string
calculation_type: string
percent: number
fixed_amount: number
description: string
}
const modalStore = useModalStore()
const companyStore = useCompanyStore()
const { t } = useI18n()
const isSaving = ref<boolean>(false)
const isEdit = ref<boolean>(false)
const currentTaxType = ref<TaxTypeForm>({
id: null,
name: '',
calculation_type: 'percentage',
percent: 0,
fixed_amount: 0,
description: '',
})
const defaultCurrency = computed(() => companyStore.selectedCompanyCurrency)
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'TaxTypeModal'
)
const rules = computed(() => ({
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 3 }),
minLength(3)
),
},
calculation_type: {
required: helpers.withMessage(t('validation.required'), required),
},
percent: {
required: helpers.withMessage(t('validation.required'), required),
between: helpers.withMessage(
t('validation.enter_valid_tax_rate'),
between(-100, 100)
),
},
fixed_amount: {
required: helpers.withMessage(t('validation.required'), required),
},
description: {
maxLength: helpers.withMessage(
t('validation.description_maxlength', { count: 255 }),
maxLength(255)
),
},
}))
const v$ = useVuelidate(rules, currentTaxType)
const fixedAmount = computed<number>({
get: () => currentTaxType.value.fixed_amount / 100,
set: (value: number) => {
currentTaxType.value.fixed_amount = Math.round(value * 100)
},
})
async function setInitialData(): Promise<void> {
if (modalStore.data && typeof modalStore.data === 'number') {
isEdit.value = true
const response = await taxTypeService.get(modalStore.data)
if (response.data) {
const tax = response.data
currentTaxType.value = {
id: tax.id,
name: tax.name,
calculation_type: tax.calculation_type ?? 'percentage',
percent: tax.percent,
fixed_amount: tax.fixed_amount,
description: tax.description ?? '',
}
}
} else {
isEdit.value = false
resetForm()
}
}
async function submitTaxTypeData(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
isSaving.value = true
try {
const payload: CreateTaxTypePayload = {
name: currentTaxType.value.name,
percent: currentTaxType.value.percent,
fixed_amount: currentTaxType.value.fixed_amount,
calculation_type: currentTaxType.value.calculation_type,
description: currentTaxType.value.description || null,
}
if (isEdit.value && currentTaxType.value.id) {
await taxTypeService.update(currentTaxType.value.id, payload)
} else {
await taxTypeService.create(payload)
}
isSaving.value = false
if (modalStore.refreshData) {
modalStore.refreshData()
}
closeTaxTypeModal()
} catch {
isSaving.value = false
}
}
function resetForm(): void {
currentTaxType.value = {
id: null,
name: '',
calculation_type: 'percentage',
percent: 0,
fixed_amount: 0,
description: '',
}
}
function closeTaxTypeModal(): void {
modalStore.closeModal()
setTimeout(() => {
resetForm()
isEdit.value = false
v$.value.$reset()
}, 300)
}
</script>
<template>
<BaseModal
:show="modalActive"
@close="closeTaxTypeModal"
@open="setInitialData"
>
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="h-6 w-6 text-muted cursor-pointer"
@click="closeTaxTypeModal"
/>
</div>
</template>
<form action="" @submit.prevent="submitTaxTypeData">
<div class="p-4 sm:p-6">
<BaseInputGrid layout="one-column">
<BaseInputGroup
:label="$t('tax_types.name')"
variant="horizontal"
:error="v$.name.$error && v$.name.$errors[0].$message"
required
>
<BaseInput
v-model="currentTaxType.name"
:invalid="v$.name.$error"
type="text"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('tax_types.tax_type')"
variant="horizontal"
required
>
<BaseSelectInput
v-model="currentTaxType.calculation_type"
:options="[
{ id: 'percentage', label: $t('tax_types.percentage') },
{ id: 'fixed', label: $t('tax_types.fixed_amount') },
]"
:allow-empty="false"
value-prop="id"
label-prop="label"
track-by="label"
:searchable="false"
/>
</BaseInputGroup>
<BaseInputGroup
v-if="currentTaxType.calculation_type === 'percentage'"
:label="$t('tax_types.percent')"
variant="horizontal"
required
>
<BaseMoney
v-model="currentTaxType.percent"
:currency="{
decimal: '.',
thousands: ',',
symbol: '% ',
precision: 2,
masked: false,
}"
/>
</BaseInputGroup>
<BaseInputGroup
v-else
:label="$t('tax_types.fixed_amount')"
variant="horizontal"
required
>
<BaseMoney v-model="fixedAmount" :currency="defaultCurrency" />
</BaseInputGroup>
<BaseInputGroup
:label="$t('tax_types.description')"
:error="
v$.description.$error && v$.description.$errors[0].$message
"
variant="horizontal"
>
<BaseTextarea
v-model="currentTaxType.description"
:invalid="v$.description.$error"
rows="4"
cols="50"
@input="v$.description.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
</div>
<div
class="z-0 flex justify-end p-4 border-t border-solid border-line-default"
>
<BaseButton
class="mr-3 text-sm"
variant="primary-outline"
type="button"
@click="closeTaxTypeModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isSaving"
:disabled="isSaving"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{ isEdit ? $t('general.update') : $t('general.save') }}
</BaseButton>
</div>
</form>
</BaseModal>
</template>

View File

@@ -4,8 +4,8 @@ import { useI18n } from 'vue-i18n'
import { useModalStore } from '../../../../stores/modal.store'
import { useUserStore } from '../../../../stores/user.store'
import { customFieldService } from '../../../../api/services/custom-field.service'
import CustomFieldDropdown from '@/scripts/admin/components/dropdowns/CustomFieldIndexDropdown.vue'
import CustomFieldModal from '@/scripts/admin/components/modal-components/custom-fields/CustomFieldModal.vue'
import CustomFieldDropdown from '@v2/features/company/settings/components/CustomFieldDropdown.vue'
import CustomFieldModal from '@v2/features/company/settings/components/CustomFieldModal.vue'
interface TableColumn {
key: string

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import InvoicesTab from '@/scripts/admin/views/settings/customization/invoices/InvoicesTab.vue'
import EstimatesTab from '@/scripts/admin/views/settings/customization/estimates/EstimatesTab.vue'
import PaymentsTab from '@/scripts/admin/views/settings/customization/payments/PaymentsTab.vue'
import ItemsTab from '@/scripts/admin/views/settings/customization/items/ItemsTab.vue'
import InvoicesTab from '@v2/features/company/settings/components/InvoicesTab.vue'
import EstimatesTab from '@v2/features/company/settings/components/EstimatesTab.vue'
import PaymentsTab from '@v2/features/company/settings/components/PaymentsTab.vue'
import ItemsTab from '@v2/features/company/settings/components/ItemsTab.vue'
</script>
<template>

View File

@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import { useModalStore } from '../../../../stores/modal.store'
import { useDialogStore } from '../../../../stores/dialog.store'
import { exchangeRateService } from '../../../../api/services/exchange-rate.service'
import ExchangeRateProviderModal from '@/scripts/admin/components/modal-components/ExchangeRateProviderModal.vue'
import ExchangeRateProviderModal from '@v2/features/company/settings/components/ExchangeRateProviderModal.vue'
interface TableColumn {
key: string

View File

@@ -3,8 +3,8 @@ import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModalStore } from '../../../../stores/modal.store'
import { expenseService } from '../../../../api/services/expense.service'
import ExpenseCategoryDropdown from '@/scripts/admin/components/dropdowns/ExpenseCategoryIndexDropdown.vue'
import CategoryModal from '@/scripts/admin/components/modal-components/CategoryModal.vue'
import ExpenseCategoryDropdown from '@v2/features/company/settings/components/ExpenseCategoryDropdown.vue'
import CategoryModal from '@v2/features/company/settings/components/CategoryModal.vue'
interface TableColumn {
key: string

View File

@@ -5,11 +5,11 @@ import { useModalStore } from '../../../../stores/modal.store'
import { companyService } from '../../../../api/services/company.service'
import { mailService } from '../../../../api/services/mail.service'
import type { MailDriver } from '../../../../api/services/mail.service'
import Smtp from '@/scripts/admin/views/settings/mail-driver/SmtpMailDriver.vue'
import Mailgun from '@/scripts/admin/views/settings/mail-driver/MailgunMailDriver.vue'
import Ses from '@/scripts/admin/views/settings/mail-driver/SesMailDriver.vue'
import Basic from '@/scripts/admin/views/settings/mail-driver/BasicMailDriver.vue'
import MailTestModal from '@/scripts/admin/components/modal-components/MailTestModal.vue'
import Smtp from '@v2/features/company/settings/components/SmtpMailDriver.vue'
import Mailgun from '@v2/features/company/settings/components/MailgunMailDriver.vue'
import Ses from '@v2/features/company/settings/components/SesMailDriver.vue'
import Basic from '@v2/features/company/settings/components/BasicMailDriver.vue'
import MailTestModal from '@v2/features/company/settings/components/MailTestModal.vue'
const { t } = useI18n()
const modalStore = useModalStore()

View File

@@ -4,8 +4,8 @@ import { useI18n } from 'vue-i18n'
import { useModalStore } from '../../../../stores/modal.store'
import { useUserStore } from '../../../../stores/user.store'
import { noteService } from '../../../../api/services/note.service'
import NoteDropdown from '@/scripts/admin/components/dropdowns/NoteIndexDropdown.vue'
import NoteModal from '@/scripts/admin/components/modal-components/NoteModal.vue'
import NoteDropdown from '@v2/features/company/settings/components/NoteDropdown.vue'
import NoteModal from '@v2/features/company/settings/components/NoteModal.vue'
interface TableColumn {
key: string

View File

@@ -3,8 +3,8 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModalStore } from '../../../../stores/modal.store'
import { paymentService } from '../../../../api/services/payment.service'
import PaymentModeModal from '@/scripts/admin/components/modal-components/PaymentModeModal.vue'
import PaymentModeDropdown from '@/scripts/admin/components/dropdowns/PaymentModeIndexDropdown.vue'
import PaymentModeModal from '@v2/features/company/settings/components/PaymentModeModal.vue'
import PaymentModeDropdown from '@v2/features/company/settings/components/PaymentModeDropdown.vue'
interface TableColumn {
key: string

View File

@@ -5,8 +5,8 @@ import { useModalStore } from '../../../../stores/modal.store'
import { useUserStore } from '../../../../stores/user.store'
import { useCompanyStore } from '../../../../stores/company.store'
import { roleService } from '../../../../api/services/role.service'
import RoleDropdown from '@/scripts/admin/components/dropdowns/RoleIndexDropdown.vue'
import RolesModal from '@/scripts/admin/components/modal-components/RolesModal.vue'
import RoleDropdown from '@v2/features/company/settings/components/RoleDropdown.vue'
import RolesModal from '@v2/features/company/settings/components/RolesModal.vue'
interface TableColumn {
key: string

View File

@@ -5,8 +5,8 @@ import { useCompanyStore } from '../../../../stores/company.store'
import { useUserStore } from '../../../../stores/user.store'
import { useModalStore } from '../../../../stores/modal.store'
import { taxTypeService } from '../../../../api/services/tax-type.service'
import TaxTypeDropdown from '@/scripts/admin/components/dropdowns/TaxTypeIndexDropdown.vue'
import TaxTypeModal from '@/scripts/admin/components/modal-components/TaxTypeModal.vue'
import TaxTypeDropdown from '@v2/features/company/settings/components/TaxTypeDropdown.vue'
import TaxTypeModal from '@v2/features/company/settings/components/TaxTypeModal.vue'
interface TableColumn {
key: string

View File

@@ -1,10 +1,10 @@
import { defineStore } from 'pinia'
import { client } from '../../api/client'
import type { Invoice } from '../../types/domain/invoice'
import type { Estimate, EstimateStatus } from '../../types/domain/estimate'
import type { Payment, PaymentMethod } from '../../types/domain/payment'
import type { Currency } from '../../types/domain/currency'
import type { Customer, Country } from '../../types/domain/customer'
import { client } from '@v2/api/client'
import type { Invoice } from '@v2/types/domain/invoice'
import type { Estimate, EstimateStatus } from '@v2/types/domain/estimate'
import type { Payment, PaymentMethod } from '@v2/types/domain/payment'
import type { Currency } from '@v2/types/domain/currency'
import type { Customer, Country } from '@v2/types/domain/customer'
// ----------------------------------------------------------------
// Types

View File

@@ -185,7 +185,7 @@ import { useI18n } from 'vue-i18n'
import { required, between, maxLength, helpers, minValue } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import DocumentItemRowTax from './DocumentItemRowTax.vue'
import DragIcon from '@/scripts/components/icons/DragIcon.vue'
import DragIcon from '@v2/components/icons/DragIcon.vue'
import type { Currency } from '../../../types/domain/currency'
import type { DocumentItem, DocumentFormData, DocumentTax } from './use-document-calculations'