mirror of
https://git.hmsn.ink/kospo/svcm/oa.git
synced 2026-03-20 04:32:22 +09:00
495 lines
12 KiB
Vue
495 lines
12 KiB
Vue
<script lang="ts">
|
|
import type { SlotsType, InjectionKey, PropType } from 'vue'
|
|
import type { VFlexTableColumn } from '../base/VFlexTable.vue'
|
|
import VFlexTableSortColumn from '../base/VFlexTableSortColumn.vue'
|
|
|
|
export type VFlexTableWrapperDataResolver<T = any> = (parameters: {
|
|
searchTerm: string
|
|
start: number
|
|
limit: number
|
|
sort?: string
|
|
controller?: AbortController
|
|
}) => T[] | Promise<T[]>
|
|
|
|
export type VFlexTableWrapperSortFunction<T = any> = (parameters: {
|
|
key: string
|
|
column: Partial<VFlexTableWrapperColumn>
|
|
order: 'asc' | 'desc'
|
|
a: T
|
|
b: T
|
|
}) => number
|
|
|
|
export type VFlexTableWrapperFilterFunction<T = any> = (parameters: {
|
|
searchTerm: string
|
|
value: any
|
|
row: T
|
|
column: Partial<VFlexTableWrapperColumn>
|
|
index: number
|
|
}) => boolean
|
|
|
|
export interface VFlexTableWrapperColumn extends VFlexTableColumn {
|
|
searchable?: boolean
|
|
sortable?: boolean
|
|
sort?: VFlexTableWrapperSortFunction
|
|
filter?: VFlexTableWrapperFilterFunction
|
|
}
|
|
|
|
export interface VFlexTableWrapperInjection {
|
|
data?: any[] | undefined
|
|
columns?: Record<string, Partial<VFlexTableWrapperColumn>>
|
|
loading?: boolean
|
|
searchInput?: string
|
|
searchTerm?: string
|
|
start?: number
|
|
limit?: number
|
|
sort?: string
|
|
page?: number
|
|
total?: number
|
|
totalPages?: number
|
|
fetchData: (controller?: AbortController) => Promise<void>
|
|
}
|
|
|
|
export const flewTableWrapperSymbol: InjectionKey<VFlexTableWrapperInjection> = Symbol()
|
|
|
|
const defaultFormatter = (value: any) => value
|
|
const defaultSortFunction: VFlexTableWrapperSortFunction = ({ key, order, a, b }) => {
|
|
const aValue = a[key]
|
|
const bValue = b[key]
|
|
|
|
if (typeof aValue === 'string') {
|
|
if (order === 'asc') {
|
|
return aValue.localeCompare(bValue)
|
|
}
|
|
else {
|
|
return bValue.localeCompare(aValue)
|
|
}
|
|
}
|
|
|
|
if (aValue > bValue) {
|
|
return order === 'asc' ? 1 : -1
|
|
}
|
|
|
|
if (aValue < bValue) {
|
|
return order === 'asc' ? -1 : 1
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
export default defineComponent({
|
|
props: {
|
|
data: {
|
|
type: [Array, Function] as PropType<any[] | VFlexTableWrapperDataResolver>,
|
|
default: undefined,
|
|
},
|
|
columns: {
|
|
type: Object as PropType<Record<string, string | Partial<VFlexTableWrapperColumn>>>,
|
|
default: undefined,
|
|
},
|
|
sort: {
|
|
type: String,
|
|
default: undefined,
|
|
},
|
|
searchTerm: {
|
|
type: String,
|
|
default: undefined,
|
|
},
|
|
limit: {
|
|
type: Number,
|
|
default: undefined,
|
|
},
|
|
page: {
|
|
type: Number,
|
|
default: undefined,
|
|
},
|
|
total: {
|
|
type: Number,
|
|
default: undefined,
|
|
},
|
|
debounceSearch: {
|
|
type: Number,
|
|
default: 300,
|
|
},
|
|
},
|
|
slots: Object as SlotsType<{
|
|
default: VFlexTableWrapperInjection
|
|
}>,
|
|
emits: ['update:sort', 'update:page', 'update:limit', 'update:searchTerm'],
|
|
setup(props, context) {
|
|
const rawData = ref<any[]>()
|
|
const loading = ref(false)
|
|
|
|
const defaultSort = ref('')
|
|
const sort = computed({
|
|
get: () => props.sort ?? defaultSort.value,
|
|
set(value) {
|
|
if (props.sort === undefined) {
|
|
defaultSort.value = value
|
|
}
|
|
else {
|
|
context.emit('update:sort', value)
|
|
}
|
|
},
|
|
})
|
|
|
|
const defaultSearchInput = ref('')
|
|
const searchInput = computed({
|
|
get: () => props.searchTerm ?? defaultSearchInput.value,
|
|
set(value) {
|
|
if (props.searchTerm === undefined) {
|
|
defaultSearchInput.value = value
|
|
}
|
|
else {
|
|
context.emit('update:searchTerm', value)
|
|
}
|
|
},
|
|
})
|
|
|
|
const defaultPage = ref(1)
|
|
const page = computed({
|
|
get: () => props.page ?? defaultPage.value,
|
|
set(value) {
|
|
if (props.page === undefined) {
|
|
defaultPage.value = value
|
|
}
|
|
else {
|
|
context.emit('update:page', value)
|
|
}
|
|
},
|
|
})
|
|
|
|
const defaultLimit = ref(10)
|
|
const limit = computed({
|
|
get: () => Math.max(1, props.limit ?? defaultLimit.value),
|
|
set(value) {
|
|
if (props.limit === undefined) {
|
|
defaultLimit.value = value
|
|
}
|
|
else {
|
|
context.emit('update:limit', value)
|
|
}
|
|
},
|
|
})
|
|
|
|
const columns = computed(() => {
|
|
const columnProps = props.columns
|
|
if (!columnProps) return columnProps
|
|
|
|
const wrapperColumns: Record<string, Partial<VFlexTableWrapperColumn>> = {}
|
|
|
|
Object.keys(columnProps).reduce((acc, key) => {
|
|
const value = columnProps[key]
|
|
|
|
if (typeof value === 'string') {
|
|
acc[key] = {
|
|
format: defaultFormatter,
|
|
label: value,
|
|
key,
|
|
}
|
|
}
|
|
else if (typeof value === 'object') {
|
|
acc[key] = {
|
|
format: defaultFormatter,
|
|
label: key,
|
|
key,
|
|
...value,
|
|
}
|
|
|
|
if (value.sortable === true) {
|
|
if (value.renderHeader) {
|
|
acc[key].renderHeader = () => {
|
|
return h(
|
|
VFlexTableSortColumn,
|
|
{
|
|
'id': key,
|
|
'noRouter': true,
|
|
'modelValue': sort.value,
|
|
'onUpdate:modelValue': value => (sort.value = value),
|
|
},
|
|
{
|
|
default: value.renderHeader,
|
|
},
|
|
)
|
|
}
|
|
}
|
|
else {
|
|
acc[key].renderHeader = () => {
|
|
return h(VFlexTableSortColumn, {
|
|
'id': key,
|
|
'label': value.label ?? key,
|
|
'noRouter': true,
|
|
'modelValue': sort.value,
|
|
'onUpdate:modelValue': value => (sort.value = value),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
if (value.searchable === true && !value.sort) {
|
|
acc[key].sort = defaultSortFunction
|
|
}
|
|
}
|
|
|
|
return acc
|
|
}, wrapperColumns)
|
|
|
|
return wrapperColumns
|
|
})
|
|
|
|
const filteredData = computed(() => {
|
|
let data = rawData.value
|
|
if (!data) return data
|
|
if (typeof props.data === 'function') return data
|
|
|
|
// filter data
|
|
if (searchTerm.value) {
|
|
const searchableColumns = columns.value
|
|
? (Object.values(columns.value).filter((column) => {
|
|
if (!column || typeof column === 'string') return false
|
|
return column.searchable === true
|
|
}) as Partial<VFlexTableWrapperColumn>[])
|
|
: []
|
|
|
|
if (searchableColumns.length) {
|
|
const _searchRe = new RegExp(searchTerm.value, 'i')
|
|
data = data.filter((row, index) => {
|
|
return searchableColumns.some((column) => {
|
|
if (!column.key) return false
|
|
|
|
const value = row[column.key]
|
|
|
|
if (column.filter) {
|
|
return column.filter({
|
|
searchTerm: searchTerm.value,
|
|
value,
|
|
row,
|
|
column,
|
|
index,
|
|
})
|
|
}
|
|
|
|
if (typeof value === 'string') return value.match(_searchRe)
|
|
|
|
return false
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
return data
|
|
})
|
|
|
|
const sortedData = computed(() => {
|
|
let data = filteredData.value
|
|
if (!data) return data
|
|
if (typeof props.data === 'function') return data
|
|
|
|
// sort data
|
|
if (sort.value && sort.value.includes(':')) {
|
|
const [sortField, sortOrder] = sort.value.split(':') as [string, 'desc' | 'asc']
|
|
|
|
const sortingColumn = columns.value
|
|
? (Object.values(columns.value).find((column) => {
|
|
if (!column || typeof column === 'string') return false
|
|
return column.sortable === true && column.key === sortField
|
|
}) as Partial<VFlexTableWrapperColumn>)
|
|
: null
|
|
|
|
if (sortingColumn) {
|
|
const sorted = [...data]
|
|
sorted.sort((a, b) => {
|
|
if (!sortingColumn.key) return 0
|
|
if (!sortingColumn.sort) return 0
|
|
|
|
return sortingColumn.sort({
|
|
order: sortOrder,
|
|
column: sortingColumn,
|
|
key: sortingColumn.key,
|
|
a,
|
|
b,
|
|
})
|
|
})
|
|
data = sorted
|
|
}
|
|
}
|
|
|
|
return data
|
|
})
|
|
|
|
const data = computed(() => {
|
|
if (typeof props.data === 'function') return rawData.value
|
|
if (!rawData.value) return rawData.value
|
|
|
|
let data = sortedData.value
|
|
|
|
// paginate data
|
|
return data?.slice(start.value, start.value + limit.value)
|
|
})
|
|
|
|
const searchTerm = useDebounce(searchInput, props.debounceSearch)
|
|
const total = computed(() => props.total ?? sortedData.value?.length ?? 0)
|
|
const start = computed(() => (page.value - 1) * limit.value)
|
|
const totalPages = computed(() =>
|
|
total.value ? Math.ceil(total.value / limit.value) : 0,
|
|
)
|
|
|
|
async function fetchData(controller?: AbortController) {
|
|
if (typeof props.data === 'function') {
|
|
loading.value = true
|
|
|
|
try {
|
|
rawData.value = await props.data({
|
|
searchTerm: searchTerm.value,
|
|
start: start.value,
|
|
limit: limit.value,
|
|
sort: sort.value,
|
|
controller,
|
|
})
|
|
}
|
|
finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
watch([searchTerm, limit], () => {
|
|
if (page.value !== 1) {
|
|
page.value = 1
|
|
}
|
|
})
|
|
|
|
watchEffect(async (onInvalidate) => {
|
|
let controller: AbortController
|
|
|
|
if (typeof props.data === 'function') {
|
|
controller = new AbortController()
|
|
await fetchData(controller)
|
|
}
|
|
else {
|
|
rawData.value = props.data
|
|
}
|
|
|
|
onInvalidate(() => {
|
|
controller?.abort()
|
|
})
|
|
})
|
|
|
|
const wrapperState = reactive({
|
|
data,
|
|
columns,
|
|
loading,
|
|
searchInput,
|
|
searchTerm,
|
|
start,
|
|
page,
|
|
limit,
|
|
sort,
|
|
total,
|
|
totalPages,
|
|
fetchData,
|
|
}) as VFlexTableWrapperInjection
|
|
|
|
provide(flewTableWrapperSymbol, wrapperState)
|
|
context.expose(wrapperState)
|
|
|
|
return () => {
|
|
const slotContent = context.slots.default?.(wrapperState)
|
|
return h('div', { class: 'flex-table-wrapper' }, slotContent)
|
|
}
|
|
},
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss">
|
|
/* 테이블 헤더 & 로우 공통 스타일 */
|
|
.flex-table-header,
|
|
.flex-table-row {
|
|
display: flex;
|
|
width: 100%;
|
|
border-bottom: 1px solid #e0e0e0;
|
|
}
|
|
|
|
/* 개별 셀 스타일 */
|
|
.flex-table-item {
|
|
flex: 1;
|
|
padding: 12px 15px;
|
|
text-align: left;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* 컬럼별 너비 설정 */
|
|
.paymentColumn1 { flex: 0 0 15%; } /* 결재번호 */
|
|
.paymentColumn2 { flex: 0 0 30%; } /* 제목 */
|
|
.paymentColumn3 { flex: 0 0 15%; } /* 작성자 */
|
|
.paymentColumn4 { flex: 0 0 20%; } /* 등록일 */
|
|
.paymentColumn5 { flex: 0 0 20%; } /* 구분 */
|
|
|
|
.flex-table-wrapper {
|
|
background: var(--white);
|
|
border: 1px solid color-mix(in oklab, var(--fade-grey), black 3%);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
|
|
.flex-table {
|
|
.flex-table-item {
|
|
margin-bottom: 0;
|
|
border-radius: 0;
|
|
border-inline-start: none;
|
|
border-inline-end: none;
|
|
border-top: none;
|
|
|
|
&:last-child {
|
|
margin-bottom: 6px;
|
|
border-bottom: none;
|
|
}
|
|
|
|
&:focus-visible {
|
|
border-radius: 4px;
|
|
outline-offset: var(--accessibility-focus-outline-offset);
|
|
outline-width: var(--accessibility-focus-outline-width);
|
|
outline-style: var(--accessibility-focus-outline-style);
|
|
outline-color: var(--accessibility-focus-outline-color);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ==========================================================================
|
|
6. Flex Table advanced wrapper Dark mode
|
|
========================================================================== */
|
|
|
|
.is-dark {
|
|
.flex-table-wrapper {
|
|
background: color-mix(in oklab, var(--dark-sidebar), white 6%);
|
|
border-color: color-mix(in oklab, var(--dark-sidebar), white 12%);
|
|
}
|
|
}
|
|
|
|
/* ==========================================================================
|
|
9. Media Queries
|
|
========================================================================== */
|
|
|
|
@media (width <= 767px) {
|
|
.flex-table-wrapper {
|
|
.flex-table {
|
|
.flex-table-header {
|
|
.is-checkbox {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
.flex-table-item {
|
|
padding-inline-start: 0;
|
|
padding-inline-end: 0;
|
|
|
|
.is-checkbox {
|
|
display: none;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style>
|