Phase 3: Typed Vue components in scripts-v2/

Migrate all shared components to TypeScript SFCs with script setup
lang=ts. 72 files, 7144 lines, zero any types.

- components/base/ (42 files): Button, Input, Textarea, Checkbox,
  Radio, Switch, Badge, Card, Modal, Dialog, Dropdown, DatePicker,
  TimePicker, Money, FileUploader, Select, Icon, Loader, Multiselect,
  TabGroup, Wizard, CustomerSelect, ItemSelect, CustomInput, alerts,
  status badges (Invoice/Estimate/Paid/RecurringInvoice), List/ListItem
- components/table/ (3 files): DataTable, TablePagination
- components/form/ (4 files): FormGroup, FormGrid, SwitchSection
- components/layout/ (11 files): Page, PageHeader, Breadcrumb,
  FilterWrapper, EmptyPlaceholder, ContentPlaceholders, SettingCard
- components/editor/ (2 files): RichEditor with Tiptap
- components/charts/ (2 files): LineChart with Chart.js
- components/notifications/ (3 files): NotificationRoot, NotificationItem
- components/icons/ (2 files): MainLogo

All use defineProps<Props>(), defineEmits<Emits>(), typed refs,
and import domain types from types/domain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Darko Gjorgjijoski
2026-04-04 05:45:00 +02:00
parent 2b996d30bf
commit e43e515614
72 changed files with 7144 additions and 0 deletions

View File

