|
|
|
@@ -0,0 +1,407 @@
|
|
|
|
|
import { computed, nextTick, ref, watch } from 'vue'
|
|
|
|
|
import { useDisplay } from 'vuetify'
|
|
|
|
|
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
|
|
|
|
|
import {
|
|
|
|
|
type StudentFormState,
|
|
|
|
|
useStudentMaintenanceForm,
|
|
|
|
|
} from '@/composables/maint/useStudentMaintenanceForm'
|
|
|
|
|
import type { MaintenancePageModel } from '@/models/page'
|
|
|
|
|
import { type SemesterRecord, useSemesterStore } from '@/stores/semesters'
|
|
|
|
|
import { type StudentRecord, useStudentStore } from '@/stores/students'
|
|
|
|
|
|
|
|
|
|
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 itemsPerPage = 10
|
|
|
|
|
|
|
|
|
|
type StudentPayload = Omit<StudentRecord, 'id'>
|
|
|
|
|
|
|
|
|
|
function toFormPayload(student: StudentRecord): StudentFormState {
|
|
|
|
|
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 toSavePayload(form: StudentFormState): StudentPayload {
|
|
|
|
|
return {
|
|
|
|
|
studentId: form.studentId.trim(),
|
|
|
|
|
name: form.name.trim(),
|
|
|
|
|
department: form.department,
|
|
|
|
|
grade: form.grade,
|
|
|
|
|
enrollYear: form.enrollYear,
|
|
|
|
|
credits: form.credits,
|
|
|
|
|
advisor: form.advisor.trim(),
|
|
|
|
|
email: form.email.trim(),
|
|
|
|
|
phone: form.phone.trim(),
|
|
|
|
|
status: form.status,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useMasterDetailAMaintenancePage() {
|
|
|
|
|
const studentStore = useStudentStore()
|
|
|
|
|
const semesterStore = useSemesterStore()
|
|
|
|
|
const students = computed(() => studentStore.students)
|
|
|
|
|
const { smAndUp } = useDisplay()
|
|
|
|
|
const isMobile = computed(() => !smAndUp.value)
|
|
|
|
|
const pageModel = computed<MaintenancePageModel>(() => ({
|
|
|
|
|
type: 'maintenance',
|
|
|
|
|
title: '主從資料維護示範A',
|
|
|
|
|
records: students.value,
|
|
|
|
|
loading: false,
|
|
|
|
|
error: null,
|
|
|
|
|
}))
|
|
|
|
|
const search = ref({ studentId: '', name: '', department: '', grade: null as number | null, status: '' })
|
|
|
|
|
const searchPanelOpen = ref(false)
|
|
|
|
|
const currentPage = ref(1)
|
|
|
|
|
const pageCount = computed(() => Math.max(1, Math.ceil(students.value.length / itemsPerPage)))
|
|
|
|
|
const pageSummary = computed(() => {
|
|
|
|
|
const total = students.value.length
|
|
|
|
|
if (total === 0) return '第 0-0 筆 / 共 0 筆'
|
|
|
|
|
const start = (currentPage.value - 1) * itemsPerPage + 1
|
|
|
|
|
const end = Math.min(currentPage.value * itemsPerPage, total)
|
|
|
|
|
return `第 ${start}-${end} 筆 / 共 ${total} 筆`
|
|
|
|
|
})
|
|
|
|
|
const dialogVisible = ref(false)
|
|
|
|
|
const editingId = ref<number | null>(null)
|
|
|
|
|
const dialogMode = ref<'create' | 'edit' | 'view'>('create')
|
|
|
|
|
const isLoading = ref(false)
|
|
|
|
|
const isSaving = ref(false)
|
|
|
|
|
const snackbarVisible = ref(false)
|
|
|
|
|
const highlightedId = ref<number | null>(null)
|
|
|
|
|
const loadSequence = ref(0)
|
|
|
|
|
const studentSemesters = ref<SemesterRecord[]>([])
|
|
|
|
|
const selectedSemesterId = ref<number | null>(null)
|
|
|
|
|
const activeMobilePanel = ref<'master' | 'detail'>('master')
|
|
|
|
|
const selectedSemester = computed(
|
|
|
|
|
() => studentSemesters.value.find((semester) => semester.id === selectedSemesterId.value) || null
|
|
|
|
|
)
|
|
|
|
|
const isDetailEditing = ref(false)
|
|
|
|
|
const detailForm = ref<SemesterRecord | null>(null)
|
|
|
|
|
const formState = useStudentMaintenanceForm({
|
|
|
|
|
departments,
|
|
|
|
|
gradeOptions,
|
|
|
|
|
enrollYears,
|
|
|
|
|
statuses,
|
|
|
|
|
students,
|
|
|
|
|
editingId,
|
|
|
|
|
highlightedId,
|
|
|
|
|
})
|
|
|
|
|
const isFormLocked = computed(() => isLoading.value || isSaving.value)
|
|
|
|
|
const tableHeaders = computed(() => [
|
|
|
|
|
{ title: '學號', key: 'studentId', sortable: true, fixed: smAndUp.value && ('start' as const), width: 120 },
|
|
|
|
|
{ title: '姓名', key: 'name', sortable: true, fixed: smAndUp.value && ('start' as const), width: 100 },
|
|
|
|
|
{ title: '系所', key: 'department', sortable: true, width: 140 },
|
|
|
|
|
{ title: '年級', key: 'grade', sortable: true, width: 90 },
|
|
|
|
|
{ title: '入學年度', key: 'enrollYear', sortable: true, width: 110 },
|
|
|
|
|
{ title: '已修學分', key: 'credits', sortable: true, width: 110 },
|
|
|
|
|
{ title: 'Email', key: 'email', sortable: true, width: 200 },
|
|
|
|
|
{ title: '電話', key: 'phone', sortable: true, width: 140 },
|
|
|
|
|
{ title: '指導老師', key: 'advisor', sortable: true, width: 110 },
|
|
|
|
|
{ title: '狀態', key: 'status', sortable: true, width: 90 },
|
|
|
|
|
{ title: '操作', key: 'actions', sortable: false, fixed: smAndUp.value && ('end' as const), width: 'auto', cellProps: { class: 'px-0 bg-background' } },
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
function resetDetailState() {
|
|
|
|
|
selectedSemesterId.value = null
|
|
|
|
|
activeMobilePanel.value = 'master'
|
|
|
|
|
isDetailEditing.value = false
|
|
|
|
|
detailForm.value = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function refreshSemesters() {
|
|
|
|
|
if (editingId.value) {
|
|
|
|
|
studentSemesters.value = semesterStore.getStudentSemesters(editingId.value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleAddSemester() {
|
|
|
|
|
if (!editingId.value) return
|
|
|
|
|
const newSemester = semesterStore.addSemester(editingId.value)
|
|
|
|
|
refreshSemesters()
|
|
|
|
|
selectedSemesterId.value = newSemester.id
|
|
|
|
|
activeMobilePanel.value = 'detail'
|
|
|
|
|
startDetailEdit()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleDeleteSemester(id: number) {
|
|
|
|
|
if (!confirm('確定要刪除此學期紀錄嗎?')) return
|
|
|
|
|
semesterStore.removeSemester(id)
|
|
|
|
|
refreshSemesters()
|
|
|
|
|
if (selectedSemesterId.value === id) {
|
|
|
|
|
resetDetailState()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startDetailEdit() {
|
|
|
|
|
if (!selectedSemester.value) return
|
|
|
|
|
detailForm.value = structuredClone(selectedSemester.value)
|
|
|
|
|
isDetailEditing.value = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cancelDetailEdit() {
|
|
|
|
|
isDetailEditing.value = false
|
|
|
|
|
detailForm.value = null
|
|
|
|
|
if (isMobile.value && selectedSemesterId.value === null) {
|
|
|
|
|
activeMobilePanel.value = 'master'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function saveDetailEdit() {
|
|
|
|
|
if (!detailForm.value?.id) return
|
|
|
|
|
semesterStore.updateSemester(detailForm.value.id, detailForm.value)
|
|
|
|
|
refreshSemesters()
|
|
|
|
|
isDetailEditing.value = false
|
|
|
|
|
detailForm.value = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openAddDialog() {
|
|
|
|
|
loadSequence.value += 1
|
|
|
|
|
dialogMode.value = 'create'
|
|
|
|
|
editingId.value = null
|
|
|
|
|
studentSemesters.value = []
|
|
|
|
|
resetDetailState()
|
|
|
|
|
formState.resetForm()
|
|
|
|
|
isLoading.value = false
|
|
|
|
|
dialogVisible.value = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadRecord(student: StudentRecord, mode: 'edit' | 'view') {
|
|
|
|
|
loadSequence.value += 1
|
|
|
|
|
const sequence = loadSequence.value
|
|
|
|
|
dialogMode.value = mode
|
|
|
|
|
editingId.value = student.id
|
|
|
|
|
studentSemesters.value = semesterStore.getStudentSemesters(student.id)
|
|
|
|
|
resetDetailState()
|
|
|
|
|
dialogVisible.value = true
|
|
|
|
|
isLoading.value = true
|
|
|
|
|
formState.clearAllErrors()
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
if (sequence !== loadSequence.value || !dialogVisible.value) return
|
|
|
|
|
formState.setForm(toFormPayload(student))
|
|
|
|
|
formState.syncInitialForm()
|
|
|
|
|
isLoading.value = false
|
|
|
|
|
}, 350)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openEditDialog(student: StudentRecord) {
|
|
|
|
|
loadRecord(student, 'edit')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openViewDialog(student: StudentRecord) {
|
|
|
|
|
loadRecord(student, 'view')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const flow = useMaintenanceCrudFlow<StudentRecord>({
|
|
|
|
|
records: students,
|
|
|
|
|
editingId,
|
|
|
|
|
dialogMode,
|
|
|
|
|
dialogVisible,
|
|
|
|
|
isLoading,
|
|
|
|
|
isSaving,
|
|
|
|
|
isDirty: formState.isDirty,
|
|
|
|
|
clearAllErrors: formState.clearAllErrors,
|
|
|
|
|
resetForm: formState.resetForm,
|
|
|
|
|
openEditDialog,
|
|
|
|
|
openViewDialog,
|
|
|
|
|
removeRecord: (id) => {
|
|
|
|
|
studentStore.removeStudent(id)
|
|
|
|
|
semesterStore.removeByStudentId(id)
|
|
|
|
|
},
|
|
|
|
|
describeRecord: (student) => `${student.studentId} ${student.name}`,
|
|
|
|
|
onCloseReset: resetDetailState,
|
|
|
|
|
})
|
|
|
|
|
const dialogTitle = computed(() => {
|
|
|
|
|
if (dialogMode.value === 'view') return '檢視主檔資料示範'
|
|
|
|
|
if (dialogMode.value === 'edit') return '修改主檔資料示範'
|
|
|
|
|
return '新增主檔資料示範'
|
|
|
|
|
})
|
|
|
|
|
const dialogSubtitle = computed(() => {
|
|
|
|
|
if (!editingId.value) return ''
|
|
|
|
|
return `${formState.form.value.studentId || '未填學號'}・${formState.form.value.name || '未填姓名'}`
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
watch(pageCount, (value) => {
|
|
|
|
|
if (currentPage.value > value) currentPage.value = value
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
function resetSearch() {
|
|
|
|
|
search.value = { studentId: '', name: '', department: '', grade: null, status: '' }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function requestSaveConfirmation() {
|
|
|
|
|
if (isSaving.value || isLoading.value || !formState.isDirty.value || flow.isViewMode.value) return
|
|
|
|
|
formState.clearAllErrors()
|
|
|
|
|
const errors = formState.validateForm()
|
|
|
|
|
if (errors.length > 0) {
|
|
|
|
|
for (const error of errors) {
|
|
|
|
|
formState.fieldErrors.value[error.field] = [error.message]
|
|
|
|
|
}
|
|
|
|
|
await nextTick()
|
|
|
|
|
const firstError = errors[0]
|
|
|
|
|
if (firstError) scrollToField(firstError.field)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
flow.confirmSaveVisible.value = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function confirmSave() {
|
|
|
|
|
flow.confirmSaveVisible.value = false
|
|
|
|
|
await saveStudent()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveStudent() {
|
|
|
|
|
if (isSaving.value || isLoading.value) return
|
|
|
|
|
isSaving.value = true
|
|
|
|
|
await new Promise((resolve) => window.setTimeout(resolve, 450))
|
|
|
|
|
const payload = toSavePayload(formState.form.value)
|
|
|
|
|
if (editingId.value) {
|
|
|
|
|
const updated = studentStore.updateStudent(editingId.value, payload)
|
|
|
|
|
if (updated) highlightedId.value = editingId.value
|
|
|
|
|
} else {
|
|
|
|
|
const createdId = studentStore.addStudent(payload)
|
|
|
|
|
semesterStore.generateForStudent(createdId)
|
|
|
|
|
highlightedId.value = createdId
|
|
|
|
|
}
|
|
|
|
|
formState.syncInitialForm()
|
|
|
|
|
dialogVisible.value = false
|
|
|
|
|
snackbarVisible.value = true
|
|
|
|
|
isSaving.value = false
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
highlightedId.value = null
|
|
|
|
|
}, 1600)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function scrollToField(field: string) {
|
|
|
|
|
const target = document.getElementById(`field-${field}`)
|
|
|
|
|
if (!target) return
|
|
|
|
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleSemesterSelect(id: number) {
|
|
|
|
|
if (isMobile.value) {
|
|
|
|
|
selectedSemesterId.value = id
|
|
|
|
|
activeMobilePanel.value = 'detail'
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
selectedSemesterId.value = selectedSemesterId.value === id ? null : id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closeDetailPanel() {
|
|
|
|
|
isDetailEditing.value = false
|
|
|
|
|
detailForm.value = null
|
|
|
|
|
if (isMobile.value) {
|
|
|
|
|
activeMobilePanel.value = 'master'
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
selectedSemesterId.value = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const masterDetailProps = computed(() => ({
|
|
|
|
|
activeMobilePanel: activeMobilePanel.value,
|
|
|
|
|
confirmCloseVisible: flow.confirmCloseVisible.value,
|
|
|
|
|
confirmDeleteVisible: flow.confirmDeleteVisible.value,
|
|
|
|
|
confirmNavigateVisible: flow.confirmNavigateVisible.value,
|
|
|
|
|
confirmSaveVisible: flow.confirmSaveVisible.value,
|
|
|
|
|
confirmSwitchVisible: flow.confirmSwitchVisible.value,
|
|
|
|
|
departments,
|
|
|
|
|
detailForm: detailForm.value,
|
|
|
|
|
dialogSubtitle: dialogSubtitle.value,
|
|
|
|
|
dialogTitle: dialogTitle.value,
|
|
|
|
|
dialogVisible: dialogVisible.value,
|
|
|
|
|
enrollYears,
|
|
|
|
|
errorSummary: formState.errorSummary.value,
|
|
|
|
|
fieldErrors: formState.fieldErrors.value,
|
|
|
|
|
form: formState.form.value,
|
|
|
|
|
gradeOptions,
|
|
|
|
|
hasNextRecord: flow.hasNextRecord.value,
|
|
|
|
|
hasPrevRecord: flow.hasPrevRecord.value,
|
|
|
|
|
isDetailEditing: isDetailEditing.value,
|
|
|
|
|
isDirty: formState.isDirty.value,
|
|
|
|
|
isEditMode: flow.isEditMode.value,
|
|
|
|
|
isFormLocked: isFormLocked.value,
|
|
|
|
|
isFormReadonly: flow.isViewMode.value,
|
|
|
|
|
isLoading: isLoading.value,
|
|
|
|
|
isMobile: isMobile.value,
|
|
|
|
|
isSaving: isSaving.value,
|
|
|
|
|
isViewMode: flow.isViewMode.value,
|
|
|
|
|
pendingDeleteLabel: flow.pendingDeleteLabel.value,
|
|
|
|
|
saveSummary: formState.saveSummary.value,
|
|
|
|
|
selectedSemester: selectedSemester.value,
|
|
|
|
|
selectedSemesterId: selectedSemesterId.value,
|
|
|
|
|
semesters: studentSemesters.value,
|
|
|
|
|
statuses,
|
|
|
|
|
}))
|
|
|
|
|
const masterDetailEvents = {
|
|
|
|
|
'add-semester': handleAddSemester,
|
|
|
|
|
'cancel-detail-edit': cancelDetailEdit,
|
|
|
|
|
'clear-field-error': formState.clearFieldError,
|
|
|
|
|
close: flow.requestCloseDialog,
|
|
|
|
|
'close-detail-panel': closeDetailPanel,
|
|
|
|
|
'confirm-close': flow.confirmClose,
|
|
|
|
|
'confirm-delete': flow.confirmDelete,
|
|
|
|
|
'confirm-navigate': flow.confirmNavigate,
|
|
|
|
|
'confirm-save': confirmSave,
|
|
|
|
|
'confirm-switch': flow.confirmSwitch,
|
|
|
|
|
delete: flow.requestDeleteConfirmation,
|
|
|
|
|
'delete-current': flow.requestDeleteCurrent,
|
|
|
|
|
'delete-semester': handleDeleteSemester,
|
|
|
|
|
'dialog-visible-change': flow.handleDialogVisibility,
|
|
|
|
|
first: () => flow.openEdgeRecord('first'),
|
|
|
|
|
last: () => flow.openEdgeRecord('last'),
|
|
|
|
|
next: () => flow.openAdjacentRecord('next'),
|
|
|
|
|
prev: () => flow.openAdjacentRecord('prev'),
|
|
|
|
|
save: requestSaveConfirmation,
|
|
|
|
|
'save-detail-edit': saveDetailEdit,
|
|
|
|
|
'scroll-to-field': scrollToField,
|
|
|
|
|
'select-semester': handleSemesterSelect,
|
|
|
|
|
'start-detail-edit': startDetailEdit,
|
|
|
|
|
'switch-to-edit': flow.switchToEditMode,
|
|
|
|
|
'switch-to-view': flow.switchToViewMode,
|
|
|
|
|
'update:confirmCloseVisible': (value: boolean) => (flow.confirmCloseVisible.value = value),
|
|
|
|
|
'update:confirmDeleteVisible': (value: boolean) => (flow.confirmDeleteVisible.value = value),
|
|
|
|
|
'update:confirmNavigateVisible': (value: boolean) => (flow.confirmNavigateVisible.value = value),
|
|
|
|
|
'update:confirmSaveVisible': (value: boolean) => (flow.confirmSaveVisible.value = value),
|
|
|
|
|
'update:confirmSwitchVisible': (value: boolean) => (flow.confirmSwitchVisible.value = value),
|
|
|
|
|
'update:detailForm': (value: SemesterRecord | null) => (detailForm.value = value),
|
|
|
|
|
'update:form': (value: StudentFormState) => (formState.form.value = value),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
currentPage,
|
|
|
|
|
departments,
|
|
|
|
|
formState,
|
|
|
|
|
gradeOptions,
|
|
|
|
|
itemsPerPage,
|
|
|
|
|
masterDetailEvents,
|
|
|
|
|
masterDetailProps,
|
|
|
|
|
openAddDialog,
|
|
|
|
|
openEditDialog,
|
|
|
|
|
openViewDialog,
|
|
|
|
|
pageCount,
|
|
|
|
|
pageModel,
|
|
|
|
|
pageSummary,
|
|
|
|
|
resetSearch,
|
|
|
|
|
search,
|
|
|
|
|
searchPanelOpen,
|
|
|
|
|
snackbarVisible,
|
|
|
|
|
statuses,
|
|
|
|
|
students,
|
|
|
|
|
tableHeaders,
|
|
|
|
|
}
|
|
|
|
|
}
|