Commit efd558d6 authored by Administrator's avatar Administrator
Browse files

refactor(master-data): useCrudList composable + 4 view decomposition — Candidate A



Extract src/composables/useCrudList.js: owns list state + dialog state
machine + applySaved/applyDeleted + 5 toggle methods. Config:
{ list, create, update, deleteFn, idKey, mapRow?, loadErrorKey? }.

Decompose 4 master-data views into table + edit-dialog + delete-dialog
SFCs (11 new files), mirroring the StoryView pattern proven in
Candidate B. View shrinks to pure wiring of useCrudList + useFlashMessage
+ child SFCs.

Structural fixes (now composition-enforced, not patch-enforced):
- D5: applySaved merges backend response into local list, so audit
  fields (created_by, creation_date, last_updated_by, last_update_date)
  are visible immediately after save, no refresh required.
- D7: useCrudList owns deleteError; only *DeleteDialog.vue reads it.
  Cross-dialog delete-error bug fixed by composition.

Net change:
  SubjectView  763  →  85
  TermView     619  →  95
  StudentView 1312  → 115
  StoryView    173  →  85
  Total       2867  → 380  (-2487 lines)

Tests: useCrudList.spec.js covers fetch (success/error/auto-clear/
mapRow/non-array fallback), applySaved (create/update/audit-preservation/
no-id/null-undefined), applyDeleted, 5 toggle methods, clearDeleteError,
idKey+mapRow together. 23 new tests, 214 total passing.

