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,94 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useItemStore } from '../store'
import { useDialogStore } from '../../../../stores/dialog.store'
import { useUserStore } from '../../../../stores/user.store'
interface RowData {
id: number
[key: string]: unknown
}
interface Props {
row: RowData | null
table?: { refresh: () => void } | null
loadData?: (() => void) | null
}
const ABILITIES = {
EDIT_ITEM: 'edit-item',
DELETE_ITEM: 'delete-item',
} as const
const props = withDefaults(defineProps<Props>(), {
row: null,
table: null,
loadData: null,
})
const dialogStore = useDialogStore()
const { t } = useI18n()
const itemStore = useItemStore()
const route = useRoute()
const userStore = useUserStore()
function removeItem(id: number): void {
dialogStore
.openDialog({
title: t('general.are_you_sure'),
message: t('items.confirm_delete'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
.then((res: boolean) => {
if (res) {
itemStore.deleteItem({ ids: [id] }).then((response) => {
if (response.success) {
props.loadData?.()
}
})
}
})
}
</script>
<template>
<BaseDropdown>
<template #activator>
<BaseButton v-if="route.name === 'items.view'" variant="primary">
<BaseIcon name="EllipsisHorizontalIcon" class="h-5 text-white" />
</BaseButton>
<BaseIcon v-else name="EllipsisHorizontalIcon" class="h-5 text-muted" />
</template>
<!-- Edit Item -->
<router-link
v-if="userStore.hasAbilities(ABILITIES.EDIT_ITEM) && row"
:to="`/admin/items/${row.id}/edit`"
>
<BaseDropdownItem>
<BaseIcon
name="PencilIcon"
class="w-5 h-5 mr-3 text-subtle group-hover:text-muted"
/>
{{ $t('general.edit') }}
</BaseDropdownItem>
</router-link>
<!-- Delete Item -->
<BaseDropdownItem
v-if="userStore.hasAbilities(ABILITIES.DELETE_ITEM) && row"
@click="removeItem(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,292 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
required,
minLength,
maxLength,
helpers,
} from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useModalStore } from '../../../../stores/modal.store'
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'
interface TaxOption {
id: number
name: string
percent: number
fixed_amount: number
calculation_type: string | null
tax_name: string
}
interface Emits {
(e: 'newItem', item: unknown): void
}
const emit = defineEmits<Emits>()
const modalStore = useModalStore()
const itemStore = useItemStore()
const companyStore = useCompanyStore()
const taxTypeStore = useTaxTypeStore()
const { t } = useI18n()
const isLoading = ref<boolean>(false)
const taxPerItemSetting = ref<string>(
companyStore.selectedCompanySettings.tax_per_item || 'NO'
)
const modalActive = computed<boolean>(
() => modalStore.active && modalStore.componentName === 'ItemModal'
)
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 currencySymbol = companyStore.selectedCompanyCurrency?.symbol ?? '$'
return {
...tax,
tax_type_id: tax.id,
tax_name: `${tax.name} (${
tax.calculation_type === 'fixed'
? tax.fixed_amount + currencySymbol
: tax.percent + '%'
})`,
}
}
return tax
}),
set: (value: TaxOption[]) => {
itemStore.currentItem.taxes = value as unknown as typeof itemStore.currentItem.taxes
},
})
const isTaxPerItemEnabled = computed<boolean>(() => {
return taxPerItemSetting.value === 'YES'
})
const rules = {
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,
computed(() => itemStore.currentItem)
)
const getTaxTypes = computed<TaxOption[]>(() => {
return taxTypeStore.taxTypes.map((tax) => {
const currencyCode = companyStore.selectedCompanyCurrency?.code ?? 'USD'
const amount =
tax.calculation_type === 'fixed'
? new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currencyCode,
}).format(tax.fixed_amount / 100)
: `${tax.percent}%`
return {
...tax,
tax_name: `${tax.name} (${amount})`,
}
}) as TaxOption[]
})
onMounted(() => {
v$.value.$reset()
itemStore.fetchItemUnits({ limit: 'all' })
})
async function submitItemData(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
const data: Record<string, unknown> = {
...itemStore.currentItem,
taxes: itemStore.currentItem.taxes.map((tax) => ({
tax_type_id: tax.id,
amount:
tax.calculation_type === 'fixed'
? tax.fixed_amount
: Math.round(price.value * tax.percent),
percent: tax.percent,
fixed_amount: tax.fixed_amount,
calculation_type: tax.calculation_type,
name: tax.name,
collective_tax: 0,
})),
}
isLoading.value = true
const action = itemStore.isEdit ? itemStore.updateItem : itemStore.addItem
try {
const res = await action(data)
isLoading.value = false
if (res.data) {
if (modalStore.refreshData) {
modalStore.refreshData(res.data)
}
}
closeItemModal()
} catch {
isLoading.value = false
}
}
function closeItemModal(): void {
modalStore.closeModal()
setTimeout(() => {
itemStore.resetCurrentItem()
v$.value.$reset()
}, 300)
}
</script>
<template>
<BaseModal :show="modalActive" @close="closeItemModal">
<template #header>
<div class="flex justify-between w-full">
{{ modalStore.title }}
<BaseIcon
name="XMarkIcon"
class="h-6 w-6 text-muted cursor-pointer"
@click="closeItemModal"
/>
</div>
</template>
<div class="item-modal">
<form action="" @submit.prevent="submitItemData">
<div class="px-8 py-8 sm:p-6">
<BaseInputGrid layout="one-column">
<BaseInputGroup
:label="$t('items.name')"
required
:error="v$.name.$error && v$.name.$errors[0].$message"
>
<BaseInput
v-model="itemStore.currentItem.name"
type="text"
:invalid="v$.name.$error"
@input="v$.name.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('items.price')">
<BaseMoney
:key="companyStore.selectedCompanyCurrency?.id"
v-model="price"
:currency="companyStore.selectedCompanyCurrency"
class="
relative
w-full
focus:border focus:border-solid focus:border-primary
"
/>
</BaseInputGroup>
<BaseInputGroup :label="$t('items.unit')">
<BaseMultiselect
v-model="itemStore.currentItem.unit_id"
label="name"
:options="itemStore.itemUnits"
value-prop="id"
:can-deselect="false"
:can-clear="false"
:placeholder="$t('items.select_a_unit')"
searchable
track-by="name"
/>
</BaseInputGroup>
<BaseInputGroup
v-if="isTaxPerItemEnabled"
:label="$t('items.taxes')"
>
<BaseMultiselect
v-model="taxes"
:options="getTaxTypes"
mode="tags"
label="tax_name"
value-prop="id"
class="w-full"
:can-deselect="false"
:can-clear="false"
searchable
track-by="tax_name"
object
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('items.description')"
:error="
v$.description.$error && v$.description.$errors[0].$message
"
>
<BaseTextarea
v-model="itemStore.currentItem.description"
rows="4"
cols="50"
:invalid="v$.description.$error"
@input="v$.description.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
</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"
@click="closeItemModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isLoading"
:disabled="isLoading"
variant="primary"
type="submit"
>
<template #left="slotProps">
<BaseIcon name="ArrowDownOnSquareIcon" :class="slotProps.class" />
</template>
{{ itemStore.isEdit ? $t('general.update') : $t('general.save') }}
</BaseButton>
</div>
</form>
</div>
</BaseModal>
</template>