354 lines
9.3 KiB
TypeScript
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,
|
|
}
|
|
}
|