Other quality wins: ~30 console.log calls in the 3 large views removed;
view-owned reference data (R2) keeps useCrudList agnostic of students/
subjects/terms.
Co-Authored-By: default avatarClaude <noreply@anthropic.com>
parent 7f6e1ee8
// Seam tests for useCrudList — the state-machine composable that owns a
// single DRF list endpoint's items + dialog state + list mutators. The
// composable does not localize, does not own form state, does not fetch
// reference data — those responsibilities stay in the view / dialog.
//
// Service functions are vi.fn()s; no axios / network involved.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { useCrudList } from '@/composables/useCrudList.js'
function makeServices(overrides = {}) {
return {
list: vi.fn().mockResolvedValue([]),
create: vi.fn(),
update: vi.fn(),
deleteFn: vi.fn(),
...overrides,
}
}
function makeCrud(overrides = {}) {
const services = makeServices(overrides.services || {})
const crud = useCrudList({
list: services.list,
create: services.create,
update: services.update,
deleteFn: services.deleteFn,
idKey: overrides.idKey || 'student_id',
mapRow: overrides.mapRow,
loadErrorKey: overrides.loadErrorKey,
})
return { crud, services }
}
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
// ─── initial state ───────────────────────────────────────────────────────
describe('initial state', () => {
it('starts with empty items and all dialog flags false', () => {
const { crud } = makeCrud()
expect(crud.items.value).toEqual([])
expect(crud.isLoading.value).toBe(false)
expect(crud.isError.value).toBe(false)
expect(crud.errorMessage.value).toBe('')
expect(crud.deleteError.value).toBe('')
expect(crud.editingItem.value).toBeNull()
expect(crud.editingDialogOpen.value).toBe(false)
expect(crud.itemToDelete.value).toBeNull()
expect(crud.deleteDialogOpen.value).toBe(false)
})
})
// ─── fetch ───────────────────────────────────────────────────────────────
describe('fetch', () => {
it('success — assigns items, flips isLoading during', async () => {
const services = makeServices({ list: vi.fn().mockResolvedValue([{ student_id: 1, name: 'A' }]) })
const crud = useCrudList({
list: services.list,
create: services.create,
update: services.update,
deleteFn: services.deleteFn,
idKey: 'student_id',
})
const p = crud.fetch()
expect(crud.isLoading.value).toBe(true)
await p
expect(crud.isLoading.value).toBe(false)
expect(crud.items.value).toEqual([{ student_id: 1, name: 'A' }])
expect(crud.isError.value).toBe(false)
})
it('success — applies mapRow to each row', async () => {
const services = makeServices({
list: vi.fn().mockResolvedValue([{ student_id: 1, name: 'alice' }]),
})
const crud = useCrudList({
list: services.list,
create: services.create,
update: services.update,
deleteFn: services.deleteFn,
idKey: 'student_id',
mapRow: (r) => ({ ...r, display: r.name.toUpperCase() }),
})
await crud.fetch()
expect(crud.items.value[0]).toEqual({
student_id: 1,
name: 'alice',
display: 'ALICE',
})
})
it('error — sets isError + errorMessage, auto-clears after 3000ms', async () => {
const services = makeServices({ list: vi.fn().mockRejectedValue(new Error('boom')) })
const crud = useCrudList({
list: services.list,
create: services.create,
update: services.update,
deleteFn: services.deleteFn,
idKey: 'student_id',
})
await crud.fetch()
expect(crud.isError.value).toBe(true)
expect(crud.errorMessage.value).toBe('boom')
vi.advanceTimersByTime(2999)
expect(crud.isError.value).toBe(true)
vi.advanceTimersByTime(1)
expect(crud.isError.value).toBe(false)
expect(crud.errorMessage.value).toBe('')
})
it('error — prefers DRF detail over error.message', async () => {
const err = new Error('generic')
err.response = { data: { detail: 'specific DRF message' } }
const services = makeServices({ list: vi.fn().mockRejectedValue(err) })
const crud = useCrudList({
list: services.list,
create: services.create,
update: services.update,
deleteFn: services.deleteFn,
idKey: 'student_id',
})
await crud.fetch()
expect(crud.errorMessage.value).toBe('specific DRF message')
})
it('error — falls back to loadErrorKey when no message present', async () => {
const services = makeServices({ list: vi.fn().mockRejectedValue(new Error('')) })
const crud = useCrudList({
list: services.list,
create: services.create,
update: services.update,
deleteFn: services.deleteFn,
idKey: 'student_id',
loadErrorKey: 'localized-fallback',
})
await crud.fetch()
expect(crud.errorMessage.value).toBe('localized-fallback')
})
it('non-array response — items defaults to []', async () => {
const services = makeServices({ list: vi.fn().mockResolvedValue({ count: 0, results: [] }) })
const crud = useCrudList({
list: services.list,
create: services.create,
update: services.update,
deleteFn: services.deleteFn,
idKey: 'student_id',
})
await crud.fetch()
expect(crud.items.value).toEqual([])
})
})
// ─── applySaved ──────────────────────────────────────────────────────────
describe('applySaved', () => {
it('creates a new row when id is unknown — returns true', () => {
const { crud } = makeCrud()
const created = crud.applySaved({ student_id: 1, name: 'A' })
expect(created).toBe(true)
expect(crud.items.value).toHaveLength(1)
expect(crud.items.value[0]).toEqual({ student_id: 1, name: 'A' })
})
it('updates an existing row in place — returns false', () => {
const { crud } = makeCrud()
crud.items.value = [{ student_id: 1, name: 'old' }]
const updated = crud.applySaved({ student_id: 1, name: 'new', extra: 'x' })
expect(updated).toBe(false)
expect(crud.items.value).toHaveLength(1)
expect(crud.items.value[0]).toEqual({ student_id: 1, name: 'new', extra: 'x' })
})
it('preserves backend audit fields (D5 fix)', () => {
const { crud } = makeCrud()
crud.items.value = [{ student_id: 1, name: 'A' }] // no audit fields
crud.applySaved({
student_id: 1,
name: 'A',
last_updated_by: 'alice',
last_update_date: '2026-07-19',
})
expect(crud.items.value[0]).toMatchObject({
last_updated_by: 'alice',
last_update_date: '2026-07-19',
})
})
it('applies mapRow on the merged result', () => {
const { crud } = makeCrud({
mapRow: (r) => ({ ...r, display: r.name?.toUpperCase() ?? null }),
})
crud.items.value = [{ student_id: 1, name: 'a' }]
crud.applySaved({ student_id: 1, name: 'b' })
expect(crud.items.value[0].display).toBe('B')
})
it('is a no-op when entity is missing the idKey', () => {
const { crud } = makeCrud()
crud.items.value = [{ student_id: 1, name: 'A' }]
const created = crud.applySaved({ name: 'no-id' })
expect(created).toBe(false)
expect(crud.items.value).toHaveLength(1)
expect(crud.items.value[0].name).toBe('A')
})
it('is a no-op when entity is null/undefined', () => {
const { crud } = makeCrud()
crud.items.value = [{ student_id: 1, name: 'A' }]
expect(crud.applySaved(null)).toBe(false)
expect(crud.applySaved(undefined)).toBe(false)
expect(crud.items.value).toHaveLength(1)
})
})
// ─── applyDeleted ────────────────────────────────────────────────────────
describe('applyDeleted', () => {
it('removes an existing id', () => {
const { crud } = makeCrud()
crud.items.value = [
{ student_id: 1, name: 'A' },
{ student_id: 2, name: 'B' },
]
crud.applyDeleted(1)
expect(crud.items.value).toEqual([{ student_id: 2, name: 'B' }])
})
it('is a no-op for an unknown id', () => {
const { crud } = makeCrud()
crud.items.value = [{ student_id: 1, name: 'A' }]
crud.applyDeleted(99)
expect(crud.items.value).toHaveLength(1)
})
it('does NOT touch deleteError (D7 — that is the delete dialog\'s ref)', () => {
const { crud } = makeCrud()
crud.applyDeleted(99)
expect(crud.deleteError.value).toBe('')
})
})
// ─── toggle methods ──────────────────────────────────────────────────────
describe('toggle methods', () => {
it('openEdit — sets editingItem + opens dialog', () => {
const { crud } = makeCrud()
const item = { student_id: 1, name: 'A' }
crud.openEdit(item)
expect(crud.editingItem.value).toEqual(item)
expect(crud.editingDialogOpen.value).toBe(true)
expect(crud.deleteDialogOpen.value).toBe(false)
})
it('openCreate — null editingItem + opens dialog', () => {
const { crud } = makeCrud()
crud.openCreate()
expect(crud.editingItem.value).toBeNull()
expect(crud.editingDialogOpen.value).toBe(true)
})
it('openDelete — sets itemToDelete + opens dialog', () => {
const { crud } = makeCrud()
const item = { student_id: 1, name: 'A' }
crud.openDelete(item)
expect(crud.itemToDelete.value).toEqual(item)
expect(crud.deleteDialogOpen.value).toBe(true)
expect(crud.editingDialogOpen.value).toBe(false)
})
it('closeEdit — clears dialog + editingItem', () => {
const { crud } = makeCrud()
crud.openEdit({ student_id: 1 })
crud.closeEdit()
expect(crud.editingDialogOpen.value).toBe(false)
expect(crud.editingItem.value).toBeNull()
})
it('closeDelete — clears dialog + itemToDelete', () => {
const { crud } = makeCrud()
crud.openDelete({ student_id: 1 })
crud.closeDelete()
expect(crud.deleteDialogOpen.value).toBe(false)
expect(crud.itemToDelete.value).toBeNull()
})
it('clearDeleteError — resets the ref', () => {
const { crud } = makeCrud()
crud.deleteError.value = 'some error'
crud.clearDeleteError()
expect(crud.deleteError.value).toBe('')
})
})
// ─── idKey + mapRow together ─────────────────────────────────────────────
describe('idKey + mapRow', () => {
it('uses the configured idKey, not a hardcoded one', () => {
const crud = useCrudList({
list: makeServices().list,
create: makeServices().create,
update: makeServices().update,
deleteFn: makeServices().deleteFn,
idKey: 'subject_id',
mapRow: (r) => ({ ...r, display: r.subject_name?.toUpperCase() ?? null }),
})
crud.items.value = [{ subject_id: 7, subject_name: 'math' }]
const created = crud.applySaved({ subject_id: 8, subject_name: 'sci' })
expect(created).toBe(true)
expect(crud.items.value[0]).toEqual({ subject_id: 8, subject_name: 'sci', display: 'SCI' })
crud.applySaved({ subject_id: 7, subject_name: 'maths' })
expect(crud.items.value[1]).toEqual({
subject_id: 7,
subject_name: 'maths',
display: 'MATHS',
})
})
})
\ No newline at end of file
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
// Avatar upload sub-component. Owns the FileReader, mime check, and 2MB
// size cap. Reports errors via @error event so the parent dialog can
// surface them in its own alert. Updates parent state via v-model
// (avatar / avatarMimeType / avatarFileName).
const props = defineProps({ // eslint-disable-line no-unused-vars
avatar: { type: String, default: '' },
avatarMimeType: { type: String, default: '' },
avatarFileName: { type: String, default: '' },
studentName: { type: String, default: '' },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits([
'update:avatar',
'update:avatarMimeType',
'update:avatarFileName',
'error',
])
const { t } = useI18n()
const fileInput = ref(null)
function getAvatarDataUrl(avatar, mimeType) {
if (!avatar || !mimeType) return null
return `data:${mimeType};base64,${avatar}`
}
function getAvatarFallback(name) {
if (!name || typeof name !== 'string') return '?'
const trimmed = name.trim()
return trimmed ? trimmed.charAt(0).toUpperCase() : '?'
}
function handleUpload(event) {
const files = event.target?.files || event
const file = Array.isArray(files) ? files[0] : files?.[0] || files
if (!file) return
if (!file.type.startsWith('image/')) {
emit('error', t('student.avatar.upload.selectImage'))
return
}
if (file.size > 2 * 1024 * 1024) {
emit('error', t('student.avatar.upload.sizeExceeded'))
return
}
const reader = new FileReader()
reader.onload = (e) => {
const base64 = e.target.result.split(',')[1]
emit('update:avatar', base64)
emit('update:avatarMimeType', file.type)
emit('update:avatarFileName', file.name)
emit('error', '')
}
reader.onerror = () => emit('error', t('student.avatar.upload.readError'))
reader.readAsDataURL(file)
}
function remove() {
emit('update:avatar', '')
emit('update:avatarMimeType', '')
emit('update:avatarFileName', '')
}
function trigger() {
fileInput.value?.click()
}
</script>
<template>
<div class="d-flex flex-column align-center">
<div class="position-relative mb-4">
<v-avatar size="120" class="elevation-4">
<template v-if="avatar && avatarMimeType">
<v-img
:src="getAvatarDataUrl(avatar, avatarMimeType)"
:alt="avatarFileName || t('student.avatar.fallback.alt')"
cover
/>
</template>
<template v-else>
<div
class="d-flex flex-column align-center justify-center h-100 bg-grey-lighten-3"
>
<v-icon size="48" color="grey-darken-1">mdi-account-circle</v-icon>
<span class="text-caption text-grey-darken-1 mt-1">{{
getAvatarFallback(studentName)
}}</span>
</div>
</template>
</v-avatar>
<v-btn
v-if="avatar"
icon="mdi-close"
size="small"
color="error"
variant="elevated"
class="position-absolute"
style="top: -8px; right: -8px"
:disabled="disabled"
@click="remove"
/>
</div>
<div class="d-flex flex-column align-center gap-3">
<input
ref="fileInput"
type="file"
accept="image/*"
style="display: none"
@change="handleUpload"
/>
<v-btn
color="primary"
variant="outlined"
size="large"
prepend-icon="mdi-camera-plus"
:disabled="disabled"
@click="trigger"
>
{{ avatar ? t('student.avatar.upload.change') : t('student.avatar.upload.button') }}
</v-btn>
<div class="text-center">
<div class="text-caption text-medium-emphasis">{{ t('student.avatar.upload.formats') }}</div>
<div class="text-caption text-medium-emphasis">{{ t('student.avatar.upload.sizeLimit') }}</div>
</div>
</div>
</div>
</template>
\ No newline at end of file
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { deleteStudent } from '@/api/studentService.js'
// Delete-confirmation dialog. Owns the deleteStudent API call, isDeleting
// state, and inline error display (the cross-dialog error bug — delete
// errors used to surface in the edit dialog's alert; now they appear
// here). Parent passes the student to delete via the `student` prop; we
// emit `@deleted(id)` on success and `@close` on cancel/after success.
const props = defineProps({
open: { type: Boolean, default: false },
student: { type: Object, default: null },
})
const emit = defineEmits(['close', 'deleted'])
const { t } = useI18n()
const isDeleting = ref(false)
const isError = ref(false)
const errorMessage = ref('')
function getAvatarDataUrl(avatar, mimeType) {
if (!avatar || !mimeType) return null
return `data:${mimeType};base64,${avatar}`
}
function getAvatarFallback(name) {
if (!name || typeof name !== 'string') return '?'
const trimmed = name.trim()
return trimmed ? trimmed.charAt(0).toUpperCase() : '?'
}
function close() {
if (isDeleting.value) return
emit('close')
}
async function confirm() {
if (!props.student?.student_id) return
isDeleting.value = true
isError.value = false
errorMessage.value = ''
try {
await deleteStudent(props.student.student_id)
emit('deleted', props.student.student_id)
emit('close')
} catch (error) {
console.error('Student deletion failed:', error)
isError.value = true
errorMessage.value = error?.message || t('student.messages.deleteError')
} finally {
isDeleting.value = false
}
}
</script>
<template>
<v-dialog :model-value="open" max-width="400px" @update:model-value="(v) => !v && close()">
<v-card>
<v-card-title class="text-h5 text-error">
<v-icon icon="mdi-alert" class="mr-2" />
{{ t('student.dialog.delete') }}
</v-card-title>
<v-card-text>
<div class="text-body-1 mb-4">
{{ t('student.deleteConfirmation.message') }}
</div>
<div v-if="student" class="bg-grey-lighten-4 pa-3 rounded d-flex align-center">
<v-avatar size="48" class="mr-3">
<template v-if="student.avatar && student.avatar_mime_type">
<v-img
:src="getAvatarDataUrl(student.avatar, student.avatar_mime_type)"
:alt="student.avatar_file_name || t('student.avatar.fallback.alt')"
cover
/>
</template>
<template v-else>
<span class="text-body-2">{{ getAvatarFallback(student.student_name) }}</span>
</template>
</v-avatar>
<div>
<div class="font-weight-medium">{{ student.student_name }}</div>
<div class="text-caption text-medium-emphasis">
{{ t('student.deleteConfirmation.studentInfo', {
name: student.student_name,
age: student.age ?? '',
grade: student.grade ?? '',
}) }}
</div>
</div>
</div>
<v-alert
v-if="isError"
type="error"
variant="tonal"
class="mt-4"
closable
@click:close="isError = false"
>
{{ errorMessage }}
</v-alert>
<div class="text-body-2 text-error mt-4">
<v-icon icon="mdi-alert-circle" size="small" class="mr-1" />
{{ t('student.deleteConfirmation.warning') }}
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="grey-darken-1"
variant="text"
:disabled="isDeleting"
@click="close"
>
{{ t('common.buttons.cancel') }}
</v-btn>
<v-btn
color="error"
variant="elevated"
prepend-icon="mdi-delete"
:loading="isDeleting"
:disabled="isDeleting"
@click="confirm"
>
{{ t('common.buttons.confirm') + ' ' + t('common.buttons.delete') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
\ No newline at end of file
<script setup>
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { fromApiBool } from '@/api/boolCodec.js'
import { createStudent, updateStudent } from '@/api/studentService.js'
import StudentAvatarUpload from '@/components/student/StudentAvatarUpload.vue'
import StudentSliderIconUpload from '@/components/student/StudentSliderIconUpload.vue'
// Create / edit dialog. `student` prop null → create mode, object → edit.
// Owns the 11-field form (4 base + avatar trio + slider-icon trio), the
// two upload sub-components, validation, save flow, and inline error.
// Delete was spun out to StudentDeleteDialog (delete flow is now
// row-triggered — the old edit-dialog delete button is gone).
const props = defineProps({
open: { type: Boolean, default: false },
student: { type: Object, default: null },
})
const emit = defineEmits(['close', 'saved'])
const { t } = useI18n()
const isCreateMode = ref(false)
const editingStudent = ref(null)
const editForm = ref({
student_name: '',
age: '',
grade: '',
enabled: true,
avatar: '',
avatar_mime_type: '',
avatar_file_name: '',
slider_icon: '',
slider_icon_mime_type: '',
slider_icon_file_name: '',
})
const isSaving = ref(false)
const isError = ref(false)
const errorMessage = ref('')
const uploadError = ref('')
const blankForm = () => ({
student_name: '',
age: '',
grade: '',
enabled: true,
avatar: '',
avatar_mime_type: '',
avatar_file_name: '',
slider_icon: '',
slider_icon_mime_type: '',
slider_icon_file_name: '',
})
function resetForm() {
editForm.value = blankForm()
uploadError.value = ''
}
function loadFromStudent(s) {
isCreateMode.value = false
editingStudent.value = s
editForm.value = {
student_name: s.student_name || '',
age: s.age ?? '',
grade: s.grade || '',
enabled: fromApiBool(s.enabled),
avatar: s.avatar || '',
avatar_mime_type: s.avatar_mime_type || '',
avatar_file_name: s.avatar_file_name || '',
slider_icon: s.slider_icon || '',
slider_icon_mime_type: s.slider_icon_mime_type || '',
slider_icon_file_name: s.slider_icon_file_name || '',
}
}
function initCreate() {
isCreateMode.value = true
editingStudent.value = null
resetForm()
}
function closeDialog() {
isError.value = false
errorMessage.value = ''
uploadError.value = ''
resetForm()
emit('close')
}
function buildPayload() {
const payload = {
student_name: editForm.value.student_name,
grade: editForm.value.grade,
age: editForm.value.age,
enabled: editForm.value.enabled,
}
if (editForm.value.avatar && editForm.value.avatar_mime_type) {
payload.avatar = editForm.value.avatar
payload.avatar_mime_type = editForm.value.avatar_mime_type
payload.avatar_file_name = editForm.value.avatar_file_name
}
if (editForm.value.slider_icon && editForm.value.slider_icon_mime_type) {
payload.slider_icon = editForm.value.slider_icon
payload.slider_icon_mime_type = editForm.value.slider_icon_mime_type
payload.slider_icon_file_name = editForm.value.slider_icon_file_name
}
return payload
}
async function save() {
if (!editForm.value.student_name) {
isError.value = true
errorMessage.value = t('student.validation.nameRequired')
return
}
isSaving.value = true
isError.value = false
errorMessage.value = ''
try {
const payload = buildPayload()
let result
if (isCreateMode.value) {
result = await createStudent(payload)
} else {
if (!editingStudent.value?.student_id) {
throw new Error(t('common.messages.error.general'))
}
result = await updateStudent(editingStudent.value.student_id, payload)
}
closeDialog()
emit('saved', result)
} catch (error) {
console.error('Student save failed:', error)
isError.value = true
errorMessage.value = error?.message || t('common.messages.error.general')
} finally {
isSaving.value = false
}
}
watch(
() => [props.open, props.student],
([isOpen, student]) => {
if (!isOpen) return
isError.value = false
errorMessage.value = ''
uploadError.value = ''
if (student) loadFromStudent(student)
else initCreate()
},
{ immediate: true },
)
function onUploadError(message) {
uploadError.value = message
}
</script>
<template>
<v-dialog :model-value="open" max-width="500px" @update:model-value="(v) => !v && closeDialog()">
<v-card>
<v-card-title>
{{ isCreateMode ? t('student.dialog.add') : t('student.dialog.edit') }}
</v-card-title>
<v-card-text>
<v-alert
v-if="isError"
type="error"
variant="tonal"
class="mb-4"
closable
@click:close="isError = false"
>
{{ errorMessage }}
</v-alert>
<v-alert
v-if="uploadError"
type="warning"
variant="tonal"
class="mb-4"
closable
@click:close="uploadError = ''"
>
{{ uploadError }}
</v-alert>
<v-form @submit.prevent="save">
<v-container>
<v-row>
<!-- Avatar upload section -->
<v-col cols="12">
<div class="mb-6">
<h4 class="text-subtitle-1 mb-4 font-weight-medium">
{{ t('student.avatar.upload.title') }}
</h4>
<StudentAvatarUpload
:avatar="editForm.avatar"
:avatar-mime-type="editForm.avatar_mime_type"
:avatar-file-name="editForm.avatar_file_name"
:student-name="editForm.student_name"
:disabled="isSaving"
@update:avatar="(v) => (editForm.avatar = v)"
@update:avatar-mime-type="(v) => (editForm.avatar_mime_type = v)"
@update:avatar-file-name="(v) => (editForm.avatar_file_name = v)"
@error="onUploadError"
/>
</div>
<!-- Slider icon upload section -->
<div class="mb-4">
<h4 class="text-subtitle-1 mb-3 font-weight-medium">
{{ t('student.sliderIcon.upload.title') }}
</h4>
<StudentSliderIconUpload
:slider-icon="editForm.slider_icon"
:slider-icon-mime-type="editForm.slider_icon_mime_type"
:slider-icon-file-name="editForm.slider_icon_file_name"
:disabled="isSaving"
@update:slider-icon="(v) => (editForm.slider_icon = v)"
@update:slider-icon-mime-type="(v) => (editForm.slider_icon_mime_type = v)"
@update:slider-icon-file-name="(v) => (editForm.slider_icon_file_name = v)"
@error="onUploadError"
/>
</div>
<v-divider class="mb-4" />
</v-col>
<!-- Base form fields -->
<v-col cols="12">
<v-text-field
v-model="editForm.student_name"
:label="t('student.form.name')"
required
variant="outlined"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model.number="editForm.age"
:label="t('student.form.age')"
type="number"
variant="outlined"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model="editForm.grade"
:label="t('student.form.grade')"
variant="outlined"
/>
</v-col>
<v-col cols="12">
<v-switch
v-model="editForm.enabled"
:label="t('student.form.enabledStatus')"
color="primary"
:true-value="true"
:false-value="false"
inset
/>
</v-col>
</v-row>
</v-container>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="grey-darken-1"
variant="text"
:disabled="isSaving"
@click="closeDialog"
>
{{ t('common.buttons.cancel') }}
</v-btn>
<v-btn
:color="isCreateMode ? 'success' : 'primary'"
variant="text"
:loading="isSaving"
:disabled="isSaving"
@click="save"
>
{{ isCreateMode ? t('common.buttons.create') : t('common.buttons.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
\ No newline at end of file
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
// Slider icon upload sub-component. Mirrors StudentAvatarUpload's contract
// but renders a smaller preview (64px) and falls back to mdi-gauge. Used
// by TaskView for the calendar slider handle.
const props = defineProps({ // eslint-disable-line no-unused-vars
sliderIcon: { type: String, default: '' },
sliderIconMimeType: { type: String, default: '' },
sliderIconFileName: { type: String, default: '' },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits([
'update:sliderIcon',
'update:sliderIconMimeType',
'update:sliderIconFileName',
'error',
])
const { t } = useI18n()
const fileInput = ref(null)
function getDataUrl(icon, mimeType) {
if (!icon || !mimeType) return null
return `data:${mimeType};base64,${icon}`
}
function handleUpload(event) {
const files = event.target?.files || event
const file = Array.isArray(files) ? files[0] : files?.[0] || files
if (!file) return
if (!file.type.startsWith('image/')) {
emit('error', t('student.sliderIcon.upload.selectImage'))
return
}
if (file.size > 2 * 1024 * 1024) {
emit('error', t('student.sliderIcon.upload.sizeExceeded'))
return
}
const reader = new FileReader()
reader.onload = (e) => {
const base64 = e.target.result.split(',')[1]
emit('update:sliderIcon', base64)
emit('update:sliderIconMimeType', file.type)
emit('update:sliderIconFileName', file.name)
emit('error', '')
}
reader.onerror = () => emit('error', t('student.sliderIcon.upload.readError'))
reader.readAsDataURL(file)
}
function remove() {
emit('update:sliderIcon', '')
emit('update:sliderIconMimeType', '')
emit('update:sliderIconFileName', '')
}
function trigger() {
fileInput.value?.click()
}
</script>
<template>
<div class="d-flex align-center">
<div class="position-relative me-4">
<v-avatar size="64" rounded="md" class="elevation-2">
<v-img
v-if="sliderIcon && sliderIconMimeType"
:src="getDataUrl(sliderIcon, sliderIconMimeType)"
:alt="sliderIconFileName || t('student.sliderIcon.previewAlt')"
cover
/>
<v-icon v-else icon="mdi-gauge" size="32" color="grey-darken-1" />
</v-avatar>
<v-btn
v-if="sliderIcon"
icon="mdi-close"
size="x-small"
color="error"
variant="elevated"
class="position-absolute"
style="top: -6px; right: -6px"
:disabled="disabled"
@click="remove"
/>
</div>
<div class="flex-grow-1">
<input
ref="fileInput"
type="file"
accept="image/*"
style="display: none"
@change="handleUpload"
/>
<v-btn
color="primary"
variant="outlined"
size="small"
prepend-icon="mdi-image-plus"
:disabled="disabled"
@click="trigger"
>
{{ sliderIcon ? t('student.sliderIcon.upload.change') : t('student.sliderIcon.upload.button') }}
</v-btn>
<div class="text-caption text-medium-emphasis mt-2">
{{ t('student.sliderIcon.upload.hint') }}
</div>
</div>
</div>
</template>
\ No newline at end of file
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { fromApiBool } from '@/api/boolCodec.js'
// Presentation-only table for students. Lifts the avatar helper (data URL)
// and the fallback-letter helper. Emits edit / delete events; parent owns
// the open-dialog state.
defineProps({
items: { type: Array, required: true },
})
const emit = defineEmits(['edit', 'delete'])
const { t } = useI18n()
function getAvatarDataUrl(avatar, mimeType) {
if (!avatar || !mimeType) return null
return `data:${mimeType};base64,${avatar}`
}
function getAvatarFallback(name) {
if (!name || typeof name !== 'string') return '?'
const trimmed = name.trim()
return trimmed ? trimmed.charAt(0).toUpperCase() : '?'
}
const headers = computed(() => [
{ title: t('student.table.headers.avatar'), value: 'avatar', align: 'start', sortable: false },
{ title: t('student.table.headers.name'), value: 'student_name', sortable: true },
{
title: t('student.table.headers.enabled'),
value: 'enabled',
sortable: true,
sort: (a, b) => {
if (fromApiBool(a) && !fromApiBool(b)) return -1
if (!fromApiBool(a) && fromApiBool(b)) return 1
return 0
},
},
{ title: t('student.table.headers.age'), value: 'age', sortable: true },
{ title: t('student.table.headers.grade'), value: 'grade', sortable: true },
{ title: t('student.table.headers.actions'), value: 'actions', sortable: false, align: 'end' },
])
</script>
<template>
<v-data-table
:headers="headers"
:items="items"
item-key="student_id"
:items-per-page="10"
class="elevation-1"
:sort-by="[{ key: 'student_name', order: 'asc' }]"
multi-sort
:items-per-page-text="t('student.table.pagination.itemsPerPage')"
:no-data-text="t('student.table.pagination.noDataText')"
:loading-text="t('student.table.pagination.loadingText')"
:items-per-page-options="[
{ value: 5, title: '5' },
{ value: 10, title: '10' },
{ value: 25, title: '25' },
{ value: 50, title: '50' },
{ value: -1, title: t('student.table.pagination.itemsPerPageAll') },
]"
>
<template v-slot:[`item.avatar`]="{ item }">
<div class="d-flex align-center py-2">
<v-avatar size="64">
<template v-if="item.avatar && item.avatar_mime_type">
<v-img
:src="getAvatarDataUrl(item.avatar, item.avatar_mime_type)"
:alt="item.avatar_file_name || t('student.avatar.fallback.alt')"
cover
/>
</template>
<template v-else>
<span class="text-body-2">{{ getAvatarFallback(item.student_name) }}</span>
</template>
</v-avatar>
</div>
</template>
<template v-slot:[`item.enabled`]="{ item }">
<v-chip
:color="fromApiBool(item.enabled) ? 'success' : 'error'"
text-color="white"
size="small"
>
{{ fromApiBool(item.enabled) ? t('common.status.enabled') : t('common.status.disabled') }}
</v-chip>
</template>
<template v-slot:[`item.actions`]="{ item }">
<v-btn
icon="mdi-pencil"
size="small"
color="primary"
variant="text"
@click="emit('edit', item)"
>
</v-btn>
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="text"
@click="emit('delete', item)"
>
</v-btn>
</template>
</v-data-table>
</template>
<style scoped>
.v-data-table {
width: 100%;
}
</style>
\ No newline at end of file
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { deleteSubject } from '@/api/subjectService.js'
// Delete-confirmation dialog. Owns the deleteSubject API call, isDeleting
// state, and inline error display. Parent passes the subject to delete via
// the `subject` prop; we emit `@deleted(id)` on success and `@close` on
// either cancel or after a successful delete.
const props = defineProps({
open: { type: Boolean, default: false },
subject: { type: Object, default: null },
})
const emit = defineEmits(['close', 'deleted'])
const { t } = useI18n()
const isDeleting = ref(false)
const isError = ref(false)
const errorMessage = ref('')
function close() {
if (isDeleting.value) return
emit('close')
}
async function confirm() {
if (!props.subject?.subject_id) return
isDeleting.value = true
isError.value = false
errorMessage.value = ''
try {
await deleteSubject(props.subject.subject_id)
emit('deleted', props.subject.subject_id)
emit('close')
} catch (error) {
console.error('Subject deletion failed:', error)
isError.value = true
errorMessage.value = error?.message || t('subject.messages.deleteError')
} finally {
isDeleting.value = false
}
}
</script>
<template>
<v-dialog :model-value="open" max-width="400px" @update:model-value="(v) => !v && close()">
<v-card>
<v-card-title class="text-h5 text-error">
<v-icon icon="mdi-alert" class="mr-2" />
{{ t('subject.dialog.delete') }}
</v-card-title>
<v-card-text>
<div class="text-body-1 mb-4">
{{ t('subject.deleteConfirmation.message') }}
</div>
<div v-if="subject" class="bg-grey-lighten-4 pa-3 rounded">
<div class="font-weight-medium">{{ subject.subject_name }}</div>
<div class="text-caption text-medium-emphasis">
{{
t('subject.deleteConfirmation.subjectInfo', {
name: subject.subject_name,
sort: subject.sort_sequence,
})
}}
</div>
</div>
<v-alert
v-if="isError"
type="error"
variant="tonal"
class="mt-4"
closable
@click:close="isError = false"
>
{{ errorMessage }}
</v-alert>
<div class="text-body-2 text-error mt-4">
<v-icon icon="mdi-alert-circle" size="small" class="mr-1" />
{{ t('subject.deleteConfirmation.warning') }}
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="grey-darken-1"
variant="text"
:disabled="isDeleting"
@click="close"
>
{{ t('common.buttons.cancel') }}
</v-btn>
<v-btn
color="error"
variant="elevated"
prepend-icon="mdi-delete"
:loading="isDeleting"
:disabled="isDeleting"
@click="confirm"
>
{{ t('common.buttons.confirm') + ' ' + t('common.buttons.delete') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
\ No newline at end of file
<script setup>
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { createSubject, updateSubject } from '@/api/subjectService.js'
// Create / edit dialog. `subject` prop null → create mode, object → edit.
// Owns the form, validation, save flow, and inline error display. Delete
// was spun out to SubjectDeleteDialog (delete flow is now row-triggered).
const props = defineProps({
open: { type: Boolean, default: false },
subject: { type: Object, default: null },
colorOptions: { type: Array, required: true },
})
const emit = defineEmits(['close', 'saved'])
const { t } = useI18n()
const isCreateMode = ref(false)
const editingSubject = ref(null)
const editForm = ref({
subject_name: '',
enabled_flag: 'Y',
primary_flag: 'N',
calendar_color: '',
sort_sequence: 0,
})
const isSaving = ref(false)
const isError = ref(false)
const errorMessage = ref('')
const blankForm = () => ({
subject_name: '',
enabled_flag: 'Y',
primary_flag: 'N',
calendar_color: '',
sort_sequence: 0,
})
function resetForm() {
editForm.value = blankForm()
}
function loadFromSubject(s) {
isCreateMode.value = false
editingSubject.value = s
editForm.value = {
subject_name: s.subject_name || '',
enabled_flag: s.enabled_flag || 'Y',
primary_flag: s.primary_flag || 'N',
calendar_color: s.calendar_color || '',
sort_sequence: s.sort_sequence ?? 0,
}
}
function initCreate() {
isCreateMode.value = true
editingSubject.value = null
resetForm()
}
function closeDialog() {
isError.value = false
errorMessage.value = ''
resetForm()
emit('close')
}
async function save() {
if (!editForm.value.subject_name) {
isError.value = true
errorMessage.value = t('subject.validation.subjectNameRequired')
return
}
isSaving.value = true
isError.value = false
errorMessage.value = ''
try {
const payload = {
subject_name: editForm.value.subject_name,
enabled_flag: editForm.value.enabled_flag,
primary_flag: editForm.value.primary_flag,
calendar_color: editForm.value.calendar_color,
sort_sequence: editForm.value.sort_sequence,
}
let result
if (isCreateMode.value) {
result = await createSubject(payload)
} else {
if (!editingSubject.value?.subject_id) {
throw new Error(t('common.messages.error.general'))
}
result = await updateSubject(editingSubject.value.subject_id, payload)
}
// closeDialog emits 'close' first; parent's close handler runs but
// is idempotent. Then emit 'saved' so the parent can merge/unshift.
closeDialog()
emit('saved', result)
} catch (error) {
console.error('Subject save failed:', error)
isError.value = true
errorMessage.value = error?.message || t('common.messages.error.general')
} finally {
isSaving.value = false
}
}
watch(
() => [props.open, props.subject],
([isOpen, subject]) => {
if (!isOpen) return
isError.value = false
errorMessage.value = ''
if (subject) loadFromSubject(subject)
else initCreate()
},
{ immediate: true },
)
</script>
<template>
<v-dialog :model-value="open" max-width="500px" @update:model-value="(v) => !v && closeDialog()">
<v-card>
<v-card-title>
{{ isCreateMode ? t('subject.dialog.add') : t('subject.dialog.edit') }}
</v-card-title>
<v-card-text>
<v-alert
v-if="isError"
type="error"
variant="tonal"
class="mb-4"
closable
@click:close="isError = false"
>
{{ errorMessage }}
</v-alert>
<v-form @submit.prevent="save">
<v-container>
<v-row>
<v-col cols="12">
<v-text-field
v-model="editForm.subject_name"
:label="t('subject.form.subject_name')"
required
variant="outlined"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model.number="editForm.sort_sequence"
:label="t('subject.form.sort_sequence')"
type="number"
variant="outlined"
/>
</v-col>
<v-col cols="6">
<v-select
v-model="editForm.calendar_color"
:label="t('subject.form.calendar_color')"
:items="colorOptions"
variant="outlined"
clearable
>
<template v-slot:item="{ props, item }">
<v-list-item v-bind="props" :title="item.title">
<template v-slot:prepend>
<div
class="color-dot"
:style="{ backgroundColor: item.raw?.color || '#e0e0e0' }"
></div>
</template>
</v-list-item>
</template>
<template v-slot:selection="{ item }">
<div class="d-flex align-center">
<div
class="color-dot"
:style="{ backgroundColor: item.raw?.color || '#e0e0e0' }"
></div>
<span>{{ item.raw?.title || item.title }}</span>
</div>
</template>
</v-select>
</v-col>
<v-col cols="6">
<v-switch
v-model="editForm.enabled_flag"
:label="t('subject.form.enabled_flag')"
color="primary"
true-value="Y"
false-value="N"
inset
/>
</v-col>
<v-col cols="6">
<v-switch
v-model="editForm.primary_flag"
:label="t('subject.form.primary_flag')"
color="primary"
true-value="Y"
false-value="N"
inset
/>
</v-col>
</v-row>
</v-container>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="grey-darken-1"
variant="text"
:disabled="isSaving"
@click="closeDialog"
>
{{ t('common.buttons.cancel') }}
</v-btn>
<v-btn
:color="isCreateMode ? 'success' : 'primary'"
variant="text"
:loading="isSaving"
:disabled="isSaving"
@click="save"
>
{{ isCreateMode ? t('common.buttons.create') : t('common.buttons.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.color-dot {
width: 16px;
height: 16px;
border-radius: 50%;
margin-right: 8px;
border: 1px solid #ddd;
display: inline-block;
flex-shrink: 0;
}
</style>
\ No newline at end of file
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { fromApiBool } from '@/api/boolCodec.js'
import { COLOR_MAPPING, COLOR_KEYS } from '@/components/constants/colors'
// Presentation-only table for subjects. Builds a Map<colorKey, {title, color}>
// for O(1) display lookup of the calendar-color column. Emits edit / delete
// events; parent owns the open-dialog state.
defineProps({
items: { type: Array, required: true },
})
const emit = defineEmits(['edit', 'delete'])
const { xs } = useDisplay()
const { t } = useI18n()
const colorMap = computed(() => {
const map = new Map()
for (const key of COLOR_KEYS) {
map.set(key, {
title: t(`subject.calendarColors.${key}`),
color: COLOR_MAPPING[key] || '#e0e0e0',
})
}
return map
})
function getColorTitle(code) {
return colorMap.value.get(code)?.title ?? code ?? '--'
}
function getColorValue(code) {
return colorMap.value.get(code)?.color ?? '#e0e0e0'
}
const headers = computed(() => [
{
title: t('subject.table.headers.subject_name'),
value: 'subject_name',
sortable: true,
width: '20%',
},
{
title: t('subject.table.headers.enabled_flag'),
value: 'enabled_flag',
sortable: true,
width: '16%',
sort: (a, b) => {
// Y (启用) sorts before N (禁用)
if (fromApiBool(a) && !fromApiBool(b)) return -1
if (!fromApiBool(a) && fromApiBool(b)) return 1
return 0
},
},
{
title: t('subject.table.headers.primary_flag'),
value: 'primary_flag',
sortable: true,
width: '16%',
},
{
title: t('subject.table.headers.calendar_color'),
value: 'calendar_color',
sortable: false,
width: '20%',
},
{
title: t('subject.table.headers.sort_sequence'),
value: 'sort_sequence',
sortable: true,
width: '16%',
},
{
title: t('subject.table.headers.actions'),
value: 'actions',
sortable: false,
align: 'end',
width: '12%',
},
])
</script>
<template>
<v-data-table
:headers="headers"
:items="items"
item-key="subject_id"
:items-per-page="10"
class="elevation-1 text-no-wrap"
:sort-by="[{ key: 'sort_sequence', order: 'asc' }]"
multi-sort
:items-per-page-text="t('subject.table.pagination.itemsPerPage')"
:no-data-text="t('subject.table.pagination.noDataText')"
:loading-text="t('subject.table.pagination.loadingText')"
:items-per-page-options="[
{ value: 5, title: '5' },
{ value: 10, title: '10' },
{ value: 25, title: '25' },
{ value: 50, title: '50' },
{ value: -1, title: t('subject.table.pagination.itemsPerPageAll') },
]"
:hide-default-footer="false"
:height="xs ? undefined : 'calc(100dvh - 360px)'"
:fixed-header="!xs"
:density="xs ? 'compact' : 'comfortable'"
>
<template v-slot:[`item.enabled_flag`]="{ item }">
<v-chip
:color="fromApiBool(item.enabled_flag) ? 'success' : 'error'"
text-color="white"
size="small"
>
{{
fromApiBool(item.enabled_flag)
? t('subject.status.enabled')
: t('subject.status.disabled')
}}
</v-chip>
</template>
<template v-slot:[`item.primary_flag`]="{ item }">
<v-chip
:color="fromApiBool(item.primary_flag) ? 'primary' : 'default'"
text-color="white"
size="small"
>
{{
fromApiBool(item.primary_flag)
? t('subject.status.primary')
: t('subject.status.notPrimary')
}}
</v-chip>
</template>
<template v-slot:[`item.calendar_color`]="{ item }">
<div class="d-flex align-center">
<v-avatar
size="16"
:color="getColorValue(item.calendar_color)"
class="mr-2"
></v-avatar>
<span class="text-caption text-truncate" style="max-width: 100px">
{{ getColorTitle(item.calendar_color) }}
</span>
</div>
</template>
<template v-slot:[`item.actions`]="{ item }">
<v-btn
icon="mdi-pencil"
size="small"
color="primary"
variant="text"
@click="emit('edit', item)"
>
</v-btn>
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="text"
@click="emit('delete', item)"
>
</v-btn>
</template>
</v-data-table>
</template>
<style scoped>
.v-data-table {
width: 100%;
}
</style>
\ No newline at end of file
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { deleteTerm } from '@/api/termService.js'
// Delete-confirmation dialog. Owns the deleteTerm API call, isDeleting
// state, and inline error display. Parent passes the term to delete via
// the `term` prop; we emit `@deleted(id)` on success and `@close` on
// either cancel or after a successful delete.
const props = defineProps({
open: { type: Boolean, default: false },
term: { type: Object, default: null },
})
const emit = defineEmits(['close', 'deleted'])
const { t } = useI18n()
const isDeleting = ref(false)
const isError = ref(false)
const errorMessage = ref('')
function formatDate(dateString) {
if (!dateString) return ''
return new Date(dateString).toLocaleDateString('zh-CN')
}
function close() {
if (isDeleting.value) return
emit('close')
}
async function confirm() {
if (!props.term?.term_id) return
isDeleting.value = true
isError.value = false
errorMessage.value = ''
try {
await deleteTerm(props.term.term_id)
emit('deleted', props.term.term_id)
emit('close')
} catch (error) {
console.error('Term deletion failed:', error)
isError.value = true
errorMessage.value = error?.message || t('term.messages.deleteError')
} finally {
isDeleting.value = false
}
}
</script>
<template>
<v-dialog :model-value="open" max-width="400px" @update:model-value="(v) => !v && close()">
<v-card>
<v-card-title class="text-h5 text-error">
<v-icon icon="mdi-alert" class="mr-2" />
{{ t('term.dialog.delete') }}
</v-card-title>
<v-card-text>
<div class="text-body-1 mb-4">
{{ t('term.deleteConfirmation.message') }}
</div>
<div v-if="term" class="bg-grey-lighten-4 pa-3 rounded">
<div class="font-weight-medium">{{ term.term_name }}</div>
<div class="text-caption text-medium-emphasis">
{{ term.student_name }} |
{{ formatDate(term.term_start_date) }} -
{{ formatDate(term.term_end_date) }}
</div>
</div>
<v-alert
v-if="isError"
type="error"
variant="tonal"
class="mt-4"
closable
@click:close="isError = false"
>
{{ errorMessage }}
</v-alert>
<div class="text-body-2 text-error mt-4">
<v-icon icon="mdi-alert-circle" size="small" class="mr-1" />
{{ t('term.deleteConfirmation.warning') }}
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="grey-darken-1"
variant="text"
:disabled="isDeleting"
@click="close"
>
{{ t('common.buttons.cancel') }}
</v-btn>
<v-btn
color="error"
variant="elevated"
prepend-icon="mdi-delete"
:loading="isDeleting"
:disabled="isDeleting"
@click="confirm"
>
{{ t('common.buttons.confirm') + ' ' + t('common.buttons.delete') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
\ No newline at end of file
<script setup>
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { createTerm, updateTerm } from '@/api/termService.js'
// Create / edit dialog. `term` prop null → create mode, object → edit.
// Owns the form, validation, date serialization, and inline error display.
// Date pickers use native v-text-field type="date" — avoids Vuetify labs.
const props = defineProps({
open: { type: Boolean, default: false },
term: { type: Object, default: null },
studentOptions: { type: Array, required: true },
})
const emit = defineEmits(['close', 'saved'])
const { t } = useI18n()
const isCreateMode = ref(false)
const editingTerm = ref(null)
const editForm = ref({
student_id: '',
term_name: '',
term_start_date: '',
term_end_date: '',
tenant_id: 1,
})
const isSaving = ref(false)
const isError = ref(false)
const errorMessage = ref('')
const blankForm = () => ({
student_id: '',
term_name: '',
term_start_date: '',
term_end_date: '',
tenant_id: 1,
})
function resetForm() {
editForm.value = blankForm()
}
// ISO date → YYYY-MM-DD (native date-input format).
function formatDateForInput(dateString) {
if (!dateString) return ''
return new Date(dateString).toISOString().split('T')[0]
}
function loadFromTerm(t_) {
isCreateMode.value = false
editingTerm.value = t_
editForm.value = {
student_id: t_.student_id ?? '',
term_name: t_.term_name || '',
term_start_date: formatDateForInput(t_.term_start_date),
term_end_date: formatDateForInput(t_.term_end_date),
tenant_id: t_.tenant_id || 1,
}
}
function initCreate() {
isCreateMode.value = true
editingTerm.value = null
resetForm()
}
function closeDialog() {
isError.value = false
errorMessage.value = ''
resetForm()
emit('close')
}
async function save() {
if (!editForm.value.student_id) {
isError.value = true
errorMessage.value = t('term.validation.studentRequired')
return
}
if (!editForm.value.term_name) {
isError.value = true
errorMessage.value = t('term.validation.termNameRequired')
return
}
if (!editForm.value.term_start_date) {
isError.value = true
errorMessage.value = t('term.validation.startDateRequired')
return
}
if (!editForm.value.term_end_date) {
isError.value = true
errorMessage.value = t('term.validation.endDateRequired')
return
}
isSaving.value = true
isError.value = false
errorMessage.value = ''
try {
const payload = {
student_id: parseInt(editForm.value.student_id),
term_name: editForm.value.term_name,
term_start_date: new Date(editForm.value.term_start_date).toISOString(),
term_end_date: new Date(editForm.value.term_end_date).toISOString(),
tenant_id: editForm.value.tenant_id,
}
let result
if (isCreateMode.value) {
result = await createTerm(payload)
} else {
if (!editingTerm.value?.term_id) {
throw new Error(t('common.messages.error.general'))
}
result = await updateTerm(editingTerm.value.term_id, payload)
}
closeDialog()
emit('saved', result)
} catch (error) {
console.error('Term save failed:', error)
isError.value = true
errorMessage.value = error?.message || t('common.messages.error.general')
} finally {
isSaving.value = false
}
}
watch(
() => [props.open, props.term],
([isOpen, term]) => {
if (!isOpen) return
isError.value = false
errorMessage.value = ''
if (term) loadFromTerm(term)
else initCreate()
},
{ immediate: true },
)
</script>
<template>
<v-dialog :model-value="open" max-width="500px" @update:model-value="(v) => !v && closeDialog()">
<v-card>
<v-card-title>
{{ isCreateMode ? t('term.dialog.add') : t('term.dialog.edit') }}
</v-card-title>
<v-card-text>
<v-alert
v-if="isError"
type="error"
variant="tonal"
class="mb-4"
closable
@click:close="isError = false"
>
{{ errorMessage }}
</v-alert>
<v-form @submit.prevent="save">
<v-container>
<v-row>
<v-col cols="12">
<v-select
v-model="editForm.student_id"
:items="studentOptions"
:label="t('term.form.student')"
required
variant="outlined"
/>
</v-col>
<v-col cols="12">
<v-text-field
v-model="editForm.term_name"
:label="t('term.form.termName')"
required
variant="outlined"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model="editForm.term_start_date"
:label="t('term.form.startDate')"
type="date"
variant="outlined"
required
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model="editForm.term_end_date"
:label="t('term.form.endDate')"
type="date"
variant="outlined"
required
/>
</v-col>
</v-row>
</v-container>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="grey-darken-1"
variant="text"
:disabled="isSaving"
@click="closeDialog"
>
{{ t('common.buttons.cancel') }}
</v-btn>
<v-btn
:color="isCreateMode ? 'success' : 'primary'"
variant="text"
:loading="isSaving"
:disabled="isSaving"
@click="save"
>
{{ isCreateMode ? t('common.buttons.create') : t('common.buttons.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
\ No newline at end of file
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// Presentation-only table for terms. `student_name` is denormalized
// client-side via the parent's mapRow — items arrive pre-joined.
// Emits edit / delete events; parent owns the open-dialog state.
defineProps({
items: { type: Array, required: true },
})
const emit = defineEmits(['edit', 'delete'])
const { xs } = useDisplay()
const { t } = useI18n()
// Format ISO date string for table display. Preserves zh-CN locale to
// match pre-refactor behaviour (intentional — see plan risk #11).
function formatDate(dateString) {
if (!dateString) return ''
return new Date(dateString).toLocaleDateString('zh-CN')
}
const headers = computed(() => [
{ title: t('term.table.headers.termName'), value: 'term_name', sortable: true },
{ title: t('term.table.headers.studentName'), value: 'student_name', sortable: true },
{ title: t('term.table.headers.startDate'), value: 'term_start_date', sortable: true },
{ title: t('term.table.headers.endDate'), value: 'term_end_date', sortable: true },
{ title: t('term.table.headers.actions'), value: 'actions', sortable: false, align: 'end' },
])
</script>
<template>
<v-data-table
:headers="headers"
:items="items"
item-key="term_id"
:items-per-page="10"
class="elevation-1 text-no-wrap"
:sort-by="[{ key: 'term_start_date', order: 'desc' }]"
multi-sort
:items-per-page-text="t('term.table.pagination.itemsPerPage')"
:no-data-text="t('term.table.pagination.noDataText')"
:loading-text="t('term.table.pagination.loadingText')"
:items-per-page-options="[
{ value: 5, title: '5' },
{ value: 10, title: '10' },
{ value: 25, title: '25' },
{ value: 50, title: '50' },
{ value: -1, title: t('term.table.pagination.itemsPerPageAll') },
]"
:hide-default-footer="false"
:height="xs ? undefined : 'calc(100dvh - 360px)'"
:fixed-header="!xs"
:density="xs ? 'compact' : 'comfortable'"
>
<template v-slot:[`item.term_start_date`]="{ item }">
{{ formatDate(item.term_start_date) }}
</template>
<template v-slot:[`item.term_end_date`]="{ item }">
{{ formatDate(item.term_end_date) }}
</template>
<template v-slot:[`item.actions`]="{ item }">
<v-btn
icon="mdi-pencil"
size="small"
color="primary"
variant="text"
@click="emit('edit', item)"
>
</v-btn>
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="text"
@click="emit('delete', item)"
>
</v-btn>
</template>
</v-data-table>
</template>
<style scoped>
.v-data-table {
width: 100%;
}
</style>
\ No newline at end of file
import { ref } from 'vue'
/**
* State machine + collection manager for a single DRF list endpoint.
*
* Owns:
* - items[], isLoading/isError/errorMessage (fetch state)
* - deleteError (separate ref so the delete dialog's banner is not
* shared with the edit dialog — fixes the cross-dialog error bug)
* - editingItem / editingDialogOpen (merged edit+create)
* - itemToDelete / deleteDialogOpen
* - applySaved / applyDeleted list mutators
* - 5 toggle methods (openEdit / openCreate / openDelete / closeEdit /
* closeDelete)
*
* Does NOT own:
* - i18n keys (caller passes already-translated strings)
* - form state (the edit dialog owns its own editForm)
* - file upload (inline in the dialog or in a sibling sub-component)
* - reference data (the caller fetches whatever it needs)
* - the success toast (caller wires useFlashMessage)
*
* @param {{
* list: () => Promise<Array|object>,
* create: (data: object) => Promise<object>,
* update: (id: any, data: object) => Promise<object>,
* deleteFn: (id: any) => Promise<unknown>,
* idKey: string,
* mapRow?: (row: object) => object,
* loadErrorKey?: string,
* }} cfg
*/
export function useCrudList({
list,
// create/update/deleteFn are part of the seam shape but called by the
// dialog components themselves (they own the form state). Composable
// validates the config by destructuring them — lint-friendly prefix.
// eslint-disable-next-line no-unused-vars
create: _create,
// eslint-disable-next-line no-unused-vars
update: _update,
// eslint-disable-next-line no-unused-vars
deleteFn: _deleteFn,
idKey,
mapRow,
loadErrorKey,
}) {
// — list & fetch state —
const items = ref([])
const isLoading = ref(false)
const isError = ref(false)
const errorMessage = ref('')
let loadTimer = null
// — dialog state —
const editingItem = ref(null) // null = create, object = edit
const editingDialogOpen = ref(false)
const itemToDelete = ref(null)
const deleteDialogOpen = ref(false)
// — delete dialog's own error ref (was previously shared with edit dialog) —
const deleteError = ref('')
function clearLoadError() {
if (loadTimer !== null) {
clearTimeout(loadTimer)
loadTimer = null
}
isError.value = false
errorMessage.value = ''
}
async function fetch() {
isLoading.value = true
clearLoadError()
try {
const data = await list()
const rows = Array.isArray(data) ? data : []
items.value = mapRow ? rows.map(mapRow) : rows
} catch (e) {
isError.value = true
errorMessage.value = e?.response?.data?.detail || e?.message || loadErrorKey || 'Failed to load'
loadTimer = setTimeout(clearLoadError, 3000)
} finally {
isLoading.value = false
}
}
// — 5 toggle methods —
function openEdit(item) {
editingItem.value = item
editingDialogOpen.value = true
}
function openCreate() {
editingItem.value = null
editingDialogOpen.value = true
}
function openDelete(item) {
itemToDelete.value = item
deleteDialogOpen.value = true
}
function closeEdit() {
editingDialogOpen.value = false
editingItem.value = null
}
function closeDelete() {
deleteDialogOpen.value = false
itemToDelete.value = null
}
function clearDeleteError() {
deleteError.value = ''
}
// — list mutators —
/**
* Merge a backend response into items. If `entity[idKey]` matches an
* existing row, splice in place; otherwise prepend. mapRow is invoked
* on the merged row so denormalized columns (e.g. TermView's
* `student_name` join) stay current after a save.
*
* @param {object} entity full backend response
* @returns {boolean} true if a new row was created, false if updated
*/
function applySaved(entity) {
if (!entity || entity[idKey] == null) return false
const idx = items.value.findIndex((r) => r[idKey] === entity[idKey])
const mergedBase = idx === -1 ? entity : { ...items.value[idx], ...entity }
const merged = mapRow ? mapRow(mergedBase) : mergedBase
if (idx === -1) {
items.value.unshift(merged)
return true
}
items.value[idx] = merged
return false
}
function applyDeleted(id) {
const idx = items.value.findIndex((r) => r[idKey] === id)
if (idx !== -1) items.value.splice(idx, 1)
}
return {
// state
items,
isLoading,
isError,
errorMessage,
deleteError,
editingItem,
editingDialogOpen,
itemToDelete,
deleteDialogOpen,
// fetch
fetch,
clearLoadError,
// toggles
openEdit,
openCreate,
openDelete,
closeEdit,
closeDelete,
clearDeleteError,
// mutators
applySaved,
applyDeleted,
}
}
\ No newline at end of file
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { getStories } from '@/api/storyService.js' import {
getStories,
createStory,
updateStory,
deleteStory,
} from '@/api/storyService.js'
import { getStudents } from '@/api/studentService.js' import { getStudents } from '@/api/studentService.js'
import { getSubjects } from '@/api/subjectService.js' import { getSubjects } from '@/api/subjectService.js'
import { getTerms } from '@/api/termService.js' import { getTerms } from '@/api/termService.js'
import { useCrudList } from '@/composables/useCrudList.js'
import { useFlashMessage } from '@/composables/useFlashMessage.js' import { useFlashMessage } from '@/composables/useFlashMessage.js'
import StoryTable from '@/components/story/StoryTable.vue' import StoryTable from '@/components/story/StoryTable.vue'
import StoryEditDialog from '@/components/story/StoryEditDialog.vue' import StoryEditDialog from '@/components/story/StoryEditDialog.vue'
...@@ -12,22 +18,19 @@ import StoryDeleteDialog from '@/components/story/StoryDeleteDialog.vue' ...@@ -12,22 +18,19 @@ import StoryDeleteDialog from '@/components/story/StoryDeleteDialog.vue'
const { t } = useI18n() const { t } = useI18n()
// List + reference data // View-owned reference data (per R2 — composable does not see it).
const stories = ref([])
const students = ref([]) const students = ref([])
const subjects = ref([]) const subjects = ref([])
const terms = ref([]) const terms = ref([])
// Fetch state for stories list const crud = useCrudList({
const isLoading = ref(false) list: getStories,
const isError = ref(false) create: createStory,
const errorMessage = ref('') update: updateStory,
deleteFn: deleteStory,
// Dialog open state idKey: 'story_id',
const editDialog = ref(false) loadErrorKey: t('story.message.loadError'),
const editingStory = ref(null) // null = create, object = edit })
const deleteDialog = ref(false)
const storyToDelete = ref(null)
const flash = useFlashMessage() const flash = useFlashMessage()
...@@ -46,81 +49,24 @@ async function fetchRelatedData() { ...@@ -46,81 +49,24 @@ async function fetchRelatedData() {
} }
} }
async function fetchStories() {
isLoading.value = true
isError.value = false
errorMessage.value = ''
try {
const data = await getStories()
stories.value = Array.isArray(data) ? data : []
} catch (e) {
isError.value = true
errorMessage.value = e?.response?.data?.detail || e?.message || t('story.message.loadError')
setTimeout(() => {
isError.value = false
errorMessage.value = ''
}, 3000)
} finally {
isLoading.value = false
}
}
// fetchStories must run AFTER fetchRelatedData — the table relies on // fetchStories must run AFTER fetchRelatedData — the table relies on
// student/subject/term arrays being populated for its Map computeds. // student/subject/term arrays being populated for its Map computeds.
// Do NOT switch to Promise.all (see Candidate 2's TaskView for contrast). // Do NOT switch to Promise.all (see Candidate 2's TaskView for contrast).
onMounted(async () => { onMounted(async () => {
await fetchRelatedData() await fetchRelatedData()
await fetchStories() await crud.fetch()
}) })
function openEditForStory(story) { function onSaved(entity) {
editingStory.value = story const isCreate = crud.applySaved(entity)
editDialog.value = true flash.show(t(isCreate ? 'story.message.createSuccess' : 'story.message.updateSuccess'))
}
function openCreateForStory() {
editingStory.value = null
editDialog.value = true
}
function openDeleteForStory(story) {
storyToDelete.value = story
deleteDialog.value = true
}
function closeEditDialog() {
editDialog.value = false
editingStory.value = null
}
function closeDeleteDialog() {
deleteDialog.value = false
storyToDelete.value = null
}
function onStorySaved(updated) {
if (!updated?.story_id) return
const idx = stories.value.findIndex((s) => s.story_id === updated.story_id)
const isUpdate = idx !== -1
if (isUpdate) {
stories.value[idx] = { ...stories.value[idx], ...updated }
} else {
stories.value.unshift(updated)
}
closeEditDialog()
flash.show(
isUpdate ? t('story.message.updateSuccess') : t('story.message.createSuccess'),
)
} }
function onStoryDeleted(deletedId) { function onDeleted(id) {
const idx = stories.value.findIndex((s) => s.story_id === deletedId) const item = crud.items.value.find((s) => s.story_id === id)
if (idx !== -1) stories.value.splice(idx, 1) crud.applyDeleted(id)
closeDeleteDialog() flash.show(t('story.message.deleteSuccess', { name: item?.story_name ?? '' }))
// Mirror the original "close edit dialog too" contract so the user crud.closeEdit()
// doesn't return to an edit form for a story that no longer exists.
closeEditDialog()
flash.show(t('story.message.deleteSuccess'))
} }
</script> </script>
...@@ -131,14 +77,14 @@ function onStoryDeleted(deletedId) { ...@@ -131,14 +77,14 @@ function onStoryDeleted(deletedId) {
<h2 class="text-h5">{{ t('story.title') }}</h2> <h2 class="text-h5">{{ t('story.title') }}</h2>
</v-col> </v-col>
<v-col cols="12" sm="6" class="text-sm-right text-right"> <v-col cols="12" sm="6" class="text-sm-right text-right">
<v-btn color="success" prepend-icon="mdi-plus" class="mr-2" @click="openCreateForStory"> <v-btn color="success" prepend-icon="mdi-plus" class="mr-2" @click="crud.openCreate()">
{{ t('story.button.create') }} {{ t('story.button.create') }}
</v-btn> </v-btn>
<v-btn <v-btn
color="primary" color="primary"
prepend-icon="mdi-refresh" prepend-icon="mdi-refresh"
:loading="isLoading" :loading="crud.isLoading.value"
@click="fetchStories" @click="crud.fetch()"
> >
{{ t('common.buttons.refresh') }} {{ t('common.buttons.refresh') }}
</v-btn> </v-btn>
...@@ -155,41 +101,41 @@ function onStoryDeleted(deletedId) { ...@@ -155,41 +101,41 @@ function onStoryDeleted(deletedId) {
{{ flash.message.value }} {{ flash.message.value }}
</v-alert> </v-alert>
<v-alert v-if="isError" type="error" class="mb-4" closable> <v-alert v-if="crud.isError.value" type="error" class="mb-4" closable>
{{ errorMessage }} {{ crud.errorMessage.value }}
</v-alert> </v-alert>
<v-progress-linear <v-progress-linear
v-if="isLoading" v-if="crud.isLoading.value"
indeterminate indeterminate
color="primary" color="primary"
class="mb-4" class="mb-4"
></v-progress-linear> ></v-progress-linear>
<StoryTable <StoryTable
:stories="stories" :stories="crud.items.value"
:students="students" :students="students"
:subjects="subjects" :subjects="subjects"
:terms="terms" :terms="terms"
@edit="openEditForStory" @edit="crud.openEdit"
@delete="openDeleteForStory" @delete="crud.openDelete"
/> />
<StoryEditDialog <StoryEditDialog
:open="editDialog" :open="crud.editingDialogOpen.value"
:story="editingStory" :story="crud.editingItem.value"
:students="students" :students="students"
:subjects="subjects" :subjects="subjects"
:terms="terms" :terms="terms"
@close="closeEditDialog" @close="crud.closeEdit()"
@saved="onStorySaved" @saved="onSaved"
/> />
<StoryDeleteDialog <StoryDeleteDialog
:open="deleteDialog" :open="crud.deleteDialogOpen.value"
:story="storyToDelete" :story="crud.itemToDelete.value"
@close="closeDeleteDialog" @close="crud.closeDelete()"
@deleted="onStoryDeleted" @deleted="onDeleted"
/> />
</v-container> </v-container>
</template> </template>
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment