feat: add SingleRecord component for student maintenance with CRUD functionality
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user