Files
skt-vuetify-templates/src/composables/maint/useEditableStudentGrid.ts
T

354 lines
9.3 KiB
TypeScript

import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { type StudentRecord, useStudentStore } from '@/stores/students'
type StudentPayload = Omit<StudentRecord, 'id'>
const departments = ['資訊工程', '企業管理', '應用外語', '視覺設計', '財務金融']
const gradeOptions = [
{ title: '大一', value: 1 },
{ title: '大二', value: 2 },
{ title: '大三', value: 3 },
{ title: '大四', value: 4 },
{ title: '研究所', value: 5 },
]
const enrollYears = [2024, 2023, 2022, 2021, 2020, 2019]
const statuses = ['在學', '休學', '畢業']
const tableHeaders = [
{ title: '', key: 'select', sortable: false, nowrap: true },
{
title: '學號',
key: 'studentId',
sortable: true,
minWidth: 120,
nowrap: true,
cellProps: { class: 'px-1' },
},
{
title: '姓名',
key: 'name',
sortable: true,
minWidth: 120,
nowrap: true,
cellProps: { class: 'px-1' },
},
{
title: '系所',
key: 'department',
sortable: true,
minWidth: 140,
nowrap: true,
cellProps: { class: 'px-1' },
},
{
title: '年級',
key: 'grade',
sortable: true,
minWidth: 140,
nowrap: true,
cellProps: { class: 'px-1' },
},
{
title: '入學年度',
key: 'enrollYear',
sortable: true,
minWidth: 120,
nowrap: true,
cellProps: { class: 'px-1' },
},
{
title: '已修學分',
key: 'credits',
sortable: true,
minWidth: 120,
nowrap: true,
cellProps: { class: 'px-1' },
},
{
title: '指導老師',
key: 'advisor',
sortable: true,
minWidth: 120,
nowrap: true,
cellProps: { class: 'px-1' },
},
{
title: 'Email',
key: 'email',
sortable: true,
minWidth: 200,
nowrap: true,
cellProps: { class: 'px-1' },
},
{
title: '電話',
key: 'phone',
sortable: true,
minWidth: 150,
nowrap: true,
cellProps: { class: 'px-1' },
},
{
title: '狀態',
key: 'status',
sortable: true,
minWidth: 120,
nowrap: true,
cellProps: { class: 'px-1' },
},
{
title: '操作',
key: 'actions',
sortable: false,
width: 'auto',
nowrap: true,
cellProps: { class: 'bg-background' },
},
] as const
const TABLE_BOTTOM_GAP = 64
const TABLE_MIN_HEIGHT = 240
export function useEditableStudentGrid() {
const studentStore = useStudentStore()
const students = computed(() => studentStore.students)
const search = ref({
studentId: '',
name: '',
department: '',
})
const isBulkEditEnabled = ref(false)
const isSearchVisible = ref(false)
const tableContainerRef = ref<HTMLElement | null>(null)
const tableScrollParentRef = ref<HTMLElement | null>(null)
const tableResizeObserver = ref<ResizeObserver | null>(null)
const tableHeight = ref(300)
const draftRows = ref<Record<number, StudentPayload>>({})
const selectedRowIds = ref<number[]>([])
function toPayload(student: StudentRecord): StudentPayload {
return {
studentId: student.studentId,
name: student.name,
department: student.department,
grade: student.grade,
enrollYear: student.enrollYear,
credits: student.credits,
advisor: student.advisor,
email: student.email,
phone: student.phone,
status: student.status,
}
}
function rebuildDraftRows() {
draftRows.value = Object.fromEntries(students.value.map((item) => [item.id, toPayload(item)]))
}
function getDraftRow(id: number) {
return draftRows.value[id] ?? null
}
const getSourceRow = (id: number) => students.value.find((item) => item.id === id) || null
function isRowDirty(id: number) {
const source = getSourceRow(id)
const draft = getDraftRow(id)
if (!source || !draft) return false
return (
source.studentId !== draft.studentId ||
source.name !== draft.name ||
source.department !== draft.department ||
source.grade !== draft.grade ||
source.enrollYear !== draft.enrollYear ||
source.credits !== draft.credits ||
source.advisor !== draft.advisor ||
source.email !== draft.email ||
source.phone !== draft.phone ||
source.status !== draft.status
)
}
const filteredStudents = computed(() => {
const keywordStudentId = search.value.studentId.trim().toLowerCase()
const keywordName = search.value.name.trim().toLowerCase()
const selectedDepartment = search.value.department
return students.value.filter((item) => {
if (studentStore.isMarkedAsDeleted(item.id)) return false
const matchesStudentId = keywordStudentId
? item.studentId.toLowerCase().includes(keywordStudentId)
: true
const matchesName = keywordName ? item.name.toLowerCase().includes(keywordName) : true
const matchesDepartment = selectedDepartment ? item.department === selectedDepartment : true
return matchesStudentId && matchesName && matchesDepartment
})
})
const visibleStudentIds = computed(() => filteredStudents.value.map((item) => item.id))
const isAllVisibleSelected = computed(
() =>
visibleStudentIds.value.length > 0 &&
visibleStudentIds.value.every((id) => selectedRowIds.value.includes(id))
)
const isPartiallyVisibleSelected = computed(
() =>
visibleStudentIds.value.some((id) => selectedRowIds.value.includes(id)) &&
!isAllVisibleSelected.value
)
const hasAnyChange = computed(
() => students.value.some((item) => isRowDirty(item.id)) || studentStore.deletedIds.size > 0
)
const hasSelectedRows = computed(() => selectedRowIds.value.length > 0)
function toggleSelectAll(checked: boolean | null) {
if (isPartiallyVisibleSelected.value) {
selectedRowIds.value = selectedRowIds.value.filter(
(id) => !visibleStudentIds.value.includes(id)
)
return
}
if (!checked) {
selectedRowIds.value = selectedRowIds.value.filter(
(id) => !visibleStudentIds.value.includes(id)
)
return
}
selectedRowIds.value = Array.from(
new Set([...selectedRowIds.value, ...visibleStudentIds.value])
)
}
function toggleSingleRowSelection(id: number, checked: boolean | null) {
if (checked) {
if (!selectedRowIds.value.includes(id)) {
selectedRowIds.value = [...selectedRowIds.value, id]
}
return
}
selectedRowIds.value = selectedRowIds.value.filter((selectedId) => selectedId !== id)
}
function deleteSingleRow(id: number) {
selectedRowIds.value = selectedRowIds.value.filter((selectedId) => selectedId !== id)
studentStore.markAsDeleted(id)
rebuildDraftRows()
}
function deleteSelectedRows() {
if (selectedRowIds.value.length === 0) return
for (const id of selectedRowIds.value) {
studentStore.markAsDeleted(id)
}
selectedRowIds.value = []
rebuildDraftRows()
}
function saveAllRows() {
for (const item of students.value) {
const draft = getDraftRow(item.id)
if (!draft) continue
if (!isRowDirty(item.id)) continue
studentStore.updateStudent(item.id, draft)
}
studentStore.commitDeleted()
rebuildDraftRows()
}
function resetAllRows() {
studentStore.restoreDeleted()
selectedRowIds.value = []
rebuildDraftRows()
}
function recalculateTableHeight() {
const container = tableContainerRef.value
if (!container) return
const scrollParent =
tableScrollParentRef.value || (container.closest('.overflow-auto') as HTMLElement | null)
tableScrollParentRef.value = scrollParent
const parentBottom = scrollParent
? scrollParent.getBoundingClientRect().bottom
: window.innerHeight
const top = container.getBoundingClientRect().top
const availableHeight = parentBottom - top - TABLE_BOTTOM_GAP
tableHeight.value = Math.max(TABLE_MIN_HEIGHT, Math.floor(availableHeight))
}
watch(students, () => {
const currentIds = new Set(students.value.map((item) => item.id))
selectedRowIds.value = selectedRowIds.value.filter((id) => currentIds.has(id))
})
watch(students, rebuildDraftRows, { immediate: true })
watch([isSearchVisible, isBulkEditEnabled], async () => {
await nextTick()
requestAnimationFrame(() => {
recalculateTableHeight()
})
})
onMounted(() => {
const container = tableContainerRef.value
if (container) {
tableScrollParentRef.value = container.closest('.overflow-auto') as HTMLElement | null
tableResizeObserver.value = new ResizeObserver(() => {
recalculateTableHeight()
})
tableResizeObserver.value.observe(container)
if (tableScrollParentRef.value) {
tableResizeObserver.value.observe(tableScrollParentRef.value)
}
}
requestAnimationFrame(() => {
recalculateTableHeight()
})
window.addEventListener('resize', recalculateTableHeight)
})
onBeforeUnmount(() => {
tableResizeObserver.value?.disconnect()
window.removeEventListener('resize', recalculateTableHeight)
})
return {
departments,
draftRows,
enrollYears,
filteredStudents,
getDraftRow,
gradeOptions,
hasAnyChange,
hasSelectedRows,
isAllVisibleSelected,
isBulkEditEnabled,
isPartiallyVisibleSelected,
isSearchVisible,
saveAllRows,
search,
selectedRowIds,
statuses,
tableContainerRef,
tableHeaders,
tableHeight,
toggleSelectAll,
toggleSingleRowSelection,
deleteSelectedRows,
deleteSingleRow,
resetAllRows,
}
}