@@ -0,0 +1,484 @@
<template>
<div class="flex flex-col">
<div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8 pb-4 lg:pb-0">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div
class="
relative
overflow-hidden
bg-surface/70 backdrop-blur-lg
border border-white/15
shadow-sm
rounded-xl
"
>
<slot name="header" />
<table :class="tableClass">
<thead :class="theadClass">
<tr>
<th
v-for="column in tableColumns"
:key="column.key"
:class="[
getThClass(column),
{
'text-bold text-heading': sort.fieldName === column.key,
},
]"
@click="changeSorting(column)"
>
{{ column.label }}
<span
v-if="sort.fieldName === column.key && sort.order === 'asc'"
class="asc-direction"
>
</span>
<span
v-if="
sort.fieldName === column.key && sort.order === 'desc'
"
class="desc-direction"
>
</span>
</th>
</tr>
</thead>
<tbody
v-if="loadingType === 'placeholder' && (loading || isLoading)"
>
<tr
v-for="placeRow in placeholderCount"
:key="placeRow"
:class="placeRow % 2 === 0 ? 'bg-surface' : 'bg-surface-secondary'"
>
<td
v-for="column in columns"
:key="column.key"
:class="getTdClass(column)"
>
<ContentPlaceholder
:class="getPlaceholderClass(column)"
:rounded="true"
>
<ContentPlaceholderText
class="w-full h-6"
:lines="1"
/>
</ContentPlaceholder>
</td>
</tr>
</tbody>
<tbody v-else>
<tr
v-for="(row, index) in sortedRows"
:key="row.data?.id ?? index"
:class="index % 2 === 0 ? 'bg-surface' : 'bg-surface-secondary'"
>
<td
v-for="column in columns"
:key="column.key"
:class="getTdClass(column)"
>
<slot :name="'cell-' + column.key" :row="row">
{{ lodashGet(row.data, column.key) }}
</slot>
</td>
</tr>
</tbody>
</table>
<div
v-if="loadingType === 'spinner' && (loading || isLoading)"
class="
absolute
top-0
left-0
z-10
flex
items-center
justify-center
w-full
h-full
bg-white/60
"
>
<SpinnerIcon class="w-10 h-10 text-primary-500" />
</div>
<div
v-else-if="
!loading && !isLoading && sortedRows && sortedRows.length === 0
"
class="
text-center text-muted
pb-2
flex
h-[160px]
justify-center
items-center
flex-col
"
>
<BaseIcon
name="ExclamationCircleIcon"
class="w-6 h-6 text-subtle"
/>
<span class="block mt-1">{{ $t('general.no_data_found') }}</span>
</div>
<TablePagination
v-if="pagination"
:pagination="pagination"
@pageChange="pageChange"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, watch, ref, reactive } from 'vue'
import { get } from 'lodash'
import TablePagination from './TablePagination.vue'
import { ContentPlaceholder, ContentPlaceholderText } from '../layout'
import SpinnerIcon from '@/scripts/components/icons/SpinnerIcon.vue'
export interface ColumnDef {
key: string
label: string
thClass?: string
defaultThClass?: string
tdClass?: string
defaultTdClass?: string
placeholderClass?: string
sortBy?: string
sortable?: boolean
hidden?: boolean
dataType?: string
filterOn?: string
}
interface TableColumn extends ColumnDef {
sortable: boolean
dataType: string
}
export interface RowData {
id?: number | string
[key: string]: unknown
}
interface TableRow {
data: RowData
columns: TableColumn[]
getValue(columnName: string): unknown
getColumn(columnName: string): TableColumn | undefined
getSortableValue(columnName: string): string | number
}
export interface PaginationData {
currentPage: number
totalPages: number
totalCount: number
count: number
limit: number
}
interface SortState {
fieldName: string
order: 'asc' | 'desc' | ''
}
type ServerDataFn = (params: { sort: SortState; page: number }) => Promise<{
data: RowData[]
pagination: PaginationData
}>
interface Props {
columns: ColumnDef[]
data: RowData[] | ServerDataFn
sortBy?: string
sortOrder?: string
tableClass?: string
theadClass?: string
tbodyClass?: string
noResultsMessage?: string
loading?: boolean
loadingType?: 'placeholder' | 'spinner'
placeholderCount?: number
}
const props = withDefaults(defineProps<Props>(), {
sortBy: '',
sortOrder: '',
tableClass: 'min-w-full divide-y divide-line-default',
theadClass: 'bg-surface-secondary',
tbodyClass: '',
noResultsMessage: 'No Results Found',
loading: false,
loadingType: 'placeholder',
placeholderCount: 3,
})
function createColumn(columnObj: ColumnDef): TableColumn {
const col: TableColumn = {
...columnObj,
dataType: columnObj.dataType ?? 'string',
sortable: columnObj.sortable ?? true,
}
return col
}
function createRow(data: RowData, columns: TableColumn[]): TableRow {
return {
data,
columns,
getValue(columnName: string): unknown {
return getNestedValue(data, columnName)
},
getColumn(columnName: string): TableColumn | undefined {
return columns.find((c) => c.key === columnName)
},
getSortableValue(columnName: string): string | number {
const col = columns.find((c) => c.key === columnName)
if (!col) return ''
const dataType = col.dataType
let value: unknown = getNestedValue(data, columnName)
if (value === undefined || value === null) {
return ''
}
if (typeof value === 'string') {
value = value.toLowerCase()
}
if (dataType === 'numeric') {
return value as number
}
return String(value)
},
}
}
function getNestedValue(object: unknown, path: string): unknown {
if (!path) return object
if (object === null || typeof object !== 'object') return object
const [head, ...rest] = path.split('.')
return getNestedValue((object as Record<string, unknown>)[head], rest.join('.'))
}
function getSortPredicate(
column: TableColumn,
sortOrder: string,
allColumns: TableColumn[]
): (a: TableRow, b: TableRow) => number {
const sortFieldName = column.sortBy || column.key
const sortColumn = allColumns.find((c) => c.key === sortFieldName)
if (!sortColumn) return () => 0
const dataType = sortColumn.dataType
if (dataType.startsWith('date') || dataType === 'numeric') {
return (row1: TableRow, row2: TableRow) => {
const value1 = row1.getSortableValue(sortFieldName)
const value2 = row2.getSortableValue(sortFieldName)
if (sortOrder === 'desc') {
return value2 < value1 ? -1 : 1
}
return value1 < value2 ? -1 : 1
}
}
return (row1: TableRow, row2: TableRow) => {
const value1 = String(row1.getSortableValue(sortFieldName))
const value2 = String(row2.getSortableValue(sortFieldName))
if (sortOrder === 'desc') {
return value2.localeCompare(value1)
}
return value1.localeCompare(value2)
}
}
const rows = ref<TableRow[]>([])
const isLoading = ref<boolean>(false)
const tableColumns = reactive<TableColumn[]>(
props.columns.map((column) => createColumn(column))
)
const sort = reactive<SortState>({
fieldName: '',
order: '',
})
const pagination = ref<PaginationData | null>(null)
const usesLocalData = computed<boolean>(() => {
return Array.isArray(props.data)
})
const sortedRows = computed<TableRow[]>(() => {
if (!usesLocalData.value) {
return rows.value
}
if (sort.fieldName === '') {
return rows.value
}
if (tableColumns.length === 0) {
return rows.value
}
const sortColumn = tableColumns.find((c) => c.key === sort.fieldName)
if (!sortColumn) {
return rows.value
}
const sorted = [...rows.value].sort(
getSortPredicate(sortColumn, sort.order, tableColumns)
)
return sorted
})
function getThClass(column: TableColumn): string {
let classes =
'whitespace-nowrap px-6 py-3 text-left text-xs font-medium text-muted uppercase tracking-wider'
if (column.defaultThClass) {
classes = column.defaultThClass
}
if (column.sortable) {
classes = `${classes} cursor-pointer`
} else {
classes = `${classes} pointer-events-none`
}
if (column.thClass) {
classes = `${classes} ${column.thClass}`
}
return classes
}
function getTdClass(column: ColumnDef): string {
let classes = 'px-6 py-4 text-sm text-muted whitespace-nowrap'
if (column.defaultTdClass) {
classes = column.defaultTdClass
}
if (column.tdClass) {
classes = `${classes} ${column.tdClass}`
}
return classes
}
function getPlaceholderClass(column: ColumnDef): string {
let classes = 'w-full'
if (column.placeholderClass) {
classes = `${classes} ${column.placeholderClass}`
}
return classes
}
function prepareLocalData(): RowData[] {
pagination.value = null
return props.data as RowData[]
}
async function fetchServerData(): Promise<RowData[] | null> {
const page = pagination.value?.currentPage ?? 1
isLoading.value = true
const response = await (props.data as ServerDataFn)({
sort,
page,
})
isLoading.value = false
const currentPage = pagination.value?.currentPage ?? 1
if (page !== currentPage) {
return null
}
pagination.value = response.pagination
return response.data
}
function changeSorting(column: TableColumn): void {
if (sort.fieldName !== column.key) {
sort.fieldName = column.key
sort.order = 'asc'
} else {
sort.order = sort.order === 'asc' ? 'desc' : 'asc'
}
if (!usesLocalData.value) {
if (pagination.value) {
pagination.value.currentPage = 1
}
mapDataToRows()
}
}
async function mapDataToRows(): Promise<void> {
let data: RowData[] | null
if (usesLocalData.value) {
data = prepareLocalData()
} else {
data = await fetchServerData()
if (data === null) {
return
}
}
rows.value = data.map((rowData) => createRow(rowData, tableColumns))
}
async function pageChange(page: number): Promise<void> {
if (pagination.value) {
pagination.value.currentPage = page
}
await mapDataToRows()
}
async function refresh(isPreservePage = false): Promise<void> {
if (pagination.value && !isPreservePage) {
pagination.value.currentPage = 1
}
await mapDataToRows()
}
function lodashGet(obj: unknown, key: string): unknown {
return get(obj, key)
}
watch(
() => props.data,
() => {
mapDataToRows()
},
{ deep: true }
)
onMounted(async () => {
await mapDataToRows()
})
defineExpose({ refresh })
</script>

