Phase 4a: Feature modules — layouts, auth, admin, dashboard,

customers, items, invoices, estimates, shared document form

77 files, 14451 lines. Typed layouts (CompanyLayout, AuthLayout,
header, sidebar, company switcher), auth views (login, register,
forgot/reset password), admin feature (dashboard, companies, users,
settings with typed store), company features (dashboard with chart/
stats, customers CRUD, items CRUD, invoices CRUD with full store,
estimates CRUD with full store), and shared document form components
(items table, item row, totals, notes, tax popup, template select,
exchange rate converter, calculation composable).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Darko Gjorgjijoski
2026-04-04 06:30:00 +02:00
parent e43e515614
commit 774b2614f0
77 changed files with 14451 additions and 0 deletions

View File

@@ -0,0 +1,331 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
required,
minLength,
maxLength,
helpers,
} from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
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'
// Tax type store - imported from original location
import { useTaxTypeStore } from '@/scripts/admin/stores/tax-type'
interface TaxOption {
id: number
name: string
percent: number
fixed_amount: number
calculation_type: string | null
tax_type_id: number
tax_name: string
}
const ABILITIES = {
VIEW_TAX_TYPE: 'view-tax-type',
} as const
const itemStore = useItemStore()
const taxTypeStore = useTaxTypeStore()
const modalStore = useModalStore()
const companyStore = useCompanyStore()
const userStore = useUserStore()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const isSaving = ref<boolean>(false)
const taxPerItem = ref<string>(companyStore.selectedCompanySettings.tax_per_item || 'NO')
const isFetchingInitialData = ref<boolean>(false)
itemStore.resetCurrentItem()
loadData()
const price = computed<number>({
get: () => itemStore.currentItem.price / 100,
set: (value: number) => {
itemStore.currentItem.price = Math.round(value * 100)
},
})
const taxes = computed({
get: () =>
itemStore.currentItem.taxes?.map((tax) => {
if (tax) {
const currencyCode = companyStore.selectedCompanyCurrency?.code ?? 'USD'
return {
...tax,
tax_type_id: tax.id,
tax_name: `${tax.name} (${
tax.calculation_type === 'fixed'
? new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currencyCode,
}).format(tax.fixed_amount / 100)
: `${tax.percent}%`
})`,
}
}
return tax
}) ?? [],
set: (value: TaxOption[]) => {
itemStore.currentItem.taxes = value as unknown as typeof itemStore.currentItem.taxes
},
})
const isEdit = computed<boolean>(() => route.name === 'items.edit')
const pageTitle = computed<string>(() =>
isEdit.value ? t('items.edit_item') : t('items.new_item')
)
const getTaxTypes = computed<TaxOption[]>(() => {
return taxTypeStore.taxTypes.map((tax) => {
const currencyCode = companyStore.selectedCompanyCurrency?.code ?? 'USD'
return {
...tax,
tax_type_id: tax.id,
tax_name: `${tax.name} (${
tax.calculation_type === 'fixed'
? new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currencyCode,
}).format(tax.fixed_amount / 100)
: `${tax.percent}%`
})`,
}
}) as TaxOption[]
})
const isTaxPerItem = computed<boolean>(() => taxPerItem.value === 'YES')
const rules = computed(() => ({
currentItem: {
name: {
required: helpers.withMessage(t('validation.required'), required),
minLength: helpers.withMessage(
t('validation.name_min_length', { count: 2 }),
minLength(2)
),
},
description: {
maxLength: helpers.withMessage(
t('validation.description_maxlength'),
maxLength(65000)
),
},
},
}))
const v$ = useVuelidate(rules, itemStore)
async function addItemUnit(): Promise<void> {
modalStore.openModal({
title: t('settings.customization.items.add_item_unit'),
componentName: 'ItemUnitModal',
size: 'sm',
})
}
async function loadData(): Promise<void> {
isFetchingInitialData.value = true
await itemStore.fetchItemUnits({ limit: 'all' })
if (userStore.hasAbilities(ABILITIES.VIEW_TAX_TYPE)) {
await taxTypeStore.fetchTaxTypes({ limit: 'all' })
}
if (isEdit.value) {
const id = Number(route.params.id)
await itemStore.fetchItem(id)
taxPerItem.value =
itemStore.currentItem.tax_per_item === 1 ||
itemStore.currentItem.tax_per_item === '1' ||
itemStore.currentItem.tax_per_item === true
? 'YES'
: 'NO'
}
isFetchingInitialData.value = false
}
async function submitItem(): Promise<void> {
v$.value.currentItem.$touch()
if (v$.value.currentItem.$invalid) {
return
}
isSaving.value = true
try {
const data: Record<string, unknown> = {
id: route.params.id,
...itemStore.currentItem,
}
if (itemStore.currentItem.taxes) {
data.taxes = itemStore.currentItem.taxes.map((tax) => ({
tax_type_id: (tax as Record<string, unknown>).tax_type_id ?? tax.id,
calculation_type: tax.calculation_type,
fixed_amount: tax.fixed_amount,
amount:
tax.calculation_type === 'fixed'
? tax.fixed_amount
: Math.round(price.value * tax.percent),
percent: tax.percent,
name: tax.name,
collective_tax: 0,
}))
}
const action = isEdit.value ? itemStore.updateItem : itemStore.addItem
await action(data)
isSaving.value = false
router.push('/admin/items')
} catch {
isSaving.value = false
}
}
</script>
<template>
<BasePage>
<BasePageHeader :title="pageTitle">
<BaseBreadcrumb>
<BaseBreadcrumbItem :title="$t('general.home')" to="dashboard" />
<BaseBreadcrumbItem :title="$t('items.item', 2)" to="/admin/items" />
<BaseBreadcrumbItem :title="pageTitle" to="#" active />
</BaseBreadcrumb>
</BasePageHeader>
<ItemUnitModal />
<form
class="grid lg:grid-cols-2 mt-6"
action="submit"
@submit.prevent="submitItem"
>
<BaseCard class="w-full">
<BaseInputGrid layout="one-column">
<BaseInputGroup
:label="$t('items.name')"
:content-loading="isFetchingInitialData"
required
:error="
v$.currentItem.name.$error &&
v$.currentItem.name.$errors[0].$message
"
>
<BaseInput
v-model="itemStore.currentItem.name"
:content-loading="isFetchingInitialData"
:invalid="v$.currentItem.name.$error"
@input="v$.currentItem.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('items.price')"
:content-loading="isFetchingInitialData"
>
<BaseMoney
v-model="price"
:content-loading="isFetchingInitialData"
/>
</BaseInputGroup>
<BaseInputGroup
:content-loading="isFetchingInitialData"
:label="$t('items.unit')"
>
<BaseMultiselect
v-model="itemStore.currentItem.unit_id"
:content-loading="isFetchingInitialData"
label="name"
:options="itemStore.itemUnits"
value-prop="id"
:placeholder="$t('items.select_a_unit')"
searchable
track-by="name"
>
<template #action>
<BaseSelectAction @click="addItemUnit">
<BaseIcon
name="PlusIcon"
class="h-4 mr-2 -ml-2 text-center text-primary-400"
/>
{{ $t('settings.customization.items.add_item_unit') }}
</BaseSelectAction>
</template>
</BaseMultiselect>
</BaseInputGroup>
<BaseInputGroup
v-if="isTaxPerItem"
:label="$t('items.taxes')"
:content-loading="isFetchingInitialData"
>
<BaseMultiselect
v-model="taxes"
:content-loading="isFetchingInitialData"
:options="getTaxTypes"
mode="tags"
label="tax_name"
class="w-full"
value-prop="id"
:can-deselect="false"
:can-clear="false"
searchable
track-by="tax_name"
object
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('items.description')"
:content-loading="isFetchingInitialData"
:error="
v$.currentItem.description.$error &&
v$.currentItem.description.$errors[0].$message
"
>
<BaseTextarea
v-model="itemStore.currentItem.description"
:content-loading="isFetchingInitialData"
name="description"
:row="2"
rows="2"
@input="v$.currentItem.description.$touch()"
/>
</BaseInputGroup>
<div>
<BaseButton
:content-loading="isFetchingInitialData"
type="submit"
:loading="isSaving"
>
<template #left="slotProps">
<BaseIcon
v-if="!isSaving"
name="ArrowDownOnSquareIcon"
:class="slotProps.class"
/>
</template>
{{ isEdit ? $t('items.update_item') : $t('items.save_item') }}
</BaseButton>
</div>
</BaseInputGrid>
</BaseCard>
</form>
</BasePage>
</template>