View File

@@ -0,0 +1,372 @@
<template>
<div
v-if="shouldShowPagination"
class="
flex
items-center
justify-between
px-4
py-3
bg-surface
border-t border-line-default
sm:px-6
"
>
<div class="flex justify-between flex-1 sm:hidden">
<a
href="#"
:class="{
'disabled cursor-normal pointer-events-none !bg-surface-tertiary !text-subtle':
pagination.currentPage === 1,
}"
class="
relative
inline-flex
items-center
px-4
py-2
text-sm
font-medium
text-body
bg-surface
border border-line-default
rounded-md
hover:bg-hover
"
@click="pageClicked(pagination.currentPage - 1)"
>
{{ $t('general.pagination.previous') }}
</a>
<a
href="#"
:class="{
'disabled cursor-default pointer-events-none !bg-surface-tertiary !text-subtle':
pagination.currentPage === pagination.totalPages,
}"
class="
relative
inline-flex
items-center
px-4
py-2
ml-3
text-sm
font-medium
text-body
bg-surface
border border-line-default
rounded-md
hover:bg-hover
"
@click="pageClicked(pagination.currentPage + 1)"
>
{{ $t('general.pagination.next') }}
</a>
</div>
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-body">
{{ $t('general.pagination.showing') }}
{{ ' ' }}
<span
v-if="pagination.limit && pagination.currentPage"
class="font-medium"
>
{{
pagination.currentPage * pagination.limit - (pagination.limit - 1)
}}
</span>
{{ ' ' }}
{{ $t('general.pagination.to') }}
{{ ' ' }}
<span
v-if="pagination.limit && pagination.currentPage"
class="font-medium"
>
<span
v-if="
pagination.currentPage * pagination.limit <=
pagination.totalCount
"
>
{{ pagination.currentPage * pagination.limit }}
</span>
<span v-else>
{{ pagination.totalCount }}
</span>
</span>
{{ ' ' }}
{{ $t('general.pagination.of') }}
{{ ' ' }}
<span v-if="pagination.totalCount" class="font-medium">
{{ pagination.totalCount }}
</span>
{{ ' ' }}
{{ $t('general.pagination.results') }}
</p>
</div>
<div>
<nav
class="relative z-0 inline-flex -space-x-px rounded-lg shadow-sm"
aria-label="Pagination"
>
<a
href="#"
:class="{
'disabled cursor-normal pointer-events-none !bg-surface-tertiary !text-subtle':
pagination.currentPage === 1,
}"
class="
relative
inline-flex
items-center
px-2
py-2
text-sm
font-medium
text-muted
bg-surface
border border-line-default
rounded-l-lg
hover:bg-hover
"
@click="pageClicked(pagination.currentPage - 1)"
>
<span class="sr-only">Previous</span>
<BaseIcon name="ChevronLeftIcon" />
</a>
<a
v-if="hasFirst"
href="#"
aria-current="page"
:class="{
'z-10 bg-primary-500 border-primary-500 text-white':
isActive(1),
'bg-surface border-line-default text-muted hover:bg-hover':
!isActive(1),
}"
class="
relative
inline-flex
items-center
px-4
py-2
text-sm
font-medium
border
"
@click="pageClicked(1)"
>
1
</a>
<span
v-if="hasFirstEllipsis"
class="
relative
inline-flex
items-center
px-4
py-2
text-sm
font-medium
text-body
bg-surface
border border-line-default
"
>
...
</span>
<a
v-for="page in pages"
:key="page"
href="#"
:class="{
'z-10 bg-primary-500 border-primary-500 text-white':
isActive(page),
'bg-surface border-line-default text-muted hover:bg-hover':
!isActive(page),
}"
class="
relative
items-center
hidden
px-4
py-2
text-sm
font-medium
border
md:inline-flex
"
@click="pageClicked(page)"
>
{{ page }}
</a>
<span
v-if="hasLastEllipsis"
class="
relative
inline-flex
items-center
px-4
py-2
text-sm
font-medium
text-body
bg-surface
border border-line-default
"
>
...
</span>
<a
v-if="hasLast"
href="#"
aria-current="page"
:class="{
'z-10 bg-primary-500 border-primary-500 text-white':
isActive(pagination.totalPages),
'bg-surface border-line-default text-muted hover:bg-hover':
!isActive(pagination.totalPages),
}"
class="
relative
inline-flex
items-center
px-4
py-2
text-sm
font-medium
border
"
@click="pageClicked(pagination.totalPages)"
>
{{ pagination.totalPages }}
</a>
<a
href="#"
class="
relative
inline-flex
items-center
px-2
py-2
text-sm
font-medium
text-muted
bg-surface
border border-line-default
rounded-r-lg
hover:bg-hover
"
:class="{
'disabled cursor-default pointer-events-none !bg-surface-tertiary !text-subtle':
pagination.currentPage === pagination.totalPages,
}"
@click="pageClicked(pagination.currentPage + 1)"
>
<span class="sr-only">Next</span>
<BaseIcon name="ChevronRightIcon" />
</a>
</nav>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
export interface PaginationInfo {
currentPage: number
totalPages: number
totalCount: number
count: number
limit: number
}
interface Props {
pagination: PaginationInfo
}
interface Emits {
(e: 'pageChange', page: number): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const pages = computed<number[]>(() => {
if (props.pagination.totalPages === undefined) return []
return pageLinks()
})
const hasFirst = computed<boolean>(() => {
return props.pagination.currentPage >= 4 || props.pagination.totalPages < 10
})
const hasLast = computed<boolean>(() => {
return (
props.pagination.currentPage <= props.pagination.totalPages - 3 ||
props.pagination.totalPages < 10
)
})
const hasFirstEllipsis = computed<boolean>(() => {
return (
props.pagination.currentPage >= 4 && props.pagination.totalPages >= 10
)
})
const hasLastEllipsis = computed<boolean>(() => {
return (
props.pagination.currentPage <= props.pagination.totalPages - 3 &&
props.pagination.totalPages >= 10
)
})
const shouldShowPagination = computed<boolean>(() => {
if (props.pagination.totalPages === undefined) {
return false
}
if (props.pagination.count === 0) {
return false
}
return props.pagination.totalPages > 1
})
function isActive(page: number): boolean {
const currentPage = props.pagination.currentPage || 1
return currentPage === page
}
function pageClicked(page: number): void {
if (
page === props.pagination.currentPage ||
page > props.pagination.totalPages ||
page < 1
) {
return
}
emit('pageChange', page)
}
function pageLinks(): number[] {
const pageList: number[] = []
let left = 2
let right = props.pagination.totalPages - 1
if (props.pagination.totalPages >= 10) {
left = Math.max(1, props.pagination.currentPage - 2)
right = Math.min(
props.pagination.currentPage + 2,
props.pagination.totalPages
)
}
for (let i = left; i <= right; i++) {
pageList.push(i)
}
return pageList
}
</script>

View File

@@ -0,0 +1,5 @@
export { default as DataTable } from './DataTable.vue'
export { default as TablePagination } from './TablePagination.vue'
export type { ColumnDef, RowData, PaginationData } from './DataTable.vue'
export type { PaginationInfo } from './TablePagination.vue'